Skip to content

Commit 08c3d59

Browse files
authored
Merge pull request #110 from everpcpc/montreal-w9zjb2qk
Improve diff panel: button spacing, click feedback, and reviewed file marking
2 parents 05481e8 + fc59f98 commit 08c3d59

File tree

2 files changed

+188
-37
lines changed

2 files changed

+188
-37
lines changed

packages/ui/src/components/panels/diff/PierreDiffViewer.tsx

Lines changed: 182 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2-
import { Eye, EyeOff, Minus, Plus, RotateCcw, UnfoldVertical } from 'lucide-react';
2+
import { Check, ChevronRight, Eye, EyeOff, Minus, Plus, RotateCcw, UnfoldVertical } from 'lucide-react';
33
import { FileDiff, WorkerPoolContextProvider } from '@pierre/diffs/react';
44
import { parsePatchFiles, type DiffLineAnnotation, type FileDiffMetadata, type Hunk } from '@pierre/diffs';
55

@@ -267,6 +267,10 @@ const FileCard = memo(function FileCard({
267267
isPreviewing,
268268
onTogglePreview,
269269
themeType,
270+
isReviewed,
271+
onToggleReviewed,
272+
isCollapsed,
273+
onToggleCollapsed,
270274
}: {
271275
fileDiff: FileDiffMetadata;
272276
stagedEntries: HunkHeaderEntry[] | undefined;
@@ -281,6 +285,10 @@ const FileCard = memo(function FileCard({
281285
isPreviewing: boolean;
282286
onTogglePreview: () => void;
283287
themeType: 'light' | 'dark';
288+
isReviewed: boolean;
289+
onToggleReviewed: () => void;
290+
isCollapsed: boolean;
291+
onToggleCollapsed: () => void;
284292
}) {
285293
const headerRef = useRef<HTMLDivElement>(null);
286294
useEffect(() => {
@@ -298,6 +306,16 @@ const FileCard = memo(function FileCard({
298306
});
299307
}, []);
300308

309+
// Flash feedback: tracks which button key just completed an action
310+
const [flashKey, setFlashKey] = useState<string | null>(null);
311+
const flashTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
312+
const triggerFlash = useCallback((key: string) => {
313+
setFlashKey(key);
314+
clearTimeout(flashTimeout.current);
315+
flashTimeout.current = setTimeout(() => setFlashKey(null), 600);
316+
}, []);
317+
useEffect(() => () => clearTimeout(flashTimeout.current), []);
318+
301319
const hunksMeta = useMemo<HunkMeta[]>(() => {
302320
return fileDiff.hunks.map((hunk, idx) => {
303321
const sig = hunkSignature(hunk);
@@ -392,6 +410,7 @@ const FileCard = memo(function FileCard({
392410
try {
393411
setPending(key, true);
394412
await API.sessions.changeFileStage(sessionId, { filePath: fileDiff.name, stage });
413+
triggerFlash(stage ? 'file-stage' : 'file-unstage');
395414
onChanged?.();
396415
} catch (err) {
397416
console.error(`[Diffs] Failed to ${stage ? 'stage' : 'unstage'} file`, { filePath: fileDiff.name, err });
@@ -400,7 +419,7 @@ const FileCard = memo(function FileCard({
400419
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
401420
}
402421
},
403-
[fileDiff.name, onChanged, sessionId, setPending]
422+
[fileDiff.name, onChanged, sessionId, setPending, triggerFlash]
404423
);
405424

406425
const restoreFile = useCallback(async () => {
@@ -409,14 +428,15 @@ const FileCard = memo(function FileCard({
409428
try {
410429
setPending(key, true);
411430
await API.sessions.restoreFile(sessionId, { filePath: fileDiff.name });
431+
triggerFlash('file-restore');
412432
onChanged?.();
413433
} catch (err) {
414434
console.error('[Diffs] Failed to restore file', { filePath: fileDiff.name, err });
415435
} finally {
416436
setPending(key, false);
417437
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
418438
}
419-
}, [fileDiff.name, onChanged, sessionId, setPending]);
439+
}, [fileDiff.name, onChanged, sessionId, setPending, triggerFlash]);
420440

421441
const stageHunk = useCallback(
422442
async (hunk: HunkMeta, stage: boolean) => {
@@ -425,6 +445,7 @@ const FileCard = memo(function FileCard({
425445
try {
426446
setPending(key, true);
427447
await API.sessions.stageHunk(sessionId, { filePath: fileDiff.name, isStaging: stage, hunkHeader: hunk.stagedHeader || hunk.unstagedHeader || hunk.header });
448+
triggerFlash(`hunk-${stage ? 'stage' : 'unstage'}-${hunk.index}`);
428449
onChanged?.();
429450
} catch (err) {
430451
console.error(`[Diffs] Failed to ${stage ? 'stage' : 'unstage'} hunk`, { filePath: fileDiff.name, hunkHeader: hunk.header, err });
@@ -433,7 +454,7 @@ const FileCard = memo(function FileCard({
433454
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
434455
}
435456
},
436-
[fileDiff.name, onChanged, sessionId, setPending]
457+
[fileDiff.name, onChanged, sessionId, setPending, triggerFlash]
437458
);
438459

439460
const restoreHunk = useCallback(
@@ -444,6 +465,7 @@ const FileCard = memo(function FileCard({
444465
try {
445466
setPending(key, true);
446467
await API.sessions.restoreHunk(sessionId, { filePath: fileDiff.name, scope: hunk.status, hunkHeader: hunk.stagedHeader || hunk.unstagedHeader || hunk.header });
468+
triggerFlash(`hunk-restore-${hunk.index}`);
447469
onChanged?.();
448470
} catch (err) {
449471
console.error('[Diffs] Failed to restore hunk', { filePath: fileDiff.name, hunkHeader: hunk.header, err });
@@ -452,7 +474,7 @@ const FileCard = memo(function FileCard({
452474
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
453475
}
454476
},
455-
[fileDiff.name, onChanged, sessionId, setPending]
477+
[fileDiff.name, onChanged, sessionId, setPending, triggerFlash]
456478
);
457479

458480
const [hoveredHunk, setHoveredHunk] = useState<HunkMeta | null>(null);
@@ -641,6 +663,14 @@ const FileCard = memo(function FileCard({
641663
fileDiff.hunks.length > 0
642664
);
643665

666+
const flashStyle = useCallback(
667+
(key: string): React.CSSProperties =>
668+
flashKey === key
669+
? { animation: 'st-btn-flash 0.6s ease-out' }
670+
: {},
671+
[flashKey]
672+
);
673+
644674
return (
645675
<div className="st-diff-file" data-testid="diff-file" data-diff-file-path={fileDiff.name}>
646676
<div
@@ -649,17 +679,53 @@ const FileCard = memo(function FileCard({
649679
className="sticky top-0 z-20 px-3 py-2 text-xs font-semibold flex items-center justify-between gap-2"
650680
style={{ backgroundColor: 'var(--st-surface)', borderBottom: '1px solid var(--st-border-variant)' }}
651681
>
652-
<div className="min-w-0 flex-1">
653-
<div className="font-mono truncate" title={fileDiff.name}>
654-
{fileDiff.name}
655-
</div>
656-
{fileDiff.prevName && fileDiff.prevName !== fileDiff.name && (
657-
<div className="text-[11px] font-mono truncate mt-0.5" style={{ color: 'var(--st-text-faint)' }} title={fileDiff.prevName}>
658-
{fileDiff.prevName}
682+
<div className="flex items-center gap-1.5 min-w-0 flex-1">
683+
<button
684+
type="button"
685+
className="st-icon-button st-focus-ring !w-5 !h-5 flex-shrink-0"
686+
onClick={onToggleCollapsed}
687+
title={isCollapsed ? 'Expand file' : 'Collapse file'}
688+
>
689+
<ChevronRight
690+
className="w-3.5 h-3.5 transition-transform duration-150"
691+
style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
692+
/>
693+
</button>
694+
<div className="min-w-0 flex-1 cursor-pointer" onClick={onToggleCollapsed}>
695+
<div className="font-mono truncate" title={fileDiff.name}>
696+
{fileDiff.name}
697+
{isReviewed && (
698+
<span
699+
className="ml-2 inline-flex items-center gap-0.5 rounded border px-1 py-[1px] text-[10px] font-medium leading-none select-none align-middle"
700+
style={{
701+
backgroundColor: 'color-mix(in srgb, var(--st-success) 12%, transparent)',
702+
borderColor: 'color-mix(in srgb, var(--st-success) 30%, transparent)',
703+
color: 'var(--st-text-faint)',
704+
}}
705+
>
706+
Viewed
707+
</span>
708+
)}
659709
</div>
660-
)}
710+
{fileDiff.prevName && fileDiff.prevName !== fileDiff.name && (
711+
<div className="text-[11px] font-mono truncate mt-0.5" style={{ color: 'var(--st-text-faint)' }} title={fileDiff.prevName}>
712+
{fileDiff.prevName}
713+
</div>
714+
)}
715+
</div>
661716
</div>
662717
<div className="flex items-center gap-1.5 flex-shrink-0">
718+
{!isCommitView && (
719+
<button
720+
type="button"
721+
className="st-icon-button st-focus-ring !w-5 !h-5"
722+
onClick={onToggleReviewed}
723+
title={isReviewed ? 'Mark as not reviewed' : 'Mark as reviewed'}
724+
style={isReviewed ? { color: 'var(--st-success)' } : { color: 'var(--st-text-faint)' }}
725+
>
726+
<Check className="w-3.5 h-3.5" />
727+
</button>
728+
)}
663729
{canLoadContext && !contextLines && (
664730
<button
665731
type="button"
@@ -690,7 +756,7 @@ const FileCard = memo(function FileCard({
690756
<button
691757
type="button"
692758
className="px-2 py-1 rounded text-[11px] font-medium st-focus-ring disabled:opacity-40"
693-
style={{ backgroundColor: 'color-mix(in srgb, var(--st-success) 14%, transparent)', color: 'var(--st-text)' }}
759+
style={{ backgroundColor: 'color-mix(in srgb, var(--st-success) 14%, transparent)', color: 'var(--st-text)', ...flashStyle('file-stage') }}
694760
disabled={!sessionId || pendingKeys.has(`file:${fileDiff.name}`)}
695761
onClick={() => stageFile(true)}
696762
title="Stage file"
@@ -702,18 +768,19 @@ const FileCard = memo(function FileCard({
702768
<button
703769
type="button"
704770
className="px-2 py-1 rounded text-[11px] font-medium st-focus-ring disabled:opacity-40"
705-
style={{ backgroundColor: 'color-mix(in srgb, var(--st-warning) 14%, transparent)', color: 'var(--st-text)' }}
771+
style={{ backgroundColor: 'color-mix(in srgb, var(--st-warning) 14%, transparent)', color: 'var(--st-text)', ...flashStyle('file-unstage') }}
706772
disabled={!sessionId || pendingKeys.has(`file:${fileDiff.name}`)}
707773
onClick={() => stageFile(false)}
708774
title="Unstage file"
709775
>
710776
Unstage
711777
</button>
712778
)}
779+
<span className="w-px h-4 mx-0.5" style={{ backgroundColor: 'var(--st-border-variant)' }} />
713780
<button
714781
type="button"
715782
className="px-2 py-1 rounded text-[11px] font-medium st-focus-ring disabled:opacity-40"
716-
style={{ backgroundColor: 'color-mix(in srgb, var(--st-danger) 10%, transparent)', color: 'var(--st-text)' }}
783+
style={{ backgroundColor: 'color-mix(in srgb, var(--st-danger) 10%, transparent)', color: 'var(--st-text)', ...flashStyle('file-restore') }}
717784
disabled={!sessionId || pendingKeys.has(`file:${fileDiff.name}:restore`)}
718785
onClick={() => restoreFile()}
719786
title="Restore file"
@@ -725,28 +792,30 @@ const FileCard = memo(function FileCard({
725792
</div>
726793
</div>
727794

728-
<div style={containerStyle}>
729-
{isPreviewing && previewContent ? (
730-
isImageFile(fileDiff.name) ? (
731-
<ImagePreview content={previewContent} filePath={fileDiff.name} />
795+
{!isCollapsed && (
796+
<div style={containerStyle}>
797+
{isPreviewing && previewContent ? (
798+
isImageFile(fileDiff.name) ? (
799+
<ImagePreview content={previewContent} filePath={fileDiff.name} />
800+
) : (
801+
<MarkdownPreview content={previewContent} />
802+
)
803+
) : fileDiff.hunks.length === 0 ? (
804+
<div className="px-3 py-6 text-xs" style={{ color: 'var(--st-text-faint)' }}>
805+
{isImageFile(fileDiff.name) ? 'Binary file (image)' : isBinaryFile(fileDiff.name) ? 'Binary file' : 'Diff unavailable.'}
806+
</div>
732807
) : (
733-
<MarkdownPreview content={previewContent} />
734-
)
735-
) : fileDiff.hunks.length === 0 ? (
736-
<div className="px-3 py-6 text-xs" style={{ color: 'var(--st-text-faint)' }}>
737-
{isImageFile(fileDiff.name) ? 'Binary file (image)' : isBinaryFile(fileDiff.name) ? 'Binary file' : 'Diff unavailable.'}
738-
</div>
739-
) : (
740-
<FileDiff<HunkStatusAnnotation>
741-
fileDiff={fileDiffForRender}
742-
options={diffOptions as any}
743-
lineAnnotations={hunkStatusAnnotations}
744-
renderAnnotation={renderHunkStatusAnnotation}
745-
renderHoverUtility={() => hoverUtility}
746-
style={{ width: '100%', maxWidth: '100%', minWidth: 0 }}
747-
/>
748-
)}
749-
</div>
808+
<FileDiff<HunkStatusAnnotation>
809+
fileDiff={fileDiffForRender}
810+
options={diffOptions as any}
811+
lineAnnotations={hunkStatusAnnotations}
812+
renderAnnotation={renderHunkStatusAnnotation}
813+
renderHoverUtility={() => hoverUtility}
814+
style={{ width: '100%', maxWidth: '100%', minWidth: 0 }}
815+
/>
816+
)}
817+
</div>
818+
)}
750819
</div>
751820
);
752821
});
@@ -775,6 +844,78 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = memo(function P
775844
const stagedEntriesByFile = useMemo(() => buildHunkHeaderEntries(stagedDiff), [stagedDiff]);
776845
const unstagedEntriesByFile = useMemo(() => buildHunkHeaderEntries(unstagedDiff), [unstagedDiff]);
777846

847+
// --- Reviewed / collapsed file tracking ---
848+
// reviewedFiles: Map<filePath, diffSignature> — stores the diff signature at the time of marking reviewed
849+
const [reviewedFiles, setReviewedFiles] = useState<Map<string, string>>(() => new Map());
850+
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(() => new Set());
851+
852+
// Compute a simple signature per file for change detection
853+
const fileDiffSignatures = useMemo(() => {
854+
const sigs = new Map<string, string>();
855+
for (const file of parsedFiles) {
856+
const parts = file.hunks.map((h) => hunkSignature(h));
857+
sigs.set(file.name, parts.join('\n---\n'));
858+
}
859+
return sigs;
860+
}, [parsedFiles]);
861+
862+
// When diff changes, check if any reviewed file's signature changed — if so, un-collapse and un-review it
863+
useEffect(() => {
864+
setReviewedFiles((prev) => {
865+
let changed = false;
866+
const next = new Map(prev);
867+
for (const [path, oldSig] of prev) {
868+
const currentSig = fileDiffSignatures.get(path);
869+
if (currentSig === undefined || currentSig !== oldSig) {
870+
next.delete(path);
871+
changed = true;
872+
}
873+
}
874+
if (!changed) return prev;
875+
// Also un-collapse those files
876+
setCollapsedFiles((prevCollapsed) => {
877+
const nextCollapsed = new Set(prevCollapsed);
878+
for (const [path] of prev) {
879+
const currentSig = fileDiffSignatures.get(path);
880+
if (currentSig === undefined || currentSig !== prev.get(path)) {
881+
nextCollapsed.delete(path);
882+
}
883+
}
884+
return nextCollapsed;
885+
});
886+
return next;
887+
});
888+
}, [fileDiffSignatures]);
889+
890+
const toggleReviewed = useCallback((filePath: string) => {
891+
setReviewedFiles((prev) => {
892+
const next = new Map(prev);
893+
if (next.has(filePath)) {
894+
next.delete(filePath);
895+
// Un-collapse when un-reviewing
896+
setCollapsedFiles((c) => {
897+
const nc = new Set(c);
898+
nc.delete(filePath);
899+
return nc;
900+
});
901+
} else {
902+
// Mark reviewed with current signature, and collapse
903+
next.set(filePath, fileDiffSignatures.get(filePath) ?? '');
904+
setCollapsedFiles((c) => new Set(c).add(filePath));
905+
}
906+
return next;
907+
});
908+
}, [fileDiffSignatures]);
909+
910+
const toggleCollapsed = useCallback((filePath: string) => {
911+
setCollapsedFiles((prev) => {
912+
const next = new Set(prev);
913+
if (next.has(filePath)) next.delete(filePath);
914+
else next.add(filePath);
915+
return next;
916+
});
917+
}, []);
918+
778919
const autoPreviewPaths = useMemo(() => {
779920
return parsedFiles.map((f) => f.name).filter((p) => isImageFile(p));
780921
}, [parsedFiles]);
@@ -811,6 +952,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = memo(function P
811952
isPreviewing={previewFiles.has(file.name)}
812953
onTogglePreview={() => togglePreview(file.name)}
813954
themeType={themeType}
955+
isReviewed={reviewedFiles.has(file.name)}
956+
onToggleReviewed={() => toggleReviewed(file.name)}
957+
isCollapsed={collapsedFiles.has(file.name)}
958+
onToggleCollapsed={() => toggleCollapsed(file.name)}
814959
/>
815960
))
816961
)}

packages/ui/src/index.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@
7878
transform: scale(0.96);
7979
}
8080

81+
@keyframes st-btn-flash {
82+
0% { filter: brightness(1); box-shadow: none; }
83+
20% { filter: brightness(1.4); box-shadow: 0 0 6px 2px color-mix(in srgb, var(--st-accent) 40%, transparent); }
84+
100% { filter: brightness(1); box-shadow: none; }
85+
}
86+
8187
/* Sidebar tree (repo + workspaces) */
8288
.st-tree-card {
8389
border-radius: var(--st-radius);

0 commit comments

Comments
 (0)