@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.
- package/LICENSE +38 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27354 -355
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +0 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +23 -10
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/DockItem.tsx +1 -1
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +45 -17
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -507
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +8 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +62 -8
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +18 -11
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -367
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- package/lib/types/client/mention.d.ts.map +0 -1
- 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
|
+
}
|