Skip to content

Commit af27e1c

Browse files
committed
feat(changes): clickable diff panel with syntax highlighting
The Changes tab listed touched files but a click only opened the file in the OS handler — useful, but no way to audit *what* changed without leaving the app. This adds a diff view below the file list: - Click row → unified diff (baseline vs. current) renders below - Right-click → context menu (open / copy paths / show in folder), reusing Explorer's ContextMenu in compact mode (read- only, no rename / delete / cut / copy / paste) - Mouse-down → drag the file path to the terminal / Gambit, same lib as Explorer's tree rows - X button → dismiss the diff panel back to list-only Backend: FileSnapshot gains an optional content blob, captured only on the baseline pass and only for text files under 1 MB (separate cap from the 5 MB stats limit so memory stays bounded). New get_baseline_content and read_text_file Tauri commands feed the panel; both lossy-UTF8 so GBK / latin-1 source files still render. Frontend: jsdiff for line-level diffing, Shiki (lazy-loaded WASM + on-demand grammars) for syntax highlighting. Theme tracks the data-theme attribute via a tiny MutationObserver hook so Coffee CLI's 9 color themes / 5 shape themes all retint cleanly without a remount. Single-render flow (await tokens before setState) avoids the plain-text → highlighted color flip on every file open. Glass shape required adding .diff-line to the panel-* :is(div, ...) :not(...) exception list in global.css so its row backgrounds aren't forced transparent (same pattern as .ctx-menu, .task-card). i18n: 3 new keys (diff.loading / diff.error / diff.no_changes) across all 11 locales. Remote SSH and other CWD-agnostic tools stay safe — file-stats already returns empty for them, so ChangesBoard's empty-state path triggers before the diff panel can mount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ca0dfb commit af27e1c

23 files changed

Lines changed: 1214 additions & 33 deletions

src-ui/package-lock.json

