@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6

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 (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CHANGELOG_EN.md +42 -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 +34811 -11818
  7. package/lib/fs-remove.js +73 -0
  8. package/lib/git-ops.js +25 -0
  9. package/lib/image-sniff.js +197 -0
  10. package/lib/index.js +404 -32
  11. package/lib/patch-model.js +223 -0
  12. package/lib/side-guard.js +55 -0
  13. package/lib/write-checked.js +164 -0
  14. package/package.json +7 -1
  15. package/src/apply-blocks.ts +215 -0
  16. package/src/atomic-json.ts +29 -5
  17. package/src/blame.ts +94 -0
  18. package/src/client/CodeEditor.tsx +317 -0
  19. package/src/client/FileBrowser.tsx +657 -0
  20. package/src/client/GitWorkbenchPanel.module.css +453 -7
  21. package/src/client/GitWorkbenchPanel.tsx +1465 -166
  22. package/src/client/ImageView.tsx +120 -0
  23. package/src/client/blame-gutter.ts +108 -0
  24. package/src/client/blame-view.ts +104 -0
  25. package/src/client/cm-diff.ts +108 -0
  26. package/src/client/cm-tokens.ts +79 -0
  27. package/src/client/diff-nav.ts +198 -0
  28. package/src/client/discard-flow.ts +82 -0
  29. package/src/client/file-icon.ts +190 -0
  30. package/src/client/file-rows.ts +184 -0
  31. package/src/client/files-place.ts +178 -0
  32. package/src/client/glyphs.tsx +86 -0
  33. package/src/client/highlight.ts +25 -0
  34. package/src/client/idle-value.ts +53 -0
  35. package/src/client/image-view.ts +106 -0
  36. package/src/client/indent.ts +74 -0
  37. package/src/client/index.ts +76 -9
  38. package/src/client/locales.ts +171 -4
  39. package/src/client/pane-size.ts +71 -0
  40. package/src/client/side-edit.ts +244 -0
  41. package/src/client/side-rows.ts +258 -0
  42. package/src/client/stable-list.ts +31 -0
  43. package/src/client/use-change-nav.ts +83 -0
  44. package/src/client/worktree-view.ts +11 -1
  45. package/src/fs-remove.ts +76 -0
  46. package/src/git-ops.ts +36 -1
  47. package/src/image-sniff.ts +204 -0
  48. package/src/index.ts +450 -32
  49. package/src/patch-model.ts +267 -0
  50. package/src/side-guard.ts +58 -0
  51. package/src/write-checked.ts +223 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Where the reader is in the Files tab — the file they have open, the folders
3
+ * they have opened, the search they typed, whether blame is on.
4
+ *
5
+ * This is separate from the browser component because the component unmounts:
6
+ * switching to Changes and back used to lose the selection and every expanded
7
+ * folder, which is the difference between a tab you return to and a tab you
8
+ * start over in. Keeping the place one level up means the tab remembers, and
9
+ * the cached path list means returning renders the tree at once instead of
10
+ * blanking while the repository is re-read.
11
+ *
12
+ * Expanded folders are NOT pruned against the current file list. A directory
13
+ * can leave `ls-tree` because a branch was checked out or a rebase is halfway
14
+ * through, and re-opening the same six folders every time that happens is the
15
+ * annoyance this module exists to remove — a stale entry renders nothing and
16
+ * costs one string. A file that has genuinely gone IS dropped, because an
17
+ * editor over a file that is not there would show an empty buffer as if the
18
+ * file itself were empty. That case is reported rather than silently applied:
19
+ * a selection that clears itself with no explanation reads as a bug.
20
+ *
21
+ * Pure: no React, no DOM, no git. `tests/files-place.test.ts` loads it.
22
+ *
23
+ * @module @young1lin/dsh-ui-gitworkbench/client/files-place
24
+ */
25
+
26
+ import { ancestorsOf } from './file-rows.ts'
27
+
28
+ /** The reader's position in the Files tab. Serialisable — no Set, no Map. */
29
+ export interface FilesPlace {
30
+ /** The open file's repo-relative path, or null for none. */
31
+ readonly open: string | null
32
+ /** Paths of the directories the reader has opened. */
33
+ readonly expanded: readonly string[]
34
+ /** The search box's text. */
35
+ readonly query: string
36
+ /** Whether the blame gutter is showing. */
37
+ readonly blameOn: boolean
38
+ }
39
+
40
+ /** Nothing open, nothing expanded — the tab before it is first used. */
41
+ export const NO_PLACE: FilesPlace = { open: null, expanded: [], query: '', blameOn: false }
42
+
43
+ /**
44
+ * Open a file: it becomes the selection, and every folder above it opens so
45
+ * that it is visible in the tree.
46
+ */
47
+ export function openAt(place: FilesPlace, path: string): FilesPlace {
48
+ const expanded = new Set(place.expanded)
49
+ for (const dir of ancestorsOf(path)) expanded.add(dir)
50
+ return { ...place, open: path, expanded: [...expanded] }
51
+ }
52
+
53
+ /** Fold or unfold one directory. */
54
+ export function toggleDir(place: FilesPlace, path: string): FilesPlace {
55
+ const expanded = new Set(place.expanded)
56
+ if (expanded.has(path)) expanded.delete(path)
57
+ else expanded.add(path)
58
+ return { ...place, expanded: [...expanded] }
59
+ }
60
+
61
+ /** What a reconciliation did, so the caller can say so. */
62
+ export interface Reconciled {
63
+ readonly place: FilesPlace
64
+ /** The path that was open and is not in the repository any more, else null. */
65
+ readonly vanished: string | null
66
+ }
67
+
68
+ /**
69
+ * Settle the place against a freshly read file list.
70
+ *
71
+ * @param place - the reader's position as it stands.
72
+ * @param paths - every path the repository now has. An EMPTY list is treated
73
+ * as "not read yet" rather than as "the repository is empty":
74
+ * the fetch is in flight for most of the time this runs, and
75
+ * blanking someone's selection on an in-flight fetch would
76
+ * clear it every time the tab is opened.
77
+ */
78
+ export function reconcilePlace(place: FilesPlace, paths: readonly string[]): Reconciled {
79
+ if (place.open === null || paths.length === 0) return { place, vanished: null }
80
+ if (paths.includes(place.open)) return { place, vanished: null }
81
+ return { place: { ...place, open: null }, vanished: place.open }
82
+ }
83
+
84
+ /**
85
+ * One place per worktree, because a worktree IS a different place: it holds
86
+ * different files, at different paths, and the file the reader had open may
87
+ * simply not exist in the one they switched to. Sharing a single place across
88
+ * sources also meant the tree kept rendering the PREVIOUS worktree's files
89
+ * until the new list arrived.
90
+ *
91
+ * Keyed by {@link pathKey}, so the same worktree is one entry however the path
92
+ * reached the drawer — `git worktree list` reports forward slashes and a
93
+ * session cwd arrives with the platform's own.
94
+ */
95
+ export type FilesPlaces = ReadonlyMap<string, FilesPlace>
96
+
97
+ /** This worktree's place, or a fresh one for a worktree not visited yet. */
98
+ export function placeAt(places: FilesPlaces, key: string): FilesPlace {
99
+ return places.get(key) ?? NO_PLACE
100
+ }
101
+
102
+ /**
103
+ * Record this worktree's place, leaving every other worktree's alone.
104
+ *
105
+ * The key is re-inserted rather than overwritten, so iteration order is
106
+ * least-recent first. That is what makes {@link encodePlaces}'s cap mean
107
+ * "the worktrees you actually work in" instead of "the first twenty you
108
+ * happened to open".
109
+ */
110
+ export function withPlace(places: FilesPlaces, key: string, place: FilesPlace): FilesPlaces {
111
+ const next = new Map(places)
112
+ next.delete(key)
113
+ next.set(key, place)
114
+ return next
115
+ }
116
+
117
+ /** Worktrees kept across restarts. Enough for the ones anybody actually works
118
+ * in; a fixture repository alone can have forty. */
119
+ export const PLACES_CAP = 20
120
+
121
+ /** One worktree's place as it is stored. */
122
+ interface StoredPlace {
123
+ readonly key: string
124
+ readonly open: string | null
125
+ readonly expanded: readonly string[]
126
+ readonly blame: boolean
127
+ }
128
+
129
+ /**
130
+ * The places, ready for storage: the most recent {@link PLACES_CAP}, without
131
+ * the search text.
132
+ *
133
+ * The search is deliberately dropped. Restoring it would filter the tree on a
134
+ * cold start, and a tree showing two rows out of nine hundred reads as broken
135
+ * when you do not remember typing anything — within one session you remember,
136
+ * a week later you do not.
137
+ */
138
+ export function encodePlaces(places: FilesPlaces): readonly StoredPlace[] {
139
+ const all = [...places.entries()]
140
+ const kept = all.length > PLACES_CAP ? all.slice(all.length - PLACES_CAP) : all
141
+ return kept.map(([key, place]) => ({
142
+ key,
143
+ open: place.open,
144
+ expanded: [...place.expanded],
145
+ blame: place.blameOn,
146
+ }))
147
+ }
148
+
149
+ /** Whether a parsed value is one stored place this build can use. */
150
+ function isStoredPlace(value: unknown): value is StoredPlace {
151
+ if (typeof value !== 'object' || value === null) return false
152
+ const entry = value as Record<string, unknown>
153
+ return typeof entry.key === 'string'
154
+ && (entry.open === null || typeof entry.open === 'string')
155
+ && Array.isArray(entry.expanded)
156
+ && entry.expanded.every(path => typeof path === 'string')
157
+ && typeof entry.blame === 'boolean'
158
+ }
159
+
160
+ /**
161
+ * Read places back. Anything unrecognisable is skipped rather than failing the
162
+ * whole list: a build that adds a field should cost the reader one worktree's
163
+ * memory, not all of them.
164
+ */
165
+ export function decodePlaces(value: unknown): FilesPlaces {
166
+ if (!Array.isArray(value)) return new Map()
167
+ const out = new Map<string, FilesPlace>()
168
+ for (const entry of value) {
169
+ if (!isStoredPlace(entry)) continue
170
+ out.set(entry.key, {
171
+ open: entry.open,
172
+ expanded: entry.expanded,
173
+ query: '',
174
+ blameOn: entry.blame,
175
+ })
176
+ }
177
+ return out
178
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The drawer's two node glyphs, shared by every view that names a file or a
3
+ * directory: the changes tree behind all three file tabs, the history filter's
4
+ * path picker, and the Files tab's repository browser. They live in their own
5
+ * module because the browser needs them too, and importing a value out of the
6
+ * panel that imports the browser would be a cycle.
7
+ *
8
+ * @module @young1lin/dsh-ui-gitworkbench/client/glyphs
9
+ */
10
+
11
+ import type { ReactNode } from 'react'
12
+
13
+ import css from './GitWorkbenchPanel.module.css'
14
+ import { fileIcon } from './file-icon.ts'
15
+
16
+ /**
17
+ * The two node glyphs, in IntelliJ's New UI icon idiom: a 16px grid, 1px
18
+ * strokes, no fill, rounded joins — outlines, where the old UI shipped filled
19
+ * silhouettes. Hand-drawn here rather than imported, because the bundle purity
20
+ * gate forbids an icon package and the drawer needs exactly these two; they
21
+ * are shapes in that language, not JetBrains' own assets.
22
+ *
23
+ * `strokeWidth` is 1 against a viewBox that renders 1:1 at 16px, so every
24
+ * stroke lands on a whole pixel instead of straddling two.
25
+ *
26
+ * Every place the drawer names a file or a directory uses these: the path
27
+ * picker in the history filter, and the file tree behind all three tabs. The
28
+ * CLASS names keep their `path` prefix — `scripts/verify_history_feature.py`
29
+ * selects the picker's file rows by `label:has([class*="pathFileGlyph"])`.
30
+ */
31
+ export function PathDirGlyph(): ReactNode {
32
+ return (
33
+ <svg
34
+ className={css.pathDirGlyph}
35
+ width="16" height="16" viewBox="0 0 16 16"
36
+ fill="none" stroke="currentColor" strokeWidth="1"
37
+ strokeLinejoin="round" strokeLinecap="round"
38
+ aria-hidden="true"
39
+ >
40
+ {/* Body, with the tab stepping up over the left third. The step is a
41
+ full 2px: at 1.3px it read as a rounded rectangle with a nick in it
42
+ rather than a folder. Every straight edge sits on a .5 coordinate so
43
+ a 1px stroke lands on one pixel instead of straddling two. */}
44
+ <path d="M2.5 12.75V4.25A.75.75 0 0 1 3.25 3.5H6l1.6 2h5.15A.75.75 0 0 1 13.5 6.25v6.5a.75.75 0 0 1-.75.75H3.25a.75.75 0 0 1-.75-.75Z" />
45
+ </svg>
46
+ )
47
+ }
48
+
49
+ /**
50
+ * @param path - the file's path, which decides its tint and monogram. Omitted
51
+ * where the caller has no path to give, and the sheet then
52
+ * paints in the row's own colour as it always did.
53
+ */
54
+ export function PathFileGlyph({ path }: { path?: string } = {}): ReactNode {
55
+ const icon = path === undefined ? null : fileIcon(path)
56
+ const paint = icon === null || icon.mono === '' ? undefined : icon.color
57
+ return (
58
+ <svg
59
+ className={css.pathFileGlyph}
60
+ width="16" height="16" viewBox="0 0 16 16"
61
+ fill="none" stroke={paint ?? 'currentColor'} strokeWidth="1"
62
+ strokeLinejoin="round" strokeLinecap="round"
63
+ aria-hidden="true"
64
+ >
65
+ {/* Sheet, cut back at the top-right for the fold. Narrower and one step
66
+ taller than the folder, sharing its optical band, so the two never
67
+ look like different-sized icons in one column. */}
68
+ <path d="M3.5 12.75V3.25A.75.75 0 0 1 4.25 2.5H9l3.5 3.5v6.75a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75Z" />
69
+ {/* The fold itself — the corner turned back on the sheet. */}
70
+ <path d="M9 2.5v2.75a.75.75 0 0 0 .75.75h2.75" />
71
+ {/* The language's monogram, sitting on the sheet's lower half. Stroke is
72
+ off for the text: a 1px stroke on a 6px glyph fills it in solid. */}
73
+ {paint === undefined ? null : (
74
+ <text
75
+ x="8" y={icon !== null && icon.mono.length > 1 ? 11.4 : 11.8}
76
+ textAnchor="middle"
77
+ fill={paint} stroke="none"
78
+ fontSize={icon !== null && icon.mono.length > 1 ? 5.5 : 7.5}
79
+ fontWeight="700"
80
+ letterSpacing={icon !== null && icon.mono.length > 1 ? -0.3 : 0}
81
+ fontFamily="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
82
+ >{icon?.mono}</text>
83
+ )}
84
+ </svg>
85
+ )
86
+ }
@@ -198,6 +198,31 @@ export function highlightFile(
198
198
  })
199
199
  }
