@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CHANGELOG_EN.md +16 -0
- package/README.md +4 -3
- package/README_EN.md +1 -1
- package/lib/client.js +1519 -477
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +192 -41
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +83 -0
- package/lib/write-checked.js +1 -1
- package/package.json +1 -1
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +116 -121
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +100 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +12 -2
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +217 -43
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +97 -0
- package/src/write-checked.ts +1 -1
package/lib/worktree.js
CHANGED
|
@@ -141,6 +141,89 @@ export function isRefName(ref) {
|
|
|
141
141
|
export function worktreeDir(repoRoot, name) {
|
|
142
142
|
return `${repoRoot.replace(/\/+$/, '')}/.agents/worktrees/${name}`;
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Deepest ancestor chain walked before the lookup gives up. Real delegation
|
|
146
|
+
* nests two or three levels; the cap exists so a corrupt lineage (a cycle the
|
|
147
|
+
* guard below somehow missed, a pathologically deep chain) costs a bounded
|
|
148
|
+
* number of lookups instead of walking forever.
|
|
149
|
+
*/
|
|
150
|
+
const LINEAGE_HOP_CAP = 8;
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the binding a session effectively works under: its own, else the
|
|
153
|
+
* nearest ancestor's.
|
|
154
|
+
*
|
|
155
|
+
* A subagent session never gets a binding of its own — `worktree_enter` is
|
|
156
|
+
* called by the session that wants the worktree — but it works wherever its
|
|
157
|
+
* parent conversation works: the standing prompt, the chip, and `worktree_exit`'s
|
|
158
|
+
* diagnostics all answer "which worktree is THIS session in" through here. The
|
|
159
|
+
* walk is re-resolved on every read, so a session exiting its worktree changes
|
|
160
|
+
* only its own binding: descendants lend the next bound ancestor up the chain
|
|
161
|
+
* on their next read (possibly none — the common case — possibly a grandparent's,
|
|
162
|
+
* which is still the conversation tree they work in) and nothing dangles.
|
|
163
|
+
*
|
|
164
|
+
* Own wins over inherited on purpose: a session that enters a worktree of its
|
|
165
|
+
* own is deliberately somewhere else than its parent.
|
|
166
|
+
* @param sessionId - the session whose effective binding is wanted.
|
|
167
|
+
* @param parentOf - session id → parent session id, as `agent/session-start`
|
|
168
|
+
* delivered it (subagent headers name their parent).
|
|
169
|
+
* @param bindingOf - binding lookup (the bindings file, or the prompt mirror).
|
|
170
|
+
* @returns the effective binding, or undefined when neither the session nor any
|
|
171
|
+
* ancestor (within the hop cap) is bound.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveEffectiveBinding(sessionId, parentOf, bindingOf) {
|
|
174
|
+
const own = bindingOf(sessionId);
|
|
175
|
+
if (own !== undefined)
|
|
176
|
+
return { binding: own, inherited: false };
|
|
177
|
+
const seen = new Set([sessionId]);
|
|
178
|
+
let ancestor = parentOf.get(sessionId);
|
|
179
|
+
for (let hops = 0; ancestor !== undefined && hops < LINEAGE_HOP_CAP; hops += 1) {
|
|
180
|
+
if (seen.has(ancestor))
|
|
181
|
+
return undefined;
|
|
182
|
+
seen.add(ancestor);
|
|
183
|
+
const binding = bindingOf(ancestor);
|
|
184
|
+
if (binding !== undefined)
|
|
185
|
+
return { binding, inherited: true };
|
|
186
|
+
ancestor = parentOf.get(ancestor);
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The session's parent edge, read off a dsh session header: the id of the
|
|
192
|
+
* session this one was delegated by, or undefined for a top-level session (or
|
|
193
|
+
* a malformed empty value). Both `parentOf` feeds — the `agent/session-start`
|
|
194
|
+
* listener and the prompt-time self-heal — go through here, so their input
|
|
195
|
+
* guards cannot drift apart.
|
|
196
|
+
*/
|
|
197
|
+
export function lineageEdgeOf(header) {
|
|
198
|
+
const parent = header?.parentSession;
|
|
199
|
+
return typeof parent === 'string' && parent.length > 0 ? parent : undefined;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The standing notice for a session's effective binding — the text the
|
|
203
|
+
* `worktree:binding` prompt context returns. Both variants carry the same two
|
|
204
|
+
* operational rules; what differs is who holds the binding, and the inherited
|
|
205
|
+
* variant must NOT offer `worktree_exit` (the caller cannot unbind a parent's
|
|
206
|
+
* binding — the exit would fail, and the model should not be told to try).
|
|
207
|
+
* @param name - worktree name (also the directory under `.agents/worktrees/`).
|
|
208
|
+
* @param branch - branch checked out there, when known.
|
|
209
|
+
* @param inherited - whether an ancestor, not this session, holds the binding.
|
|
210
|
+
*/
|
|
211
|
+
export function bindingNotice(name, branch, inherited) {
|
|
212
|
+
const rel = `.agents/worktrees/${name}`;
|
|
213
|
+
const branchNote = branch === undefined ? '' : ` (branch ${branch})`;
|
|
214
|
+
const opening = inherited
|
|
215
|
+
? `This session works in git worktree "${name}"${branchNote}, entered by its parent session.`
|
|
216
|
+
: `This session is bound to git worktree "${name}"${branchNote}.`;
|
|
217
|
+
const closing = inherited
|
|
218
|
+
? 'A path without that prefix acts on the MAIN worktree, not the worktree this conversation works in. '
|
|
219
|
+
+ '(The binding belongs to the parent session; worktree_exit here would not unbind it.)'
|
|
220
|
+
: 'A path without that prefix acts on the MAIN worktree, not the bound one. Call worktree_exit to unbind.';
|
|
221
|
+
return `${opening}\n`
|
|
222
|
+
+ 'The session working directory is still the repository root, so the binding is a convention you must apply yourself:\n'
|
|
223
|
+
+ `- shell commands: pass workdir "${rel}"\n`
|
|
224
|
+
+ `- file tools: prefix every path with ${rel}/\n`
|
|
225
|
+
+ closing;
|
|
226
|
+
}
|
|
144
227
|
export function parseWorktreeList(porcelain) {
|
|
145
228
|
const out = [];
|
|
146
229
|
let path = '';
|
package/lib/write-checked.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { randomBytes } from 'node:crypto';
|
|
33
33
|
import { renameWithRetry } from './atomic-json.js';
|
|
34
|
-
import { resolveInside } from './
|
|
34
|
+
import { resolveInside } from './path-lock.js';
|
|
35
35
|
import { decodesAsUtf8, isSafePathArg } from './git-ops.js';
|
|
36
36
|
/**
|
|
37
37
|
* Run one checked write end to end. Never throws: every failure, including an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -16,6 +16,11 @@ const CHROME_GLYPH = {
|
|
|
16
16
|
// and at 14px the only thing telling them apart is the arrow count.
|
|
17
17
|
refresh: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z',
|
|
18
18
|
close: 'M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z',
|
|
19
|
+
// Drawn here rather than borrowed: Bootstrap has no wrap glyph, and the
|
|
20
|
+
// three text rules with a return arrow are what every editor spells this
|
|
21
|
+
// with. Same 16 viewBox and single fill as the rest, so the row still reads
|
|
22
|
+
// as one set.
|
|
23
|
+
wrap: 'M1 3.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5zM1 7.5a.5.5 0 0 1 .5-.5h11a2.5 2.5 0 0 1 0 5h-2.293l1.147 1.146a.5.5 0 0 1-.708.708l-2-2a.5.5 0 0 1 0-.708l2-2a.5.5 0 0 1 .708.708L10.207 11H12.5a1.5 1.5 0 0 0 0-3h-11a.5.5 0 0 1-.5-.5zM1 12.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5z',
|
|
19
24
|
} as const
|
|
20
25
|
|
|
21
26
|
export function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
@@ -66,6 +66,9 @@ const paintFacet = Facet.define<PaintFn | null, PaintFn | null>({
|
|
|
66
66
|
combine: values => values.length > 0 ? values[0]! : null,
|
|
67
67
|
})
|
|
68
68
|
const paintCompartment = new Compartment()
|
|
69
|
+
/** Soft wrap, in and out without rebuilding the view — the caret, the undo
|
|
70
|
+
* stack and the selection all survive the toggle. */
|
|
71
|
+
const wrapCompartment = new Compartment()
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* How long after the last keystroke the editor recomputes what it paints.
|
|
@@ -422,7 +425,7 @@ const paneTheme = EditorView.theme({
|
|
|
422
425
|
...SEARCH_PANEL_THEME,
|
|
423
426
|
})
|
|
424
427
|
|
|
425
|
-
export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
|
|
428
|
+
export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick, wrap }: {
|
|
426
429
|
/** The pane's buffer. The view is written to only when this really differs. */
|
|
427
430
|
value: string
|
|
428
431
|
/** The other side's whole text — the index side, for the unstaged layer this
|
|
@@ -450,6 +453,11 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
|
|
|
450
453
|
readOnly?: boolean
|
|
451
454
|
/** A click in the blame gutter, with the 1-based line number. */
|
|
452
455
|
onBlameClick?: (line: number) => void
|
|
456
|
+
/** Soft wrap. CodeMirror owns variable line heights natively — its height
|
|
457
|
+
* oracle measures wrapped lines and its own viewport walk stays correct —
|
|
458
|
+
* so this is the whole change here, unlike the diff panes, whose windowing
|
|
459
|
+
* assumes a fixed row height. */
|
|
460
|
+
wrap?: boolean
|
|
453
461
|
}): ReactNode {
|
|
454
462
|
/**
|
|
455
463
|
* Editable, and SAID to be editable, from one boolean.
|
|
@@ -486,6 +494,7 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
|
|
|
486
494
|
searchCount,
|
|
487
495
|
highlightActiveLine(),
|
|
488
496
|
paintCompartment.of(paintFacet.of(paint)),
|
|
497
|
+
wrapCompartment.of(wrap === true ? EditorView.lineWrapping : []),
|
|
489
498
|
painter,
|
|
490
499
|
blameField,
|
|
491
500
|
blameCompartment.of([]),
|
|
@@ -565,5 +574,14 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
|
|
|
565
574
|
current.dispatch({ effects: paintCompartment.reconfigure(paintFacet.of(paint)) })
|
|
566
575
|
}, [paint])
|
|
567
576
|
|
|
577
|
+
// Wrap in or out. Through the compartment rather than a rebuild: the reader
|
|
578
|
+
// toggles this to look at the line they are already on, and a rebuilt view
|
|
579
|
+
// would drop the caret and the undo stack to show it to them.
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
const current = view.current
|
|
582
|
+
if (current === null) return
|
|
583
|
+
current.dispatch({ effects: wrapCompartment.reconfigure(wrap === true ? EditorView.lineWrapping : []) })
|
|
584
|
+
}, [wrap])
|
|
585
|
+
|
|
568
586
|
return <div ref={host} className={css.cmHost} data-editable={editable ? '' : undefined} />
|
|
569
587
|
}
|
package/src/client/DiffViews.tsx
CHANGED
|
@@ -15,10 +15,13 @@ import {
|
|
|
15
15
|
type EditState, type WriteResult,
|
|
16
16
|
} from './side-edit.ts'
|
|
17
17
|
import { PaneDivider } from './PaneDivider.tsx'
|
|
18
|
+
import { SideRails } from './SideRails.tsx'
|
|
18
19
|
import { CodeEditor, type PaintFn } from './CodeEditor.tsx'
|
|
19
20
|
import { detectIndent } from './indent.ts'
|
|
20
21
|
import { grammarLoadCount, highlightForRowsWindow, highlightRange, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
21
22
|
import { useRowWindow } from './use-row-window.ts'
|
|
23
|
+
import { rowMark, useVariableRowWindow } from './use-variable-row-window.ts'
|
|
24
|
+
import { RowSpacer, SideCells } from './diff-cells.tsx'
|
|
22
25
|
import type { BlockAsk, BlockMode, FileSides, GitOpResult, SideLayer, Translate } from './git-workbench-types.ts'
|
|
23
26
|
import css from './GitWorkbenchPanel.module.css'
|
|
24
27
|
|
|
@@ -63,11 +66,15 @@ function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
|
|
|
63
66
|
* a block child of a scroller is only ever as wide as the scrollport. A header
|
|
64
67
|
* outside the scrolled box has neither problem.
|
|
65
68
|
*/
|
|
66
|
-
export function DiffView({ segment, path, palette, t }: {
|
|
69
|
+
export function DiffView({ segment, path, palette, t, wrap }: {
|
|
67
70
|
segment: string
|
|
68
71
|
path: string
|
|
69
72
|
palette: string
|
|
70
73
|
t: Translate
|
|
74
|
+
/** Soft wrap. Changes the pane's height model, not just its white-space:
|
|
75
|
+
* the window below is exact arithmetic while every row is one line tall,
|
|
76
|
+
* and measured once they are not. */
|
|
77
|
+
wrap: boolean
|
|
71
78
|
}): ReactNode {
|
|
72
79
|
const lang = shikiLangOf(path)
|
|
73
80
|
const shikiTheme = shikiThemeOf(palette)
|
|
@@ -75,10 +82,15 @@ export function DiffView({ segment, path, palette, t }: {
|
|
|
75
82
|
const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
|
|
76
83
|
const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
|
|
77
84
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
const preRef = useRef<HTMLPreElement>(null)
|
|
78
86
|
// Windowed for the same reason the side-by-side pane is: a unified diff of a
|
|
79
87
|
// long file put every row in the DOM and re-lexed every one of them, so
|
|
80
|
-
// opening one froze the pane in exactly the same way.
|
|
81
|
-
|
|
88
|
+
// opening one froze the pane in exactly the same way. Two models, one live
|
|
89
|
+
// at a time: exact `i * 20px` while nothing wraps, measured once it does.
|
|
90
|
+
const fixed = useRowWindow(scrollRef, rowsWithWords.length, path, !wrap)
|
|
91
|
+
const texts = useMemo(() => rowsWithWords.map(row => row.text), [rowsWithWords])
|
|
92
|
+
const flow = useVariableRowWindow({ scrollRef, rowsRef: preRef, texts, mountKey: path, scope: 'u', enabled: wrap })
|
|
93
|
+
const win = wrap ? flow.win : fixed
|
|
82
94
|
const syntax = useMemo(
|
|
83
95
|
() => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
|
|
84
96
|
[rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
@@ -89,9 +101,16 @@ export function DiffView({ segment, path, palette, t }: {
|
|
|
89
101
|
// the block being walked to.
|
|
90
102
|
const blocksForNav = useRef<readonly number[]>(blocks)
|
|
91
103
|
blocksForNav.current = blocks
|
|
104
|
+
// Same reason the blocks are held in a ref: the walk is built once, and by
|
|
105
|
+
// the time it runs the pane may have been wrapped, unwrapped or re-measured.
|
|
106
|
+
const placeRow = useRef<((index: number) => number) | undefined>(undefined)
|
|
107
|
+
placeRow.current = wrap ? flow.rowTop : undefined
|
|
92
108
|
const { goToChange } = useChangeNav(
|
|
93
109
|
scrollRef,
|
|
94
|
-
useCallback(
|
|
110
|
+
useCallback(
|
|
111
|
+
() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP, placeRow.current),
|
|
112
|
+
[],
|
|
113
|
+
),
|
|
95
114
|
)
|
|
96
115
|
// Read by the key listener below, which is attached once. `goToChange` only
|
|
97
116
|
// ever touches refs, but pinning it here says so rather than relying on it.
|
|
@@ -137,12 +156,12 @@ export function DiffView({ segment, path, palette, t }: {
|
|
|
137
156
|
</div>
|
|
138
157
|
) : null}
|
|
139
158
|
<div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
|
|
140
|
-
<pre className={css.diffPre}>
|
|
159
|
+
<pre ref={preRef} className={wrap ? `${css.diffPre} ${css.diffPreWrap}` : css.diffPre}>
|
|
141
160
|
{win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
|
|
142
161
|
{rowsWithWords.slice(win.start, win.end).map((row, k) => {
|
|
143
162
|
const i = win.start + k
|
|
144
163
|
return (
|
|
145
|
-
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
|
|
164
|
+
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined} {...rowMark('u', i)}>
|
|
146
165
|
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
147
166
|
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
148
167
|
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
@@ -222,10 +241,14 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
|
|
|
222
241
|
* (history and compare keep it unconditionally), with a notice — a silently
|
|
223
242
|
* different view reads as a broken one, not a guarded one.
|
|
224
243
|
*/
|
|
225
|
-
export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
244
|
+
export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
226
245
|
t: Translate
|
|
227
246
|
path: string
|
|
228
247
|
palette: string
|
|
248
|
+
/** Soft wrap. Passed through to the editor and the unified fallback; the two
|
|
249
|
+
* aligned columns are the case it costs the most, since a row's height is
|
|
250
|
+
* whichever side wrapped further. */
|
|
251
|
+
wrap: boolean
|
|
229
252
|
statsPath: string | undefined
|
|
230
253
|
fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
231
254
|
/** Save the editor buffer; the host refuses a stale sha and nothing is written. */
|
|
@@ -390,7 +413,31 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
390
413
|
// line by line. Declared here because both the render and the highlighting
|
|
391
414
|
// below are bounded by it.
|
|
392
415
|
const rowWindowKey = `${scopeKey}\x1f${path}\x1f${layer}\x1f${sides?.diffSha ?? ''}`
|
|
393
|
-
const
|
|
416
|
+
const alignedGridRef = useRef<HTMLDivElement>(null)
|
|
417
|
+
const denseGridRef = useRef<HTMLDivElement>(null)
|
|
418
|
+
// The two columns themselves, for the rails that scroll them — see
|
|
419
|
+
// SideRails.tsx: each column's own scrollbar is drawn at the bottom of the
|
|
420
|
+
// FILE, which is not a place a scrollbar can be used from.
|
|
421
|
+
const leftColRef = useRef<HTMLDivElement>(null)
|
|
422
|
+
const rightColRef = useRef<HTMLDivElement>(null)
|
|
423
|
+
const fixedWin = useRowWindow(scrollRef, rows.length, rowWindowKey, !wrap)
|
|
424
|
+
// The taller of a row's two sides is what the row is worth, so the estimate
|
|
425
|
+
// is fed the longer of the two texts.
|
|
426
|
+
const rowTexts = useMemo(
|
|
427
|
+
() => rows.map(row => {
|
|
428
|
+
const left = row.left?.text ?? ''
|
|
429
|
+
const right = row.right?.text ?? ''
|
|
430
|
+
return left.length >= right.length ? left : right
|
|
431
|
+
}),
|
|
432
|
+
[rows],
|
|
433
|
+
)
|
|
434
|
+
// Both columns, because a row is as tall as its taller side; the width is
|
|
435
|
+
// read from one column, because that is what a line wraps inside.
|
|
436
|
+
const flow = useVariableRowWindow({
|
|
437
|
+
scrollRef, rowsRef: colsRef, widthRef: alignedGridRef,
|
|
438
|
+
texts: rowTexts, mountKey: rowWindowKey, scope: 'a', enabled: wrap,
|
|
439
|
+
})
|
|
440
|
+
const win = wrap ? flow.win : fixedWin
|
|
394
441
|
//
|
|
395
442
|
// Two passes with two lifetimes. The whole-file pass runs once per file and
|
|
396
443
|
// is what knows about block comments and template literals; the per-line
|
|
@@ -469,7 +516,17 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
469
516
|
// two columns render two different row lists while the editor is armed: the
|
|
470
517
|
// right side is a buffer, and the left side is then the index side DENSE,
|
|
471
518
|
// one row per index line rather than one per aligned row.
|
|
472
|
-
const
|
|
519
|
+
const fixedLeftWin = useRowWindow(scrollRef, leftRows.length, rowWindowKey, !wrap)
|
|
520
|
+
const leftTexts = useMemo(() => leftRows.map(entry => entry.row.left?.text ?? ''), [leftRows])
|
|
521
|
+
// Its own height model: the dense column's rows are the INDEX side's lines,
|
|
522
|
+
// a different list from the aligned rows, so it cannot share theirs. Nothing
|
|
523
|
+
// has to line up with it — the other column is a CodeMirror buffer that wraps
|
|
524
|
+
// on its own — but its spacers still have to add up to what it renders.
|
|
525
|
+
const leftFlow = useVariableRowWindow({
|
|
526
|
+
scrollRef, rowsRef: denseGridRef,
|
|
527
|
+
texts: leftTexts, mountKey: rowWindowKey, scope: 'd', enabled: wrap,
|
|
528
|
+
})
|
|
529
|
+
const leftWin = wrap ? leftFlow.win : fixedLeftWin
|
|
473
530
|
|
|
474
531
|
// Arming drops the caret straight into the buffer: the click that armed the
|
|
475
532
|
// editor said "I want to type here", and a second click to focus is a tax.
|
|
@@ -666,7 +723,7 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
666
723
|
|
|
667
724
|
/** The pane the drawer had before this view existed, notice included. */
|
|
668
725
|
const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
|
|
669
|
-
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
|
|
726
|
+
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} wrap={wrap} />
|
|
670
727
|
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
|
|
671
728
|
|
|
672
729
|
if (failed) return unifiedFallback()
|
|
@@ -868,6 +925,7 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
868
925
|
{bodyState.kind === 'empty' ? (
|
|
869
926
|
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
870
927
|
) : (
|
|
928
|
+
<>
|
|
871
929
|
<div
|
|
872
930
|
ref={colsRef}
|
|
873
931
|
className={css.sideCols}
|
|
@@ -875,8 +933,11 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
875
933
|
onMouseDown={onBodySelect}
|
|
876
934
|
onMouseLeave={() => { setHotBlock(null) }}
|
|
877
935
|
>
|
|
878
|
-
<div className={css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
|
|
879
|
-
<div
|
|
936
|
+
<div ref={leftColRef} className={wrap ? `${css.sideCol} ${css.sideColWrap}` : css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
|
|
937
|
+
<div
|
|
938
|
+
ref={bodyState.kind === 'editor' ? denseGridRef : alignedGridRef}
|
|
939
|
+
className={wrap ? `${css.sideColGrid} ${css.sideColGridWrap}` : css.sideColGrid}
|
|
940
|
+
>
|
|
880
941
|
{bodyState.kind === 'editor' ? (
|
|
881
942
|
/* While armed the left column renders the index side DENSE —
|
|
882
943
|
one row per index line, no diff holes — because the right
|
|
@@ -888,16 +949,18 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
888
949
|
const k = leftWin.start + kk
|
|
889
950
|
const { row, i } = entry
|
|
890
951
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
891
|
-
const current = row.block >= 0 && row.block === currentBlock
|
|
892
|
-
const hotClass = blockHotClass(rows, i, 'left', current)
|
|
893
952
|
return (
|
|
894
|
-
<
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
953
|
+
<SideCells
|
|
954
|
+
key={`l${i}`}
|
|
955
|
+
row={row} side="left" index={i} rows={rows}
|
|
956
|
+
current={row.block >= 0 && row.block === currentBlock}
|
|
957
|
+
tokens={leftSyntax?.[i]}
|
|
958
|
+
// Marked by its DENSE index: this column's rows are the
|
|
959
|
+
// index side's lines, not the aligned ones. Nothing to
|
|
960
|
+
// impose a height for — the buffer beside it wraps itself.
|
|
961
|
+
mark={wrap ? rowMark('d', k) : undefined}
|
|
962
|
+
bar={hot && k === hotFirstLeft ? blockBar(row.block) : null}
|
|
963
|
+
/>
|
|
901
964
|
)
|
|
902
965
|
})}
|
|
903
966
|
<RowSpacer height={leftWin.padBottom} />
|
|
@@ -908,20 +971,19 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
908
971
|
{rows.slice(win.start, win.end).map((row, k) => {
|
|
909
972
|
const i = win.start + k
|
|
910
973
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
911
|
-
const current = row.block >= 0 && row.block === currentBlock
|
|
912
|
-
const hotClass = blockHotClass(rows, i, 'left', current)
|
|
913
|
-
// The block's action bar rides in this column only for a row
|
|
914
|
-
// with no right-hand side — a pure deletion, where the right
|
|
915
|
-
// column has no cell to hang it on.
|
|
916
|
-
const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
|
|
917
974
|
return (
|
|
918
|
-
<
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
975
|
+
<SideCells
|
|
976
|
+
key={i}
|
|
977
|
+
row={row} side="left" index={i} rows={rows}
|
|
978
|
+
current={row.block >= 0 && row.block === currentBlock}
|
|
979
|
+
tokens={leftSyntax?.[i]}
|
|
980
|
+
mark={wrap ? rowMark('a', i) : undefined}
|
|
981
|
+
minHeight={wrap ? flow.rowHeight(i) : undefined}
|
|
982
|
+
// The block's action bar rides in this column only for a
|
|
983
|
+
// row with no right-hand side — a pure deletion, where the
|
|
984
|
+
// right column has no cell to hang it on.
|
|
985
|
+
bar={hot && i === hotFirst && row.right === null ? blockBar(row.block) : null}
|
|
986
|
+
/>
|
|
925
987
|
)
|
|
926
988
|
})}
|
|
927
989
|
<RowSpacer height={win.padBottom} />
|
|
@@ -930,7 +992,11 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
930
992
|
</div>
|
|
931
993
|
</div>
|
|
932
994
|
<PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
|
|
933
|
-
<div
|
|
995
|
+
<div
|
|
996
|
+
ref={rightColRef}
|
|
997
|
+
className={wrap ? `${css.sideCol} ${css.sideColRight} ${css.sideColWrap}` : `${css.sideCol} ${css.sideColRight}`}
|
|
998
|
+
style={{ paddingTop: blockBarClearance }}
|
|
999
|
+
>
|
|
934
1000
|
{bodyState.kind === 'editor' ? (
|
|
935
1001
|
<CodeEditor
|
|
936
1002
|
value={edit.buffer}
|
|
@@ -940,28 +1006,26 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
940
1006
|
indent={indentOfBuffer}
|
|
941
1007
|
ariaLabel={path}
|
|
942
1008
|
onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
|
|
1009
|
+
wrap={wrap}
|
|
943
1010
|
/>
|
|
944
1011
|
) : (
|
|
945
|
-
<div className={css.sideColGrid}>
|
|
1012
|
+
<div className={wrap ? `${css.sideColGrid} ${css.sideColGridWrap}` : css.sideColGrid}>
|
|
946
1013
|
<RowSpacer height={win.padTop} />
|
|
947
1014
|
{rows.slice(win.start, win.end).map((row, k) => {
|
|
948
1015
|
const i = win.start + k
|
|
949
1016
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
950
|
-
const current = row.block >= 0 && row.block === currentBlock
|
|
951
|
-
const hotClass = blockHotClass(rows, i, 'right', current)
|
|
952
|
-
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
953
1017
|
return (
|
|
954
|
-
<
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1018
|
+
<SideCells
|
|
1019
|
+
key={i}
|
|
1020
|
+
row={row} side="right" index={i} rows={rows}
|
|
1021
|
+
current={row.block >= 0 && row.block === currentBlock}
|
|
1022
|
+
tokens={rightSyntax?.[i]}
|
|
1023
|
+
mark={wrap ? rowMark('a', i) : undefined}
|
|
1024
|
+
minHeight={wrap ? flow.rowHeight(i) : undefined}
|
|
1025
|
+
bar={hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null}
|
|
1026
|
+
armable={layer === 'unstaged' && armable}
|
|
1027
|
+
onArm={layer === 'unstaged' && armable ? armFromCell : undefined}
|
|
1028
|
+
/>
|
|
965
1029
|
)
|
|
966
1030
|
})}
|
|
967
1031
|
<RowSpacer height={win.padBottom} />
|
|
@@ -969,6 +1033,8 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
969
1033
|
)}
|
|
970
1034
|
</div>
|
|
971
1035
|
</div>
|
|
1036
|
+
<SideRails leftRef={leftColRef} rightRef={rightColRef} split={split} />
|
|
1037
|
+
</>
|
|
972
1038
|
)}
|
|
973
1039
|
</div>
|
|
974
1040
|
{pendingLayer !== null ? (
|
|
@@ -1026,74 +1092,3 @@ export function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
|
|
|
1026
1092
|
</div>
|
|
1027
1093
|
)
|
|
1028
1094
|
}
|
|
1029
|
-
|
|
1030
|
-
/** Classes that paint only a block's OUTER perimeter. Internal rows carry the
|
|
1031
|
-
* vertical edges but no top/bottom line, avoiding the blue ladder a large
|
|
1032
|
-
* addition block used to draw. Absent side-cells return no class at all. */
|
|
1033
|
-
function blockHotClass(rows: readonly SideRow[], index: number, side: 'left' | 'right', hot: boolean): string {
|
|
1034
|
-
if (!hot) return ''
|
|
1035
|
-
const edge = blockEdge(rows, index, side)
|
|
1036
|
-
if (edge === null) return ''
|
|
1037
|
-
const first = edge === 'first' || edge === 'single' ? ` ${css.sideBlockHotFirst}` : ''
|
|
1038
|
-
const last = edge === 'last' || edge === 'single' ? ` ${css.sideBlockHotLast}` : ''
|
|
1039
|
-
return ` ${css.sideBlockHot}${first}${last}`
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
/** Line-number cell class: a PRESENT cell of a changed row carries its side's
|
|
1043
|
-
* tint into the gutter; an absent one stays blank, the way a split diff shows
|
|
1044
|
-
* a one-sided change with an empty opposite pane rather than a tinted void. */
|
|
1045
|
-
function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
|
|
1046
|
-
const cell = side === 'left' ? row.left : row.right
|
|
1047
|
-
if (cell === null || row.kind === 'same') return css.sideNum
|
|
1048
|
-
return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
/** Code cell class: deletions tint left, additions right, context stays quiet. */
|
|
1052
|
-
function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
1053
|
-
const cell = side === 'left' ? row.left : row.right
|
|
1054
|
-
if (cell === null || row.kind === 'same') return css.sideCodeSame
|
|
1055
|
-
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/** One text with every carriage return drawn as the CR glyph. No CR means
|
|
1059
|
-
* the text comes back untouched — the common line, on both sides, costs one
|
|
1060
|
-
* `includes`. The glyph spans are aria-hidden and unselectable, so copying a
|
|
1061
|
-
* line copies code, not markers. */
|
|
1062
|
-
function renderWithCrMarks(text: string): ReactNode {
|
|
1063
|
-
const parts = splitOnCr(text)
|
|
1064
|
-
if (parts.length === 1) return text
|
|
1065
|
-
const out: ReactNode[] = [parts[0]!]
|
|
1066
|
-
for (let i = 1; i < parts.length; i += 1) {
|
|
1067
|
-
out.push(<span key={`cr${i}`} className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>)
|
|
1068
|
-
out.push(parts[i]!)
|
|
1069
|
-
}
|
|
1070
|
-
return out
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/** One cell's Shiki runs, or its plain text when no tokens exist; either way
|
|
1074
|
-
* each carriage return in the cell is drawn, so a line whose only change is
|
|
1075
|
-
* its ending shows the difference instead of two identical-looking cells. */
|
|
1076
|
-
function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
1077
|
-
if (cell === null) return ''
|
|
1078
|
-
if (tokens === undefined || tokens.length === 0) return renderWithCrMarks(cell.text)
|
|
1079
|
-
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return renderWithCrMarks(cell.text)
|
|
1080
|
-
return tokens.map((tok, i) => (
|
|
1081
|
-
<span
|
|
1082
|
-
key={i}
|
|
1083
|
-
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
1084
|
-
>{renderWithCrMarks(tok.text)}</span>
|
|
1085
|
-
))
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* The spacer standing in for the rows above or below the window.
|
|
1092
|
-
*
|
|
1093
|
-
* It spans every column of the grid, so a blame gutter does not change it.
|
|
1094
|
-
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
1095
|
-
*/
|
|
1096
|
-
function RowSpacer({ height }: { height: number }): ReactNode {
|
|
1097
|
-
if (height <= 0) return null
|
|
1098
|
-
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
1099
|
-
}
|