Lines changed: 570 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-ui/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
"@xterm/addon-web-links": "^0.12.0",
1919
"@xterm/addon-webgl": "^0.19.0",
2020
"@xterm/xterm": "^6.0.0",
21+
"diff": "^9.0.0",
2122
"js-dos": "^8.3.20",
2223
"react": "^19.2.4",
23-
"react-dom": "^19.2.4"
24+
"react-dom": "^19.2.4",
25+
"shiki": "^4.0.2"
2426
},
2527
"devDependencies": {
2628
"@eslint/js": "^9.39.4",

src-ui/src/components/left/Explorer.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@ const normPath = (p: string) => p.replace(/\\/g, '/');
2020

2121
// ─── Context Menu ────────────────────────────────────────────────────────────
2222

23-
interface CtxMenuState {
23+
export interface CtxMenuState {
2424
x: number;
2525
y: number;
2626
absolutePath: string;
2727
relativePath: string;
2828
isDir?: boolean;
2929
onRename?: () => void;
30+
// ChangesBoard reuses this menu read-only — the audit view shouldn't
31+
// mutate the agent's just-edited files. Hides cut/copy/paste/rename/delete
32+
// + the relative-path entry.
33+
compact?: boolean;
3034
}
3135

3236
// Module-level clipboard: survives menu close/open cycles
@@ -44,7 +48,7 @@ function dispatchFsRefresh(dirPath: string) {
4448
window.dispatchEvent(new CustomEvent('fs-refresh', { detail: { dirPath } }));
4549
}
4650

47-
function ContextMenu({ menu, onClose }: { menu: CtxMenuState; onClose: () => void }) {
51+
export function ContextMenu({ menu, onClose }: { menu: CtxMenuState; onClose: () => void }) {
4852
const t = useT();
4953
const menuRef = useRef<HTMLDivElement>(null);
5054

@@ -191,7 +195,8 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenuState; onClose: () => voi
191195
{t('menu.copy_ref' as any)}
192196
</button>
193197

194-
{/* File operation group */}
198+
{/* File operation group — hidden in compact mode (read-only audit view) */}
199+
{!menu.compact && <>
195200
<div className="ctx-menu-divider" />
196201
<button className="ctx-menu-item" onClick={handleCut}>
197202
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -232,6 +237,7 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenuState; onClose: () => voi
232237
</svg>
233238
{t('menu.delete' as any)}
234239
</button>
240+
</>}
235241
<div className="ctx-menu-divider" />
236242
<button className="ctx-menu-item" onClick={handleShowInFolder}>
237243
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

src-ui/src/components/right/ChangesBoard.css

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,43 @@
1+
.changes-split {
2+
display: flex;
3+
flex-direction: column;
4+
height: 100%;
5+
min-height: 0;
6+
}
7+
8+
.changes-split-top {
9+
flex: 0 0 40%;
10+
min-height: 80px;
11+
display: flex;
12+
flex-direction: column;
13+
min-height: 0;
14+
}
15+
16+
.changes-split-bottom {
17+
flex: 1;
18+
min-height: 0;
19+
display: flex;
20+
flex-direction: column;
21+
/* Escape .task-board's 16px outer padding so the diff body (and its
22+
* row backgrounds) reaches panel-edge to panel-edge, no left/right
23+
* gaps. Combined with the panel-theme border-top below, this also
24+
* makes the divider above the filename a clean full-width hairline. */
25+
margin-left: -16px;
26+
margin-right: -16px;
27+
}
28+
29+
/* Panel shape: single full-width separator between the file list and the
30+
* diff body. Other shapes (soft / slab / sharp / glass) leave it flush so
31+
* the audit view feels continuous, not cardified. */
32+
[data-shape="panel"] .changes-split-bottom {
33+
border-top: 1px solid var(--border, rgba(127, 127, 127, 0.18));
34+
}
35+
136
.changes-list {
237
display: flex;
338
flex-direction: column;
439
gap: 1px;
5-
padding: 6px 0 80px 0;
40+
padding: 6px 0 12px 0;
641
}
742

843
.changes-row {
@@ -18,10 +53,20 @@
1853
color: var(--text-1);
1954
font-size: 12px;
2055
line-height: 1.4;
56+
/* Match Explorer's tree row chrome (Explorer.css L182-198): rounded
57+
* corners + theme-driven hover/active backgrounds. The previous
58+
* --surface-hover / --surface-active variables don't exist in
59+
* global.css so they fell through to hardcoded grey, breaking
60+
* theme parity. */
61+
border-radius: var(--radius-sm);
2162
}
2263

2364
.changes-row:hover {
24-
background: var(--surface-hover, rgba(127, 127, 127, 0.08));
65+
background: var(--bg-hover);
66+
}
67+
68+
.changes-row.selected {
69+
background: var(--bg-active);
2570
}
2671

2772
.changes-name {

src-ui/src/components/right/ChangesBoard.tsx

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
// ChangesBoard.tsx — flat list of files modified since the active tab's
22
// session began. Reads the same FileStats map Explorer's tree badges read,
33
// so the user can see "what's been touched" without expanding directories.
4+
//
5+
// Layout (Step 3+): top = list of changed files (40% by default), bottom =
6+
// DiffPanel for the row the user clicked (60%). Click = view diff.
7+
// Right-click = file actions menu (read-only: open / copy paths / show in folder).
8+
// Mirrors VS Code / GitHub Desktop / JetBrains: both interactions coexist.
49

5-
import { useMemo } from 'react';
10+
import { useMemo, useState } from 'react';
611
import { useAppState } from '../../store/app-state';
712
import { useT } from '../../i18n/useT';
813
import { useFileStats } from '../../lib/file-stats';
9-
import { commands } from '../../tauri';
1014
import { ScrollPanel } from '../common/ScrollPanel';
15+
import { ContextMenu } from '../left/Explorer';
16+
import type { CtxMenuState } from '../left/Explorer';
17+
import { beginExplorerDrag } from '../../lib/explorer-drag';
18+
import { DiffPanel } from './DiffPanel';
1119
import './ChangesBoard.css';
1220

1321
export function ChangesBoard() {
@@ -16,6 +24,9 @@ export function ChangesBoard() {
1624
const fileStats = useFileStats();
1725
const activeSession = state.terminals.find(s => s.id === state.activeTerminalId);
1826
const folderPath = activeSession?.folderPath || null;
27+
const sessionId = activeSession?.id || null;
28+
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
29+
const [selectedPath, setSelectedPath] = useState<string | null>(null);
1930

2031
const rows = useMemo(() => {
2132
if (!fileStats || fileStats.size === 0 || !folderPath) return [];
@@ -32,9 +43,10 @@ export function ChangesBoard() {
3243
return list;
3344
}, [fileStats, folderPath]);
3445

35-
const handleOpen = (path: string) => {
36-
commands.openUrl(path).catch(e => console.error('[ChangesBoard] open failed:', e));
37-
};
46+
// If the selected file disappears from the list (reverted, deleted, or tab
47+
// switch), drop the diff panel rather than showing stale content.
48+
const selectedStillExists = selectedPath && rows.some(r => r.path === selectedPath);
49+
const effectiveSelected = selectedStillExists ? selectedPath : null;
3850

3951
if (!folderPath) {
4052
return (
@@ -57,24 +69,45 @@ export function ChangesBoard() {
5769
}
5870

5971
return (
60-
<ScrollPanel>
61-
<div className="changes-list">
62-
{rows.map(row => (
63-
<button
64-
key={row.path}
65-
type="button"
66-
className="changes-row"
67-
onClick={() => handleOpen(row.path)}
68-
>
69-
<span className="changes-name">{row.basename}</span>
70-
<span className="changes-path">{row.rel === row.basename ? '' : row.rel.slice(0, -row.basename.length - 1)}</span>
71-
<span className="changes-stats">
72-
<span className="diff-add">+{row.added}</span>
73-
<span className="diff-del">-{row.deleted}</span>
74-
</span>
75-
</button>
76-
))}
72+
<div className="changes-split">
73+
<div className="changes-split-top">
74+
<ScrollPanel>
75+
<div className="changes-list">
76+
{rows.map(row => (
77+
<div
78+
key={row.path}
79+
className={`changes-row ${effectiveSelected === row.path ? 'selected' : ''}`}
80+
onClick={() => setSelectedPath(row.path)}
81+
onMouseDown={(e) => beginExplorerDrag(row.path, e)}
82+
onContextMenu={(e) => {
83+
e.preventDefault();
84+
setCtxMenu({
85+
x: e.clientX,
86+
y: e.clientY,
87+
absolutePath: row.path,
88+
relativePath: row.rel,
89+
isDir: false,
90+
compact: true,
91+
});
92+
}}
93+
>
94+
<span className="changes-name">{row.basename}</span>
95+
<span className="changes-path">{row.rel === row.basename ? '' : row.rel.slice(0, -row.basename.length - 1)}</span>
96+
<span className="changes-stats">
97+
<span className="diff-add">+{row.added}</span>
98+
<span className="diff-del">-{row.deleted}</span>
99+
</span>
100+
</div>
101+
))}
102+
</div>
103+
</ScrollPanel>
77104
</div>
78-
</ScrollPanel>
105+
{effectiveSelected && sessionId && (
106+
<div className="changes-split-bottom">
107+
<DiffPanel sessionId={sessionId} path={effectiveSelected} onClose={() => setSelectedPath(null)} />
108+
</div>
109+
)}
110+
{ctxMenu && <ContextMenu menu={ctxMenu} onClose={() => setCtxMenu(null)} />}
111+
</div>
79112
);
80113
}

0 commit comments

Comments
 (0)