@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.
- package/CHANGELOG.md +43 -0
- package/CHANGELOG_EN.md +43 -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 +34960 -11826
- package/lib/git-ops.js +25 -0
- package/lib/image-sniff.js +197 -0
- package/lib/index.js +401 -7
- 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 +491 -12
- package/src/client/GitWorkbenchPanel.tsx +1655 -190
- 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/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/history-layout.ts +52 -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 +59 -0
- package/src/client/locales.ts +179 -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/git-ops.ts +36 -1
- package/src/image-sniff.ts +204 -0
- package/src/index.ts +447 -7
- package/src/patch-model.ts +267 -0
- package/src/side-guard.ts +58 -0
- package/src/write-checked.ts +223 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Files tab: open any file in the repository, read it, blame it, edit it.
|
|
3
|
+
*
|
|
4
|
+
* The Changes tab lists what git has something to say about, which is the
|
|
5
|
+
* wrong list for "who wrote this line" — that question is almost always about
|
|
6
|
+
* a file nobody has touched today. So this view browses the repository itself:
|
|
7
|
+
* the tracked paths `repoTree` reports, plus whatever exists in the working
|
|
8
|
+
* tree but not in HEAD, because a browser that cannot open the file you just
|
|
9
|
+
* created reads as broken rather than as principled.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here needed a new host RPC. `fileSides` already carries the whole
|
|
12
|
+
* working-tree text (it is the editor's initial buffer in the diff pane) along
|
|
13
|
+
* with the binary, size and encoding guards attached to it; `writeChecked`
|
|
14
|
+
* already refuses a save whose sha moved; `blame` already answers per line.
|
|
15
|
+
* This view is those three, arranged around a tree.
|
|
16
|
+
*
|
|
17
|
+
* Blame renders in a CodeMirror gutter rather than a column of the pane's own
|
|
18
|
+
* grid, because here the file IS the editor. It is withheld while the buffer
|
|
19
|
+
* is dirty: once lines have been typed the numbers no longer match the commits
|
|
20
|
+
* behind them, and an annotation quietly pointing at the wrong line is worse
|
|
21
|
+
* than no annotation.
|
|
22
|
+
*
|
|
23
|
+
* @module @young1lin/dsh-ui-gitworkbench/client/FileBrowser
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
27
|
+
|
|
28
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
29
|
+
import { CodeEditor } from './CodeEditor.tsx'
|
|
30
|
+
import { buildDirTree } from './dir-tree.ts'
|
|
31
|
+
import { mergePaths, rootFiles, searchRows, treeRows, type FileRow } from './file-rows.ts'
|
|
32
|
+
import { openAt, reconcilePlace, toggleDir, type FilesPlace } from './files-place.ts'
|
|
33
|
+
import { sameList } from './stable-list.ts'
|
|
34
|
+
import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
|
|
35
|
+
import { ImageView, type Picture } from './ImageView.tsx'
|
|
36
|
+
import { decodeBase64, formatBytes, looksLikeImagePath, shouldAskForImage } from './image-view.ts'
|
|
37
|
+
import { IMAGE_BYTE_CAP, sniffImage } from '../image-sniff.ts'
|
|
38
|
+
import { blameWhen, shortHash } from './blame-view.ts'
|
|
39
|
+
import { emptyQueryFilter, serializeLogQuery } from './log-filter-query.ts'
|
|
40
|
+
import { highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded } from './highlight.ts'
|
|
41
|
+
import { HIGHLIGHT_IDLE_MS, HIGHLIGHT_LINE_CAP, useIdleValue } from './idle-value.ts'
|
|
42
|
+
import { detectIndent } from './indent.ts'
|
|
43
|
+
import {
|
|
44
|
+
DISARMED, applySaveOk, applySides, armEdit, armRefusal, isDirty, markConflict,
|
|
45
|
+
type EditState, type WriteResult,
|
|
46
|
+
} from './side-edit.ts'
|
|
47
|
+
import type { BlameAnswer, BlameLine, FileImage, FileSides, SideLayer, Translate } from './GitWorkbenchPanel.tsx'
|
|
48
|
+
|
|
49
|
+
/** Count lines without allocating the split — the buffer can be megabytes and
|
|
50
|
+
* this runs on a timer while somebody is typing. */
|
|
51
|
+
function countLines(text: string): number {
|
|
52
|
+
let n = 1
|
|
53
|
+
for (let i = text.indexOf(String.fromCharCode(10)); i !== -1; i = text.indexOf(String.fromCharCode(10), i + 1)) n += 1
|
|
54
|
+
return n
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Most search hits rendered at once — a one-letter query must not paint a
|
|
58
|
+
* whole repository into the DOM. */
|
|
59
|
+
const SEARCH_CAP = 300
|
|
60
|
+
|
|
61
|
+
/** Indent per tree level, in em. Matches the changes tree's own step. */
|
|
62
|
+
const INDENT_EM = 0.85
|
|
63
|
+
|
|
64
|
+
/** Files rendered per directory before the rest become one "and N others"
|
|
65
|
+
* row. A generated directory can hold thousands, and every row is a button
|
|
66
|
+
* and two icons — the cost is DOM nodes, not the walk that produced them.
|
|
67
|
+
* The history filter's path picker has capped at this number since it
|
|
68
|
+
* shipped; the search box is the way to a file in a crowded directory. */
|
|
69
|
+
const FILES_PER_DIR = 100
|
|
70
|
+
|
|
71
|
+
export function FileBrowser({
|
|
72
|
+
t, palette, statsPath, extraPaths, gen, treeStyle, treeRef, divider, place, onPlace, cached, onTree,
|
|
73
|
+
fetchRepoTree, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, onSaved, onDirtyChange, onShowHistory,
|
|
74
|
+
}: {
|
|
75
|
+
t: Translate
|
|
76
|
+
palette: string
|
|
77
|
+
statsPath: string | undefined
|
|
78
|
+
/** Working-tree paths that are not in HEAD — untracked files, which
|
|
79
|
+
* `git ls-tree` cannot know about. Deleted files are filtered out by the
|
|
80
|
+
* caller: opening one would only fail. */
|
|
81
|
+
extraPaths: readonly string[]
|
|
82
|
+
/** Refresh generation; a new one re-reads the tree and the open file. */
|
|
83
|
+
gen: number
|
|
84
|
+
treeStyle: CSSProperties | undefined
|
|
85
|
+
treeRef: { current: HTMLDivElement | null }
|
|
86
|
+
/** The drawer's own drag handle, passed in rather than imported, so this
|
|
87
|
+
* module does not import a value out of the panel that imports it. */
|
|
88
|
+
divider: ReactNode
|
|
89
|
+
fetchRepoTree: (worktreePath: string | undefined, signal: AbortSignal) => Promise<{ paths: string[]; truncated: boolean } | null>
|
|
90
|
+
fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
91
|
+
writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
92
|
+
fetchBlame: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<BlameAnswer | null>
|
|
93
|
+
/** One file's bytes, when the host confirms they are an image. */
|
|
94
|
+
fetchFileImage: (worktreePath: string | undefined, path: string, signal: AbortSignal) => Promise<FileImage | null>
|
|
95
|
+
/** After a successful save: the drawer re-reads, since the file moved. */
|
|
96
|
+
onSaved: () => void
|
|
97
|
+
/** The unsaved-edits flag the drawer guards its own gestures on. */
|
|
98
|
+
onDirtyChange: (dirty: boolean) => void
|
|
99
|
+
/** Hand a filter query to the History tab and switch to it. */
|
|
100
|
+
onShowHistory: (query: string) => void
|
|
101
|
+
/** Where the reader is — held above this component because this component
|
|
102
|
+
* unmounts every time the reader looks at another tab. */
|
|
103
|
+
place: FilesPlace
|
|
104
|
+
onPlace: (next: FilesPlace) => void
|
|
105
|
+
/** The last file list read, kept for the same reason: coming back should
|
|
106
|
+
* render the tree, not blank it while the repository is re-read. */
|
|
107
|
+
cached: { readonly paths: readonly string[]; readonly truncated: boolean }
|
|
108
|
+
onTree: (next: { readonly paths: readonly string[]; readonly truncated: boolean }) => void
|
|
109
|
+
}): ReactNode {
|
|
110
|
+
const { open, query, blameOn } = place
|
|
111
|
+
const { paths, truncated } = cached
|
|
112
|
+
const expanded = useMemo(() => new Set(place.expanded), [place.expanded])
|
|
113
|
+
/** A file that was open and is not in the repository any more. Reported
|
|
114
|
+
* rather than silently applied: a selection that clears itself with no
|
|
115
|
+
* explanation reads as a bug. */
|
|
116
|
+
const [vanished, setVanished] = useState<string | null>(null)
|
|
117
|
+
const [sides, setSides] = useState<FileSides | null>(null)
|
|
118
|
+
const [loading, setLoading] = useState(false)
|
|
119
|
+
const [edit, setEdit] = useState<EditState>(DISARMED)
|
|
120
|
+
const [saving, setSaving] = useState(false)
|
|
121
|
+
const [saveFailed, setSaveFailed] = useState<string | null>(null)
|
|
122
|
+
const [blame, setBlame] = useState<BlameAnswer | null>(null)
|
|
123
|
+
/**
|
|
124
|
+
* The host's answer about this file's bytes, tagged with the path it is
|
|
125
|
+
* about. The tag is what keeps the PREVIOUS file's picture off the screen
|
|
126
|
+
* while the next one is being fetched — without it, clicking through a
|
|
127
|
+
* directory of screenshots shows each one under the following one's name.
|
|
128
|
+
*/
|
|
129
|
+
const [image, setImage] = useState<{ readonly path: string; readonly answer: FileImage | null } | null>(null)
|
|
130
|
+
/** Reading an SVG's markup rather than looking at the picture it draws. */
|
|
131
|
+
const [asSource, setAsSource] = useState(false)
|
|
132
|
+
// "Asked and got nothing" must not render as "not asked": a toggle that
|
|
133
|
+
// lights up and then shows an empty gutter reads as broken.
|
|
134
|
+
const [blameFailed, setBlameFailed] = useState(false)
|
|
135
|
+
/** A file click held back by unsaved edits, waiting on the reader's answer. */
|
|
136
|
+
const [pending, setPending] = useState<string | null>(null)
|
|
137
|
+
/** The line whose commit the reader asked to see, 1-based. */
|
|
138
|
+
const [picked, setPicked] = useState<number | null>(null)
|
|
139
|
+
const [refetch, setRefetch] = useState(0)
|
|
140
|
+
/** Which file the buffer currently belongs to, so a refetch can be told
|
|
141
|
+
* apart from a genuine open. */
|
|
142
|
+
const openRef = useRef<string | null>(null)
|
|
143
|
+
/**
|
|
144
|
+
* Files this view has actually shown, on disk, since it mounted.
|
|
145
|
+
*
|
|
146
|
+
* The vanished notice means "it disappeared while you were here". A place
|
|
147
|
+
* restored from a previous run carries a path that may have been deleted
|
|
148
|
+
* days ago, and reporting that on the first open would be an apology for
|
|
149
|
+
* something the reader does not remember doing — so a path that was never
|
|
150
|
+
* successfully shown is dropped in silence instead.
|
|
151
|
+
*/
|
|
152
|
+
const shownRef = useRef<Set<string>>(new Set())
|
|
153
|
+
// A grammar loads asynchronously the first time a language is seen; this
|
|
154
|
+
// counter re-renders the highlight once it lands.
|
|
155
|
+
const [, setGrammarTick] = useState(0)
|
|
156
|
+
|
|
157
|
+
const dirty = isDirty(edit)
|
|
158
|
+
|
|
159
|
+
useEffect(() => subscribeGrammarLoaded(() => { setGrammarTick(n => n + 1) }), [])
|
|
160
|
+
useEffect(() => { onDirtyChange(dirty) }, [dirty, onDirtyChange])
|
|
161
|
+
|
|
162
|
+
// The repository's paths.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const ctrl = new AbortController()
|
|
165
|
+
let alive = true
|
|
166
|
+
void fetchRepoTree(statsPath, ctrl.signal)
|
|
167
|
+
.then(answer => {
|
|
168
|
+
if (!alive || answer === null) return
|
|
169
|
+
onTree({ paths: answer.paths, truncated: answer.truncated })
|
|
170
|
+
})
|
|
171
|
+
.catch(() => { /* an old host half: the tree stays empty and says so */ })
|
|
172
|
+
return () => { alive = false; ctrl.abort() }
|
|
173
|
+
}, [fetchRepoTree, statsPath, gen, onTree])
|
|
174
|
+
|
|
175
|
+
// The open file's text. `unstaged` is the layer whose target is the file on
|
|
176
|
+
// disk — the only one this view is about.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (open === null) { setSides(null); setEdit(DISARMED); return }
|
|
179
|
+
const ctrl = new AbortController()
|
|
180
|
+
let alive = true
|
|
181
|
+
setLoading(true)
|
|
182
|
+
void fetchFileSides(statsPath, open, 'unstaged', ctrl.signal)
|
|
183
|
+
.then(answer => {
|
|
184
|
+
if (!alive) return
|
|
185
|
+
setSides(answer)
|
|
186
|
+
if (answer === null) { setEdit(DISARMED); return }
|
|
187
|
+
// A NEW file adopts the payload outright; a refetch of the SAME file
|
|
188
|
+
// — the drawer's poll, a worktree switch, the refetch a refused save
|
|
189
|
+
// triggers — goes through applySides, which keeps a dirty buffer and
|
|
190
|
+
// only records that the file moved underneath. Without that split,
|
|
191
|
+
// every poll silently threw away whatever had been typed since the
|
|
192
|
+
// last one. Armed straight away because this view IS an editor and
|
|
193
|
+
// there is no gesture to arm with; armEdit still refuses a payload
|
|
194
|
+
// it must not hold.
|
|
195
|
+
const fresh = openRef.current !== open
|
|
196
|
+
openRef.current = open
|
|
197
|
+
// An empty target sha means git has no blob for it: the path is not a
|
|
198
|
+
// file on disk, so this view never showed it.
|
|
199
|
+
if (answer.targetSha.length > 0 && open !== null) shownRef.current.add(open)
|
|
200
|
+
setEdit(prev => fresh ? armEdit(DISARMED, answer) : applySides(prev, answer))
|
|
201
|
+
})
|
|
202
|
+
.catch(() => { if (alive) { setSides(null); setEdit(DISARMED) } })
|
|
203
|
+
.finally(() => { if (alive) setLoading(false) })
|
|
204
|
+
return () => { alive = false; ctrl.abort() }
|
|
205
|
+
}, [open, fetchFileSides, statsPath, gen, refetch])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!blameOn || open === null) { setBlame(null); setBlameFailed(false); return }
|
|
209
|
+
const ctrl = new AbortController()
|
|
210
|
+
let alive = true
|
|
211
|
+
setBlameFailed(false)
|
|
212
|
+
void fetchBlame(statsPath, open, ctrl.signal)
|
|
213
|
+
.then(answer => {
|
|
214
|
+
if (!alive) return
|
|
215
|
+
setBlame(answer)
|
|
216
|
+
setBlameFailed(answer === null)
|
|
217
|
+
})
|
|
218
|
+
.catch(() => { if (alive) { setBlame(null); setBlameFailed(true) } })
|
|
219
|
+
return () => { alive = false; ctrl.abort() }
|
|
220
|
+
}, [blameOn, open, fetchBlame, statsPath, gen])
|
|
221
|
+
|
|
222
|
+
// A file the text side declined may still be worth showing. `sides` is the
|
|
223
|
+
// trigger rather than the file's name because the name is not evidence:
|
|
224
|
+
// asking is driven by what the TEXT path concluded, and the host decides
|
|
225
|
+
// what the bytes actually are. The dependency is safe as an object because
|
|
226
|
+
// `sides` gets a new identity only on a real refetch — the drawer does not
|
|
227
|
+
// poll this view.
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (open === null || sides === null || !shouldAskForImage(open, sides)) { setImage(null); return }
|
|
230
|
+
const asked = open
|
|
231
|
+
const ctrl = new AbortController()
|
|
232
|
+
let alive = true
|
|
233
|
+
void fetchFileImage(statsPath, asked, ctrl.signal)
|
|
234
|
+
.then(answer => { if (alive) setImage({ path: asked, answer }) })
|
|
235
|
+
// A host half older than this client has no such method and the call
|
|
236
|
+
// throws; the text side's own verdict then stands, unchanged.
|
|
237
|
+
.catch(() => { if (alive) setImage({ path: asked, answer: null }) })
|
|
238
|
+
return () => { alive = false; ctrl.abort() }
|
|
239
|
+
}, [open, sides, fetchFileImage, statsPath])
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The untracked paths, held at one identity for as long as they say the same
|
|
243
|
+
* thing.
|
|
244
|
+
*
|
|
245
|
+
* They are derived from the drawer's polled `git status`, so a fresh array
|
|
246
|
+
* arrives every 3-15 seconds whether or not anything changed — and it is the
|
|
247
|
+
* head of the chain below: merge, then the directory tree, then the rows.
|
|
248
|
+
* On a 20,000-file repository that chain is ~60ms of sorting and tree
|
|
249
|
+
* building, spent to produce exactly what was already on screen, on a timer,
|
|
250
|
+
* forever. Writing a ref during render is safe here because it is a cache:
|
|
251
|
+
* the value it holds is always equal to the value it replaced.
|
|
252
|
+
*/
|
|
253
|
+
const extraHeld = useRef<readonly string[]>(extraPaths)
|
|
254
|
+
if (!sameList(extraHeld.current, extraPaths)) extraHeld.current = extraPaths
|
|
255
|
+
const steadyExtra = extraHeld.current
|
|
256
|
+
|
|
257
|
+
const all = useMemo(() => mergePaths(paths, steadyExtra), [paths, steadyExtra])
|
|
258
|
+
const tree = useMemo(() => buildDirTree(all), [all])
|
|
259
|
+
const roots = useMemo(() => rootFiles(all), [all])
|
|
260
|
+
const rows = useMemo(
|
|
261
|
+
() => query.trim().length > 0
|
|
262
|
+
? searchRows(all, query, SEARCH_CAP)
|
|
263
|
+
: treeRows(tree, roots, expanded, FILES_PER_DIR),
|
|
264
|
+
[query, all, tree, roots, expanded],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
useEffect(() => { setPicked(null) }, [open, blameOn])
|
|
268
|
+
useEffect(() => { setAsSource(false) }, [open])
|
|
269
|
+
|
|
270
|
+
// A re-read — a worktree switch, a refresh, an agent's write — can remove
|
|
271
|
+
// the file that was open. Settled only once the new list has arrived, and
|
|
272
|
+
// never out from under unsaved edits: those are guarded one level up, where
|
|
273
|
+
// the tab and source switches are asked about.
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (dirty) return
|
|
276
|
+
const settled = reconcilePlace(place, all)
|
|
277
|
+
if (settled.vanished === null) return
|
|
278
|
+
onPlace(settled.place)
|
|
279
|
+
setVanished(shownRef.current.has(settled.vanished) ? settled.vanished : null)
|
|
280
|
+
setSides(null)
|
|
281
|
+
setEdit(DISARMED)
|
|
282
|
+
}, [all, place, dirty, onPlace])
|
|
283
|
+
|
|
284
|
+
const refusal = sides === null ? null : armRefusal(sides)
|
|
285
|
+
const readOnly = refusal !== null
|
|
286
|
+
/** The commit behind the picked line, or null when nothing is picked and
|
|
287
|
+
* when the blame does not reach that far. */
|
|
288
|
+
const pickedEntry: BlameLine | null =
|
|
289
|
+
picked === null || blame === null ? null : blame.lines[picked - 1] ?? null
|
|
290
|
+
/** The host's verdict on THIS file's bytes; null while it is in flight or
|
|
291
|
+
* when the question does not arise. */
|
|
292
|
+
const shot = image !== null && image.path === open ? image.answer : null
|
|
293
|
+
/** The question has been asked and not yet answered. Tracked so the pane
|
|
294
|
+
* shows a load rather than flashing "binary file" and then a picture. */
|
|
295
|
+
const askingImage = open !== null && sides !== null && shouldAskForImage(open, sides)
|
|
296
|
+
&& (image === null || image.path !== open)
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* An SVG the text path already fetched.
|
|
300
|
+
*
|
|
301
|
+
* SVG is markup, so it never reaches the binary branch above — it arrives
|
|
302
|
+
* as text and would otherwise open as XML, which is not what "click the
|
|
303
|
+
* picture" means. The bytes are already here, so the preview costs no round
|
|
304
|
+
* trip at all; it costs re-encoding the string that was decoded from them.
|
|
305
|
+
*
|
|
306
|
+
* The NAME is required as well as the signature, and that gate earns its
|
|
307
|
+
* place: sweeping a hundred and twenty-five thousand real files turned up
|
|
308
|
+
* a hundred and forty-eight `.svelte` components that begin with a literal
|
|
309
|
+
* `<svg>` element. They are markup about an icon, not an icon, and their
|
|
310
|
+
* author opened them to read the code.
|
|
311
|
+
*
|
|
312
|
+
* A lossy decode is excluded: re-encoding it would produce different bytes
|
|
313
|
+
* from the ones on disk, and a picture drawn from those is a picture of
|
|
314
|
+
* something that is not in the repository.
|
|
315
|
+
*/
|
|
316
|
+
const svg: Picture | null = useMemo(() => {
|
|
317
|
+
if (open === null || sides === null) return null
|
|
318
|
+
if (sides.binary || sides.tooLarge || sides.lossyEncoding === true) return null
|
|
319
|
+
if (!looksLikeImagePath(open)) return null
|
|
320
|
+
const bytes = new TextEncoder().encode(sides.targetText)
|
|
321
|
+
const found = sniffImage(bytes)
|
|
322
|
+
return found === null ? null : { bytes, mime: found.mime, kind: found.kind }
|
|
323
|
+
}, [open, sides])
|
|
324
|
+
|
|
325
|
+
/** The host's picture, decoded once. Keyed on the ANSWER alone: a decode is
|
|
326
|
+
* a pass over four megabytes at the cap, and folding the SVG toggle into
|
|
327
|
+
* the same memo would re-run it every time that button is pressed. */
|
|
328
|
+
const decoded: Picture | null = useMemo(
|
|
329
|
+
() => shot !== null && shot.ok
|
|
330
|
+
? { bytes: decodeBase64(shot.base64), mime: shot.mime, kind: shot.kind }
|
|
331
|
+
: null,
|
|
332
|
+
[shot],
|
|
333
|
+
)
|
|
334
|
+
/** The bytes on screen as a picture, from whichever half produced them.
|
|
335
|
+
* Both operands are memoised, so this keeps a stable identity. */
|
|
336
|
+
const picture: Picture | null = decoded ?? (asSource ? null : svg)
|
|
337
|
+
|
|
338
|
+
const showingImage = picture !== null
|
|
339
|
+
const showBlame = blameOn && !dirty && !showingImage
|
|
340
|
+
const buffer = edit.buffer
|
|
341
|
+
|
|
342
|
+
// Highlighting lags the buffer: a repaint is a Shiki pass over the whole
|
|
343
|
+
// file, and paying that per keystroke is what makes a large file unusable.
|
|
344
|
+
// CodeMirror maps the decorations it already has through each change, so
|
|
345
|
+
// the colours ride along with the text until this catches up.
|
|
346
|
+
const painted = useIdleValue(buffer, HIGHLIGHT_IDLE_MS)
|
|
347
|
+
const tooBigToPaint = useMemo(() => countLines(painted) > HIGHLIGHT_LINE_CAP, [painted])
|
|
348
|
+
const syntax = useMemo(
|
|
349
|
+
() => tooBigToPaint
|
|
350
|
+
? undefined
|
|
351
|
+
: highlightWholeFile(painted.split('\n'), shikiLangOf(open ?? ''), shikiThemeOf(palette)),
|
|
352
|
+
[tooBigToPaint, painted, open, palette],
|
|
353
|
+
)
|
|
354
|
+
const indent = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
|
|
355
|
+
|
|
356
|
+
/** Open a file, revealing it in the tree — and asking first if the buffer
|
|
357
|
+
* holds edits, because opening another file drops them. */
|
|
358
|
+
const openFile = (path: string): void => {
|
|
359
|
+
if (path === open) return
|
|
360
|
+
if (dirty) { setPending(path); return }
|
|
361
|
+
setSaveFailed(null)
|
|
362
|
+
setVanished(null)
|
|
363
|
+
onPlace(openAt(place, path))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const foldDir = (path: string): void => { onPlace(toggleDir(place, path)) }
|
|
367
|
+
|
|
368
|
+
const idRef = useRef(0)
|
|
369
|
+
useEffect(() => { idRef.current += 1 }, [open])
|
|
370
|
+
|
|
371
|
+
const save = async (): Promise<void> => {
|
|
372
|
+
if (sides === null || !dirty || saving || readOnly) return
|
|
373
|
+
const savedText = edit.buffer
|
|
374
|
+
const session = idRef.current
|
|
375
|
+
setSaving(true)
|
|
376
|
+
try {
|
|
377
|
+
const result = await writeChecked(statsPath, open ?? '', savedText, edit.baseSha, new AbortController().signal)
|
|
378
|
+
const stillHere = idRef.current === session
|
|
379
|
+
if (result === null) {
|
|
380
|
+
if (stillHere) setSaveFailed(t('saveUnavailable'))
|
|
381
|
+
} else if (result.ok) {
|
|
382
|
+
if (stillHere) {
|
|
383
|
+
setSaveFailed(null)
|
|
384
|
+
setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
|
|
385
|
+
}
|
|
386
|
+
onSaved()
|
|
387
|
+
} else if (result.failure === 'stale') {
|
|
388
|
+
// The file moved under the buffer. The buffer stands; the reader
|
|
389
|
+
// decides, and the refetch below makes the fresh text available.
|
|
390
|
+
if (stillHere) {
|
|
391
|
+
setEdit(prev => markConflict(prev))
|
|
392
|
+
setSaveFailed(t('staleBody'))
|
|
393
|
+
setRefetch(n => n + 1)
|
|
394
|
+
}
|
|
395
|
+
} else if (stillHere) {
|
|
396
|
+
setSaveFailed(`${t('saveFailed')}${(result.error ?? '').trim().length > 0 ? `: ${(result.error ?? '').trim()}` : ''}`)
|
|
397
|
+
}
|
|
398
|
+
} finally {
|
|
399
|
+
setSaving(false)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A directory can end with both markers, and they share a path and a
|
|
404
|
+
// depth; `more` is what separates them.
|
|
405
|
+
const rowKey = (row: FileRow): string => `${row.kind}:${row.path}:${row.more ?? ''}`
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Click targets for the memoised rows.
|
|
409
|
+
*
|
|
410
|
+
* Both close over `place`, `open` and the dirty buffer, so the row elements
|
|
411
|
+
* must not capture the ones that existed when they were built. A ref read at
|
|
412
|
+
* click time always has the current pair; the alternative — depending on
|
|
413
|
+
* their identity — would rebuild every row on every render, which is the
|
|
414
|
+
* cost this memo exists to remove.
|
|
415
|
+
*/
|
|
416
|
+
const acts = useRef({ foldDir, openFile })
|
|
417
|
+
acts.current = { foldDir, openFile }
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* `t` arrives in the host's slot props and its identity is not ours to rely
|
|
421
|
+
* on, so the memo watches the STRING it produces instead: that is what has
|
|
422
|
+
* to change when the language does, and it costs one lookup per render.
|
|
423
|
+
*/
|
|
424
|
+
const langKey = t('filesMore', { count: 0 })
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The rendered rows.
|
|
428
|
+
*
|
|
429
|
+
* An expanded tree is thousands of `li`s, each with a button and two icons,
|
|
430
|
+
* and the drawer re-renders on every poll. Handing React the SAME element
|
|
431
|
+
* array lets it skip the subtree entirely; rebuilding it was measured at
|
|
432
|
+
* ~180ms per poll with 1,462 rows on screen, growing with every directory
|
|
433
|
+
* the reader opens — the shape of "it gets laggy once there are a lot of
|
|
434
|
+
* files".
|
|
435
|
+
*/
|
|
436
|
+
const list = useMemo(() => rows.map(row => (
|
|
437
|
+
<li key={rowKey(row)}>
|
|
438
|
+
{row.kind === 'more' ? (
|
|
439
|
+
<span
|
|
440
|
+
className={css.fbMore}
|
|
441
|
+
style={{ paddingLeft: `${0.4 + row.depth * INDENT_EM}em` }}
|
|
442
|
+
>{t('filesMore', { count: row.hidden ?? 0 })}</span>
|
|
443
|
+
) : (
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
title={row.path}
|
|
447
|
+
aria-expanded={row.kind === 'dir' ? row.open : undefined}
|
|
448
|
+
className={row.kind === 'file' && row.path === open
|
|
449
|
+
? `${css.fbRow} ${css.fbRowActive}`
|
|
450
|
+
: css.fbRow}
|
|
451
|
+
style={{ paddingLeft: `${0.4 + row.depth * INDENT_EM}em` }}
|
|
452
|
+
onClick={() => {
|
|
453
|
+
row.kind === 'dir' ? acts.current.foldDir(row.path) : acts.current.openFile(row.path)
|
|
454
|
+
}}
|
|
455
|
+
>
|
|
456
|
+
{row.kind === 'dir' ? (
|
|
457
|
+
<>
|
|
458
|
+
<span className={`${css.chevron} ${row.open ? css.chevronOpen : ''}`}>▸</span>
|
|
459
|
+
<PathDirGlyph />
|
|
460
|
+
</>
|
|
461
|
+
) : (
|
|
462
|
+
<>
|
|
463
|
+
<span className={css.chevron} aria-hidden="true" />
|
|
464
|
+
<PathFileGlyph path={row.path} />
|
|
465
|
+
</>
|
|
466
|
+
)}
|
|
467
|
+
<span className={row.kind === 'dir' ? css.fbDirName : css.fbFileName}>{row.name}</span>
|
|
468
|
+
</button>
|
|
469
|
+
)}
|
|
470
|
+
</li>
|
|
471
|
+
)),
|
|
472
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `t` is read through
|
|
473
|
+
// `langKey`, and the handlers through `acts`; see both comments above.
|
|
474
|
+
[rows, open, langKey])
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<>
|
|
478
|
+
<div ref={treeRef} className={css.fbTree} style={treeStyle} data-gs-part="fileTree">
|
|
479
|
+
<input
|
|
480
|
+
type="search"
|
|
481
|
+
className={css.fbSearch}
|
|
482
|
+
value={query}
|
|
483
|
+
placeholder={t('fileSearchPlaceholder')}
|
|
484
|
+
aria-label={t('fileSearchPlaceholder')}
|
|
485
|
+
onChange={event => { onPlace({ ...place, query: event.target.value }) }}
|
|
486
|
+
/>
|
|
487
|
+
{truncated ? <div className={css.fbNote}>{t('filesTruncated')}</div> : null}
|
|
488
|
+
{rows.length === 0 ? (
|
|
489
|
+
<div className={css.empty}>{all.length === 0 ? t('filesEmpty') : t('filesNoMatch')}</div>
|
|
490
|
+
) : (
|
|
491
|
+
<ul className={css.fbList}>{list}</ul>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
{divider}
|
|
495
|
+
<div className={css.fbMain} data-gs-part="fileView">
|
|
496
|
+
{open === null ? (
|
|
497
|
+
<div className={css.empty}>
|
|
498
|
+
{vanished === null ? t('filesPick') : t('filesVanished', { path: vanished })}
|
|
499
|
+
</div>
|
|
500
|
+
) : (
|
|
501
|
+
<>
|
|
502
|
+
<div className={css.fbHeader}>
|
|
503
|
+
<span className={css.fbPath} title={open}>{open}</span>
|
|
504
|
+
<span className={css.sideActions}>
|
|
505
|
+
{!readOnly && !showingImage && sides !== null && !sides.binary && !sides.tooLarge ? (
|
|
506
|
+
<>
|
|
507
|
+
<button
|
|
508
|
+
type="button"
|
|
509
|
+
className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
|
|
510
|
+
disabled={!dirty || saving}
|
|
511
|
+
onClick={() => { void save() }}
|
|
512
|
+
>{t('fileSave')}</button>
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
className={css.blockBtn}
|
|
516
|
+
disabled={!dirty || saving}
|
|
517
|
+
onClick={() => { setSaveFailed(null); setEdit(prev => ({ ...prev, buffer: prev.baseText })) }}
|
|
518
|
+
>{t('fileRevert')}</button>
|
|
519
|
+
</>
|
|
520
|
+
) : null}
|
|
521
|
+
{svg === null ? null : (
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
className={css.blockBtn}
|
|
525
|
+
aria-pressed={asSource}
|
|
526
|
+
onClick={() => { setAsSource(on => !on) }}
|
|
527
|
+
>{t(asSource ? 'imagePreview' : 'imageSource')}</button>
|
|
528
|
+
)}
|
|
529
|
+
{showingImage ? null : (
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
aria-pressed={blameOn}
|
|
533
|
+
title={t('blameHint')}
|
|
534
|
+
className={blameOn ? `${css.blockBtn} ${css.sideSaveReady}` : css.blockBtn}
|
|
535
|
+
onClick={() => { onPlace({ ...place, blameOn: !blameOn }) }}
|
|
536
|
+
>{t('blameToggle')}</button>
|
|
537
|
+
)}
|
|
538
|
+
</span>
|
|
539
|
+
</div>
|
|
540
|
+
{pending !== null ? (
|
|
541
|
+
<div className={css.sideNotice}>
|
|
542
|
+
{t('filesUnsavedAsk')}
|
|
543
|
+
<span className={css.sideActions}>
|
|
544
|
+
<button
|
|
545
|
+
type="button"
|
|
546
|
+
className={css.blockBtn}
|
|
547
|
+
onClick={() => {
|
|
548
|
+
const next = pending
|
|
549
|
+
setPending(null)
|
|
550
|
+
setEdit(DISARMED)
|
|
551
|
+
setSaveFailed(null)
|
|
552
|
+
setVanished(null)
|
|
553
|
+
onPlace(openAt(place, next))
|
|
554
|
+
}}
|
|
555
|
+
>{t('filesDiscardOpen')}</button>
|
|
556
|
+
<button
|
|
557
|
+
type="button"
|
|
558
|
+
className={css.blockBtn}
|
|
559
|
+
onClick={() => { setPending(null) }}
|
|
560
|
+
>{t('discardCancel')}</button>
|
|
561
|
+
</span>
|
|
562
|
+
</div>
|
|
563
|
+
) : null}
|
|
564
|
+
{refusal !== null ? (
|
|
565
|
+
<div className={css.sideNotice}>{t(refusal === 'encoding' ? 'fileReadOnlyEncoding' : 'fileReadOnlyCrlf')}</div>
|
|
566
|
+
) : null}
|
|
567
|
+
{saveFailed !== null ? <div className={css.sideNotice}>{saveFailed}</div> : null}
|
|
568
|
+
{tooBigToPaint ? <div className={css.sideNotice}>{t('paintTooLarge')}</div> : null}
|
|
569
|
+
{blameOn && dirty ? <div className={css.sideNotice}>{t('blameWhileEditing')}</div> : null}
|
|
570
|
+
{showBlame && (blameFailed || (blame !== null && blame.error !== undefined))
|
|
571
|
+
? <div className={css.sideNotice}>{t('blameFailed')}</div> : null}
|
|
572
|
+
{showBlame && blame !== null && blame.truncated
|
|
573
|
+
? <div className={css.sideNotice}>{t('blameTruncated')}</div> : null}
|
|
574
|
+
{/* The strip is present for as long as blame is, so the space does
|
|
575
|
+
not jump as lines are picked — and while nothing is picked it
|
|
576
|
+
says that picking is a thing, which is the whole affordance:
|
|
577
|
+
the gutter is names, and nothing about a name looks clickable. */}
|
|
578
|
+
{showBlame ? (
|
|
579
|
+
<div className={css.fbCommit}>
|
|
580
|
+
{pickedEntry === null ? (
|
|
581
|
+
<span className={css.fbCommitHint}>{t('blamePick')}</span>
|
|
582
|
+
) : (
|
|
583
|
+
<>
|
|
584
|
+
<span className={css.fbCommitLine}>{t('blameLine', { line: picked ?? 0 })}</span>
|
|
585
|
+
{pickedEntry.uncommitted ? (
|
|
586
|
+
<span className={css.fbCommitWho}>{t('blameUncommitted')}</span>
|
|
587
|
+
) : (
|
|
588
|
+
<>
|
|
589
|
+
<span className={css.fbCommitWho}>{pickedEntry.author}</span>
|
|
590
|
+
<span className={css.fbCommitWhen}>{blameWhen(pickedEntry.time)}</span>
|
|
591
|
+
<code className={css.fbCommitHash}>{shortHash(pickedEntry.hash)}</code>
|
|
592
|
+
<span className={css.fbCommitWhat} title={pickedEntry.summary}>{pickedEntry.summary}</span>
|
|
593
|
+
{/* The question a name in the gutter raises is rarely
|
|
594
|
+
about this one line — it is "what else did they do to
|
|
595
|
+
this file". The History tab already answers that, with
|
|
596
|
+
a filter grammar that takes both halves, so the strip
|
|
597
|
+
hands it the query rather than growing a second commit
|
|
598
|
+
list of its own. */}
|
|
599
|
+
<button
|
|
600
|
+
type="button"
|
|
601
|
+
className={css.blockBtn}
|
|
602
|
+
onClick={() => {
|
|
603
|
+
onShowHistory(serializeLogQuery({
|
|
604
|
+
...emptyQueryFilter(),
|
|
605
|
+
users: [pickedEntry.author],
|
|
606
|
+
paths: [open],
|
|
607
|
+
}))
|
|
608
|
+
}}
|
|
609
|
+
>{t('blameInHistory')}</button>
|
|
610
|
+
</>
|
|
611
|
+
)}
|
|
612
|
+
<button
|
|
613
|
+
type="button"
|
|
614
|
+
className={css.blockBtn}
|
|
615
|
+
onClick={() => { setPicked(null) }}
|
|
616
|
+
>{t('close')}</button>
|
|
617
|
+
</>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
) : null}
|
|
621
|
+
<div className={css.fbBody}>
|
|
622
|
+
{loading && sides === null ? <div className={css.empty}>{t('loading')}</div>
|
|
623
|
+
: sides === null ? <div className={css.empty}>{t('saveUnavailable')}</div>
|
|
624
|
+
: picture !== null ? <ImageView picture={picture} path={open} t={t} />
|
|
625
|
+
: askingImage ? <div className={css.empty}>{t('loading')}</div>
|
|
626
|
+
: sides.binary ? <div className={css.empty}>{t('binaryFile')}</div>
|
|
627
|
+
: sides.tooLarge ? (
|
|
628
|
+
// An image past the preview cap gets its own sentence: the
|
|
629
|
+
// diff pane's "too large" is about a patch nobody asked for
|
|
630
|
+
// here, and the size is the fact that explains the refusal.
|
|
631
|
+
shot !== null && shot.reason === 'tooLarge'
|
|
632
|
+
? <div className={css.empty}>{t('imageTooLarge', { size: formatBytes(shot.bytes), cap: formatBytes(IMAGE_BYTE_CAP) })}</div>
|
|
633
|
+
: <div className={css.empty}>{t('diffTooLarge')}</div>
|
|
634
|
+
)
|
|
635
|
+
: (
|
|
636
|
+
<CodeEditor
|
|
637
|
+
key={open}
|
|
638
|
+
value={edit.buffer}
|
|
639
|
+
original={edit.baseText}
|
|
640
|
+
onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
|
|
641
|
+
syntax={syntax}
|
|
642
|
+
indent={indent}
|
|
643
|
+
ariaLabel={open}
|
|
644
|
+
onSave={() => { void save() }}
|
|
645
|
+
blame={showBlame && blame !== null ? blame.lines : null}
|
|
646
|
+
onBlameClick={line => { setPicked(line) }}
|
|
647
|
+
notCommitted={t('blameUncommitted')}
|
|
648
|
+
readOnly={readOnly}
|
|
649
|
+
/>
|
|
650
|
+
)}
|
|
651
|
+
</div>
|
|
652
|
+
</>
|
|
653
|
+
)}
|
|
654
|
+
</div>
|
|
655
|
+
</>
|
|
656
|
+
)
|
|
657
|
+
}
|