200
200
 
201
+ /**
202
+ * Tokenize a file that really is one — the whole of it, in one pass.
203
+ *
204
+ * {@link highlightFile} re-lexes every non-comment line ON ITS OWN because a
205
+ * diff reconstruction is not a real file. That costs one Shiki call per line:
206
+ * measured on 1000 lines, the whole-file pass is 42ms and the thousand solo
207
+ * passes on top of it are another 644ms. It also throws away the only pass
208
+ * that knows about multi-line strings, block comments and template literals.
209
+ *
210
+ * When the lines ARE a complete file — the file browser's buffer, the diff
211
+ * pane's editor buffer — none of that applies: the file pass is both cheaper
212
+ * and more accurate, so it is the whole answer.
213
+ *
214
+ * @param lines - the file's lines, complete and in order.
215
+ * @param lang - from {@link shikiLangOf}.
216
+ * @param theme - from {@link shikiThemeOf}.
217
+ */
218
+ export function highlightWholeFile(
219
+ lines: readonly string[],
220
+ lang: string | undefined,
221
+ theme = 'github-dark-default',
222
+ ): HighlightRun[][] | undefined {
223
+ return tokenizeLines(lines, lang, theme)
224
+ }
225
+
201
226
  function looksLikeCommentLine(text: string): boolean {
202
227
  const t = text.trimStart()
203
228
  return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#')
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A value that lags its source until the source stops changing.
3
+ *
4
+ * Syntax highlighting is the reason this exists. Recomputing it per keystroke
5
+ * costs 42ms on a thousand-line file and scales linearly, so a fast typist
6
+ * spends the whole session behind the main thread. CodeMirror maps its
7
+ * existing decorations through document changes, so the colours already ride
8
+ * along with the text while this waits — the repaint only has to be prompt
9
+ * enough that the reader never notices the last few characters were plain.
10
+ *
11
+ * Not a pure function, so no unit test: it is six lines of standard React and
12
+ * its behaviour is a timer. The rules it implements are stated above.
13
+ *
14
+ * @module @young1lin/dsh-ui-gitworkbench/client/idle-value
15
+ */
16
+
17
+ import { useEffect, useState } from 'react'
18
+
19
+ /**
20
+ * @param value - the live value.
21
+ * @param ms - how long the value must hold still before it is adopted.
22
+ * @returns the last value that stayed put for `ms`.
23
+ */
24
+ export function useIdleValue<T>(value: T, ms: number): T {
25
+ const [held, setHeld] = useState(value)
26
+ useEffect(() => {
27
+ if (Object.is(held, value)) return
28
+ const timer = setTimeout(() => { setHeld(value) }, ms)
29
+ return () => { clearTimeout(timer) }
30
+ }, [value, ms, held])
31
+ return held
32
+ }
33
+
34
+ /** How long typing must pause before the highlight is recomputed. Long enough
35
+ * that a burst of typing costs one repaint, short enough that the pause after
36
+ * a word is already over by the time the eye gets back to the line. */
37
+ export const HIGHLIGHT_IDLE_MS = 180
38
+
39
+ /**
40
+ * Lines past which a file is shown uncoloured.
41
+ *
42
+ * Shiki costs about 0.3ms a line, and that is the whole-file pass alone — the
43
+ * floor, not an implementation detail we can tune away. Measured: 155ms at 500
44
+ * lines, 620ms at 2000, 1537ms at 5000. Debouncing keeps a burst of typing to
45
+ * one repaint, but it cannot make one repaint cheap, and a 1.5-second freeze
46
+ * every time the reader pauses is worse than plain text.
47
+ *
48
+ * So above this the file renders without colour and says so. The honest fix is
49
+ * to highlight only the viewport, which needs the tokens to be computed where
50
+ * the scroll position is known rather than in the pane; this cap is what holds
51
+ * until then.
52
+ */
53
+ export const HIGHLIGHT_LINE_CAP = 2_000
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The image preview's rules: when to ask the host for bytes, how to turn the
3
+ * answer into something an `<img>` can point at, and how to caption it.
4
+ *
5
+ * The gate here is deliberately weaker than the host's. The host decides what
6
+ * an image IS, by reading the signature; this only decides what is worth
7
+ * ASKING about, and getting that wrong costs a wasted round trip rather than a
8
+ * wrong render. So the extension is allowed to be the hint — in the one
9
+ * direction where a hint cannot hurt.
10
+ *
11
+ * Pure: no React, no DOM beyond the base64 decoder every browser and every
12
+ * supported node ship. `tests/image-view.test.ts` loads it directly.
13
+ *
14
+ * @module @young1lin/dsh-ui-gitworkbench/client/image-view
15
+ */
16
+
17
+ /**
18
+ * Extensions worth one round trip.
19
+ *
20
+ * Exactly the formats the host's sniffer admits, because a name the sniffer
21
+ * would refuse anyway is a request with a known answer. Absent on purpose:
22
+ * `.tif` and `.heic` are images that browsers will not draw, so asking would
23
+ * end in "could not decode" where "binary file" is the more honest reply.
24
+ */
25
+ const IMAGE_EXTS: ReadonlySet<string> = new Set([
26
+ 'png', 'jpg', 'jpeg', 'jpe', 'gif', 'webp', 'bmp', 'dib', 'ico', 'cur', 'avif', 'svg',
27
+ ])
28
+
29
+ /** Does this path's name suggest an image? A hint, never a verdict. */
30
+ export function looksLikeImagePath(path: string): boolean {
31
+ const name = (path.split('/').pop() ?? '').toLowerCase()
32
+ const dot = name.lastIndexOf('.')
33
+ if (dot <= 0) return false
34
+ return IMAGE_EXTS.has(name.slice(dot + 1))
35
+ }
36
+
37
+ /**
38
+ * Should the view ask the host for image bytes, given what the text side said?
39
+ *
40
+ * Two different reasons, so two different rules:
41
+ *
42
+ * - `binary` is asked about unconditionally. It is the case that matters —
43
+ * the text path has already given up, so the round trip either produces a
44
+ * picture or reproduces the same dead end. An extensionless PNG is found
45
+ * here and nowhere else.
46
+ * - `tooLarge` is asked about only when the NAME agrees, because the text
47
+ * guard cuts at a smaller size than the image cap and a large file that is
48
+ * not an image is usually a large file of text. Asking anyway would have
49
+ * the host read a multi-megabyte log to conclude what its name already
50
+ * said.
51
+ */
52
+ export function shouldAskForImage(
53
+ path: string,
54
+ sides: { readonly binary: boolean; readonly tooLarge: boolean },
55
+ ): boolean {
56
+ if (sides.binary) return true
57
+ return sides.tooLarge && looksLikeImagePath(path)
58
+ }
59
+
60
+ /** base64 to bytes, for the Blob the `<img>` points at. */
61
+ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
62
+ const binary = atob(base64)
63
+ // Backed by an explicit ArrayBuffer rather than the default: a Blob part
64
+ // must not be a view over a SharedArrayBuffer, and the default type is the
65
+ // union of both.
66
+ const out = new Uint8Array(new ArrayBuffer(binary.length))
67
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i)
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * A byte count as a reader reads it.
73
+ *
74
+ * 1024-based with KB/MB labels — the convention every editor and browser
75
+ * devtools uses, so the number agrees with the one the reader would see
76
+ * anywhere else for the same file.
77
+ */
78
+ export function formatBytes(bytes: number): string {
79
+ if (!Number.isFinite(bytes) || bytes < 0) return ''
80
+ if (bytes < 1024) return `${bytes} B`
81
+ const kb = bytes / 1024
82
+ if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)} KB`
83
+ const mb = kb / 1024
84
+ return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)} MB`
85
+ }
86
+
87
+ /**
88
+ * The line under the picture: what it is, how big it is on screen, how big it
89
+ * is on disk.
90
+ *
91
+ * Dimensions are omitted rather than guessed when the browser reports none —
92
+ * an SVG with no intrinsic size is the ordinary case, and `0 × 0` under a
93
+ * picture that is plainly there reads as a bug.
94
+ */
95
+ export function imageCaption(
96
+ kind: string,
97
+ bytes: number,
98
+ size: { readonly width: number; readonly height: number } | null,
99
+ ): string {
100
+ const parts: string[] = []
101
+ if (kind.length > 0) parts.push(kind)
102
+ if (size !== null && size.width > 0 && size.height > 0) parts.push(`${size.width} × ${size.height}`)
103
+ const weight = formatBytes(bytes)
104
+ if (weight.length > 0) parts.push(weight)
105
+ return parts.join(' · ')
106
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The indentation unit a file uses, learned from the file itself.
3
+ *
4
+ * The editor's Tab key is CodeMirror's `indentWithTab`, which inserts and
5
+ * removes whatever `indentUnit` is configured with — so the only decision left
6
+ * is what that unit should be, and it is not a setting. This pane saves WHOLE
7
+ * files: indenting with two spaces inside a four-space project writes
8
+ * whitespace the project's own formatter will fight, and shows up in the next
9
+ * diff as a change to lines nobody edited.
10
+ *
11
+ * So the unit is detected: a tab-indented Go file gets a tab, four-space Java
12
+ * gets four.
13
+ *
14
+ * Pure: no React, no DOM, no git. `tests/indent.test.ts` loads it directly.
15
+ *
16
+ * @module @young1lin/dsh-ui-gitworkbench/client/indent
17
+ */
18
+
19
+ /** Fallback unit for a file with no indentation to learn from. */
20
+ export const DEFAULT_INDENT = ' '
21
+
22
+ /**
23
+ * The file's own indentation unit, learned from its lines.
24
+ *
25
+ * Tabs win on count, because a single tab-indented line is unambiguous while
26
+ * spaces need a step to be inferred. For spaces the step is the most common
27
+ * NON-ZERO difference between the indents of consecutive lines — the same
28
+ * reasoning editors use, and the reason a file whose every line happens to sit
29
+ * at four spaces still reports four rather than its total depth.
30
+ *
31
+ * @param text - the whole buffer.
32
+ * @returns the unit to insert: a tab, N spaces, or {@link DEFAULT_INDENT}.
33
+ */
34
+ export function detectIndent(text: string): string {
35
+ const lines = text.split('\n')
36
+ let tabs = 0
37
+ let spaced = 0
38
+ const widths: number[] = []
39
+ for (const line of lines) {
40
+ if (line.trim().length === 0) continue
41
+ if (line.startsWith('\t')) { tabs += 1; continue }
42
+ const width = line.length - line.trimStart().length
43
+ if (width > 0) spaced += 1
44
+ widths.push(width)
45
+ }
46
+ if (tabs > spaced) return '\t'
47
+
48
+ // The unit divides every level the file uses, so it is the GCD of the
49
+ // indents themselves — not the most common step between consecutive lines.
50
+ // A Java file with a text block indents its body by two levels at once, and
51
+ // counting steps makes that 8 the winner in a file indented by 4; 12 is not
52
+ // a multiple of 8, so a GCD cannot make that mistake.
53
+ //
54
+ // Widths seen only ONCE are set aside first: a single continuation line
55
+ // aligned under an open paren is at an arbitrary column, and one of those
56
+ // would drag the GCD down to 1. If nothing repeats there is no majority to
57
+ // protect, so the second pass reads them all.
58
+ const seen = new Map<number, number>()
59
+ for (const width of widths) {
60
+ if (width > 0) seen.set(width, (seen.get(width) ?? 0) + 1)
61
+ }
62
+ const repeated = [...seen].filter(([, count]) => count > 1).map(([width]) => width)
63
+ const unit = gcdOf(repeated.length > 0 ? repeated : [...seen.keys()])
64
+ return unit === 0 ? DEFAULT_INDENT : ' '.repeat(unit)
65
+ }
66
+
67
+ /** GCD of a list, 0 for an empty one. */
68
+ function gcdOf(values: readonly number[]): number {
69
+ return values.reduce((a, b) => gcd(a, b), 0)
70
+ }
71
+
72
+ function gcd(a: number, b: number): number {
73
+ return b === 0 ? a : gcd(b, a % b)
74
+ }
@@ -19,8 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-runtime' // informational inject ed
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-slots' // SlotMap is reused, not extended
20
20
  import {
21
21
  GitWorkbenchPanel,
22
- type DiscardPreview, type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
22
+ type DiscardAnswer, type DiscardPreview, type GitCommit, type GitOpName, type GitOpPayload, type GitOpResult,
23
23
  type WorkbenchStats, type SyncStatus, type WorktreeStatus,
24
+ type BlameAnswer, type FileImage, type FileSides, type SideLayer, type WriteResult,
24
25
  } from './GitWorkbenchPanel.tsx'
25
26
  import type { StyleEntry, StyleScope, StyleSettings } from './themes.ts'
26
27
  import type { LogFilter } from '../log-filter.ts'
@@ -92,6 +93,64 @@ export function apply(ctx: ClientContext): void {
92
93
  ) as { ok: true; value: WorkbenchStats } | { ok: false; error: { message?: string } }
93
94
  return result.ok ? result.value : null
94
95
  },
