Skip to content

Commit f99122d

Browse files
committed
right-panel: file checkbox follows hunk staging
1 parent cd61a2b commit f99122d

File tree

3 files changed

+62
-20
lines changed

3 files changed

+62
-20
lines changed

packages/desktop/src/features/git/DiffManager.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ export interface GitCommit {
2929
export type WorkingTreeScope = 'all' | 'staged' | 'unstaged' | 'untracked';
3030

3131
export type WorkingTreeGroups = {
32-
staged: Array<{ path: string; additions: number; deletions: number; type: 'added' | 'deleted' | 'modified' | 'renamed' }>;
33-
unstaged: Array<{ path: string; additions: number; deletions: number; type: 'added' | 'deleted' | 'modified' | 'renamed' }>;
34-
untracked: Array<{ path: string; additions: number; deletions: number; type: 'added' | 'deleted' | 'modified' | 'renamed' }>;
32+
staged: Array<{ path: string; additions: number; deletions: number; type: 'added' | 'deleted' | 'modified' | 'renamed'; isNew?: boolean }>;
33+
unstaged: Array<{ path: string; additions: number; deletions: number; type: 'added' | 'deleted' | 'modified' | 'renamed'; isNew?: boolean }>;
34+
untracked: Array<{ path: string; additions: number; deletions: number; type: 'added' | 'deleted' | 'modified' | 'renamed'; isNew?: boolean }>;
3535
};
3636

3737
const MAX_UNTRACKED_FILE_BYTES = 1024 * 1024; // 1MB
@@ -324,6 +324,20 @@ export class GitDiffManager {
324324
return input;
325325
}
326326

327+
private async headPathExists(worktreePath: string, filePath: string, sessionId?: string | null): Promise<boolean> {
328+
const object = `HEAD:${filePath}`;
329+
const res = await this.gitExecutor.run({
330+
sessionId: sessionId ?? undefined,
331+
cwd: worktreePath,
332+
argv: ['git', 'cat-file', '-e', object],
333+
op: 'read',
334+
recordTimeline: false,
335+
meta: { source: 'gitDiff', operation: 'cat-file-exists', object },
336+
timeoutMs: 15_000,
337+
});
338+
return res.exitCode === 0;
339+
}
340+
327341
private statusType(code: string): 'added' | 'deleted' | 'modified' | 'renamed' {
328342
if (code === 'A') return 'added';
329343
if (code === 'D') return 'deleted';
@@ -404,9 +418,17 @@ export class GitDiffManager {
404418
).stdout
405419
);
406420

421+
const stagedNewFlags = new Map<string, boolean>();
422+
const stagedAdded = stagedPaths.filter((p) => p.type === 'added').map((p) => p.path);
423+
for (const p of stagedAdded) {
424+
const exists = await this.headPathExists(worktreePath, p, sessionId);
425+
stagedNewFlags.set(p, !exists);
426+
}
427+
407428
for (const item of stagedPaths) {
408429
const stats = stagedStats.get(item.path) || { additions: 0, deletions: 0 };
409-
groups.staged.push({ path: item.path, additions: stats.additions, deletions: stats.deletions, type: item.type });
430+
const isNew = item.type === 'added' ? stagedNewFlags.get(item.path) : undefined;
431+
groups.staged.push({ path: item.path, additions: stats.additions, deletions: stats.deletions, type: item.type, isNew });
410432
}
411433
for (const item of unstagedPaths) {
412434
const stats = unstagedStats.get(item.path) || { additions: 0, deletions: 0 };
@@ -424,7 +446,7 @@ export class GitDiffManager {
424446
} catch {
425447
// ignore
426448
}
427-
groups.untracked.push({ path: p, additions, deletions: 0, type: 'added' });
449+
groups.untracked.push({ path: p, additions, deletions: 0, type: 'added', isNew: true });
428450
}
429451
}
430452

packages/ui/src/components/layout/RightPanel.tsx

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,8 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
743743
if (e.kind !== 'git.command') return;
744744
if (e.status !== 'finished' && e.status !== 'failed') return;
745745
const meta = (e.meta || {}) as Record<string, unknown>;
746-
if (meta.source !== 'agent') return;
746+
const source = typeof meta.source === 'string' ? meta.source : '';
747+
if (source !== 'agent' && source !== 'gitStaging') return;
747748
const cmd = typeof e.command === 'string' ? e.command.trim() : '';
748749
if (!cmd) return;
749750
if (!/^(git|gh)\b/.test(cmd)) return;
@@ -838,19 +839,30 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
838839
const additions = (staged?.additions || 0) + (unstaged?.additions || 0);
839840
const deletions = (staged?.deletions || 0) + (unstaged?.deletions || 0);
840841
const stageState: TriState = staged && unstaged ? 'indeterminate' : staged ? 'checked' : 'unchecked';
841-
merged.push({ file: { path, type, additions, deletions }, stageState });
842+
const isNew = Boolean(staged?.isNew);
843+
merged.push({ file: { path, type, additions, deletions, isNew }, stageState });
842844
}
843845

