Skip to content

Commit 0db0a26

Browse files
committed
Implemented toolbar when hovering over gutter
1 parent b1be8c5 commit 0db0a26

6 files changed

Lines changed: 524 additions & 64 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ Append new entry points under the matching sub-heading. Keep entries to one line
249249
- **Editor view**`packages/ui/src/features/editor/`. A real, editable **CodeMirror 6** editor on nav screen `editor`. `PidEditorView` composes the tab strip (`PidEditorTabBar`), styled breadcrumb (`PidEditorBreadcrumb`), and the `CodeMirrorEditor` host. Files open from a single-click in the file tree (`PidFileTree`'s `onSelectionChange``useEditorStore.openFile` + `setScreen("editor")`).
250250
- **State**`useEditorStore.ts`: open tabs + active id + per-tab content/baseline/eol/dirty/cursor/readOnly/indent. Loads via `fs.readFile` + `git.fileBaseline`; saves via `fs.writeFile` (Ctrl/Cmd+S). The store holds tab metadata only — CodeMirror `EditorState`s are cached per tab inside `CodeMirrorEditor` so switching tabs preserves undo history, selection, and scroll.
251251
- **Theme + highlighting**`editorTheme.ts` maps CodeMirror chrome + a `HighlightStyle` (lezer tags) onto theme tokens, so syntax colours track the active pi-deck theme (the `dark` flag is reconfigured via a compartment on light/dark flips). Languages by extension in `languages.ts` (also the tab type-badge).
252-
- **Diff gutter**`diffExtension.ts` paints live add/mod/del line tints against the git HEAD baseline using `@codemirror/merge`'s `Chunk` diff primitive (no inline-original merge UI). Recomputes as you type; `null` baseline (untracked / no repo) shows no tints.
252+
- **Diff gutter**`diffExtension.ts` paints live add/mod/del line tints against the git HEAD baseline using `@codemirror/merge`'s `Chunk` diff primitive (no inline-original merge UI). Recomputes as you type; `null` baseline (untracked / no repo) shows no tints. Hovering a block's gutter thickens its bar; *clicking* the gutter opens a pinned floating toolbar (`PidDiffBlockToolbar`) — prev/next change, revert-block (undoable buffer edit via `revertDiffChunk`), open-in-Diff — dismissed on outside-click / Escape / scroll / edit / tab switch. Inline per-block commit is deferred — it needs hunk-level git staging.
253253
- **Status bar**`PidEditorStatus.tsx`, rendered in `PidFooter` only on the `editor` screen: cursor Ln/Col + selection, indentation, UTF-8, LF/CRLF, language.
254254

255255
### UI primitives

packages/ui/src/components/icons/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
export {
22
Archive,
33
ArchiveRestore,
4+
ArrowDown,
45
ArrowDownToLine,
56
ArrowLeft,
7+
ArrowLeftRight,
68
ArrowRight,
9+
ArrowUp,
710
ArrowUpFromLine,
811
Bot,
912
Box,
@@ -13,6 +16,7 @@ export {
1316
ChevronRight,
1417
ChevronsDownUp,
1518
ChevronsUpDown,
19+
ChevronUp,
1620
Copy,
1721
CornerDownLeft,
1822
Edit3,

packages/ui/src/features/editor/CodeMirrorEditor.tsx

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,24 @@ import {
2323
keymap,
2424
lineNumbers,
2525
} from "@codemirror/view";
26-
import { useCallback, useEffect, useRef } from "react";
26+
import { useCallback, useEffect, useRef, useState } from "react";
27+
import { useNavStore } from "../../lib/useNavStore.js";
2728
import { useThemeStore } from "../../theme/useThemeStore.js";
2829
import { useProjectsStore } from "../sessions/useProjectsStore.js";
29-
import { baselineText, diffGutter, setDiffBaseline } from "./diffExtension.js";
30+
import {
31+
baselineText,
32+
type DiffHoverInfo,
33+
diffChunkInfo,
34+
diffGutter,
35+
diffHoverAt,
36+
gotoDiffChunk,
37+
revertDiffChunk,
38+
setActiveDiffChunk,
39+
setDiffBaseline,
40+
} from "./diffExtension.js";
3041
import { cmHighlight, cmTheme } from "./editorTheme.js";
3142
import { languageForFile } from "./languages.js";
43+
import { PidDiffBlockToolbar } from "./PidDiffBlockToolbar.js";
3244
import {
3345
type EditorTab,
3446
selectActiveTabId,
@@ -76,11 +88,74 @@ export function CodeMirrorEditor() {
7688
const dark = useThemeIsDark();
7789

7890
const containerRef = useRef<HTMLDivElement>(null);
91+
const wrapRef = useRef<HTMLDivElement>(null);
7992
const viewRef = useRef<EditorView | null>(null);
8093
const cacheRef = useRef<Map<string, TabCacheEntry>>(new Map());
8194
const prevIdRef = useRef<string | null>(null);
8295
const darkRef = useRef(dark);
8396

97+
// Diff-block toolbar: opens on a gutter *click* (pinned), closes on outside-click / Escape /
98+
// scroll / edit / tab switch. Hovering the gutter only thickens the block's bar.
99+
const [openChunk, setOpenChunk] = useState<DiffHoverInfo | null>(null);
100+
const openIdxRef = useRef<number | null>(null);
101+
const activeIdxRef = useRef<number | null>(null);
102+
const toolbarRef = useRef<HTMLDivElement>(null);
103+
104+
const setActive = useCallback((view: EditorView, idx: number | null) => {
105+
if (activeIdxRef.current === idx) return;
106+
activeIdxRef.current = idx;
107+
setActiveDiffChunk(view, idx);
108+
}, []);
109+
110+
const closeToolbar = useCallback(() => {
111+
openIdxRef.current = null;
112+
const v = viewRef.current;
113+
if (v) setActive(v, null);
114+
setOpenChunk(null);
115+
}, [setActive]);
116+
117+
// Stable ref so the per-tab CM updateListener + scroll listener can close without re-subscribing.
118+
const closeToolbarRef = useRef(closeToolbar);
119+
closeToolbarRef.current = closeToolbar;
120+
121+
// Hover → thicken the hovered block's bar (suppressed while the toolbar is pinned open).
122+
const handleWrapMouseMove = useCallback(
123+
(e: React.MouseEvent) => {
124+
if (openIdxRef.current !== null) return;
125+
const view = viewRef.current;
126+
if (!view) return;
127+
const info = diffHoverAt(view, e.clientX, e.clientY);
128+
setActive(view, info?.overGutter ? info.index : null);
129+
},
130+
[setActive],
131+
);
132+
133+
const handleWrapMouseLeave = useCallback(() => {
134+
if (openIdxRef.current !== null) return;
135+
const view = viewRef.current;
136+
if (view) setActive(view, null);
137+
}, [setActive]);
138+
139+
// Click the gutter → open/switch the toolbar; click elsewhere in the editor → dismiss.
140+
const handleWrapClick = useCallback(
141+
(e: React.MouseEvent) => {
142+
if (toolbarRef.current && e.target instanceof Node && toolbarRef.current.contains(e.target)) {
143+
return; // a toolbar button owns this click
144+
}
145+
const view = viewRef.current;
146+
if (!view) return;
147+
const info = diffHoverAt(view, e.clientX, e.clientY);
148+
if (info?.overGutter) {
149+
openIdxRef.current = info.index;
150+
setActive(view, info.index);
151+
setOpenChunk(info);
152+
} else if (openIdxRef.current !== null) {
153+
closeToolbar();
154+
}
155+
},
156+
[setActive, closeToolbar],
157+
);
158+
84159
// Persist `content` to disk, then mark the in-editor baseline clean. Capturing the doc we wrote
85160
// (not the post-await doc) means edits made during the save round-trip correctly stay dirty.
86161
const handleSave = useCallback((view: EditorView, id: string) => {
@@ -113,6 +188,8 @@ export function CodeMirrorEditor() {
113188
const listener = EditorView.updateListener.of((update) => {
114189
const id = tab.id;
115190
if (update.docChanged) {
191+
// Editing shifts/destroys chunks — drop the toolbar.
192+
closeToolbarRef.current();
116193
const entry = cacheRef.current.get(id);
117194
const isDirty = entry ? !update.state.doc.eq(entry.savedText) : true;
118195
useEditorStore.getState().setDirty(id, isDirty);
@@ -181,7 +258,11 @@ export function CodeMirrorEditor() {
181258
if (!parent) return;
182259
const view = new EditorView({ parent, state: EditorState.create({ doc: "" }) });
183260
viewRef.current = view;
261+
// Scrolling invalidates the toolbar's anchor — drop it.
262+
const onScroll = () => closeToolbarRef.current();
263+
view.scrollDOM.addEventListener("scroll", onScroll, { passive: true });
184264
return () => {
265+
view.scrollDOM.removeEventListener("scroll", onScroll);
185266
view.destroy();
186267
viewRef.current = null;
187268
};
@@ -199,6 +280,7 @@ export function CodeMirrorEditor() {
199280
e.state = view.state;
200281
e.scrollTop = view.scrollDOM.scrollTop;
201282
}
283+
closeToolbarRef.current(); // a pinned toolbar belongs to the tab we're leaving
202284
}
203285
prevIdRef.current = activeTabId;
204286
if (!activeTabId) return;
@@ -227,6 +309,26 @@ export function CodeMirrorEditor() {
227309
viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(cmTheme(dark)) });
228310
}, [dark]);
229311

312+
// While the toolbar is pinned open, dismiss it on Escape or a click outside the editor.
313+
useEffect(() => {
314+
if (!openChunk) return;
315+
const onKey = (e: KeyboardEvent) => {
316+
if (e.key === "Escape") closeToolbarRef.current();
317+
};
318+
const onDocDown = (e: MouseEvent) => {
319+
const t = e.target;
320+
if (t instanceof Node && wrapRef.current && !wrapRef.current.contains(t)) {
321+
closeToolbarRef.current();
322+
}
323+
};
324+
document.addEventListener("keydown", onKey);
325+
document.addEventListener("mousedown", onDocDown, true);
326+
return () => {
327+
document.removeEventListener("keydown", onKey);
328+
document.removeEventListener("mousedown", onDocDown, true);
329+
};
330+
}, [openChunk]);
331+
230332
// Drop cached states for tabs that were closed.
231333
// biome-ignore lint/correctness/useExhaustiveDependencies: `order` is the prune trigger; membership is read from the live store.
232334
useEffect(() => {
@@ -237,11 +339,51 @@ export function CodeMirrorEditor() {
237339
}, [order]);
238340

239341
const overlay = renderOverlay(activeTabId, status, blocked, errorMessage);
342+
const view = viewRef.current;
343+
344+
// Prev/next: navigate to the neighbour block and re-anchor the pinned toolbar to it.
345+
const reanchor = (view: EditorView, index: number) => {
346+
openIdxRef.current = index;
347+
activeIdxRef.current = index;
348+
requestAnimationFrame(() => {
349+
const info = diffChunkInfo(view, index);
350+
if (info) setOpenChunk(info);
351+
else closeToolbar();
352+
});
353+
};
240354

241355
return (
242-
<div className="pid-editor-cm-wrap">
356+
// biome-ignore lint/a11y/noStaticElementInteractions: pointer-only affordance (gutter click opens the toolbar, hover thickens the bar); the same actions live in the dedicated Diff view.
357+
// biome-ignore lint/a11y/useKeyWithClickEvents: the click is event-delegation for the non-focusable gutter; the toolbar's actions are also reachable in the keyboard-accessible Diff view.
358+
<div
359+
className="pid-editor-cm-wrap"
360+
ref={wrapRef}
361+
onClick={handleWrapClick}
362+
onMouseMove={handleWrapMouseMove}
363+
onMouseLeave={handleWrapMouseLeave}
364+
>
243365
<div className="pid-editor-cm" ref={containerRef} />
244366
{overlay}
367+
{openChunk && view ? (
368+
<PidDiffBlockToolbar
369+
info={openChunk}
370+
rootRef={toolbarRef}
371+
wrapRef={wrapRef}
372+
onPrev={() => reanchor(view, gotoDiffChunk(view, openChunk.index, -1))}
373+
onNext={() => reanchor(view, gotoDiffChunk(view, openChunk.index, 1))}
374+
onRevert={() => {
375+
revertDiffChunk(view, openChunk.index);
376+
closeToolbar();
377+
}}
378+
onOpenDiff={() => {
379+
const tab = activeTabId ? useEditorStore.getState().tabs[activeTabId] : undefined;
380+
if (tab) {
381+
useNavStore.getState().openDiff({ projectId: tab.projectId, path: tab.relPath });
382+
}
383+
closeToolbar();
384+
}}
385+
/>
386+
) : null}
245387
</div>
246388
);
247389
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { RefObject } from "react";
2+
import { ArrowDown, ArrowLeftRight, ArrowUp, Undo2 } from "../../components/icons/index.js";
3+
import { Tooltip } from "../../components/ui/Tooltip.js";
4+
import type { DiffHoverInfo } from "./diffExtension.js";
5+
6+
interface PidDiffBlockToolbarProps {
7+
info: DiffHoverInfo;
8+
/** Ref to the toolbar root — the editor uses it to tell toolbar clicks from dismiss clicks. */
9+
rootRef: RefObject<HTMLDivElement | null>;
10+
/** The editor wrap the toolbar is positioned within (to convert client to local coords). */
11+
wrapRef: RefObject<HTMLDivElement | null>;
12+
onPrev: () => void;
13+
onNext: () => void;
14+
onRevert: () => void;
15+
onOpenDiff: () => void;
16+
}
17+
18+
/**
19+
* Floating actions for the selected diff block — prev/next change, revert (undoable), open Diff.
20+
* Opened by clicking the block's gutter; pinned at the clicked line until dismissed.
21+
*/
22+
export function PidDiffBlockToolbar({
23+
info,
24+
rootRef,
25+
wrapRef,
26+
onPrev,
27+
onNext,
28+
onRevert,
29+
onOpenDiff,
30+
}: PidDiffBlockToolbarProps) {
31+
const wrap = wrapRef.current?.getBoundingClientRect();
32+
const top = info.clientTop - (wrap?.top ?? 0);
33+
const left = info.clientLeft - (wrap?.left ?? 0);
34+
35+
return (
36+
<div
37+
ref={rootRef}
38+
className={`pid-diff-block-toolbar pid-diff-block-toolbar-${info.kind}`}
39+
style={{ top, left }}
40+
role="toolbar"
41+
aria-label="Diff block actions"
42+
>
43+
<Tooltip content="Previous change">
44+
<button
45+
type="button"
46+
className="pid-diff-block-toolbar-btn"
47+
onClick={onPrev}
48+
aria-label="Previous change"
49+
>
50+
<ArrowUp size={13} aria-hidden="true" />
51+
</button>
52+
</Tooltip>
53+
<Tooltip content="Next change">
54+
<button
55+
type="button"
56+
className="pid-diff-block-toolbar-btn"
57+
onClick={onNext}
58+
aria-label="Next change"
59+
>
60+
<ArrowDown size={13} aria-hidden="true" />
61+
</button>
62+
</Tooltip>
63+
<Tooltip content="Revert this block">
64+
<button
65+
type="button"
66+
className="pid-diff-block-toolbar-btn"
67+
onClick={onRevert}
68+
aria-label="Revert this block"
69+
>
70+
<Undo2 size={13} aria-hidden="true" />
71+
</button>
72+
</Tooltip>
73+
<Tooltip content="Show Diff for lines">
74+
<button
75+
type="button"
76+
className="pid-diff-block-toolbar-btn"
77+
onClick={onOpenDiff}
78+
aria-label="Show Diff for lines"
79+
>
80+
<ArrowLeftRight size={13} aria-hidden="true" />
81+
</button>
82+
</Tooltip>
83+
</div>
84+
);
85+
}

0 commit comments

Comments
 (0)