@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.
- package/CHANGELOG.md +42 -0
- package/CHANGELOG_EN.md +42 -0
- package/lib/apply-blocks.js +159 -0
- package/lib/atomic-json.js +23 -5
- package/lib/blame.js +83 -0
- package/lib/client.js +34811 -11818
- package/lib/fs-remove.js +73 -0
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +404 -32
- package/lib/patch-model.js +223 -0
- package/lib/side-guard.js +55 -0
- package/lib/write-checked.js +164 -0
- package/package.json +7 -1
- package/src/apply-blocks.ts +215 -0
- package/src/atomic-json.ts +29 -5
- package/src/blame.ts +94 -0
- package/src/client/CodeEditor.tsx +317 -0
- package/src/client/FileBrowser.tsx +657 -0
- package/src/client/GitWorkbenchPanel.module.css +453 -7
- package/src/client/GitWorkbenchPanel.tsx +1465 -166
- package/src/client/ImageView.tsx +120 -0
- package/src/client/blame-gutter.ts +108 -0
- package/src/client/blame-view.ts +104 -0
- package/src/client/cm-diff.ts +108 -0
- package/src/client/cm-tokens.ts +79 -0
- package/src/client/diff-nav.ts +198 -0
- package/src/client/discard-flow.ts +82 -0
- package/src/client/file-icon.ts +190 -0
- package/src/client/file-rows.ts +184 -0
- package/src/client/files-place.ts +178 -0
- package/src/client/glyphs.tsx +86 -0
- package/src/client/highlight.ts +25 -0
- package/src/client/idle-value.ts +53 -0
- package/src/client/image-view.ts +106 -0
- package/src/client/indent.ts +74 -0
- package/src/client/index.ts +76 -9
- package/src/client/locales.ts +171 -4
- package/src/client/pane-size.ts +71 -0
- package/src/client/side-edit.ts +244 -0
- package/src/client/side-rows.ts +258 -0
- package/src/client/stable-list.ts +31 -0
- package/src/client/use-change-nav.ts +83 -0
- package/src/client/worktree-view.ts +11 -1
- package/src/fs-remove.ts +76 -0
- package/src/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +450 -32
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- 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
|
+
}
|
package/src/client/highlight.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -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<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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(
|