844846
merged.sort((a, b) => a.file.path.localeCompare(b.file.path));
845847
return merged;
846848
}, [workingTree]);
847849

848-
const untrackedFiles = useMemo(() => {
850+
const trackedList = useMemo(() => trackedFiles.filter((x) => !x.file.isNew), [trackedFiles]);
851+
852+
const untrackedList = useMemo(() => {
849853
if (!workingTree) return [];
850-
const list = [...workingTree.untracked];
851-
list.sort((a, b) => a.path.localeCompare(b.path));
852-
return list;
853-
}, [workingTree]);
854+
855+
const fromMap = trackedFiles.filter((x) => x.file.isNew);
856+
const fromStatus = workingTree.untracked.map((f) => ({ file: f, stageState: 'unchecked' as TriState }));
857+
858+
const byPath = new Map<string, { file: FileChange; stageState: TriState }>();
859+
for (const x of [...fromStatus, ...fromMap]) {
860+
byPath.set(x.file.path, x);
861+
}
862+
863+
const merged = Array.from(byPath.values()).sort((a, b) => a.file.path.localeCompare(b.file.path));
864+
return merged;
865+
}, [trackedFiles, workingTree]);
854866

855867
const stageAllState: TriState = useMemo(() => {
856868
if (!workingTree) return 'unchecked';
@@ -1145,7 +1157,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
11451157
</div>
11461158
) : (
11471159
<div className="py-2">
1148-
{trackedFiles.length > 0 && (
1160+
{trackedList.length > 0 && (
11491161
<div className="mb-2">
11501162
<div
11511163
className="px-3 pb-1 text-[10px] font-semibold tracking-wider uppercase"
@@ -1154,7 +1166,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
11541166
Tracked
11551167
</div>
11561168
<div>
1157-
{trackedFiles.map(({ file, stageState }) => (
1169+
{trackedList.map(({ file, stageState }) => (
11581170
<WorkingFileRow
11591171
key={`tracked:${file.path}`}
11601172
file={file}
@@ -1164,7 +1176,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
11641176
const stage = stageState !== 'checked';
11651177
void handleChangeFileStage(file.path, stage);
11661178
}}
1167-
onClick={() => handleWorkingFileClick('all', file, trackedFiles.map((x) => x.file))}
1179+
onClick={() => handleWorkingFileClick('all', file, trackedList.map((x) => x.file))}
11681180
isSelected={selectedFile === file.path && selectedFileScope === 'all'}
11691181
testId={`right-panel-file-tracked-${file.path}`}
11701182
/>
@@ -1173,7 +1185,7 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
11731185
</div>
11741186
)}
11751187

1176-
{untrackedFiles.length > 0 && (
1188+
{untrackedList.length > 0 && (
11771189
<div className="mb-2">
11781190
<div
11791191
className="px-3 pb-1 text-[10px] font-semibold tracking-wider uppercase"
@@ -1182,14 +1194,17 @@ export const RightPanel: React.FC<RightPanelProps> = React.memo(({
11821194
Untracked
11831195
</div>
11841196
<div>
1185-
{untrackedFiles.map((file) => (
1197+
{untrackedList.map(({ file, stageState }) => (
11861198
<WorkingFileRow
11871199
key={`untracked:${file.path}`}
11881200
file={file}
1189-
stageState="unchecked"
1201+
stageState={stageState}
11901202
disabled={isLoading || isStageChanging}
1191-
onToggleStage={() => void handleChangeFileStage(file.path, true)}
1192-
onClick={() => handleWorkingFileClick('untracked', file, untrackedFiles)}
1203+
onToggleStage={() => {
1204+
const stage = stageState !== 'checked';
1205+
void handleChangeFileStage(file.path, stage);
1206+
}}
1207+
onClick={() => handleWorkingFileClick('untracked', file, untrackedList.map((x) => x.file))}
11931208
isSelected={selectedFile === file.path && selectedFileScope === 'untracked'}
11941209
testId={`right-panel-file-untracked-${file.path}`}
11951210
/>

packages/ui/src/components/layout/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ export interface FileChange {
88
additions: number;
99
deletions: number;
1010
type: 'added' | 'deleted' | 'modified' | 'renamed';
11+
/**
12+
* True when the file does not exist in HEAD (e.g. newly added but not committed).
13+
* Used to keep "Untracked" stable across staging operations until commit.
14+
*/
15+
isNew?: boolean;
1116
}
1217

1318
export interface WorkspaceHeaderProps {

0 commit comments

Comments
 (0)