@lijian-ui/dsh-file-manager 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +38 -38
  2. package/README.i18n.yaml +6 -6
  3. package/README.md +66 -66
  4. package/README.zh.md +66 -66
  5. package/cordis.patch.yml +13 -13
  6. package/lib/client.js +27354 -355
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +474 -42
  9. package/lib/tsconfig.client.tsbuildinfo +1 -1
  10. package/lib/tsconfig.host.tsbuildinfo +1 -1
  11. package/lib/types/client/file-source.d.ts +10 -0
  12. package/lib/types/client/file-source.d.ts.map +1 -1
  13. package/lib/types/client/fileType.d.ts +19 -4
  14. package/lib/types/client/fileType.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts +0 -1
  16. package/lib/types/client/index.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +3 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/mount.d.ts +1 -1
  20. package/lib/types/client/mount.d.ts.map +1 -1
  21. package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
  22. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
  23. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  24. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  25. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  26. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  27. package/lib/types/client/preview/content.d.ts +3 -1
  28. package/lib/types/client/preview/content.d.ts.map +1 -1
  29. package/lib/types/client/preview/office.d.ts +23 -0
  30. package/lib/types/client/preview/office.d.ts.map +1 -0
  31. package/lib/types/client/reference.d.ts +23 -10
  32. package/lib/types/client/reference.d.ts.map +1 -1
  33. package/lib/types/client/store.d.ts.map +1 -1
  34. package/lib/types/host/fs-service.d.ts.map +1 -1
  35. package/lib/types/host/office-preview.d.ts +13 -0
  36. package/lib/types/host/office-preview.d.ts.map +1 -0
  37. package/lib/types/host/routes.d.ts.map +1 -1
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/package.json +19 -4
  40. package/src/client/DockItem.tsx +1 -1
  41. package/src/client/FileManagerSettingsCard.tsx +117 -117
  42. package/src/client/PluginSettingsCard.tsx +337 -337
  43. package/src/client/chat/file-ref.ts +144 -144
  44. package/src/client/chat/mermaid-chat.tsx +102 -102
  45. package/src/client/chat/placeholder-hint.tsx +46 -46
  46. package/src/client/components/ExplorerPanel.tsx +534 -534
  47. package/src/client/components/FileIcon.tsx +46 -46
  48. package/src/client/components/ScmPanel.tsx +480 -480
  49. package/src/client/components/a11y.ts +20 -20
  50. package/src/client/components/icons.tsx +274 -274
  51. package/src/client/components/overlay.tsx +225 -225
  52. package/src/client/drag/DragFileInlay.tsx +89 -89
  53. package/src/client/drag/file-drag.ts +67 -67
  54. package/src/client/drag.ts +154 -154
  55. package/src/client/file-source.ts +34 -0
  56. package/src/client/fileType.ts +32 -5
  57. package/src/client/hooks/useResizableSplit.ts +91 -91
  58. package/src/client/hooks/useStore.ts +15 -15
  59. package/src/client/index.ts +45 -17
  60. package/src/client/layout.ts +523 -523
  61. package/src/client/locales.ts +6 -0
  62. package/src/client/maximize.ts +34 -34
  63. package/src/client/mount.tsx +2 -1
  64. package/src/client/persist.ts +194 -194
  65. package/src/client/picker/FilePickerModal.tsx +526 -507
  66. package/src/client/picker/file-picker.ts +86 -86
  67. package/src/client/preview/PreviewPanel.tsx +8 -1
  68. package/src/client/preview/PreviewTabs.tsx +159 -159
  69. package/src/client/preview/PreviewToolbar.tsx +3 -2
  70. package/src/client/preview/content.tsx +7 -2
  71. package/src/client/preview/markdown.ts +351 -351
  72. package/src/client/preview/mermaid.ts +274 -274
  73. package/src/client/preview/office.tsx +321 -0
  74. package/src/client/reference.ts +62 -8
  75. package/src/client/settings-card.module.css +316 -316
  76. package/src/client/settings-form.ts +451 -451
  77. package/src/client/store.ts +17 -12
  78. package/src/client/styles/drag.module.css +30 -30
  79. package/src/client/styles/explorer.module.css +371 -371
  80. package/src/client/styles/picker.module.css +344 -344
  81. package/src/client/styles/preview.module.css +18 -11
  82. package/src/client/styles/scm.module.css +380 -380
  83. package/src/client/styles/tokens.module.css +376 -367
  84. package/src/core/types.ts +162 -162
  85. package/src/host/fs-service.ts +7 -1
  86. package/src/host/gate.ts +73 -73
  87. package/src/host/git-runner.ts +116 -116
  88. package/src/host/git-service.ts +395 -395
  89. package/src/host/loopback.ts +63 -63
  90. package/src/host/office-preview.ts +399 -0
  91. package/src/host/poll-guard.ts +109 -109
  92. package/src/host/routes.ts +72 -1
  93. package/src/index.ts +3 -1
  94. package/src/mount-once.ts +48 -48
  95. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  96. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  97. package/lib/types/client/floating.d.ts +0 -27
  98. package/lib/types/client/floating.d.ts.map +0 -1
  99. package/lib/types/client/mention.d.ts +0 -44
  100. package/lib/types/client/mention.d.ts.map +0 -1
  101. package/src/client/styles/chip.module.css +0 -38
