@young1lin/dsh-ui-gitworkbench 0.1.5 → 0.1.7

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CHANGELOG_EN.md +43 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34960 -11826
  7. package/lib/git-ops.js +25 -0
  8. package/lib/image-sniff.js +197 -0
  9. package/lib/index.js +401 -7
  10. package/lib/patch-model.js +223 -0
  11. package/lib/side-guard.js +55 -0
  12. package/lib/write-checked.js +164 -0
  13. package/package.json +7 -1
  14. package/src/apply-blocks.ts +215 -0
  15. package/src/atomic-json.ts +29 -5
  16. package/src/blame.ts +94 -0
  17. package/src/client/CodeEditor.tsx +317 -0
  18. package/src/client/FileBrowser.tsx +657 -0
  19. package/src/client/GitWorkbenchPanel.module.css +491 -12
  20. package/src/client/GitWorkbenchPanel.tsx +1655 -190
  21. package/src/client/ImageView.tsx +120 -0
  22. package/src/client/blame-gutter.ts +108 -0
  23. package/src/client/blame-view.ts +104 -0
  24. package/src/client/cm-diff.ts +108 -0
  25. package/src/client/cm-tokens.ts +79 -0
  26. package/src/client/diff-nav.ts +198 -0
  27. package/src/client/file-icon.ts +190 -0
  28. package/src/client/file-rows.ts +184 -0
  29. package/src/client/files-place.ts +178 -0
  30. package/src/client/glyphs.tsx +86 -0
  31. package/src/client/highlight.ts +25 -0
  32. package/src/client/history-layout.ts +52 -0
  33. package/src/client/idle-value.ts +53 -0
  34. package/src/client/image-view.ts +106 -0
  35. package/src/client/indent.ts +74 -0
  36. package/src/client/index.ts +59 -0
  37. package/src/client/locales.ts +179 -4
  38. package/src/client/pane-size.ts +71 -0
  39. package/src/client/side-edit.ts +244 -0
  40. package/src/client/side-rows.ts +258 -0
  41. package/src/client/stable-list.ts +31 -0
  42. package/src/client/use-change-nav.ts +83 -0
  43. package/src/client/worktree-view.ts +11 -1
  44. package/src/git-ops.ts +36 -1
  45. package/src/image-sniff.ts +204 -0
  46. package/src/index.ts +447 -7
  47. package/src/patch-model.ts +267 -0
  48. package/src/side-guard.ts +58 -0
  49. package/src/write-checked.ts +223 -0
