Skip to content

Commit 5d2717e

Browse files
authored
feat: autosave diff comments (#820)
Signed-off-by: Matt Toohey <contact@matttoohey.com>
1 parent 37d1727 commit 5d2717e

16 files changed

Lines changed: 1110 additions & 231 deletions

apps/differ/src/App.svelte

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@
9696
// ==========================================================================
9797
9898
type DiffMode = 'all' | 'staged' | 'branch' | 'commit' | 'stack';
99+
type DiffViewerHandle = {
100+
flushCommentEditors: () => Promise<boolean>;
101+
};
99102
100103
let diffMode = $state<DiffMode>('all');
101104
let diffSpec = $state<DiffSpec>(commands.specUncommitted());
@@ -122,6 +125,7 @@
122125
let loading = $state(true);
123126
let loadingFile = $state<string | null>(null);
124127
let error = $state<string | null>(null);
128+
let diffViewer = $state<DiffViewerHandle | null>(null);
125129
126130
let localComments = $state<Comment[]>([]);
127131
let copiedFeedback = $state(false);
@@ -255,7 +259,21 @@
255259
// Diff mode switching
256260
// ==========================================================================
257261
258-
function setMode(mode: DiffMode, commit?: CommitInfo) {
262+
async function flushCommentEditorsForDiffChange(): Promise<boolean> {
263+
return (await diffViewer?.flushCommentEditors()) ?? true;
264+
}
265+
266+
function resetDiffState() {
267+
files = [];
268+
diffCache = new Map();
269+
selectedFile = null;
270+
localComments = [];
271+
error = null;
272+
}
273+
274+
async function setMode(mode: DiffMode, commit?: CommitInfo): Promise<boolean> {
275+
if (!(await flushCommentEditorsForDiffChange())) return false;
276+
259277
showCommitPicker = false;
260278
showStackPicker = false;
261279
diffMode = mode;
@@ -299,26 +317,22 @@
299317
break;
300318
}
301319
302-
files = [];
303-
diffCache = new Map();
304-
selectedFile = null;
305-
localComments = [];
306-
error = null;
320+
resetDiffState();
307321
loadDiff();
322+
return true;
308323
}
309324
310-
function selectCommitBySha(sha: string) {
325+
async function selectCommitBySha(sha: string): Promise<boolean> {
326+
if (!(await flushCommentEditorsForDiffChange())) return false;
327+
311328
diffMode = 'commit';
312329
diffSpec = commands.specCommit(sha);
313330
diffLabel = `Commit ${sha.slice(0, 7)}`;
314331
selectedCommit = null;
315332
316-
files = [];
317-
diffCache = new Map();
318-
selectedFile = null;
319-
localComments = [];
320-
error = null;
333+
resetDiffState();
321334
loadDiff();
335+
return true;
322336
}
323337
324338
async function toggleCommitPicker() {
@@ -341,42 +355,42 @@
341355
showStackPicker = !showStackPicker;
342356
}
343357
344-
function selectStackBranch(branch: StackBranchInfo) {
358+
async function selectStackBranch(branch: StackBranchInfo): Promise<boolean> {
359+
if (!(await flushCommentEditorsForDiffChange())) return false;
360+
345361
stackViewTarget = branch;
346362
diffSpec = commands.specStackBranch(branch.name, branch.parentRef);
347363
diffLabel = `Stack: ${branch.name.split('/').pop()}`;
348364
diffMode = 'stack';
349365
showStackPicker = false;
350366
351-
files = [];
352-
diffCache = new Map();
353-
selectedFile = null;
354-
localComments = [];
355-
error = null;
367+
resetDiffState();
356368
loadDiff();
369+
return true;
357370
}
358371
359-
function selectStackCommittedOnly() {
360-
if (!stackInfo) return;
372+
async function selectStackCommittedOnly(): Promise<boolean> {
373+
if (!stackInfo) return false;
374+
if (!(await flushCommentEditorsForDiffChange())) return false;
375+
361376
stackViewTarget = null;
362377
diffSpec = commands.specStackCommitted(stackInfo.parentBranch);
363378
diffLabel = `Stack vs ${stackInfo.parentBranch.split('/').pop()} (committed)`;
364379
diffMode = 'stack';
365380
showStackPicker = false;
366381
367-
files = [];
368-
diffCache = new Map();
369-
selectedFile = null;
370-
localComments = [];
371-
error = null;
382+
resetDiffState();
372383
loadDiff();
384+
return true;
373385
}
374386
375387
// ==========================================================================
376388
// Load diff
377389
// ==========================================================================
378390
379391
async function loadDiff() {
392+
if (!(await flushCommentEditorsForDiffChange())) return;
393+
380394
loading = true;
381395
error = null;
382396
try {
@@ -394,7 +408,10 @@
394408
}
395409
}
396410
397-
async function selectFile(path: string | null) {
411+
async function selectFile(path: string | null): Promise<boolean> {
412+
if (path === selectedFile) return true;
413+
if (!(await flushCommentEditorsForDiffChange())) return false;
414+
398415
const thisGeneration = ++selectionGeneration;
399416
400417
// Handle search-related behavior (expand and select first result)
@@ -408,7 +425,7 @@
408425
loadingFile = path;
409426
try {
410427
const diff = await commands.getFileDiff(diffSpec, path);
411-
if (selectionGeneration !== thisGeneration) return;
428+
if (selectionGeneration !== thisGeneration) return false;
412429
const newCache = new Map(diffCache);
413430
newCache.set(path, diff);
414431
diffCache = newCache;
@@ -418,6 +435,8 @@
418435
loadingFile = null;
419436
}
420437
}
438+
439+
return true;
421440
}
422441
423442
// ==========================================================================
@@ -426,7 +445,11 @@
426445
427446
let nextCommentId = 0;
428447
429-
async function handleAddComment(path: string, span: Span, content: string): Promise<void> {
448+
async function handleAddComment(
449+
path: string,
450+
span: Span,
451+
content: string
452+
): Promise<Comment | null> {
430453
const comment: Comment = {
431454
id: `local-${++nextCommentId}`,
432455
path,
@@ -443,6 +466,7 @@
443466
commitSessionId: null,
444467
};
445468
localComments = [...localComments, comment];
469+
return comment;
446470
}
447471
448472
async function handleUpdateComment(commentId: string, content: string): Promise<void> {
@@ -475,7 +499,7 @@
475499
// ==========================================================================
476500
477501
function handleSelectFile(file: FileEntry) {
478-
selectFile(file.path);
502+
void selectFile(file.path);
479503
}
480504
481505
// Load a file's diff without changing the selection (for search)
@@ -514,6 +538,8 @@
514538
// ==========================================================================
515539
516540
async function handleFolderSelect(path: string) {
541+
if (!(await flushCommentEditorsForDiffChange())) return;
542+
517543
showFolderPicker = false;
518544
try {
519545
await commands.setRepoPath(path);
@@ -527,11 +553,7 @@
527553
diffMode = 'all';
528554
diffSpec = commands.specUncommitted();
529555
diffLabel = 'All Changes';
530-
files = [];
531-
diffCache = new Map();
532-
selectedFile = null;
533-
localComments = [];
534-
error = null;
556+
resetDiffState();
535557
536558
// Fetch repo info without triggering a diff load yet —
537559
// we need to check for a Graphite stack first to avoid a race
@@ -543,7 +565,7 @@
543565
const si = await commands.getStackInfo();
544566
stackInfo = si;
545567
if (si) {
546-
setMode('stack');
568+
await setMode('stack');
547569
} else {
548570
loadDiff();
549571
}
@@ -602,7 +624,7 @@
602624
}
603625
604626
// Select the file and scroll to the match
605-
await selectFile(filePath);
627+
if (!(await selectFile(filePath))) return;
606628
// Scroll to the specific line
607629
lineJumpToken += 1;
608630
jumpToLine = { lineIndex: match.lineIndex, token: lineJumpToken };
@@ -936,6 +958,7 @@
936958
</div>
937959
{:else}
938960
<DiffViewer
961+
bind:this={diffViewer}
939962
diff={currentDiff}
940963
comments={localComments.filter((c) => c.path === selectedFile)}
941964
{jumpToLine}

apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { onMount } from 'svelte';
2+
import { onMount, tick } from 'svelte';
33
import Send from '@lucide/svelte/icons/send';
44
import Spinner from '../../shared/Spinner.svelte';
55
import { Button } from '$lib/components/ui/button';
@@ -25,7 +25,8 @@
2525
githubRepo?: string;
2626
subpath?: string | null;
2727
isRemote: boolean;
28-
onStarted: () => void;
28+
onBeforeStart?: () => boolean | Promise<boolean>;
29+
onStarted: () => void | Promise<void>;
2930
}
3031
3132
let {
@@ -38,6 +39,7 @@
3839
githubRepo,
3940
subpath,
4041
isRemote,
42+
onBeforeStart = () => true,
4143
onStarted,
4244
}: Props = $props();
4345
@@ -141,16 +143,21 @@
141143
}
142144
143145
async function handleSubmit() {
144-
let finalPrompt = draftPrompt.trim();
145-
if (!finalPrompt || starting) return;
146-
147-
// Prepend a reference to the review when launched from a review context
148-
if (reviewId) {
149-
finalPrompt = `Re: #review:${reviewId}\n${finalPrompt}`;
150-
}
146+
if (starting || !draftPrompt.trim()) return;
151147
152148
starting = true;
153149
try {
150+
if (!(await onBeforeStart())) return;
151+
await tick();
152+
153+
let finalPrompt = draftPrompt.trim();
154+
if (!finalPrompt) return;
155+
156+
// Prepend a reference to the review when launched from a review context
157+
if (reviewId) {
158+
finalPrompt = `Re: #review:${reviewId}\n${finalPrompt}`;
159+
}
160+
154161
await refreshQueueState(true);
155162
156163
const launchContext = {
@@ -172,7 +179,7 @@
172179
launchContext
173180
);
174181
175-
onStarted();
182+
await onStarted();
176183
} catch (e) {
177184
toast.error('Unable to start commit session', {
178185
description: e instanceof Error ? e.message : String(e),

apps/staged/src/lib/features/diff/DiffFileTreeSection.svelte

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
isOpen: boolean;
2727
fileResults: Map<string, FileSearchResult>;
2828
collapsedSearchResults: Set<string>;
29+
currentResultIndex: number;
2930
};
3031
toggleSearchResults: (filePath: string) => void;
3132
areSearchResultsCollapsed: (filePath: string) => boolean;
@@ -42,7 +43,7 @@
4243
diffCache: Map<string, FileDiff>;
4344
};
4445
getCurrentDiff: () => FileDiff | null;
45-
selectFile: (path: string) => Promise<void>;
46+
selectFile: (path: string) => Promise<boolean | void>;
4647
}
4748
4849
interface Props {
@@ -56,7 +57,7 @@
5657
selectedFile: string | null;
5758
isCollapsed: (path: string) => boolean;
5859
onToggleDir: (path: string) => void;
59-
onSelectFile: (file: FileEntry) => void;
60+
onSelectFile: (file: FileEntry) => boolean | void | Promise<boolean | void>;
6061
onToggleReviewed: (event: MouseEvent | KeyboardEvent, file: FileEntry) => void | Promise<void>;
6162
onJumpToLine?: (lineIndex: number) => void;
6263
searchState?: SearchStateHandle;
@@ -129,16 +130,27 @@
129130
) {
130131
if (!searchState || !diffViewerState) return;
131132
133+
const previousResultIndex = searchState.state.currentResultIndex;
134+
const wasCollapsed = searchState.areSearchResultsCollapsed(filePath);
135+
132136
// Update current result index
133137
searchState.setCurrentResult(globalIndex);
134138
135139
// Auto-expand search results for this file
136-
if (searchState.areSearchResultsCollapsed(filePath)) {
140+
if (wasCollapsed) {
137141
searchState.toggleSearchResults(filePath);
138142
}
139143
140144
// Select the file and scroll to the match
141-
await diffViewerState.selectFile(filePath);
145+
const selected = (await diffViewerState.selectFile(filePath)) !== false;
146+
if (!selected) {
147+
searchState.setCurrentResult(previousResultIndex);
148+
if (wasCollapsed) {
149+
searchState.toggleSearchResults(filePath);
150+
}
151+
return;
152+
}
153+
142154
// Scroll to the specific line
143155
if (onJumpToLine) {
144156
onJumpToLine(match.lineIndex);

0 commit comments

Comments
 (0)