96
+ // One layer of one file for the side-by-side diff pane: that layer's
97
+ // full-context diff, the right-hand text, and the shas block mutations
98
+ // will check against. Null (an older host half, a failed call) makes
99
+ // the pane fall back to the unified view rather than go blank.
100
+ fetchFileSides: async (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal): Promise<FileSides | null> => {
101
+ const result = await connection.rpc.call(
102
+ '/api',
103
+ 'gitWorkbench/fileSides',
104
+ { args: { worktreePath: worktreePath ?? '', path, layer } },
105
+ signal,
106
+ ) as { ok: true; value: FileSides } | { ok: false; error: { message?: string } }
107
+ return result.ok ? result.value : null
108
+ },
109
+ // One file's blame for the side pane's gutter. Read-only, so a
110
+ // failure is simply no gutter: null makes the toggle report that
111
+ // blame is unavailable rather than leaving an empty column that
112
+ // looks like the file has no history.
113
+ fetchBlame: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<BlameAnswer | null> => {
114
+ const result = await connection.rpc.call(
115
+ '/api',
116
+ 'gitWorkbench/blame',
117
+ { args: { worktreePath: worktreePath ?? '', path } },
118
+ signal,
119
+ ) as { ok: true; value: BlameAnswer } | { ok: false; error: { message?: string } }
120
+ return result.ok ? result.value : null
121
+ },
122
+ // One file's bytes, when the host's signature check confirms they are
123
+ // an image. Asked only for a file the text side has already declined,
124
+ // so null — an older host half with no such method, a failed call —
125
+ // simply leaves that decline standing.
126
+ fetchFileImage: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<FileImage | null> => {
127
+ const result = await connection.rpc.call(
128
+ '/api',
129
+ 'gitWorkbench/fileImage',
130
+ { args: { worktreePath: worktreePath ?? '', path } },
131
+ signal,
132
+ ) as { ok: true; value: FileImage } | { ok: false; error: { message?: string } }
133
+ return result.ok ? result.value : null
134
+ },
135
+ // Save the side pane's editor buffer. The buffer travels with the blob
136
+ // sha it was opened with and the host re-derives that sha at the moment
137
+ // of the write, so a file that moved underneath the editor comes back
138
+ // `failure: 'stale'` with NOTHING written — null (transport failure, or
139
+ // a host half older than this client) reads as a failed save the same
140
+ // way every write op here folds its transport errors.
141
+ writeChecked: async (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal): Promise<WriteResult | null> => {
142
+ try {
143
+ const result = await connection.rpc.call(
144
+ '/api',
145
+ 'gitWorkbench/writeChecked',
146
+ { args: { worktreePath: worktreePath ?? '', path, text, expectedSha } },
147
+ signal,
148
+ ) as { ok: true; value: WriteResult } | { ok: false; error: { message?: string } }
149
+ return result.ok ? result.value : null
150
+ } catch {
151
+ return null
152
+ }
153
+ },
95
154
  // One page of the commit log past what `stats` bundles, so the history