@@ -1,480 +1,480 @@
1
- /**
2
- * The Changes (SCM) panel: per-repo working-tree status grouped into staged /
3
- * unstaged / untracked, with stage/unstage/discard actions on every row and
4
- * bulk actions in the section header. The host status is the only truth — no
5
- * optimistic rows; a failed batch surfaces its paths and the next refresh
6
- * clears the flag. Discard confirms with copy split by recoverability
7
- * (untracked = delete vs tracked = irreversible restore).
8
- *
9
- * FileManager ScmPanel behavior (Apache-2.0, re-implemented): window focus
10
- * refreshes (external editors write without git events), unknown states
11
- * render as a quiet '?', conflicted rows are visually distinct AND have no
12
- * actions.
13
- * @module dsh-filemgr/client/components/ScmPanel
14
- */
15
-
16
- import { useEffect, useMemo, useRef, useState } from 'react'
17
- import type { JSX } from 'react'
18
- import type { GitChangeRow } from '../../core/types.ts'
19
- import { t, format } from '../locales.ts'
20
- import { useStore } from '../hooks/useStore.ts'
21
- import type { PanelStores } from '../store.ts'
22
- import { ConfirmDialog } from './overlay.tsx'
23
- import { activateOnKey } from './a11y.ts'
24
- import { FileTypeIcon } from './FileIcon.tsx'
25
- import { BranchIcon, ChevronDownIcon, ChevronRightIcon, ListIcon, MinusIcon, PlusIcon, TreeIcon, UndoIcon } from './icons.tsx'
26
- import scmCss from '../styles/scm.module.css'
27
-
28
- /** Minimum gap between window-focus SCM refreshes (ms). */
29
- const FOCUS_REFRESH_MIN_MS = 5_000
30
-
31
- /** Badge letter + color class per state. */
32
- const BADGE: Record<string, { letter: string; className: string }> = {
33
- created: { letter: 'A', className: scmCss.badgeCreated },
34
- modified: { letter: 'M', className: scmCss.badgeModified },
35
- deleted: { letter: 'D', className: scmCss.badgeDeleted },
36
- renamed: { letter: 'R', className: scmCss.badgeCreated },
37
- conflicted: { letter: '!', className: scmCss.badgeConflicted },
38
- untracked: { letter: '?', className: scmCss.badgeUntracked },
39
- unknown: { letter: '?', className: scmCss.badgeUntracked },
40
- }
41
-
42
- /** The parent dir of a path ('' for root-level). */
43
- function dirOf(path: string): string {
44
- const idx = path.lastIndexOf('/')
45
- return idx > 0 ? path.slice(0, idx) : ''
46
- }
47
-
48
- /** Build a display-only directory tree from rows. */
49
- function buildTree(rows: GitChangeRow[]): Map<string, GitChangeRow[]> {
50
- const byDir = new Map<string, GitChangeRow[]>()
51
- for (const row of rows) {
52
- const dir = dirOf(row.path)
53
- const list = byDir.get(dir)
54
- if (list === undefined) byDir.set(dir, [row])
55
- else list.push(row)
56
- }
57
- return byDir
58
- }
59
-
60
- /** The SCM tab body.
61
- * @param stores - the panel store bundle.
62
- */
63
- export function ScmPanel({ stores }: { stores: PanelStores }): JSX.Element {
64
- const scm = stores.scm
65
- const preview = stores.preview
66
- const state = useStore(scm)
67
- const [discardTargets, setDiscardTargets] = useState<GitChangeRow[] | null>(null)
68
-
69
- // Window focus refreshes (catches external editors writing the tree).
70
- // Throttled: a focus burst must not spawn a git status per event — the
71
- // fs watch (host) and the 30s host poll already cover the steady state.
72
- // -Infinity so the first focus after mount always fires (production
73
- // Date.now() is enormous anyway; the sentinel makes the throttle explicit
74
- // and testable at clock 0).
75
- const lastFocusRefresh = useRef(-Infinity)
76
- useEffect(() => {
77
- const onFocus = (): void => {
78
- const now = Date.now()
79
- if (now - lastFocusRefresh.current < FOCUS_REFRESH_MIN_MS) return
80
- lastFocusRefresh.current = now
81
- void scm.refresh()
82
- }
83
- window.addEventListener('focus', onFocus)
84
- return () => window.removeEventListener('focus', onFocus)
85
- }, [scm])
86
-
87
- const status = state.status
88
- const changesSectionOpen = state.sectionCollapsed['changes'] !== true
89
-
90
- const requestDiscard = (rows: GitChangeRow[]): void => {
91
- if (rows.length === 0) return
92
- setDiscardTargets(rows)
93
- }
94
- const confirmDiscard = (): void => {
95
- if (discardTargets === null) return
96
- void scm.discard(discardTargets.map((row) => row.path))
97
- setDiscardTargets(null)
98
- }
99
-
100
- if (state.loading && status === null) {
101
- return <div className={`filemgr-root ${scmCss.panel}`}><div className={scmCss.loading}>{t('scm.loading')}</div></div>
102
- }
103
- if (state.gitMissing) {
104
- return <div className={`filemgr-root ${scmCss.panel}`}><div className={scmCss.notRepo}>{t('scm.gitMissing')}</div></div>
105
- }
106
- if (status === null) {
107
- return <div className={`filemgr-root ${scmCss.panel}`}><div className={scmCss.notRepo}>{t('scm.notRepo')}</div></div>
108
- }
109
-
110
- const staged = status.staged
111
- const unstaged = status.unstaged
112
- const untracked = status.untracked
113
- const hasChanges = staged.length + unstaged.length + untracked.length > 0
114
- const allUntracked = discardTargets !== null && discardTargets.every((row) => row.state === 'untracked')
115
-
116
- return (
117
- <div className={`filemgr-root ${scmCss.panel}`}>
118
- {/* Changes section. */}
119
- <div className={scmCss.section} style={{ flex: changesSectionOpen ? 1 : undefined, maxHeight: changesSectionOpen ? undefined : 24 }}>
120
- <div
121
- className={scmCss.sectionHeader}
122
- onClick={() => scm.setSectionCollapsed('changes', changesSectionOpen)}
123
- onKeyDown={activateOnKey(() => { scm.setSectionCollapsed('changes', changesSectionOpen) })}
124
- role="button"
125
- tabIndex={0}
126
- aria-expanded={changesSectionOpen}
127
- >
128
- <span className={`${scmCss.sectionChevron}${changesSectionOpen ? ` ${scmCss.sectionChevronOpen}` : ''}`}>
129
- <ChevronRightIcon size={13} />
130
- </span>
131
- <span className={scmCss.sectionTitle}>{t('scm.changes')}</span>
132
- {status.branch !== '' && (
133
- <span className={scmCss.branchName} style={{ fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', display: 'flex', alignItems: 'center', gap: 4 }}>
134
- <BranchIcon size={12} />
135
- {status.branch}
136
- </span>
137
- )}
138
- <span
139
- style={{ display: 'flex', alignItems: 'center', gap: 2, marginLeft: 'auto' }}
140
- onClick={(event) => event.stopPropagation()}
141
- >
142
- <button
143
- type="button"
144
- className={scmCss.sectionAction}
145
- title={t('scm.stageAll')}
146
- onClick={() => void scm.stage(unstaged.map((row) => row.path))}
147
- disabled={unstaged.length === 0}
148
- >
149
- <PlusIcon size={13} />
150
- </button>
151
- <button
152
- type="button"
153
- className={scmCss.sectionAction}
154
- title={t('scm.discardAll')}
155
- onClick={() => requestDiscard([...unstaged, ...untracked])}
156
- disabled={unstaged.length + untracked.length === 0}
157
- >
158
- <UndoIcon size={13} />
159
- </button>
160
- <button
161
- type="button"
162
- className={scmCss.sectionAction}
163
- title={t('scm.viewList')}
164
- style={{ color: state.viewMode === 'list' ? 'var(--dsw-alias-brand-primary)' : undefined }}
165
- onClick={() => scm.setViewMode('list')}
166
- >
167
- <ListIcon size={13} />
168
- </button>
169
- <button
170
- type="button"
171
- className={scmCss.sectionAction}
172
- title={t('scm.viewTree')}
173
- style={{ color: state.viewMode === 'tree' ? 'var(--dsw-alias-brand-primary)' : undefined }}
174
- onClick={() => scm.setViewMode('tree')}
175
- >
176
- <TreeIcon size={13} />
177
- </button>
178
- </span>
179
- </div>
180
-
181
- {changesSectionOpen && (
182
- <div className={scmCss.sectionBody}>
183
- {!hasChanges && <div className={scmCss.empty}>{t('scm.empty')}</div>}
184
- {hasChanges && (
185
- <Group
186
- scm={scm}
187
- preview={preview}
188
- title={staged.length > 0 ? t('scm.staged') : undefined}
189
- rows={staged}
190
- bulkLabel={t('scm.unstage')}
191
- onBulk={(rows) => void scm.unstage(rows.map((row) => row.path))}
192
- onDiscard={requestDiscard}
193
- />
194
- )}
195
- {hasChanges && unstaged.length > 0 && (
196
- <Group
197
- scm={scm}
198
- preview={preview}
199
- rows={unstaged}
200
- bulkLabel={t('scm.stage')}
201
- onBulk={(rows) => void scm.stage(rows.map((row) => row.path))}
202
- onDiscard={requestDiscard}
203
- />
204
- )}
205
- {untracked.length > 0 && (
206
- <Group
207
- scm={scm}
208
- preview={preview}
209
- title={t('scm.untracked')}
210
- rows={untracked}
211
- bulkLabel={t('scm.stage')}
212
- onBulk={(rows) => void scm.stage(rows.map((row) => row.path))}
213
- onDiscard={requestDiscard}
214
- />
215
- )}
216
- </div>
217
- )}
218
- </div>
219
-
220
- {discardTargets !== null && (
221
- <ConfirmDialog
222
- title={t('scm.discard')}
223
- body={allUntracked
224
- ? format(t('scm.discardConfirmUntracked'), { count: discardTargets.length })
225
- : format(t('scm.discardConfirmTracked'), { count: discardTargets.length })}
226
- confirmLabel={t('common.delete')}
227
- danger
228
- onConfirm={confirmDiscard}
229
- onCancel={() => setDiscardTargets(null)}
230
- />
231
- )}
232
- </div>
233
- )
234
- }
235
-
236
- /** One change group (staged / unstaged / untracked) with list or tree body. */
237
- function Group({
238
- scm,
239
- preview,
240
- rows,
241
- title,
242
- bulkLabel,
243
- onBulk,
244
- onDiscard,
245
- }: {
246
- scm: PanelStores['scm']
247
- preview: PanelStores['preview']
248
- rows: GitChangeRow[]
249
- title?: string
250
- bulkLabel: string
251
- onBulk: (rows: GitChangeRow[]) => void
252
- onDiscard: (rows: GitChangeRow[]) => void
253
- }): JSX.Element {
254
- const state = useStore(scm)
255
- const tree = useMemo(() => buildTree(rows), [rows])
256
- const viewTree = state.viewMode === 'tree'
257
- const allActionable = rows.filter((row) => row.state !== 'conflicted')
258
- // O(1) lookups for row rendering: an includes() per row per array is
259
- // O(rows x ops) on every batch operation.
260
- const busySet = useMemo(() => new Set(state.busy), [state.busy])
261
- const failedSet = useMemo(() => new Set(state.failed), [state.failed])
262
- const expandedSet = useMemo(() => new Set(state.treeExpanded), [state.treeExpanded])
263
-
264
- return (
265
- <div>
266
- {title !== undefined && (
267
- <div className={scmCss.groupTitle}>
268
- {title}
269
- <button
270
- type="button"
271
- className={scmCss.groupAction}
272
- title={bulkLabel}
273
- onClick={() => onBulk(allActionable)}
274
- disabled={allActionable.length === 0}
275
- >
276
- {bulkLabel === t('scm.unstage') ? <MinusIcon size={12} /> : <PlusIcon size={12} />}
277
- </button>
278
- </div>
279
- )}
280
- {viewTree ? (
281
- [...tree.entries()].map(([dir, dirRows]) => (
282
- <DirNode
283
- key={dir === '' ? '\u0000' : dir}
284
- dir={dir}
285
- rows={dirRows}
286
- depth={0}
287
- state={state}
288
- expandedSet={expandedSet}
289
- busySet={busySet}
290
- failedSet={failedSet}
291
- scm={scm}
292
- preview={preview}
293
- onDiscard={onDiscard}
294
- />
295
- ))
296
- ) : (
297
- rows.map((row) => (
298
- <ChangeRow
299
- key={`${row.staged ? 's' : 'u'}:${row.path}`}
300
- row={row}
301
- state={state}
302
- busy={busySet.has(row.path)}
303
- failed={failedSet.has(row.path)}
304
- scm={scm}
305
- preview={preview}
306
- onDiscard={onDiscard}
307
- />
308
- ))
309
- )}
310
- </div>
311
- )
312
- }
313
-
314
- /** Tree-view directory node (expandable). */
315
- function DirNode({
316
- dir,
317
- rows,
318
- depth,
319
- state,
320
- expandedSet,
321
- busySet,
322
- failedSet,
323
- scm,
324
- preview,
325
- onDiscard,
326
- }: {
327
- dir: string
328
- rows: GitChangeRow[]
329
- depth: number
330
- state: ReturnType<PanelStores['scm']['getSnapshot']>
331
- expandedSet: ReadonlySet<string>
332
- busySet: ReadonlySet<string>
333
- failedSet: ReadonlySet<string>
334
- scm: PanelStores['scm']
335
- preview: PanelStores['preview']
336
- onDiscard: (rows: GitChangeRow[]) => void
337
- }): JSX.Element {
338
- const expanded = expandedSet.has(dir)
339
- const label = dir === '' ? '/' : dir.split('/').pop() ?? dir
340
- const toggleExpanded = (): void => {
341
- const next = expanded
342
- ? state.treeExpanded.filter((item) => item !== dir)
343
- : [...state.treeExpanded, dir]
344
- scm.setTreeExpanded(next)
345
- }
346
- return (
347
- <>
348
- <div
349
- className={scmCss.dirRow}
350
- style={{ paddingLeft: 12 + depth * 12 }}
351
- title={dir}
352
- role="button"
353
- tabIndex={0}
354
- aria-expanded={expanded}
355
- onClick={toggleExpanded}
356
- onKeyDown={activateOnKey(toggleExpanded)}
357
- >
358
- <span className={`${scmCss.dirArrow}${expanded ? ` ${scmCss.dirArrowOpen}` : ''}`}>
359
- <ChevronRightIcon size={13} />
360
- </span>
361
- <FileTypeIcon name={label} isDir expanded={expanded} />
362
- <span style={{ fontSize: 13, color: 'var(--dsw-alias-label-primary)' }}>{label}</span>
363
- </div>
364
- {expanded &&
365
- rows.map((row) => (
366
- <ChangeRow
367
- key={`${row.staged ? 's' : 'u'}:${row.path}`}
368
- row={row}
369
- state={state}
370
- busy={busySet.has(row.path)}
371
- failed={failedSet.has(row.path)}
372
- scm={scm}
373
- preview={preview}
374
- onDiscard={onDiscard}
375
- indent={depth + 1}
376
- hideDir
377
- />
378
- ))}
379
- </>
380
- )
381
- }
382
-
383
- /** One change row: badge + name + dimmed dir + hover actions.
384
- * Clicking the row opens the path's diff in the preview panel (every state
385
- * has a diff — deleted rows show the removal, untracked rows a new-file diff).
386
- */
387
- function ChangeRow({
388
- row,
389
- state,
390
- busy,
391
- failed,
392
- scm,
393
- preview,
394
- onDiscard,
395
- indent = 0,
396
- hideDir = false,
397
- }: {
398
- row: GitChangeRow
399
- state: ReturnType<PanelStores['scm']['getSnapshot']>
400
- busy: boolean
401
- failed: boolean
402
- scm: PanelStores['scm']
403
- preview: PanelStores['preview']
404
- onDiscard: (rows: GitChangeRow[]) => void
405
- indent?: number
406
- hideDir?: boolean
407
- }): JSX.Element {
408
- const badge = BADGE[row.state] ?? BADGE.unknown
409
- const conflicted = row.state === 'conflicted'
410
- const displayName = row.oldPath !== undefined ? `${row.oldPath.split('/').pop()} -> ${row.path.split('/').pop()}` : (row.path.split('/').pop() ?? row.path)
411
- const dir = dirOf(row.path)
412
-
413
- const openInPreview = (): void => {
414
- scm.select(row.path)
415
- // Staged rows diff the index against HEAD; unstaged rows the worktree
416
- // against the index — the side the row was listed under.
417
- preview.openDiff(state.root, row.path, row.staged)
418
- }
419
-
420
- return (
421
- <div
422
- className={`${scmCss.changeRow}${state.selected === row.path ? ` ${scmCss.changeRowSelected}` : ''}${failed ? ` ${scmCss.rowFailed}` : ''}`}
423
- style={{ paddingLeft: 12 + indent * 12 }}
424
- title={row.path}
425
- onClick={openInPreview}
426
- onKeyDown={activateOnKey(openInPreview)}
427
- role="button"
428
- tabIndex={0}
429
- >
430
- <span className={`${scmCss.badge} ${badge.className}`}>{badge.letter}</span>
431
- <span className={scmCss.changeName}>{displayName}</span>
432
- {!hideDir && dir !== '' && <span className={scmCss.changeDir}>{dir}</span>}
433
- <span className={`${scmCss.rowActions}${busy || failed ? ` ${scmCss.rowActionsVisible}` : ''}`}>
434
- {conflicted ? null : row.staged ? (
435
- <>
436
- <button
437
- type="button"
438
- className={scmCss.rowAction}
439
- title={t('scm.unstage')}
440
- disabled={busy}
441
- onClick={(event) => { event.stopPropagation(); void scm.unstage([row.path]) }}
442
- >
443
- <MinusIcon size={13} />
444
- </button>
445
- <button
446
- type="button"
447
- className={scmCss.rowAction}
448
- title={t('scm.discard')}
449
- disabled={busy}
450
- onClick={(event) => { event.stopPropagation(); onDiscard([row]) }}
451
- >
452
- <UndoIcon size={13} />
453
- </button>
454
- </>
455
- ) : (
456
- <>
457
- <button
458
- type="button"
459
- className={scmCss.rowAction}
460
- title={t('scm.stage')}
461
- disabled={busy}
462
- onClick={(event) => { event.stopPropagation(); void scm.stage([row.path]) }}
463
- >
464
- <PlusIcon size={13} />
465
- </button>
466
- <button
467
- type="button"
468
- className={scmCss.rowAction}
469
- title={t('scm.discard')}
470
- disabled={busy}
471
- onClick={(event) => { event.stopPropagation(); onDiscard([row]) }}
472
- >
473
- <UndoIcon size={13} />
474
- </button>
475
- </>
476
- )}
477
- </span>
478
- </div>
479
- )
480
- }
1
+ /**
2
+ * The Changes (SCM) panel: per-repo working-tree status grouped into staged /
3
+ * unstaged / untracked, with stage/unstage/discard actions on every row and
4
+ * bulk actions in the section header. The host status is the only truth — no
5
+ * optimistic rows; a failed batch surfaces its paths and the next refresh
6
+ * clears the flag. Discard confirms with copy split by recoverability
7
+ * (untracked = delete vs tracked = irreversible restore).
8
+ *
9
+ * FileManager ScmPanel behavior (Apache-2.0, re-implemented): window focus
10
+ * refreshes (external editors write without git events), unknown states
11
+ * render as a quiet '?', conflicted rows are visually distinct AND have no
12
+ * actions.
13
+ * @module dsh-filemgr/client/components/ScmPanel
14
+ */
15
+
16
+ import { useEffect, useMemo, useRef, useState } from 'react'
17
+ import type { JSX } from 'react'
18
+ import type { GitChangeRow } from '../../core/types.ts'
19
+ import { t, format } from '../locales.ts'
20
+ import { useStore } from '../hooks/useStore.ts'
21
+ import type { PanelStores } from '../store.ts'
22
+ import { ConfirmDialog } from './overlay.tsx'
23
+ import { activateOnKey } from './a11y.ts'
24
+ import { FileTypeIcon } from './FileIcon.tsx'
25
+ import { BranchIcon, ChevronDownIcon, ChevronRightIcon, ListIcon, MinusIcon, PlusIcon, TreeIcon, UndoIcon } from './icons.tsx'
26
+ import scmCss from '../styles/scm.module.css'
27
+
28
+ /** Minimum gap between window-focus SCM refreshes (ms). */
29
+ const FOCUS_REFRESH_MIN_MS = 5_000
30
+
31
+ /** Badge letter + color class per state. */
32
+ const BADGE: Record<string, { letter: string; className: string }> = {
33
+ created: { letter: 'A', className: scmCss.badgeCreated },
34
+ modified: { letter: 'M', className: scmCss.badgeModified },
35
+ deleted: { letter: 'D', className: scmCss.badgeDeleted },
36
+ renamed: { letter: 'R', className: scmCss.badgeCreated },
37
+ conflicted: { letter: '!', className: scmCss.badgeConflicted },
38
+ untracked: { letter: '?', className: scmCss.badgeUntracked },
39
+ unknown: { letter: '?', className: scmCss.badgeUntracked },
40
+ }
41
+
42
+ /** The parent dir of a path ('' for root-level). */
43
+ function dirOf(path: string): string {
44
+ const idx = path.lastIndexOf('/')
45
+ return idx > 0 ? path.slice(0, idx) : ''
46
+ }
47
+
48
+ /** Build a display-only directory tree from rows. */
49
+ function buildTree(rows: GitChangeRow[]): Map<string, GitChangeRow[]> {
50
+ const byDir = new Map<string, GitChangeRow[]>()
51
+ for (const row of rows) {
52
+ const dir = dirOf(row.path)
53
+ const list = byDir.get(dir)
54
+ if (list === undefined) byDir.set(dir, [row])
55
+ else list.push(row)
56
+ }
57
+ return byDir
58
+ }
59
+
60
+ /** The SCM tab body.
61
+ * @param stores - the panel store bundle.
62
+ */
63
+ export function ScmPanel({ stores }: { stores: PanelStores }): JSX.Element {
64
+ const scm = stores.scm
65
+ const preview = stores.preview
66
+ const state = useStore(scm)
67
+ const [discardTargets, setDiscardTargets] = useState<GitChangeRow[] | null>(null)
68
+
69
+ // Window focus refreshes (catches external editors writing the tree).
70
+ // Throttled: a focus burst must not spawn a git status per event — the
71
+ // fs watch (host) and the 30s host poll already cover the steady state.
72
+ // -Infinity so the first focus after mount always fires (production
73
+ // Date.now() is enormous anyway; the sentinel makes the throttle explicit
74
+ // and testable at clock 0).
75
+ const lastFocusRefresh = useRef(-Infinity)
76
+ useEffect(() => {
77
+ const onFocus = (): void => {
78
+ const now = Date.now()
79
+ if (now - lastFocusRefresh.current < FOCUS_REFRESH_MIN_MS) return
80
+ lastFocusRefresh.current = now
81
+ void scm.refresh()
82
+ }
83
+ window.addEventListener('focus', onFocus)
84
+ return () => window.removeEventListener('focus', onFocus)
85
+ }, [scm])
86
+
87
+ const status = state.status
88
+ const changesSectionOpen = state.sectionCollapsed['changes'] !== true
89
+
90
+ const requestDiscard = (rows: GitChangeRow[]): void => {
91
+ if (rows.length === 0) return
92
+ setDiscardTargets(rows)
93
+ }
94
+ const confirmDiscard = (): void => {
95
+ if (discardTargets === null) return
96
+ void scm.discard(discardTargets.map((row) => row.path))
97
+ setDiscardTargets(null)
98
+ }
99
+
100
+ if (state.loading && status === null) {
101
+ return <div className={`filemgr-root ${scmCss.panel}`}><div className={scmCss.loading}>{t('scm.loading')}</div></div>
102
+ }
103
+ if (state.gitMissing) {
104
+ return <div className={`filemgr-root ${scmCss.panel}`}><div className={scmCss.notRepo}>{t('scm.gitMissing')}</div></div>
105
+ }
106
+ if (status === null) {
107
+ return <div className={`filemgr-root ${scmCss.panel}`}><div className={scmCss.notRepo}>{t('scm.notRepo')}</div></div>
108
+ }
109
+
110
+ const staged = status.staged
111
+ const unstaged = status.unstaged
112
+ const untracked = status.untracked
113
+ const hasChanges = staged.length + unstaged.length + untracked.length > 0
114
+ const allUntracked = discardTargets !== null && discardTargets.every((row) => row.state === 'untracked')
115
+
116
+ return (
117
+ <div className={`filemgr-root ${scmCss.panel}`}>
118
+ {/* Changes section. */}
119
+ <div className={scmCss.section} style={{ flex: changesSectionOpen ? 1 : undefined, maxHeight: changesSectionOpen ? undefined : 24 }}>
120
+ <div
121
+ className={scmCss.sectionHeader}
122
+ onClick={() => scm.setSectionCollapsed('changes', changesSectionOpen)}
123
+ onKeyDown={activateOnKey(() => { scm.setSectionCollapsed('changes', changesSectionOpen) })}
124
+ role="button"
125
+ tabIndex={0}
126
+ aria-expanded={changesSectionOpen}
127
+ >
128
+ <span className={`${scmCss.sectionChevron}${changesSectionOpen ? ` ${scmCss.sectionChevronOpen}` : ''}`}>
129
+ <ChevronRightIcon size={13} />
130
+ </span>
131
+ <span className={scmCss.sectionTitle}>{t('scm.changes')}</span>
132
+ {status.branch !== '' && (
133
+ <span className={scmCss.branchName} style={{ fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', display: 'flex', alignItems: 'center', gap: 4 }}>
134
+ <BranchIcon size={12} />
135
+ {status.branch}
136
+ </span>
137
+ )}
138
+ <span
139
+ style={{ display: 'flex', alignItems: 'center', gap: 2, marginLeft: 'auto' }}
140
+ onClick={(event) => event.stopPropagation()}
141
+ >
142
+ <button
143
+ type="button"
144
+ className={scmCss.sectionAction}
145
+ title={t('scm.stageAll')}
146
+ onClick={() => void scm.stage(unstaged.map((row) => row.path))}
147
+ disabled={unstaged.length === 0}
148
+ >
149
+ <PlusIcon size={13} />
150
+ </button>
151
+ <button
152
+ type="button"
153
+ className={scmCss.sectionAction}
154
+ title={t('scm.discardAll')}
155
+ onClick={() => requestDiscard([...unstaged, ...untracked])}
156
+ disabled={unstaged.length + untracked.length === 0}
157
+ >
158
+ <UndoIcon size={13} />
159
+ </button>
160
+ <button
161
+ type="button"
162
+ className={scmCss.sectionAction}
163
+ title={t('scm.viewList')}
164
+ style={{ color: state.viewMode === 'list' ? 'var(--dsw-alias-brand-primary)' : undefined }}
165
+ onClick={() => scm.setViewMode('list')}
166
+ >
167
+ <ListIcon size={13} />
168
+ </button>
169
+ <button
170
+ type="button"
171
+ className={scmCss.sectionAction}
172
+ title={t('scm.viewTree')}
173
+ style={{ color: state.viewMode === 'tree' ? 'var(--dsw-alias-brand-primary)' : undefined }}
174
+ onClick={() => scm.setViewMode('tree')}
175
+ >
176
+ <TreeIcon size={13} />
177
+ </button>
178
+ </span>
179
+ </div>
180
+
181
+ {changesSectionOpen && (
182
+ <div className={scmCss.sectionBody}>
183
+ {!hasChanges && <div className={scmCss.empty}>{t('scm.empty')}</div>}
184
+ {hasChanges && (
185
+ <Group
186
+ scm={scm}
187
+ preview={preview}
188
+ title={staged.length > 0 ? t('scm.staged') : undefined}
189
+ rows={staged}
190
+ bulkLabel={t('scm.unstage')}
191
+ onBulk={(rows) => void scm.unstage(rows.map((row) => row.path))}
192
+ onDiscard={requestDiscard}
193
+ />
194
+ )}
195
+ {hasChanges && unstaged.length > 0 && (
196
+ <Group
197
+ scm={scm}
198
+ preview={preview}
199
+ rows={unstaged}
200
+ bulkLabel={t('scm.stage')}
201
+ onBulk={(rows) => void scm.stage(rows.map((row) => row.path))}
202
+ onDiscard={requestDiscard}
203
+ />
204
+ )}
205
+ {untracked.length > 0 && (
206
+ <Group
207
+ scm={scm}
208
+ preview={preview}
209
+ title={t('scm.untracked')}
210
+ rows={untracked}
211
+ bulkLabel={t('scm.stage')}
212
+ onBulk={(rows) => void scm.stage(rows.map((row) => row.path))}
213
+ onDiscard={requestDiscard}
214
+ />
215
+ )}
216
+ </div>
217
+ )}
218
+ </div>
219
+
220
+ {discardTargets !== null && (
221
+ <ConfirmDialog
222
+ title={t('scm.discard')}
223
+ body={allUntracked
224
+ ? format(t('scm.discardConfirmUntracked'), { count: discardTargets.length })
225
+ : format(t('scm.discardConfirmTracked'), { count: discardTargets.length })}
226
+ confirmLabel={t('common.delete')}
227
+ danger
228
+ onConfirm={confirmDiscard}
229
+ onCancel={() => setDiscardTargets(null)}
230
+ />
231
+ )}
232
+ </div>
233
+ )
234
+ }
235
+
236
+ /** One change group (staged / unstaged / untracked) with list or tree body. */
237
+ function Group({
238
+ scm,
239
+ preview,
240
+ rows,
241
+ title,
242
+ bulkLabel,
243
+ onBulk,
244
+ onDiscard,
245
+ }: {
246
+ scm: PanelStores['scm']
247
+ preview: PanelStores['preview']
248
+ rows: GitChangeRow[]
249
+ title?: string
250
+ bulkLabel: string
251
+ onBulk: (rows: GitChangeRow[]) => void
252
+ onDiscard: (rows: GitChangeRow[]) => void
253
+ }): JSX.Element {
254
+ const state = useStore(scm)
255
+ const tree = useMemo(() => buildTree(rows), [rows])
256
+ const viewTree = state.viewMode === 'tree'
257
+ const allActionable = rows.filter((row) => row.state !== 'conflicted')
258
+ // O(1) lookups for row rendering: an includes() per row per array is
259
+ // O(rows x ops) on every batch operation.
260
+ const busySet = useMemo(() => new Set(state.busy), [state.busy])
261
+ const failedSet = useMemo(() => new Set(state.failed), [state.failed])
262
+ const expandedSet = useMemo(() => new Set(state.treeExpanded), [state.treeExpanded])
263
+
264
+ return (
265
+ <div>
266
+ {title !== undefined && (
267
+ <div className={scmCss.groupTitle}>
268
+ {title}
269
+ <button
270
+ type="button"
271
+ className={scmCss.groupAction}
272
+ title={bulkLabel}
273
+ onClick={() => onBulk(allActionable)}
274
+ disabled={allActionable.length === 0}
275
+ >
276
+ {bulkLabel === t('scm.unstage') ? <MinusIcon size={12} /> : <PlusIcon size={12} />}
277
+ </button>
278
+ </div>
279
+ )}
280
+ {viewTree ? (
281
+ [...tree.entries()].map(([dir, dirRows]) => (
282
+ <DirNode
283
+ key={dir === '' ? '\u0000' : dir}
284
+ dir={dir}
285
+ rows={dirRows}
286
+ depth={0}
287
+ state={state}
288
+ expandedSet={expandedSet}
289
+ busySet={busySet}
290
+ failedSet={failedSet}
291
+ scm={scm}
292
+ preview={preview}
293
+ onDiscard={onDiscard}
294
+ />
295
+ ))
296
+ ) : (
297
+ rows.map((row) => (
298
+ <ChangeRow
299
+ key={`${row.staged ? 's' : 'u'}:${row.path}`}
300
+ row={row}
301
+ state={state}
302
+ busy={busySet.has(row.path)}
303
+ failed={failedSet.has(row.path)}
304
+ scm={scm}
305
+ preview={preview}
306
+ onDiscard={onDiscard}
307
+ />
308
+ ))
309
+ )}
310
+ </div>
311
+ )
312
+ }
313
+
314
+ /** Tree-view directory node (expandable). */
315
+ function DirNode({
316
+ dir,
317
+ rows,
318
+ depth,
319
+ state,
320
+ expandedSet,
321
+ busySet,
322
+ failedSet,
323
+ scm,
324
+ preview,
325
+ onDiscard,
326
+ }: {
327
+ dir: string
328
+ rows: GitChangeRow[]
329
+ depth: number
330
+ state: ReturnType<PanelStores['scm']['getSnapshot']>
331
+ expandedSet: ReadonlySet<string>
332
+ busySet: ReadonlySet<string>
333
+ failedSet: ReadonlySet<string>
334
+ scm: PanelStores['scm']
335
+ preview: PanelStores['preview']
336
+ onDiscard: (rows: GitChangeRow[]) => void
337
+ }): JSX.Element {
338
+ const expanded = expandedSet.has(dir)
339
+ const label = dir === '' ? '/' : dir.split('/').pop() ?? dir
340
+ const toggleExpanded = (): void => {
341
+ const next = expanded
342
+ ? state.treeExpanded.filter((item) => item !== dir)
343
+ : [...state.treeExpanded, dir]
344
+ scm.setTreeExpanded(next)
345
+ }
346
+ return (
347
+ <>
348
+ <div
349
+ className={scmCss.dirRow}
350
+ style={{ paddingLeft: 12 + depth * 12 }}
351
+ title={dir}
352
+ role="button"
353
+ tabIndex={0}
354
+ aria-expanded={expanded}
355
+ onClick={toggleExpanded}
356
+ onKeyDown={activateOnKey(toggleExpanded)}
357
+ >
358
+ <span className={`${scmCss.dirArrow}${expanded ? ` ${scmCss.dirArrowOpen}` : ''}`}>
359
+ <ChevronRightIcon size={13} />
360
+ </span>
361
+ <FileTypeIcon name={label} isDir expanded={expanded} />
362
+ <span style={{ fontSize: 13, color: 'var(--dsw-alias-label-primary)' }}>{label}</span>
363
+ </div>
364
+ {expanded &&
365
+ rows.map((row) => (
366
+ <ChangeRow
367
+ key={`${row.staged ? 's' : 'u'}:${row.path}`}
368
+ row={row}
369
+ state={state}
370
+ busy={busySet.has(row.path)}
371
+ failed={failedSet.has(row.path)}
372
+ scm={scm}
373
+ preview={preview}
374
+ onDiscard={onDiscard}
375
+ indent={depth + 1}
376
+ hideDir
377
+ />
378
+ ))}
379
+ </>
380
+ )
381
+ }
382
+
383
+ /** One change row: badge + name + dimmed dir + hover actions.
384
+ * Clicking the row opens the path's diff in the preview panel (every state
385
+ * has a diff — deleted rows show the removal, untracked rows a new-file diff).
386
+ */
387
+ function ChangeRow({
388
+ row,
389
+ state,
390
+ busy,
391
+ failed,
392
+ scm,
393
+ preview,
394
+ onDiscard,
395
+ indent = 0,
396
+ hideDir = false,
397
+ }: {
398
+ row: GitChangeRow
399
+ state: ReturnType<PanelStores['scm']['getSnapshot']>
400
+ busy: boolean
401
+ failed: boolean
402
+ scm: PanelStores['scm']
403
+ preview: PanelStores['preview']
404
+ onDiscard: (rows: GitChangeRow[]) => void
405
+ indent?: number
406
+ hideDir?: boolean
407
+ }): JSX.Element {
408
+ const badge = BADGE[row.state] ?? BADGE.unknown
409
+ const conflicted = row.state === 'conflicted'
410
+ const displayName = row.oldPath !== undefined ? `${row.oldPath.split('/').pop()} -> ${row.path.split('/').pop()}` : (row.path.split('/').pop() ?? row.path)
411
+ const dir = dirOf(row.path)
412
+
413
+ const openInPreview = (): void => {
414
+ scm.select(row.path)
415
+ // Staged rows diff the index against HEAD; unstaged rows the worktree
416
+ // against the index — the side the row was listed under.
417
+ preview.openDiff(state.root, row.path, row.staged)
418
+ }
419
+
420
+ return (
421
+ <div
422
+ className={`${scmCss.changeRow}${state.selected === row.path ? ` ${scmCss.changeRowSelected}` : ''}${failed ? ` ${scmCss.rowFailed}` : ''}`}
423
+ style={{ paddingLeft: 12 + indent * 12 }}
424
+ title={row.path}
425
+ onClick={openInPreview}
426
+ onKeyDown={activateOnKey(openInPreview)}
427
+ role="button"
428
+ tabIndex={0}
429
+ >
430
+ <span className={`${scmCss.badge} ${badge.className}`}>{badge.letter}</span>
431
+ <span className={scmCss.changeName}>{displayName}</span>
432
+ {!hideDir && dir !== '' && <span className={scmCss.changeDir}>{dir}</span>}
433
+ <span className={`${scmCss.rowActions}${busy || failed ? ` ${scmCss.rowActionsVisible}` : ''}`}>
434
+ {conflicted ? null : row.staged ? (
435
+ <>
436
+ <button
437
+ type="button"
438
+ className={scmCss.rowAction}
439
+ title={t('scm.unstage')}
440
+ disabled={busy}
441
+ onClick={(event) => { event.stopPropagation(); void scm.unstage([row.path]) }}
442
+ >
443
+ <MinusIcon size={13} />
444
+ </button>
445
+ <button
446
+ type="button"
447
+ className={scmCss.rowAction}
448
+ title={t('scm.discard')}
449
+ disabled={busy}
450
+ onClick={(event) => { event.stopPropagation(); onDiscard([row]) }}
451
+ >
452
+ <UndoIcon size={13} />
453
+ </button>
454
+ </>
455
+ ) : (
456
+ <>
457
+ <button
458
+ type="button"
459
+ className={scmCss.rowAction}
460
+ title={t('scm.stage')}
461
+ disabled={busy}
462
+ onClick={(event) => { event.stopPropagation(); void scm.stage([row.path]) }}
463
+ >
464
+ <PlusIcon size={13} />
465
+ </button>
466
+ <button
467
+ type="button"
468
+ className={scmCss.rowAction}
469
+ title={t('scm.discard')}
470
+ disabled={busy}
471
+ onClick={(event) => { event.stopPropagation(); onDiscard([row]) }}
472
+ >
473
+ <UndoIcon size={13} />
474
+ </button>
475
+ </>
476
+ )}
477
+ </span>
478
+ </div>
479
+ )
480
+ }