@@ -22,10 +22,9 @@ function delay(ms: number): Promise<void> {
22
22
  * Write a JSON value so a crash can never leave a truncated file behind.
23
23
  *
24
24
  * The value is staged into `<path>.tmp` and renamed over the destination, which
25
- * is atomic within a filesystem. On Windows that rename fails with EPERM while
26
- * another handle briefly holds the destination open (a concurrent read, an
27
- * antivirus scan, the search indexer); those locks are short-lived, so the
28
- * rename is retried with backoff before the error is surfaced.
25
+ * is atomic within a filesystem. The rename itself goes through
26
+ * {@link renameWithRetry} the one home of the Windows EPERM backoff, shared
27
+ * with `write-checked.ts`'s worktree writes rather than duplicated per caller.
29
28
  * @param ensureDir - creates the containing directory, recursively.
30
29
  * @param writeText - writes a file's whole text.
31
30
  * @param rename - renames a path over another.
@@ -43,9 +42,34 @@ export async function saveJsonAtomic(
43
42
  await ensureDir(join(path, '..'))
44
43
  const tmp = `${path}.tmp`
45
44
  await writeText(tmp, `${JSON.stringify(value, null, 2)}\n`)
45
+ await renameWithRetry(rename, delay, tmp, path)
46
+ }
47
+
48
+ /**
49
+ * Rename `from` over `to`, retrying the short-lived Windows failures.
50
+ *
51
+ * On Windows a rename over an existing destination fails with EPERM while
52
+ * another handle briefly holds it open (a concurrent read, an antivirus scan,
53
+ * the search indexer); those locks are short-lived, so the rename is retried
54
+ * with backoff before the error is surfaced. Extracted from
55
+ * {@link saveJsonAtomic} when the editable diff needed the same atomic write
56
+ * for worktree files: one mechanism, two callers, not two mechanisms that can
57
+ * drift.
58
+ * @param rename - renames a path over another.
59
+ * @param delay - waits the given milliseconds.
60
+ * @param from - the staged temp path.
61
+ * @param to - the destination.
62
+ * @throws whatever `rename` threw, once the retries are exhausted.
63
+ */
64
+ export async function renameWithRetry(
65
+ rename: (from: string, to: string) => Promise<void>,
66
+ delay: (ms: number) => Promise<void>,
67
+ from: string,
68
+ to: string,
69
+ ): Promise<void> {
46
70
  for (let attempt = 0; ; attempt++) {
47
71
  try {
48
- await rename(tmp, path)
72
+ await rename(from, to)
49
73
  return
50
74
  } catch (error) {
51
75
  if (attempt >= RENAME_RETRY_DELAYS.length) throw error
package/src/blame.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `git blame --line-porcelain`, parsed into one record per line.
3
+ *
4
+ * Porcelain is the only blame format worth parsing: the human format packs
5
+ * author, date and code into fixed-width columns that shift with the longest
6
+ * name in the file, and reading it back means guessing where the columns are.
7
+ * The porcelain repeats a full header for every line — verbose on the wire,
8
+ * unambiguous to read.
9
+ *
10
+ * A line nobody has committed yet gets the all-zero sha and git's own English
11
+ * "Not Committed Yet" as its author. That flag is reported separately so the
12
+ * drawer can say it in the reader's language rather than passing git's string
13
+ * through untranslated.
14
+ *
15
+ * Pure: no node, no git, no React. `tests/blame.test.ts` loads it directly.
16
+ *
17
+ * @module @young1lin/dsh-ui-gitworkbench/blame
18
+ */
19
+
20
+ /** One line's provenance. Every field is JSON-safe and always present. */
21
+ export interface BlameLine {
22
+ /** Full commit sha; all zeros for a line not committed yet. */
23
+ readonly hash: string
24
+ readonly author: string
25
+ /** Author time, unix seconds. 0 when git did not say. */
26
+ readonly time: number
27
+ /** The commit's subject line; '' when git did not say. */
28
+ readonly summary: string
29
+ /** Whether this line has no commit behind it yet. */
30
+ readonly uncommitted: boolean
31
+ }
32
+
33
+ /** A header line: sha, line in the original, line in the final file, [count]. */
34
+ const ENTRY = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/
35
+
36
+ const ZERO_SHA = '0'.repeat(40)
37
+
38
+ /**
39
+ * Parse blame porcelain into per-line records, indexed by final line number.
40
+ *
41
+ * @param text - stdout of `git blame --line-porcelain -- <path>`.
42
+ * @returns one entry per line of the file, in file order. Gaps cannot happen
43
+ * in well-formed output, but a truncated stream yields a shorter
44
+ * array rather than a hole — the caller renders what it has.
45
+ */
46
+ export function parseBlame(text: string): BlameLine[] {
47
+ if (text.length === 0) return []
48
+ const byLine = new Map<number, BlameLine>()
49
+ let line = 0
50
+ let hash = ''
51
+ let author = ''
52
+ let time = 0
53
+ let summary = ''
54
+
55
+ for (const raw of text.split('\n')) {
56
+ const head = ENTRY.exec(raw)
57
+ if (head !== null) {
58
+ hash = head[1]!
59
+ line = Number.parseInt(head[3]!, 10)
60
+ // Each entry restates its own fields; carrying the previous line's over
61
+ // would attribute a line to whatever came before it in the stream.
62
+ author = ''
63
+ time = 0
64
+ summary = ''
65
+ continue
66
+ }
67
+ if (raw.startsWith('author ')) { author = raw.slice(7); continue }
68
+ if (raw.startsWith('author-time ')) {
69
+ const parsed = Number.parseInt(raw.slice(12), 10)
70
+ time = Number.isFinite(parsed) ? parsed : 0
71
+ continue
72
+ }
73
+ if (raw.startsWith('summary ')) { summary = raw.slice(8); continue }
74
+ // The content line, which closes the entry. Its text is the file's own and
75
+ // the drawer already has it, so only the provenance is kept.
76
+ if (raw.startsWith('\t') && line > 0) {
77
+ byLine.set(line, {
78
+ hash,
79
+ author,
80
+ time,
81
+ summary,
82
+ uncommitted: hash === ZERO_SHA,
83
+ })
84
+ line = 0
85
+ }
86
+ }
87
+
88
+ const highest = byLine.size === 0 ? 0 : Math.max(...byLine.keys())
89
+ const out: BlameLine[] = []
90
+ for (let at = 1; at <= highest; at += 1) {
91
+ out.push(byLine.get(at) ?? { hash: '', author: '', time: 0, summary: '', uncommitted: false })
92
+ }
93
+ return out
94
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * The side pane's editor: a real CodeMirror 6 view, wired to the pane's
3
+ * existing buffer state.
4
+ *
5
+ * What this replaces was a transparent textarea laid over its own rendered
6
+ * lines. That kept the caret on the glyphs, but a textarea is a textarea: Tab
7
+ * left the field, there was no undo stack of its own, no multiple selections,
8
+ * no find-in-file. Those are not polish — they are what "editing" means to
9
+ * anyone who has used an editor, and the pane was asking people to edit.
10
+ *
11
+ * CodeMirror is here for the EDITING only. Highlighting still comes from
12
+ * shiki, through {@link tokenRanges}: the diff columns beside this editor are
13
+ * painted by shiki, and a second grammar engine would cost another megabyte of
14
+ * bundle to render the same file in slightly different colours. So the tokens
15
+ * the pane already computed are handed over as decorations.
16
+ *
17
+ * The document is CONTROLLED by the pane, not owned here: `value` is the
18
+ * pane's buffer, and every change is reported back through `onChange`. The
19
+ * effect below writes an incoming `value` into the view only when it actually
20
+ * differs from what the view holds, so a save's round trip does not fight the
21
+ * caret.
22
+ *
23
+ * @module @young1lin/dsh-ui-gitworkbench/client/CodeEditor
24
+ */
25
+
26
+ import { useEffect, useRef, type ReactNode } from 'react'
27
+ import { EditorState, StateEffect, StateField, type Extension } from '@codemirror/state'
28
+ import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, type DecorationSet } from '@codemirror/view'
29
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
30
+ import { indentUnit } from '@codemirror/language'
31
+ import { search, searchKeymap } from '@codemirror/search'
32
+
33
+ import css from './GitWorkbenchPanel.module.css'
34
+ import { blameCompartment, blameField, blameGutter, setBlame } from './blame-gutter.ts'
35
+ import { bufferDiff } from './cm-diff.ts'
36
+ import { tokenRanges } from './cm-tokens.ts'
37
+ import type { HighlightRun } from './highlight.ts'
38
+ import type { BlameLine } from './GitWorkbenchPanel.tsx'
39
+
40
+ /** Carries a fresh set of shiki-derived decorations into the view. */
41
+ const setPaint = StateEffect.define<DecorationSet>()
42
+
43
+ /**
44
+ * The decoration layer. It maps through document changes so the colours stay
45
+ * on their text between repaints — without that, a keystroke would smear every
46
+ * token after the caret until the next highlight pass landed.
47
+ */
48
+ const paintField = StateField.define<DecorationSet>({
49
+ create: () => Decoration.none,
50
+ update(paint, tr) {
51
+ for (const effect of tr.effects) {
52
+ if (effect.is(setPaint)) return effect.value
53
+ }
54
+ return tr.docChanged ? paint.map(tr.changes) : paint
55
+ },
56
+ provide: field => EditorView.decorations.from(field),
57
+ })
58
+
59
+ /** One span's inline style, from the shiki run that produced it. */
60
+ function styleFor(color: string | undefined, italic: boolean | undefined): string {
61
+ const paint = color === undefined ? '' : 'color:' + color + ';'
62
+ return italic === true ? paint + 'font-style:italic;' : paint
63
+ }
64
+
65
+ /** Build the decoration set for one buffer's worth of shiki runs. */
66
+ function paintFor(text: string, syntax: readonly (readonly HighlightRun[])[] | undefined): DecorationSet {
67
+ const ranges = tokenRanges(text.split('\n'), syntax)
68
+ return Decoration.set(
69
+ ranges.map(range => Decoration
70
+ .mark({ attributes: { style: styleFor(range.color, range.italic) } })
71
+ .range(range.from, range.to)),
72
+ true,
73
+ )
74
+ }
75
+
76
+ /** The other side's text, kept in state so the diff layer can recompute from
77
+ * a transaction alone rather than from a closure over some past render. */
78
+ const setOriginal = StateEffect.define<string>()
79
+ const originalText = StateField.define<string>({
80
+ create: () => '',
81
+ update(held, tr) {
82
+ for (const effect of tr.effects) {
83
+ if (effect.is(setOriginal)) return effect.value
84
+ }
85
+ return held
86
+ },
87
+ })
88
+
89
+ /**
90
+ * The add/delete tint, recomputed on every keystroke.
91
+ *
92
+ * Arming the editor used to take the diff colours away, because the pane's
93
+ * tints come from git's diff and git has not seen a keystroke. Watching the
94
+ * change take shape is the reason to edit inside a diff view at all, so the
95
+ * tint is recomputed here from the text on both sides instead.
96
+ *
97
+ * This diff is a READING AID. No git operation uses it: the block actions
98
+ * still send line indices against the host's own `diffSha`-stamped patch.
99
+ */
100
+ const diffField = StateField.define<DecorationSet>({
101
+ create: state => diffDecorations(state),
102
+ update(deco, tr) {
103
+ const reset = tr.effects.some(effect => effect.is(setOriginal))
104
+ return tr.docChanged || reset ? diffDecorations(tr.state) : deco
105
+ },
106
+ provide: field => EditorView.decorations.from(field),
107
+ })
108
+
109
+ function diffDecorations(state: EditorState): DecorationSet {
110
+ const original = state.field(originalText, false) ?? ''
111
+ const doc = state.doc.toString()
112
+ const { changed, deletedBefore } = bufferDiff(original, doc)
113
+ const marks: { at: number; deco: Decoration }[] = []
114
+ const lineCount = state.doc.lines
115
+ for (const line of changed) {
116
+ if (line <= lineCount) marks.push({ at: state.doc.line(line).from, deco: CHANGED_LINE })
117
+ }
118
+ for (const line of deletedBefore) {
119
+ if (line <= lineCount) marks.push({ at: state.doc.line(line).from, deco: DELETED_AT })
120
+ }
121
+ marks.sort((a, b) => a.at - b.at)
122
+ return Decoration.set(marks.map(mark => mark.deco.range(mark.at)), true)
123
+ }
124
+
125
+ const CHANGED_LINE = Decoration.line({ class: 'cm-gwChanged' })
126
+ const DELETED_AT = Decoration.line({ class: 'cm-gwDeleted' })
127
+
128
+ /**
129
+ * Metrics restated to match the diff columns beside this editor exactly: same
130
+ * family, same size, same 20px rhythm, ligatures off. The pane's own CSS
131
+ * variables carry the colours, so the editor follows the drawer's palette
132
+ * without a CodeMirror theme per palette.
133
+ */
134
+ const paneTheme = EditorView.theme({
135
+ '&': { backgroundColor: 'transparent', color: 'var(--gs-fg)', height: 'auto' },
136
+ '&.cm-focused': { outline: 'none' },
137
+ '.cm-scroller': {
138
+ overflow: 'visible',
139
+ fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
140
+ fontSize: 'var(--gs-t-dense)',
141
+ lineHeight: '20px',
142
+ fontVariantLigatures: 'none',
143
+ },
144
+ '.cm-content': { padding: '0', caretColor: 'var(--gs-accent)' },
145
+ '.cm-gutters': {
146
+ backgroundColor: 'transparent',
147
+ color: 'var(--gs-fg-faint)',
148
+ border: 'none',
149
+ paddingRight: '8px',
150
+ },
151
+ '.cm-activeLine': { backgroundColor: 'var(--gs-hover)' },
152
+ // Same tints the diff columns use, so a line the reader just typed reads as
153
+ // the same kind of thing as a line git already knows about.
154
+ '.cm-gwChanged': { backgroundColor: 'var(--gs-add-line)' },
155
+ '.cm-gwDeleted': { boxShadow: 'inset 0 2px 0 0 var(--gs-del-line)' },
156
+ '.cm-activeLineGutter': { backgroundColor: 'transparent', color: 'var(--gs-fg-dim)' },
157
+ '.cm-cursor': { borderLeftColor: 'var(--gs-accent)' },
158
+ // The blame gutter. Dim and monospaced-narrow: it sits beside code the
159
+ // reader came to read, so it must be legible without competing.
160
+ '.cm-gwBlameGutter': {
161
+ color: 'var(--gs-fg-faint)',
162
+ fontSize: 'var(--gs-t-meta)',
163
+ paddingRight: '10px',
164
+ borderRight: '1px solid var(--gs-border)',
165
+ marginRight: '6px',
166
+ },
167
+ '.cm-gwBlameCell': {
168
+ display: 'block',
169
+ maxWidth: '12ch',
170
+ overflow: 'hidden',
171
+ textOverflow: 'ellipsis',
172
+ whiteSpace: 'nowrap',
173
+ cursor: 'pointer',
174
+ },
175
+ })
176
+
177
+ export function CodeEditor({ value, original, onChange, syntax, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
178
+ /** The pane's buffer. The view is written to only when this really differs. */
179
+ value: string
180
+ /** The other side's whole text — the index side, for the unstaged layer this
181
+ * editor lives on. What the live tint is computed against. */
182
+ original: string
183
+ onChange: (next: string) => void
184
+ /** `highlightFile`'s runs for this buffer; undefined while a grammar loads. */
185
+ syntax: readonly (readonly HighlightRun[])[] | undefined
186
+ /** One indent level, from `detectIndent` — what Tab inserts. */
187
+ indent: string
188
+ ariaLabel: string
189
+ /** Ctrl/Cmd+S. Bound inside the view too: CodeMirror sees the key first, and
190
+ * a save shortcut that works everywhere except inside the editor is worse
191
+ * than none at all. */
192
+ onSave: () => void
193
+ /** Per-line provenance for the blame gutter, or null for "not showing".
194
+ * The gutter is added and removed with it, because an installed gutter with
195
+ * no markers still reserves its column. */
196
+ blame?: readonly BlameLine[] | null
197
+ /** The drawer's own wording for a line no commit covers yet. */
198
+ notCommitted?: string
199
+ /** A file the editor may show but must not change — a CRLF or non-UTF-8
200
+ * file, where any save would rewrite bytes nobody touched. */
201
+ readOnly?: boolean
202
+ /** A click in the blame gutter, with the 1-based line number. */
203
+ onBlameClick?: (line: number) => void
204
+ }): ReactNode {
205
+ /**
206
+ * Editable, and SAID to be editable, from one boolean.
207
+ *
208
+ * Nothing about rendered code looks like a field. In the Files tab this
209
+ * editor is live the moment a file opens — no button in between — so
210
+ * without a mark the reader learns they may type by typing, and learns
211
+ * they may NOT by typing into a pane that swallows it. The stylesheet
212
+ * draws a rule down the leading edge for this attribute and lights it
213
+ * while the caret is inside.
214
+ *
215
+ * Derived here rather than taken as a second prop so the mark and
216
+ * `EditorState.readOnly` below cannot disagree: a pane that shows the
217
+ * rule and then refuses the keystroke is worse than one with no rule.
218
+ */
219
+ const editable = readOnly !== true
220
+ const host = useRef<HTMLDivElement>(null)
221
+ const view = useRef<EditorView | null>(null)
222
+ // Read inside CodeMirror's own callbacks, which close over the render that
223
+ // created the view — several states old by the time a key is pressed.
224
+ const latest = useRef({ onChange, onSave, onBlameClick })
225
+ latest.current = { onChange, onSave, onBlameClick }
226
+ // Stable across renders so reconfiguring the gutter does not depend on a
227
+ // callback identity that changes every time the pane re-renders.
228
+ const pick = useRef((line: number) => { latest.current.onBlameClick?.(line) }).current
229
+
230
+ useEffect(() => {
231
+ const parent = host.current
232
+ if (parent === null) return
233
+ const extensions: Extension[] = [
234
+ lineNumbers(),
235
+ history(),
236
+ search({ top: true }),
237
+ highlightActiveLine(),
238
+ paintField,
239
+ blameField,
240
+ blameCompartment.of([]),
241
+ EditorState.readOnly.of(!editable),
242
+ originalText.init(() => original),
243
+ diffField,
244
+ paneTheme,
245
+ keymap.of([
246
+ { key: 'Mod-s', preventDefault: true, run: () => { latest.current.onSave(); return true } },
247
+ ...searchKeymap,
248
+ ...historyKeymap,
249
+ indentWithTab,
250
+ ...defaultKeymap,
251
+ ]),
252
+ EditorView.updateListener.of(update => {
253
+ if (update.docChanged) latest.current.onChange(update.state.doc.toString())
254
+ }),
255
+ EditorState.allowMultipleSelections.of(true),
256
+ EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
257
+ ]
258
+ const created = new EditorView({ state: EditorState.create({ doc: value, extensions }), parent })
259
+ view.current = created
260
+ // Arming dropped the caret into the buffer for the textarea too: the click
261
+ // that armed the editor said "I want to type here".
262
+ created.focus()
263
+ return () => { created.destroy(); view.current = null }
264
+ // Built once per armed session. `value` and `syntax` flow in through the
265
+ // effects below; rebuilding the view on either would drop the caret, the
266
+ // undo stack and the selection on every keystroke.
267
+ }, [])
268
+
269
+ // Blame arriving, changing, or being switched off. The gutter itself goes
270
+ // in and out through the compartment; the field carries the lines.
271
+ useEffect(() => {
272
+ const current = view.current
273
+ if (current === null) return
274
+ const lines = blame ?? null
275
+ current.dispatch({
276
+ effects: [
277
+ blameCompartment.reconfigure(lines === null ? [] : blameGutter(notCommitted ?? '', pick)),
278
+ setBlame.of(lines),
279
+ ],
280
+ })
281
+ }, [blame, notCommitted])
282
+
283
+ // The other side, when a refresh brings a new one in.
284
+ useEffect(() => {
285
+ const current = view.current
286
+ if (current === null) return
287
+ if (current.state.field(originalText, false) === original) return
288
+ current.dispatch({ effects: setOriginal.of(original) })
289
+ }, [original])
290
+
291
+ // The indent unit can change when the pane moves to another file.
292
+ useEffect(() => {
293
+ const current = view.current
294
+ if (current === null) return
295
+ current.dispatch({ effects: StateEffect.appendConfig.of(indentUnit.of(indent)) })
296
+ }, [indent])
297
+
298
+ // The pane's buffer, written in only when it really differs — a save's round
299
+ // trip hands back the same text, and rewriting it would move the caret.
300
+ useEffect(() => {
301
+ const current = view.current
302
+ if (current === null) return
303
+ const held = current.state.doc.toString()
304
+ if (held === value) return
305
+ current.dispatch({ changes: { from: 0, to: held.length, insert: value } })
306
+ }, [value])
307
+
308
+ // Repaint. Depends on `value` as well as `syntax` so it runs after the write
309
+ // above, against the text the view now actually holds.
310
+ useEffect(() => {
311
+ const current = view.current
312
+ if (current === null) return
313
+ current.dispatch({ effects: setPaint.of(paintFor(current.state.doc.toString(), syntax)) })
314
+ }, [syntax, value])
315
+
316
+ return <div ref={host} className={css.cmHost} data-editable={editable ? '' : undefined} />
317
+ }