Skip to content

Commit 80887e9

Browse files
Copilotletmaik
andauthored
Add automatic checkbox reset based on file modification time (#123)
Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com>
1 parent e304c38 commit 80887e9

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,8 @@ By default, the tree view is located in its own container accessible from the ac
5656

5757
`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.
5858

59+
`gitTreeCompare.showCheckboxes` When enabled, shows checkboxes next to files and folders, allowing you to tick off items, for example when reviewing changes.
60+
61+
`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.
62+
5963
`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.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@
416416
"description": "Whether to show checkboxes such that files or folders can be ticked off, for example when reviewing.",
417417
"default": false
418418
},
419+
"gitTreeCompare.resetCheckboxOnFileChange": {
420+
"type": "boolean",
421+
"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.",
422+
"default": false
423+
},
419424
"gitTreeCompare.refSortOrder": {
420425
"type": "string",
421426
"enum": [

src/treeProvider.ts

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { debounce, throttle } from './git/decorators'
1717
import { normalizePath } from './fsUtils';
1818
import { API as GitAPI, Repository as GitAPIRepository } from './typings/git';
1919

20+
interface CheckboxStateInfo {
21+
state: TreeItemCheckboxState;
22+
timestamp: number; // When the checkbox was checked
23+
}
24+
2025
class FileElement implements IDiffStatus {
2126
constructor(
2227
public srcAbsPath: string,
@@ -104,6 +109,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
104109
private showCollapsed: boolean;
105110
private compactFolders: boolean;
106111
private showCheckboxes: boolean;
112+
private resetCheckboxOnFileChange: boolean;
107113

108114
// Dynamic options
109115
private repository: Repository | undefined;
@@ -133,7 +139,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
133139
// UI state
134140
private treeView: TreeView<Element>;
135141
private isPaused: boolean;
136-
private checkboxStates: Map<string, TreeItemCheckboxState> = new Map<string, TreeItemCheckboxState>();
142+
private checkboxStates: Map<string, CheckboxStateInfo> = new Map<string, CheckboxStateInfo>();
137143

138144
// Other
139145
private readonly disposables: Disposable[] = [];
@@ -307,7 +313,10 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
307313
private async handleChangeCheckboxState(e: TreeCheckboxChangeEvent<Element>) {
308314
for (let [element, state] of e.items) {
309315
if (element instanceof FileElement || element instanceof FolderElement) {
310-
this.checkboxStates.set(element.dstAbsPath, state);
316+
this.checkboxStates.set(element.dstAbsPath, {
317+
state: state,
318+
timestamp: Date.now()
319+
});
311320
}
312321
}
313322
}
@@ -344,6 +353,7 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
344353
this.showCollapsed = config.get<boolean>('collapsed', false);
345354
this.compactFolders = config.get<boolean>('compactFolders', false);
346355
this.showCheckboxes = config.get<boolean>('showCheckboxes', false);
356+
this.resetCheckboxOnFileChange = config.get<boolean>('resetCheckboxOnFileChange', false);
347357
}
348358

349359
private async getStoredBaseRef(): Promise<string | undefined> {
@@ -381,13 +391,47 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
381391
getTreeItem(element: Element): TreeItem {
382392
let checkboxState: TreeItemCheckboxState | undefined;
383393
if (this.showCheckboxes) {
384-
if (element instanceof FileElement || element instanceof FolderElement) {
385-
checkboxState = this.checkboxStates.get(element.dstAbsPath) ?? TreeItemCheckboxState.Unchecked;
394+
if (element instanceof FileElement) {
395+
const stateInfo = this.checkboxStates.get(element.dstAbsPath);
396+
checkboxState = stateInfo?.state ?? TreeItemCheckboxState.Unchecked;
397+
} else if (element instanceof FolderElement) {
398+
// Compute folder state from children: checked if all children are checked
399+
checkboxState = this.computeFolderCheckboxState(element);
386400
}
387401
}
388402
return toTreeItem(element, this.openChangesOnSelect, this.iconsMinimal, this.showCollapsed, this.viewAsList, checkboxState, this.asAbsolutePath);
389403
}
390404

405+
private computeFolderCheckboxState(folder: FolderElement): TreeItemCheckboxState {
406+
// Check if user explicitly set state on this folder
407+
const explicitState = this.checkboxStates.get(folder.dstAbsPath);
408+
if (explicitState) {
409+
return explicitState.state;
410+
}
411+
412+
// Otherwise derive from files: folder is checked only if ALL files under it are checked
413+
const files = folder.useFilesOutsideTreeRoot ? this.filesOutsideTreeRoot : this.filesInsideTreeRoot;
414+
let hasFiles = false;
415+
let allChecked = true;
416+
417+
for (const [folderPath, fileEntries] of files.entries()) {
418+
// Check if this folder is under the target folder
419+
if (folderPath === folder.dstAbsPath || folderPath.startsWith(folder.dstAbsPath + path.sep)) {
420+
for (const file of fileEntries) {
421+
hasFiles = true;
422+
const stateInfo = this.checkboxStates.get(file.dstAbsPath);
423+
if (!stateInfo || stateInfo.state !== TreeItemCheckboxState.Checked) {
424+
allChecked = false;
425+
break;
426+
}
427+
}
428+
if (!allChecked) break;
429+
}
430+
}
431+
432+
return (hasFiles && allChecked) ? TreeItemCheckboxState.Checked : TreeItemCheckboxState.Unchecked;
433+
}
434+
391435
async getChildren(element?: Element): Promise<Element[]> {
392436
if (!element) {
393437
if (!this.repository) {
@@ -510,6 +554,10 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
510554
const untrackedCount = diff.reduce((prev, cur, _) => prev + (cur.status === 'U' ? 1 : 0), 0);
511555
this.log(`${diff.length} diff entries (${untrackedCount} untracked)`);
512556

557+
const newFilePaths = new Set<string>();
558+
// Collect files that need mtime checking for async batch processing
559+
const filesToCheckMtime: Array<{filePath: string, stateInfo: CheckboxStateInfo}> = [];
560+
513561
for (const entry of diff) {
514562
const folder = path.dirname(entry.dstAbsPath);
515563

@@ -532,6 +580,57 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
532580

533581
const entries = files.get(folder)!;
534582
entries.push(entry);
583+
584+
// Track new file paths
585+
newFilePaths.add(entry.dstAbsPath);
586+
587+
// Collect checked files for mtime checking to reset if modified after being checked
588+
if (this.resetCheckboxOnFileChange) {
589+
const stateInfo = this.checkboxStates.get(entry.dstAbsPath);
590+
if (stateInfo && stateInfo.state === TreeItemCheckboxState.Checked) {
591+
filesToCheckMtime.push({filePath: entry.dstAbsPath, stateInfo});
592+
}
593+
}
594+
}
595+
596+
// Check file modification times asynchronously in parallel
597+
if (this.resetCheckboxOnFileChange && filesToCheckMtime.length > 0) {
598+
const statPromises = filesToCheckMtime.map(async ({filePath, stateInfo}) => {
599+
try {
600+
const stats = await fs.promises.stat(filePath);
601+
const fileMtime = stats.mtimeMs;
602+
603+
// If file was modified after checkbox was checked, reset it
604+
if (fileMtime > stateInfo.timestamp) {
605+
return filePath;
606+
}
607+
} catch (error: unknown) {
608+
// File might be deleted or inaccessible - this is expected in some cases
609+
const errorMessage = error instanceof Error ? error.message : String(error);
610+
this.log(`Could not stat file for checkbox reset check: ${filePath}: ${errorMessage}`);
611+
}
612+
return null;
613+
});
614+
615+
const pathsToReset = await Promise.all(statPromises);
616+
const actualPathsToReset = pathsToReset.filter((filePath): filePath is string => filePath !== null);
617+
actualPathsToReset.forEach(filePath => this.checkboxStates.delete(filePath));
618+
619+
// Fire tree refresh to update checkbox UI
620+
if (actualPathsToReset.length > 0) {
621+
this._onDidChangeTreeData.fire();
622+
}
623+
}
624+
625+
// Clear checkbox state for files that no longer exist in the diff
626+
const pathsToDelete: string[] = [];
627+
for (const [filePath] of this.checkboxStates) {
628+
if (!newFilePaths.has(filePath)) {
629+
pathsToDelete.push(filePath);
630+
}
631+
}
632+
for (const filePath of pathsToDelete) {
633+
this.checkboxStates.delete(filePath);
535634
}
536635

537636
let treeHasChanged = false;

0 commit comments

Comments
 (0)