diff --git a/README.md b/README.md index d034daf..ef51dfd 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,8 @@ By default, the tree view is located in its own container accessible from the ac `gitTreeCompare.compactFolders` When enabled, compacts (flattens) single-child folders into a single tree element. Useful for Java package structures, for example. May have a performance impact for large diff trees. +`gitTreeCompare.showCheckboxes` When enabled, shows checkboxes next to files and folders, allowing you to tick off items, for example when reviewing changes. + +`gitTreeCompare.resetCheckboxOnFileChange` When enabled, automatically resets a file's checkbox when the file is modified after being checked. This ensures that checked files reflect their reviewed state, and any subsequent modifications require re-review. Only effective when `showCheckboxes` is enabled. + `gitTreeCompare.refSortOrder` Determines how refs (branches, tags) are sorted when changing the comparison base. Default is `committerdate` which sorts by most recently committed first, making it easy to find recently-used branches. Can be set to `alphabetically` for alphabetical sorting. diff --git a/package.json b/package.json index cf914f2..c4687ad 100644 --- a/package.json +++ b/package.json @@ -416,6 +416,11 @@ "description": "Whether to show checkboxes such that files or folders can be ticked off, for example when reviewing.", "default": false }, + "gitTreeCompare.resetCheckboxOnFileChange": { + "type": "boolean", + "description": "When enabled, automatically resets checkboxes when a file is modified after being checked. This ensures that checked files reflect their reviewed state, and any subsequent modifications require re-review.", + "default": false + }, "gitTreeCompare.refSortOrder": { "type": "string", "enum": [ diff --git a/src/treeProvider.ts b/src/treeProvider.ts index c138c7e..76ee2d7 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -17,6 +17,11 @@ import { debounce, throttle } from './git/decorators' import { normalizePath } from './fsUtils'; import { API as GitAPI, Repository as GitAPIRepository } from './typings/git'; +interface CheckboxStateInfo { + state: TreeItemCheckboxState; + timestamp: number; // When the checkbox was checked +} + class FileElement implements IDiffStatus { constructor( public srcAbsPath: string, @@ -104,6 +109,7 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos private showCollapsed: boolean; private compactFolders: boolean; private showCheckboxes: boolean; + private resetCheckboxOnFileChange: boolean; // Dynamic options private repository: Repository | undefined; @@ -133,7 +139,7 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos // UI state private treeView: TreeView; private isPaused: boolean; - private checkboxStates: Map = new Map(); + private checkboxStates: Map = new Map(); // Other private readonly disposables: Disposable[] = []; @@ -307,7 +313,10 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos private async handleChangeCheckboxState(e: TreeCheckboxChangeEvent) { for (let [element, state] of e.items) { if (element instanceof FileElement || element instanceof FolderElement) { - this.checkboxStates.set(element.dstAbsPath, state); + this.checkboxStates.set(element.dstAbsPath, { + state: state, + timestamp: Date.now() + }); } } } @@ -344,6 +353,7 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos this.showCollapsed = config.get('collapsed', false); this.compactFolders = config.get('compactFolders', false); this.showCheckboxes = config.get('showCheckboxes', false); + this.resetCheckboxOnFileChange = config.get('resetCheckboxOnFileChange', false); } private async getStoredBaseRef(): Promise { @@ -381,13 +391,47 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos getTreeItem(element: Element): TreeItem { let checkboxState: TreeItemCheckboxState | undefined; if (this.showCheckboxes) { - if (element instanceof FileElement || element instanceof FolderElement) { - checkboxState = this.checkboxStates.get(element.dstAbsPath) ?? TreeItemCheckboxState.Unchecked; + if (element instanceof FileElement) { + const stateInfo = this.checkboxStates.get(element.dstAbsPath); + checkboxState = stateInfo?.state ?? TreeItemCheckboxState.Unchecked; + } else if (element instanceof FolderElement) { + // Compute folder state from children: checked if all children are checked + checkboxState = this.computeFolderCheckboxState(element); } } return toTreeItem(element, this.openChangesOnSelect, this.iconsMinimal, this.showCollapsed, this.viewAsList, checkboxState, this.asAbsolutePath); } + private computeFolderCheckboxState(folder: FolderElement): TreeItemCheckboxState { + // Check if user explicitly set state on this folder + const explicitState = this.checkboxStates.get(folder.dstAbsPath); + if (explicitState) { + return explicitState.state; + } + + // Otherwise derive from files: folder is checked only if ALL files under it are checked + const files = folder.useFilesOutsideTreeRoot ? this.filesOutsideTreeRoot : this.filesInsideTreeRoot; + let hasFiles = false; + let allChecked = true; + + for (const [folderPath, fileEntries] of files.entries()) { + // Check if this folder is under the target folder + if (folderPath === folder.dstAbsPath || folderPath.startsWith(folder.dstAbsPath + path.sep)) { + for (const file of fileEntries) { + hasFiles = true; + const stateInfo = this.checkboxStates.get(file.dstAbsPath); + if (!stateInfo || stateInfo.state !== TreeItemCheckboxState.Checked) { + allChecked = false; + break; + } + } + if (!allChecked) break; + } + } + + return (hasFiles && allChecked) ? TreeItemCheckboxState.Checked : TreeItemCheckboxState.Unchecked; + } + async getChildren(element?: Element): Promise { if (!element) { if (!this.repository) { @@ -510,6 +554,10 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const untrackedCount = diff.reduce((prev, cur, _) => prev + (cur.status === 'U' ? 1 : 0), 0); this.log(`${diff.length} diff entries (${untrackedCount} untracked)`); + const newFilePaths = new Set(); + // Collect files that need mtime checking for async batch processing + const filesToCheckMtime: Array<{filePath: string, stateInfo: CheckboxStateInfo}> = []; + for (const entry of diff) { const folder = path.dirname(entry.dstAbsPath); @@ -532,6 +580,57 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const entries = files.get(folder)!; entries.push(entry); + + // Track new file paths + newFilePaths.add(entry.dstAbsPath); + + // Collect checked files for mtime checking to reset if modified after being checked + if (this.resetCheckboxOnFileChange) { + const stateInfo = this.checkboxStates.get(entry.dstAbsPath); + if (stateInfo && stateInfo.state === TreeItemCheckboxState.Checked) { + filesToCheckMtime.push({filePath: entry.dstAbsPath, stateInfo}); + } + } + } + + // Check file modification times asynchronously in parallel + if (this.resetCheckboxOnFileChange && filesToCheckMtime.length > 0) { + const statPromises = filesToCheckMtime.map(async ({filePath, stateInfo}) => { + try { + const stats = await fs.promises.stat(filePath); + const fileMtime = stats.mtimeMs; + + // If file was modified after checkbox was checked, reset it + if (fileMtime > stateInfo.timestamp) { + return filePath; + } + } catch (error: unknown) { + // File might be deleted or inaccessible - this is expected in some cases + const errorMessage = error instanceof Error ? error.message : String(error); + this.log(`Could not stat file for checkbox reset check: ${filePath}: ${errorMessage}`); + } + return null; + }); + + const pathsToReset = await Promise.all(statPromises); + const actualPathsToReset = pathsToReset.filter((filePath): filePath is string => filePath !== null); + actualPathsToReset.forEach(filePath => this.checkboxStates.delete(filePath)); + + // Fire tree refresh to update checkbox UI + if (actualPathsToReset.length > 0) { + this._onDidChangeTreeData.fire(); + } + } + + // Clear checkbox state for files that no longer exist in the diff + const pathsToDelete: string[] = []; + for (const [filePath] of this.checkboxStates) { + if (!newFilePaths.has(filePath)) { + pathsToDelete.push(filePath); + } + } + for (const filePath of pathsToDelete) { + this.checkboxStates.delete(filePath); } let treeHasChanged = false;