Skip to content

Added checkboxes for viewed files #302

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,7 @@
"@segment/analytics-node": "^2.1.3",
"@testing-library/react": "^12.1.5",
"@types/mustache": "^4.0.1",
"@vscode-elements/elements": "^1.14.0",
"@vscode/webview-ui-toolkit": "^1.4.0",
"awesome-debounce-promise": "^2.1.0",
"axios": "^1.7.4",
Expand Down
8 changes: 8 additions & 0 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { Logger } from './logger';
import { SearchJiraHelper } from './views/jira/searchJiraHelper';
import { featureFlagClientInitializedEvent } from './analytics';
import { openPullRequest } from './commands/bitbucket/pullRequest';
import { CheckboxStateManager } from './views/nodes/checkBoxStateManager';

const isDebuggingRegex = /^--(debug|inspect)\b(-brk\b|(?!-))=?/;
const ConfigTargetKey = 'configurationTarget';
Expand All @@ -87,6 +88,8 @@ export class Container {
static async initialize(context: ExtensionContext, config: IConfig, version: string) {
const analyticsEnv: string = this.isDebugging ? 'staging' : 'prod';

this._checkboxStateManager = new CheckboxStateManager(context);

this._analyticsClient = analyticsClient({
origin: 'desktop',
env: analyticsEnv,
Expand Down Expand Up @@ -531,4 +534,9 @@ export class Container {
public static get pmfStats() {
return this._pmfStats;
}

private static _checkboxStateManager: CheckboxStateManager;
public static get checkboxStateManager() {
return this._checkboxStateManager;
}
}
30 changes: 30 additions & 0 deletions src/views/BitbucketExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,46 @@ import { DescriptionNode, PullRequestTitlesNode } from './pullrequest/pullReques
import { PullRequestNodeDataProvider } from './pullRequestNodeDataProvider';
import { RefreshTimer } from './RefreshTimer';
import { BitbucketActivityMonitor } from './BitbucketActivityMonitor';
import { PullRequestFilesNode } from './nodes/pullRequestFilesNode';
import { DirectoryNode } from './nodes/directoryNode';
import { TreeView } from 'vscode';
import { AbstractBaseNode } from './nodes/abstractBaseNode';

export abstract class BitbucketExplorer extends Explorer implements Disposable {
private _disposable: Disposable;

private monitor: BitbucketActivityMonitor | undefined;
private _refreshTimer: RefreshTimer;
private _onDidChangeTreeData = new vscode.EventEmitter<AbstractBaseNode | undefined>();

protected newTreeView(): TreeView<AbstractBaseNode> | undefined {
super.newTreeView();
this.setupCheckboxHandling();
return this.treeView;
}

private setupCheckboxHandling(): void {
if (!this.treeView) {
return;
}
this.treeView.onDidChangeCheckboxState((event) => {
event.items.forEach(([item, state]) => {
const checked = state === vscode.TreeItemCheckboxState.Checked;
if (item instanceof PullRequestFilesNode || item instanceof DirectoryNode) {
item.checked = checked;
this._onDidChangeTreeData.fire(item);
}
});
});
}

constructor(protected ctx: BitbucketContext) {
super(() => this.dispose());

setTimeout(() => {
this.setupCheckboxHandling();
}, 50);

Container.context.subscriptions.push(configuration.onDidChange(this._onConfigurationChanged, this));

this._refreshTimer = new RefreshTimer(this.explorerEnabledConfiguration(), this.refreshConfiguration(), () =>
Expand Down
64 changes: 64 additions & 0 deletions src/views/nodes/checkBoxStateManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Logger } from 'src/logger';
import vscode from 'vscode';
interface CheckboxState {
id: string;
timestamp: number;
}
export class CheckboxStateManager {
private readonly STATE_EXPIRY = 24 * 60 * 60 * 1000;
private readonly STORAGE_KEY = 'bitbucked.viewedFiles';

constructor(private context: vscode.ExtensionContext) {
this.cleanup();
}

private async cleanup(): Promise<void> {
const states = this.context.workspaceState.get<CheckboxState[]>(this.STORAGE_KEY, []);
const now = Date.now();

const validStates = states.filter((state) => {
const isValid = now - state.timestamp < this.STATE_EXPIRY;
if (!isValid) {
Logger.debug(`Removing expired checkbox state: ${state.id}`);
}
return isValid;
});

await this.context.workspaceState.update(this.STORAGE_KEY, validStates);
Logger.debug(`Cleanup complete. Remaining states: ${validStates.length}`);
}

isChecked(id: string): boolean {
const states = this.context.workspaceState.get<CheckboxState[]>(this.STORAGE_KEY, []);
const state = states.find((state) => state.id === id);

if (state) {
if (Date.now() - state.timestamp >= this.STATE_EXPIRY) {
this.setChecked(id, false);
return false;
}
return true;
}
return false;
}

setChecked(id: string, checked: boolean): void {
const states = this.context.workspaceState.get<CheckboxState[]>(this.STORAGE_KEY, []);

if (checked) {
const existingIndex = states.findIndex((state) => state.id === id);
if (existingIndex !== -1) {
states.splice(existingIndex, 1);
}
states.push({ id, timestamp: Date.now() });
} else {
const index = states.findIndex((state) => state.id === id);
if (index !== -1) {
states.splice(index, 1);
}
}

this.context.workspaceState.update(this.STORAGE_KEY, states);
Logger.debug(`Checkbox state updated: ${id} = ${checked}`);
}
}
16 changes: 12 additions & 4 deletions src/views/nodes/commitNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ export class CommitNode extends AbstractBaseNode {
//TODO: pass tasks if commit-level tasks exist
//TODO: if there is more than one parent, there should probably be a notification about diff ambiguity, unless I can figure
//out a way to resolve this
const children = await createFileChangesNodes(this.pr, paginatedComments, diffs, conflictedFiles, [], {
lhs: this.commit.parentHashes?.[0] ?? '', //The only time I can think of this being undefined is for an initial commit, but what should the parent be there?
rhs: this.commit.hash,
});
const children = await createFileChangesNodes(
this.pr,
paginatedComments,
diffs,
conflictedFiles,
[],
{
lhs: this.commit.parentHashes?.[0] ?? '', //The only time I can think of this being undefined is for an initial commit, but what should the parent be there?
rhs: this.commit.hash,
},
'commits',
);
return children;
} catch (e) {
Logger.debug('error fetching changed files', e);
Expand Down
100 changes: 91 additions & 9 deletions src/views/nodes/directoryNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,109 @@ import * as vscode from 'vscode';
import { PRDirectory } from '../pullrequest/diffViewHelper';
import { AbstractBaseNode } from './abstractBaseNode';
import { PullRequestFilesNode } from './pullRequestFilesNode';
import { Container } from 'src/container';
import { Logger } from 'src/logger';

export class DirectoryNode extends AbstractBaseNode {
constructor(private directoryData: PRDirectory) {
constructor(
private directoryData: PRDirectory,
private prUrl: string,
private section: 'files' | 'commits' = 'files',
private commitHash?: string,
) {
super();
}

private _isDirectClick = false;

get directoryId(): string {
const prUrlPath = vscode.Uri.parse(this.prUrl).path;
const prId = prUrlPath.slice(prUrlPath.lastIndexOf('/') + 1);
const repoUrl = this.prUrl.slice(0, this.prUrl.indexOf('/pull-requests'));
const repoId = repoUrl.slice(repoUrl.lastIndexOf('/') + 1);
const dirPath = this.directoryData.fullPath;

if (this.directoryData.treeHash) {
return `repo-${repoId}-pr-${prId}-${this.section}-${this.commitHash || 'main'}-tree-${this.directoryData.treeHash}`;
}
return `repo-${repoId}-pr-${prId}-${this.section}-${this.commitHash || 'main'}-directory-${dirPath}`;
}

private areAllChildrenChecked(): boolean {
const allFilesChecked = this.directoryData.files.every((file) => {
const fileNode = new PullRequestFilesNode(file, this.section, this.commitHash);
return fileNode.checked;
});

const allSubdirsChecked = Array.from(this.directoryData.subdirs.values()).every((subdir) => {
const subdirNode = new DirectoryNode(subdir, this.prUrl, this.section, this.commitHash);
return subdirNode.checked;
});

return allFilesChecked && allSubdirsChecked;
}

set checked(value: boolean) {
Logger.debug(`Setting directory ${this.directoryId} checked state to ${value}`);
Container.checkboxStateManager.setChecked(this.directoryId, value);
if (this._isDirectClick) {
Logger.debug('Propagating state to children');
this.directoryData.files.forEach((file) => {
const fileNode = new PullRequestFilesNode(file, this.section, this.commitHash);
Container.checkboxStateManager.setChecked(fileNode.fileId, value);
});
this.directoryData.subdirs.forEach((subdir) => {
const subdirNode = new DirectoryNode(subdir, this.prUrl, this.section, this.commitHash);
Container.checkboxStateManager.setChecked(subdirNode.directoryId, value);
});
}
}
get checked(): boolean {
return Container.checkboxStateManager.isChecked(this.directoryId);
}

async getTreeItem(): Promise<vscode.TreeItem> {
const item = new vscode.TreeItem(this.directoryData.name, vscode.TreeItemCollapsibleState.Expanded);
// For root files folder we do not want to show a checkbox and we want to show the folder icon and we do not want it expanded
const isRootFilesDirectory =
this.section === 'files' && this.directoryData.name === 'Files' && this.directoryData.fullPath === '';

const item = new vscode.TreeItem(
this.directoryData.name,
isRootFilesDirectory ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded,
);
item.tooltip = this.directoryData.name;
item.iconPath = vscode.ThemeIcon.Folder;

if (!isRootFilesDirectory) {
item.iconPath = vscode.ThemeIcon.Folder;
}

const allChecked = this.areAllChildrenChecked();
if (!this._isDirectClick) {
Container.checkboxStateManager.setChecked(this.directoryId, allChecked);
}

if (!isRootFilesDirectory) {
item.checkboxState = this.checked
? vscode.TreeItemCheckboxState.Checked
: vscode.TreeItemCheckboxState.Unchecked;
item.contextValue = `directory${allChecked ? '.checked' : ''}`;
}

item.id = this.directoryId;

return item;
}

async getChildren(element?: AbstractBaseNode): Promise<AbstractBaseNode[]> {
async getChildren(): Promise<AbstractBaseNode[]> {
const fileNodes: AbstractBaseNode[] = this.directoryData.files.map(
(diffViewArg) => new PullRequestFilesNode(diffViewArg, this.section, this.commitHash),
);

const directoryNodes: DirectoryNode[] = Array.from(
this.directoryData.subdirs.values(),
(subdir) => new DirectoryNode(subdir),
(subdir) => new DirectoryNode(subdir, this.prUrl, this.section, this.commitHash),
);
const fileNodes: AbstractBaseNode[] = this.directoryData.files.map(
(diffViewArg) => new PullRequestFilesNode(diffViewArg),
);
return fileNodes.concat(directoryNodes);

return [...fileNodes, ...directoryNodes];
}
}
Loading
Loading