96
155
  // list can grow instead of stopping at the first page. The filter is
97
156
  // compiled into git log arguments host-side (IDEA-style pushdown).
@@ -211,14 +270,22 @@ export function apply(ctx: ClientContext): void {
211
270
  // is the difference between "goes back to its committed content" and
212
271
  // "leaves the disk and cannot come back" — which is the entire question
213
272
  // the dialog exists to ask.
214
- fetchDiscardPlan: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<DiscardPreview | null> => {
215
- const result = await connection.rpc.call(
216
- '/api',
217
- 'gitWorkbench/discardPlan',
218
- { args: { worktreePath: worktreePath ?? '', path } },
219
- signal,
220
- ) as { ok: true; value: DiscardPreview } | { ok: false; error: { message?: string } }
221
- return result.ok ? result.value : null
273
+ fetchDiscardPlan: async (worktreePath: string | undefined, path: string, signal: AbortSignal): Promise<DiscardAnswer> => {
274
+ // A throw here used to be nobody's: the click had already put the
275
+ // drawer into "asking the host", and an unhandled rejection left it
276
+ // there with no dialog and no way back except closing the drawer.
277
+ try {
278
+ const result = await connection.rpc.call(
279
+ '/api',
280
+ 'gitWorkbench/discardPlan',
281
+ { args: { worktreePath: worktreePath ?? '', path } },
282
+ signal,
283
+ ) as { ok: boolean; value?: DiscardPreview; error?: { message?: string } }
284
+ if (result.ok && result.value !== undefined) return { kind: 'plan', plan: result.value }
285
+ return { kind: 'failed', error: result.error?.message ?? '' }
286
+ } catch (error) {
287
+ return { kind: 'failed', error: error instanceof Error ? error.message : String(error) }
288
+ }
222
289
  },
223
290
  runGitOp: async (op: GitOpName, worktreePath: string | undefined, payload: GitOpPayload, signal: AbortSignal): Promise<GitOpResult> => {
224
291
  const result = await connection.rpc.call(