@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13
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/AGENTS.md +1 -1
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +100 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6902 -6256
- package/package.json +1 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +505 -0
- package/src/client/styles/controls.css +236 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +113 -0
- package/src/client/styles/history-filters.css +400 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +431 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- package/src/client/use-row-window.ts +55 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { filterFiles } from './file-filter.ts'
|
|
4
|
+
import { PathDirGlyph, PathFileGlyph } from './glyphs.tsx'
|
|
5
|
+
import { fileCheckState, rollUp, type CheckState } from './stage-tree.ts'
|
|
6
|
+
import type { GitFile, GitFileStatus, Translate } from './git-workbench-types.ts'
|
|
7
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
8
|
+
|
|
9
|
+
const STATUS_BADGE: Record<GitFileStatus, string> = {
|
|
10
|
+
added: css.stAdded, untracked: css.stUntracked, modified: css.stModified,
|
|
11
|
+
renamed: css.stRenamed, deleted: css.stDeleted,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Filter this list: a magnifier, not the funnel above the commit list. The two
|
|
16
|
+
* are deliberately different glyphs because they do different things — the
|
|
17
|
+
* funnel asks git for a different set of commits, this only hides rows already
|
|
18
|
+
* on screen — and the drawer shows both at once.
|
|
19
|
+
*/
|
|
20
|
+
function FilterGlyph(): ReactNode {
|
|
21
|
+
return (
|
|
22
|
+
<svg
|
|
23
|
+
width="13" height="13" viewBox="0 0 16 16"
|
|
24
|
+
fill="none" stroke="currentColor" strokeWidth="1.25"
|
|
25
|
+
strokeLinecap="round" strokeLinejoin="round"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
>
|
|
28
|
+
<circle cx="7" cy="7" r="4" />
|
|
29
|
+
<path d="M10 10l3.5 3.5" />
|
|
30
|
+
</svg>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Nothing folded. A constant so the filtered tree does not allocate a new Set
|
|
35
|
+
* on every render and re-run `TreeChildren`'s memo. */
|
|
36
|
+
const EMPTY_COLLAPSED: ReadonlySet<string> = new Set<string>()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Roll back: the counter-clockwise arc every editor and VCS uses for undo,
|
|
40
|
+
* drawn in the same New UI idiom as the node glyphs beside it — 16px grid,
|
|
41
|
+
* 1px stroke, no fill — so the row does not mix an outlined file icon with a
|
|
42
|
+
* solid action icon.
|
|
43
|
+
*/
|
|
44
|
+
function RollbackGlyph(): ReactNode {
|
|
45
|
+
return (
|
|
46
|
+
<svg
|
|
47
|
+
width="14" height="14" viewBox="0 0 16 16"
|
|
48
|
+
fill="none" stroke="currentColor" strokeWidth="1.25"
|
|
49
|
+
strokeLinecap="round" strokeLinejoin="round"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
>
|
|
52
|
+
{/* The arc, open at the upper left where the head goes. */}
|
|
53
|
+
<path d="M3.5 6.5a5 5 0 1 0 1.9-2.2" />
|
|
54
|
+
{/* The head: a corner, not a triangle — a filled arrowhead this small
|
|
55
|
+
turns into a dot at 1x. */}
|
|
56
|
+
<path d="M2.6 3.2v3.4h3.4" />
|
|
57
|
+
</svg>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ---------- file tree ---------- */
|
|
62
|
+
|
|
63
|
+
/** Horizontal step per nesting level. */
|
|
64
|
+
const TREE_INDENT = 14
|
|
65
|
+
/** The gutter every row starts at. Equals `--gs-gutter-pane`, so depth-0 ticks
|
|
66
|
+
* line up with the toolbar's own content — and everything interactive stays
|
|
67
|
+
* clear of the 10px resizer the drawer paints over its left edge. */
|
|
68
|
+
const TREE_BASE_INDENT = 12
|
|
69
|
+
/** Chevron width plus its gap. A file row adds this so its status badge starts at
|
|
70
|
+
* the directory NAME's column rather than under the directory's chevron. */
|
|
71
|
+
const TREE_LEAF_OFFSET = 16
|
|
72
|
+
/** Where a level's indent guide sits: inside the chevron, so it points at the
|
|
73
|
+
* rows it groups. */
|
|
74
|
+
const TREE_RAIL_OFFSET = 12
|
|
75
|
+
/** The tick's own width. Must match `.checkBox` — the row's content starts after
|
|
76
|
+
* it, and the indent guides are positioned from it. */
|
|
77
|
+
const TREE_CHECK_W = 22
|
|
78
|
+
/** Custom property the stylesheet reads to place one level's indent guide. */
|
|
79
|
+
const RAIL_VAR = '--gs-rail'
|
|
80
|
+
|
|
81
|
+
interface DirNode {
|
|
82
|
+
readonly name: string
|
|
83
|
+
readonly path: string
|
|
84
|
+
readonly dirs: Map<string, DirNode>
|
|
85
|
+
readonly files: GitFile[]
|
|
86
|
+
fileCount: number
|
|
87
|
+
added: number
|
|
88
|
+
deleted: number
|
|
89
|
+
/** Every descendant's tick, rolled up. Computed once with the other totals. */
|
|
90
|
+
check: CheckState
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildTree(files: readonly GitFile[]): DirNode {
|
|
94
|
+
const root: DirNode = { name: '', path: '', dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
let node = root
|
|
97
|
+
const parts = file.path.split('/')
|
|
98
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
99
|
+
const name = parts[i]
|
|
100
|
+
let child = node.dirs.get(name)
|
|
101
|
+
if (child === undefined) {
|
|
102
|
+
child = { name, path: parts.slice(0, i + 1).join('/'), dirs: new Map(), files: [], fileCount: 0, added: 0, deleted: 0, check: 'off' }
|
|
103
|
+
node.dirs.set(name, child)
|
|
104
|
+
}
|
|
105
|
+
node = child
|
|
106
|
+
}
|
|
107
|
+
node.files.push(file)
|
|
108
|
+
}
|
|
109
|
+
const aggregate = (node: DirNode): void => {
|
|
110
|
+
node.fileCount = node.files.length
|
|
111
|
+
node.added = node.files.reduce((sum, f) => sum + f.addedLines, 0)
|
|
112
|
+
node.deleted = node.files.reduce((sum, f) => sum + f.deletedLines, 0)
|
|
113
|
+
const ticks: CheckState[] = node.files.map(fileCheckState)
|
|
114
|
+
for (const child of node.dirs.values()) {
|
|
115
|
+
aggregate(child)
|
|
116
|
+
node.fileCount += child.fileCount
|
|
117
|
+
node.added += child.added
|
|
118
|
+
node.deleted += child.deleted
|
|
119
|
+
ticks.push(child.check)
|
|
120
|
+
}
|
|
121
|
+
node.check = rollUp(ticks)
|
|
122
|
+
}
|
|
123
|
+
aggregate(root)
|
|
124
|
+
return compactChains(root)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Merge every directory that holds nothing but one subdirectory into that child.
|
|
129
|
+
*
|
|
130
|
+
* `docs/superpowers/specs/design.md` otherwise costs three rows and three indent
|
|
131
|
+
* levels to reach one file, and none of those three rows carries a choice — each
|
|
132
|
+
* has exactly one way down. Merging them into a single `docs/superpowers/specs`
|
|
133
|
+
* row is what VS Code calls compact folders, and it makes indentation depth mean
|
|
134
|
+
* "where the tree branches" rather than "how long the path is".
|
|
135
|
+
*
|
|
136
|
+
* The merged node keeps the DEEPEST path, so it stays the one the collapse set
|
|
137
|
+
* and the reveal-the-active-file walk already address.
|
|
138
|
+
* @param node - directory whose descendants are compacted.
|
|
139
|
+
* @returns the node with compacted children.
|
|
140
|
+
*/
|
|
141
|
+
function compactChains(node: DirNode): DirNode {
|
|
142
|
+
const dirs = new Map<string, DirNode>()
|
|
143
|
+
for (const child of node.dirs.values()) {
|
|
144
|
+
let merged = compactChains(child)
|
|
145
|
+
while (merged.files.length === 0 && merged.dirs.size === 1) {
|
|
146
|
+
const only = merged.dirs.values().next().value as DirNode
|
|
147
|
+
merged = { ...only, name: `${merged.name}/${only.name}` }
|
|
148
|
+
}
|
|
149
|
+
dirs.set(merged.name, merged)
|
|
150
|
+
}
|
|
151
|
+
return { ...node, dirs }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface FileTreeProps {
|
|
155
|
+
t: Translate
|
|
156
|
+
/** Whether the view has nothing to show yet — already resolved by the caller
|
|
157
|
+
* via {@link showsPending}, NOT the raw in-flight flag. This pane used to
|
|
158
|
+
* re-derive it from `loading && files.length === 0`, and that second copy of
|
|
159
|
+
* the rule is precisely what the header then got wrong. */
|
|
160
|
+
loading?: boolean
|
|
161
|
+
/** What the list holds, prepended to the count — the working-tree view says
|
|
162
|
+
* which worktree it is reading; commit views are already named by history. */
|
|
163
|
+
lead?: string
|
|
164
|
+
files: readonly GitFile[]
|
|
165
|
+
active: string | null
|
|
166
|
+
onSelect: (path: string) => void
|
|
167
|
+
/** Undefined until the user interacts: then it shows defaults. Lifted to the
|
|
168
|
+
* panel so background polls (new `files` identity) and drawer close/reopen
|
|
169
|
+
* never reset the user's expansion choices. */
|
|
170
|
+
collapsed: Set<string> | undefined
|
|
171
|
+
onCollapsedChange: (next: Set<string>) => void
|
|
172
|
+
/** Add or remove files from the commit set. Undefined outside the working-tree
|
|
173
|
+
* view, where what a commit contains was decided long ago. */
|
|
174
|
+
onCheck?: (files: readonly GitFile[], state: CheckState) => void
|
|
175
|
+
/** Roll one file back to HEAD; working-tree view only. */
|
|
176
|
+
onDiscard?: (file: GitFile) => void
|
|
177
|
+
/** Rendered under the tree in the working-tree view only. */
|
|
178
|
+
footer?: ReactNode
|
|
179
|
+
/** Names what this list is OF — the working tree, or one commit, or one
|
|
180
|
+
* comparison. The filter clears when it changes: a query typed against a
|
|
181
|
+
* 140-file commit would otherwise carry over to the next commit and hide
|
|
182
|
+
* most of it, with nothing on screen saying why. */
|
|
183
|
+
scopeKey: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function FileTree({ t, loading, lead, files, active, onSelect, collapsed, onCollapsedChange, onCheck, onDiscard, footer, scopeKey }: FileTreeProps): ReactNode {
|
|
187
|
+
/**
|
|
188
|
+
* The filter over this list. Local, because it describes a way of LOOKING at
|
|
189
|
+
* the pane rather than anything the drawer stores: closing and reopening on
|
|
190
|
+
* an unfiltered list is what someone expects, and a query kept in the panel
|
|
191
|
+
* would have to be cleared from four places instead of one.
|
|
192
|
+
*/
|
|
193
|
+
const [query, setQuery] = useState('')
|
|
194
|
+
const [filterOpen, setFilterOpen] = useState(false)
|
|
195
|
+
const filterRef = useRef<HTMLInputElement>(null)
|
|
196
|
+
useEffect(() => { setQuery(''); setFilterOpen(false) }, [scopeKey])
|
|
197
|
+
|
|
198
|
+
const shownFiles = useMemo(() => filterFiles(files, query), [files, query])
|
|
199
|
+
const filtering = shownFiles !== files
|
|
200
|
+
const tree = useMemo(() => buildTree(shownFiles), [shownFiles])
|
|
201
|
+
/** Default: a dir collapses when it holds more than 12 files anywhere below it. */
|
|
202
|
+
const effective = collapsed ?? defaultCollapsed(tree)
|
|
203
|
+
|
|
204
|
+
// Reveal the active file by expanding its ancestor chain — ONLY when the
|
|
205
|
+
// selection itself changes. Listening to `collapsed` here would instantly
|
|
206
|
+
// revert manual folds of any directory containing the active file.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (active === null) return
|
|
209
|
+
const parts = active.split('/')
|
|
210
|
+
let touched = false
|
|
211
|
+
const next = new Set(collapsed ?? defaultCollapsed(tree))
|
|
212
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
213
|
+
const dir = parts.slice(0, i).join('/')
|
|
214
|
+
if (next.delete(dir)) touched = true
|
|
215
|
+
}
|
|
216
|
+
if (touched) onCollapsedChange(next)
|
|
217
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reveal is a selection-change event, not an invariant over `collapsed`
|
|
218
|
+
}, [active])
|
|
219
|
+
|
|
220
|
+
const setAll = (open: boolean): void => {
|
|
221
|
+
onCollapsedChange(open ? new Set() : allDirs(tree))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const toggleOne = (path: string): void => {
|
|
225
|
+
const next = new Set(effective)
|
|
226
|
+
if (next.has(path)) next.delete(path)
|
|
227
|
+
else next.add(path)
|
|
228
|
+
onCollapsedChange(next)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stageLabels = { stage: t('stage'), unstage: t('unstage') }
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className={css.treeWrap}>
|
|
235
|
+
<div className={css.treeTools}>
|
|
236
|
+
{/* The root tick replaces the Stage all / Unstage all pair: it says the
|
|
237
|
+
same two things in the column the rows already read down, and gives
|
|
238
|
+
the toolbar back the room those two buttons needed. The toolbar's own
|
|
239
|
+
pane gutter is its indent, so it lines up with the depth-0 rows. */}
|
|
240
|
+
<div className={css.treeLead}>
|
|
241
|
+
{onCheck !== undefined ? (
|
|
242
|
+
<CheckBox
|
|
243
|
+
state={tree.check}
|
|
244
|
+
label={tree.check === 'on' ? t('unstageAll') : t('stageAll')}
|
|
245
|
+
indent={0}
|
|
246
|
+
// `shownFiles`, not `files`: a tick IS a git call, and the root
|
|
247
|
+
// one must stage exactly the rows it sits above. Reaching past a
|
|
248
|
+
// filter into files the pane is hiding is how "stage all" ends up
|
|
249
|
+
// meaning something the reader never saw.
|
|
250
|
+
onToggle={() => onCheck(shownFiles, tree.check)}
|
|
251
|
+
/>
|
|
252
|
+
) : null}
|
|
253
|
+
<span className={css.treeLabel}>
|
|
254
|
+
{loading === true
|
|
255
|
+
? t('loading')
|
|
256
|
+
: `${lead !== undefined ? `${lead} · ` : ''}${filtering
|
|
257
|
+
? t('filesFiltered', { shown: shownFiles.length, count: files.length })
|
|
258
|
+
: t('files', { count: files.length })}`}
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
<div className={css.treeActions} data-gs-part="tree-actions">
|
|
262
|
+
{/* Filtering is about the list, so it sits with the list's own two
|
|
263
|
+
controls rather than in the drawer chrome — and it stays lit while
|
|
264
|
+
a query is set, because a pane showing 6 of 140 files with no
|
|
265
|
+
visible reason is the one way this feature can mislead. */}
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
className={filterOpen || filtering ? `${css.treeIcon} ${css.treeIconOn}` : css.treeIcon}
|
|
269
|
+
data-gs-part="filter-files"
|
|
270
|
+
title={t('filterFiles')} aria-label={t('filterFiles')}
|
|
271
|
+
aria-pressed={filterOpen}
|
|
272
|
+
onClick={() => {
|
|
273
|
+
// Closing is also clearing. A hidden box still holding a query
|
|
274
|
+
// would leave the pane filtered with its only explanation
|
|
275
|
+
// folded away.
|
|
276
|
+
if (filterOpen) { setQuery(''); setFilterOpen(false); return }
|
|
277
|
+
setFilterOpen(true)
|
|
278
|
+
window.setTimeout(() => filterRef.current?.focus(), 0)
|
|
279
|
+
}}
|
|
280
|
+
><FilterGlyph /></button>
|
|
281
|
+
{/* Icon-only, with the label on `title`/`aria-label`: the glyph is the
|
|
282
|
+
same one the rows carry, so each button previews its own result. */}
|
|
283
|
+
<button
|
|
284
|
+
type="button" className={css.treeIcon} data-gs-part="expand-all"
|
|
285
|
+
title={t('expandAll')} aria-label={t('expandAll')}
|
|
286
|
+
onClick={() => setAll(true)}
|
|
287
|
+
><span className={`${css.treeIconGlyph} ${css.treeIconDown}`}>▸</span></button>
|
|
288
|
+
<button
|
|
289
|
+
type="button" className={css.treeIcon} data-gs-part="collapse-all"
|
|
290
|
+
title={t('collapseAll')} aria-label={t('collapseAll')}
|
|
291
|
+
onClick={() => setAll(false)}
|
|
292
|
+
><span className={css.treeIconGlyph}>▸</span></button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
{filterOpen ? (
|
|
296
|
+
<div className={css.treeFilter}>
|
|
297
|
+
<input
|
|
298
|
+
ref={filterRef}
|
|
299
|
+
className={css.treeFilterInput}
|
|
300
|
+
type="text"
|
|
301
|
+
value={query}
|
|
302
|
+
placeholder={t('filterFilesPlaceholder')}
|
|
303
|
+
aria-label={t('filterFiles')}
|
|
304
|
+
spellCheck={false}
|
|
305
|
+
onChange={event => setQuery(event.target.value)}
|
|
306
|
+
onKeyDown={event => {
|
|
307
|
+
if (event.key !== 'Escape') return
|
|
308
|
+
// Escape belongs to the box while it has something to undo;
|
|
309
|
+
// only an already-empty box lets it through to close the drawer.
|
|
310
|
+
if (query.length > 0) { event.stopPropagation(); setQuery(''); return }
|
|
311
|
+
event.stopPropagation()
|
|
312
|
+
setFilterOpen(false)
|
|
313
|
+
}}
|
|
314
|
+
/>
|
|
315
|
+
{query.length > 0 ? (
|
|
316
|
+
<button
|
|
317
|
+
type="button" className={css.treeFilterClear}
|
|
318
|
+
title={t('filterFilesClear')} aria-label={t('filterFilesClear')}
|
|
319
|
+
onClick={() => { setQuery(''); filterRef.current?.focus() }}
|
|
320
|
+
>×</button>
|
|
321
|
+
) : null}
|
|
322
|
+
</div>
|
|
323
|
+
) : null}
|
|
324
|
+
{loading === true ? (
|
|
325
|
+
<div className={css.treeEmpty} data-gs-part="tree-loading">{t('loading')}</div>
|
|
326
|
+
) : filtering && shownFiles.length === 0 ? (
|
|
327
|
+
<div className={css.treeEmpty} data-gs-part="tree-no-match">{t('filterNoMatch')}</div>
|
|
328
|
+
) : (
|
|
329
|
+
<ul className={css.tree}>
|
|
330
|
+
{/* A filtered tree ignores the fold state entirely: the reader asked
|
|
331
|
+
for these files, and leaving them behind a directory they
|
|
332
|
+
collapsed twenty minutes ago reads as "no matches". */}
|
|
333
|
+
<TreeChildren
|
|
334
|
+
node={tree} depth={0} active={active} collapsed={filtering ? EMPTY_COLLAPSED : effective}
|
|
335
|
+
onToggle={toggleOne} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={t('discardAction')}
|
|
336
|
+
/>
|
|
337
|
+
</ul>
|
|
338
|
+
)}
|
|
339
|
+
{footer}
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function defaultCollapsed(root: DirNode): Set<string> {
|
|
345
|
+
const out = new Set<string>()
|
|
346
|
+
const walk = (node: DirNode): void => {
|
|
347
|
+
for (const child of node.dirs.values()) {
|
|
348
|
+
if (child.fileCount > 12) out.add(child.path)
|
|
349
|
+
walk(child)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
walk(root)
|
|
353
|
+
return out
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function allDirs(node: DirNode): Set<string> {
|
|
357
|
+
const out = new Set<string>()
|
|
358
|
+
const walk = (n: DirNode): void => {
|
|
359
|
+
for (const child of n.dirs.values()) { out.add(child.path); walk(child) }
|
|
360
|
+
}
|
|
361
|
+
walk(node)
|
|
362
|
+
return out
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* One tick. A sibling of the row it belongs to rather than a child of it: a
|
|
367
|
+
* button inside a button is invalid HTML, and the two clicks mean different
|
|
368
|
+
* things — this one changes the commit set, the row opens the diff.
|
|
369
|
+
*
|
|
370
|
+
* The tick carries its row's own indent and stands at the node it includes,
|
|
371
|
+
* IDEA-style, rather than in a column pinned to the pane edge. A pinned column
|
|
372
|
+
* reads at a glance, but it detaches each tick from its node — and its first
|
|
373
|
+
* 10px sat underneath the drawer's edge resizer, so a click on the left half of
|
|
374
|
+
* a depth-0 tick dragged the drawer instead of staging anything.
|
|
375
|
+
*/
|
|
376
|
+
function CheckBox({ state, label, indent, onToggle }: {
|
|
377
|
+
state: CheckState
|
|
378
|
+
label: string
|
|
379
|
+
/** The row's left edge, carried here so the tick stands at its own node. */
|
|
380
|
+
indent: number
|
|
381
|
+
onToggle: () => void
|
|
382
|
+
}): ReactNode {
|
|
383
|
+
const mark = state === 'on' ? css.checkMarkOn : state === 'partial' ? css.checkMarkPartial : ''
|
|
384
|
+
return (
|
|
385
|
+
<button
|
|
386
|
+
type="button"
|
|
387
|
+
role="checkbox"
|
|
388
|
+
aria-checked={state === 'partial' ? 'mixed' : state === 'on'}
|
|
389
|
+
className={css.checkBox}
|
|
390
|
+
style={{ marginLeft: indent }}
|
|
391
|
+
title={label}
|
|
392
|
+
aria-label={label}
|
|
393
|
+
onClick={onToggle}
|
|
394
|
+
>
|
|
395
|
+
<span className={`${css.checkMark} ${mark}`} aria-hidden="true">
|
|
396
|
+
{state === 'on' ? '✓' : state === 'partial' ? '–' : ''}
|
|
397
|
+
</span>
|
|
398
|
+
</button>
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Every file at or under a node, for a tick that acts on a whole directory. */
|
|
403
|
+
function filesUnder(node: DirNode): GitFile[] {
|
|
404
|
+
const out = [...node.files]
|
|
405
|
+
for (const child of node.dirs.values()) out.push(...filesUnder(child))
|
|
406
|
+
return out
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
interface TreeChildrenProps {
|
|
410
|
+
node: DirNode
|
|
411
|
+
depth: number
|
|
412
|
+
active: string | null
|
|
413
|
+
/** Read-only: a filtered tree is handed a shared empty set rather than a copy. */
|
|
414
|
+
collapsed: ReadonlySet<string>
|
|
415
|
+
onToggle: (path: string) => void
|
|
416
|
+
onSelect: (path: string) => void
|
|
417
|
+
/** Add or remove files from the commit set. Undefined outside the working-tree
|
|
418
|
+
* view, where what a commit contains was decided long ago. */
|
|
419
|
+
onCheck?: (files: readonly GitFile[], state: CheckState) => void
|
|
420
|
+
/** Roll one file back to HEAD. Undefined outside the working-tree view for
|
|
421
|
+
* the same reason `onCheck` is: a commit's files are history, and there is
|
|
422
|
+
* nothing there to roll back. Directories never offer it — the irreversible
|
|
423
|
+
* action does not get a gesture that takes a subtree with it. */
|
|
424
|
+
onDiscard?: (file: GitFile) => void
|
|
425
|
+
/** Pre-translated, so the row does not have to carry `t` for two strings. */
|
|
426
|
+
stageLabels: { stage: string; unstage: string }
|
|
427
|
+
/** Label for the roll-back action, pre-translated like `stageLabels`. */
|
|
428
|
+
discardLabel?: string
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function TreeChildren({ node, depth, active, collapsed, onToggle, onSelect, onCheck, onDiscard, stageLabels, discardLabel }: TreeChildrenProps): ReactNode {
|
|
432
|
+
const dirNodes = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
433
|
+
const fileNodes = [...node.files].sort((a, b) => basePart(a.path).localeCompare(basePart(b.path)))
|
|
434
|
+
const checkColumn = onCheck !== undefined ? TREE_CHECK_W : 0
|
|
435
|
+
// With ticks, the tick carries this indent and the button starts at 0; without
|
|
436
|
+
// them (history, compare) the button carries it, as it always did.
|
|
437
|
+
const indent = TREE_BASE_INDENT + depth * TREE_INDENT
|
|
438
|
+
return (
|
|
439
|
+
<>
|
|
440
|
+
{dirNodes.map(dir => {
|
|
441
|
+
const open = !collapsed.has(dir.path)
|
|
442
|
+
const containsActive = active !== null && active.startsWith(`${dir.path}/`)
|
|
443
|
+
return (
|
|
444
|
+
<li key={dir.path} className={css.treeDirLi}>
|
|
445
|
+
<div className={css.treeRow}>
|
|
446
|
+
{onCheck !== undefined ? (
|
|
447
|
+
<CheckBox
|
|
448
|
+
state={dir.check}
|
|
449
|
+
label={dir.check === 'on' ? stageLabels.unstage : stageLabels.stage}
|
|
450
|
+
indent={indent}
|
|
451
|
+
onToggle={() => onCheck(filesUnder(dir), dir.check)}
|
|
452
|
+
/>
|
|
453
|
+
) : null}
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
className={`${css.treeDir} ${containsActive ? css.treeDirActive : ''}`}
|
|
457
|
+
style={{ paddingLeft: onCheck !== undefined ? 0 : indent }}
|
|
458
|
+
onClick={() => onToggle(dir.path)}
|
|
459
|
+
title={dir.path}
|
|
460
|
+
>
|
|
461
|
+
<span className={`${css.chevron} ${open ? css.chevronOpen : ''}`}>▸</span>
|
|
462
|
+
<PathDirGlyph />
|
|
463
|
+
<span className={css.treeDirName}>{dir.name}</span>
|
|
464
|
+
<span className={css.treeDirCount}>{dir.fileCount}</span>
|
|
465
|
+
<span className={css.treeDirCounts}>
|
|
466
|
+
{dir.added > 0 ? <span className={css.fileCountAdd}>+{dir.added}</span> : null}
|
|
467
|
+
{dir.deleted > 0 ? <span className={css.fileCountDel}>−{dir.deleted}</span> : null}
|
|
468
|
+
</span>
|
|
469
|
+
</button>
|
|
470
|
+
</div>
|
|
471
|
+
{open ? (
|
|
472
|
+
<ul
|
|
473
|
+
className={css.treeSub}
|
|
474
|
+
// The rail hangs off the parent's chevron, which sits after the
|
|
475
|
+
// row's tick — so the tick's width is part of the offset.
|
|
476
|
+
style={{ [RAIL_VAR]: `${checkColumn + TREE_BASE_INDENT + depth * TREE_INDENT + TREE_RAIL_OFFSET}px` } as CSSProperties}
|
|
477
|
+
>
|
|
478
|
+
<TreeChildren node={dir} depth={depth + 1} active={active} collapsed={collapsed} onToggle={onToggle} onSelect={onSelect} onCheck={onCheck} onDiscard={onDiscard} stageLabels={stageLabels} discardLabel={discardLabel} />
|
|
479
|
+
</ul>
|
|
480
|
+
) : null}
|
|
481
|
+
</li>
|
|
482
|
+
)
|
|
483
|
+
})}
|
|
484
|
+
{fileNodes.map(file => {
|
|
485
|
+
const check = fileCheckState(file)
|
|
486
|
+
return (
|
|
487
|
+
<li key={file.path} className={css.fileLi}>
|
|
488
|
+
{onCheck !== undefined ? (
|
|
489
|
+
<CheckBox
|
|
490
|
+
state={check}
|
|
491
|
+
label={check === 'on' ? stageLabels.unstage : stageLabels.stage}
|
|
492
|
+
indent={indent}
|
|
493
|
+
onToggle={() => onCheck([file], check)}
|
|
494
|
+
/>
|
|
495
|
+
) : null}
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
className={active === file.path ? `${css.file} ${css.fileActive}` : css.file}
|
|
499
|
+
style={{ paddingLeft: (onCheck !== undefined ? 0 : indent) + TREE_LEAF_OFFSET }}
|
|
500
|
+
onClick={() => onSelect(file.path)}
|
|
501
|
+
title={file.previousPath !== undefined ? `${file.previousPath} → ${file.path}` : file.path}
|
|
502
|
+
>
|
|
503
|
+
{/* Icon then name, status on the right with the line counts.
|
|
504
|
+
The badge used to lead, which put two glyphs side by side the
|
|
505
|
+
moment the row gained a file icon; both IDEA and VS Code read
|
|
506
|
+
left-to-right as "what this is, then what happened to it",
|
|
507
|
+
and the badge still lands in an aligned column — `.filePath`
|
|
508
|
+
is the only flexible child. */}
|
|
509
|
+
<PathFileGlyph path={file.path} />
|
|
510
|
+
<span className={css.filePath}>{basePart(file.path)}</span>
|
|
511
|
+
{file.binary ? <span className={css.fileBinary}>BIN</span> : (
|
|
512
|
+
<span className={css.fileCounts}>
|
|
513
|
+
<span className={css.fileCountAdd}>{file.addedLines > 0 ? `+${file.addedLines}` : ''}</span>{' '}
|
|
514
|
+
<span className={css.fileCountDel}>{file.deletedLines > 0 ? `−${file.deletedLines}` : ''}</span>
|
|
515
|
+
</span>
|
|
516
|
+
)}
|
|
517
|
+
<span className={`${css.fileStatus} ${STATUS_BADGE[file.status]}`}>{statusGlyph(file.status)}</span>
|
|
518
|
+
</button>
|
|
519
|
+
{onDiscard !== undefined ? (
|
|
520
|
+
/* Outside the row button, not inside it: a button in a button is
|
|
521
|
+
invalid, and clicking roll-back must not also select the file. */
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
className={css.fileDiscard}
|
|
525
|
+
title={discardLabel}
|
|
526
|
+
aria-label={`${discardLabel ?? ''} ${file.path}`}
|
|
527
|
+
onClick={event => { event.stopPropagation(); onDiscard(file) }}
|
|
528
|
+
><RollbackGlyph /></button>
|
|
529
|
+
) : null}
|
|
530
|
+
</li>
|
|
531
|
+
)
|
|
532
|
+
})}
|
|
533
|
+
</>
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function statusGlyph(status: GitFileStatus): string {
|
|
538
|
+
switch (status) {
|
|
539
|
+
case 'added': return 'A'
|
|
540
|
+
case 'untracked': return 'U'
|
|
541
|
+
case 'modified': return 'M'
|
|
542
|
+
case 'renamed': return 'R'
|
|
543
|
+
case 'deleted': return 'D'
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function basePart(path: string): string {
|
|
548
|
+
const cut = path.lastIndexOf('/')
|
|
549
|
+
return cut >= 0 ? path.slice(cut + 1) : path
|
|
550
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The drawer's window controls, as glyphs.
|
|
5
|
+
*
|
|
6
|
+
* Four words in four identical pills read as a paragraph, not as controls — and
|
|
7
|
+
* three of these four are the actions every window on the machine already spells
|
|
8
|
+
* with a picture. Bootstrap Icons (MIT), one 16 viewBox, one fill, so the row
|
|
9
|
+
* reads as a set rather than four drawings that happen to sit together.
|
|
10
|
+
*/
|
|
11
|
+
const CHROME_GLYPH = {
|
|
12
|
+
settings: 'M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z',
|
|
13
|
+
maximize: 'M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z',
|
|
14
|
+
restore: 'M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5zm5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5zM0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zm10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4z',
|
|
15
|
+
// Single arrow, deliberately: the sync bar's Fetch is the two-arrow circle,
|
|
16
|
+
// and at 14px the only thing telling them apart is the arrow count.
|
|
17
|
+
refresh: 'M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z',
|
|
18
|
+
close: 'M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z',
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
export function ChromeGlyph({ of }: { of: keyof typeof CHROME_GLYPH }): ReactNode {
|
|
22
|
+
return (
|
|
23
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
24
|
+
<path d={CHROME_GLYPH[of]} />
|
|
25
|
+
</svg>
|
|
26
|
+
)
|
|
27
|
+
}
|