@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,534 @@
1
+ /**
2
+ * The Explorer column: Files/Changes tab bar (37px), the persistent filename
3
+ * search at the top of the Files tab (150ms debounced; a hit click REVEALS
4
+ * the file in the tree — expand ancestors + select — never opens preview),
5
+ * the lazy file tree (34px rows, full-row expand/collapse, 16px icons), and
6
+ * the in-column collapse chevron.
7
+ *
8
+ * FileManager Explorer behavior (Apache-2.0, re-implemented): row click toggles
9
+ * folders (no need to hit the arrow), search results are reveal-only, and
10
+ * clicking a file opens it in the preview panel (dedup focuses the tab).
11
+ * @module dsh-filemgr/client/components/ExplorerPanel
12
+ */
13
+
14
+ import { memo, useCallback, useMemo, useRef, useState } from 'react'
15
+ import type { DragEvent, JSX, MouseEvent as ReactMouseEvent } from 'react'
16
+ import type { FsEntry } from '../../core/types.ts'
17
+ import { parentRel } from '../fileType.ts'
18
+ import { t } from '../locales.ts'
19
+ import { useStore } from '../hooks/useStore.ts'
20
+ import type { PanelStores } from '../store.ts'
21
+ import { FileTypeIcon } from './FileIcon.tsx'
22
+ import { ChevronRightIcon, CloseIcon, ExpandRightIcon, MaximizeIcon, RestoreIcon, SearchIcon } from './icons.tsx'
23
+ import { ConfirmDialog, ContextMenu, PromptDialog, toast, type MenuEntry, type MenuState } from './overlay.tsx'
24
+ import { ScmPanel } from './ScmPanel.tsx'
25
+ import { activateOnKey } from './a11y.ts'
26
+ import { FILE_DRAG_MIME } from '../drag/file-drag.ts'
27
+ import explorerCss from '../styles/explorer.module.css'
28
+ import '../styles/tokens.module.css'
29
+
30
+ /** Row indent step per tree depth (px). */
31
+ const INDENT_STEP = 16
32
+
33
+ /**
34
+ * The whole explorer column content.
35
+ * @param stores - the panel store bundle.
36
+ * @param onToggleCollapse - collapse the column (host chrome).
37
+ */
38
+ export function ExplorerPanel({
39
+ stores,
40
+ onToggleCollapse,
41
+ onReference,
42
+ }: {
43
+ stores: PanelStores
44
+ onToggleCollapse: () => void
45
+ onReference: (path: string, isDir: boolean) => void
46
+ }): JSX.Element {
47
+ const state = useStore(stores.explorer)
48
+ const layoutState = useStore(stores.layout)
49
+ const maximizedExplorer = layoutState.maximized === 'explorer'
50
+ const [searchFocus, setSearchFocus] = useState(false)
51
+ const [menu, setMenu] = useState<MenuState | null>(null)
52
+ const [prompt, setPrompt] = useState<{
53
+ kind: 'rename' | 'newFile' | 'newFolder'
54
+ targetRel: string
55
+ initialValue: string
56
+ } | null>(null)
57
+ const [deleteTarget, setDeleteTarget] = useState<FsEntry | null>(null)
58
+
59
+ /** Absolute path of one entry (root + rel), for copy/reveal. */
60
+ const absolutePath = (entry: FsEntry): string => {
61
+ const basePath = state.root.replace(/[\\/]+$/, '')
62
+ const sep = state.root.includes('\\') ? '\\' : '/'
63
+ return entry.path === '' ? basePath : `${basePath}${sep}${entry.path.split('/').join(sep)}`
64
+ }
65
+
66
+ const copyText = async (text: string): Promise<void> => {
67
+ try {
68
+ await navigator.clipboard.writeText(text)
69
+ toast(t('common.copied'))
70
+ } catch {
71
+ toast(t('explorer.opFailed'))
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Open the file-tree context menu. Stable across re-renders (useCallback
77
+ * on root + stores) so the memoized tree rows do not re-render when the
78
+ * panel state changes.
79
+ */
80
+ const openMenu = useCallback((event: ReactMouseEvent, entry: FsEntry): void => {
81
+ event.preventDefault()
82
+ event.stopPropagation()
83
+ const explorerStore = stores.explorer
84
+ explorerStore.select(entry.path)
85
+ const parent = parentRel(entry.path)
86
+ const createTarget = entry.isDir ? entry.path : parent
87
+ const entries: MenuEntry[] = [
88
+ {
89
+ key: 'copy-path',
90
+ label: t('explorer.menu.copyPath'),
91
+ onSelect: () => void copyText(absolutePath(entry)),
92
+ },
93
+ {
94
+ key: 'copy-name',
95
+ label: t('explorer.menu.copyName'),
96
+ onSelect: () => void copyText(entry.name),
97
+ },
98
+ { key: 'sep-1', label: '---' },
99
+ {
100
+ key: 'reveal',
101
+ label: t('explorer.menu.reveal'),
102
+ onSelect: () => {
103
+ void explorerStore.revealInFileManager(entry.path).then((ok) => {
104
+ if (!ok) toast(t('explorer.opFailed'))
105
+ })
106
+ },
107
+ },
108
+ ]
109
+ if (!entry.isDir) {
110
+ entries.push({
111
+ key: 'open-with-default',
112
+ label: t('explorer.menu.openWithDefault'),
113
+ onSelect: () => {
114
+ void explorerStore.openWithDefaultApp(entry.path).then((ok) => {
115
+ if (!ok) toast(t('explorer.opFailed'))
116
+ })
117
+ },
118
+ })
119
+ }
120
+ entries.push(
121
+ { key: 'sep-2', label: '---' },
122
+ {
123
+ key: 'rename',
124
+ label: t('explorer.menu.rename'),
125
+ onSelect: () => setPrompt({ kind: 'rename', targetRel: entry.path, initialValue: entry.name }),
126
+ },
127
+ {
128
+ key: 'new-file',
129
+ label: t('explorer.menu.newFile'),
130
+ onSelect: () => setPrompt({ kind: 'newFile', targetRel: createTarget, initialValue: '' }),
131
+ },
132
+ {
133
+ key: 'new-folder',
134
+ label: t('explorer.menu.newFolder'),
135
+ onSelect: () => setPrompt({ kind: 'newFolder', targetRel: createTarget, initialValue: '' }),
136
+ },
137
+ { key: 'sep-3', label: '---' },
138
+ {
139
+ key: 'delete',
140
+ label: t('explorer.menu.delete'),
141
+ danger: true,
142
+ onSelect: () => setDeleteTarget(entry),
143
+ },
144
+ )
145
+ setMenu({ x: event.clientX, y: event.clientY, entries })
146
+ }, [state.root, stores])
147
+
148
+ const submitPrompt = (value: string): void => {
149
+ if (prompt === null) return
150
+ const { kind, targetRel } = prompt
151
+ const name = value.trim()
152
+ if (name === '') return
153
+ const op = kind === 'rename'
154
+ ? stores.explorer.renameEntry(prompt.targetRel, name)
155
+ : kind === 'newFolder'
156
+ ? stores.explorer.createDir(targetRel === '' ? name : `${targetRel}/${name}`)
157
+ : stores.explorer.createFile(targetRel === '' ? name : `${targetRel}/${name}`)
158
+ void op.then((ok) => {
159
+ if (!ok) toast(t('explorer.opFailed'))
160
+ })
161
+ setPrompt(null)
162
+ }
163
+
164
+ return (
165
+ <div className="filemgr-root" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
166
+ {/* The Files/Changes tab bar. */}
167
+ <div className={explorerCss.tabBar}>
168
+ <button
169
+ type="button"
170
+ className={state.activeTab === 'files' ? explorerCss.tabBtnActive : explorerCss.tabBtn}
171
+ onClick={() => stores.explorer.setActiveTab('files')}
172
+ >
173
+ {t('explorer.tabs.files')}
174
+ </button>
175
+ <button
176
+ type="button"
177
+ className={state.activeTab === 'changes' ? explorerCss.tabBtnActive : explorerCss.tabBtn}
178
+ onClick={() => stores.explorer.setActiveTab('changes')}
179
+ >
180
+ {t('explorer.tabs.changes')}
181
+ </button>
182
+ {/* Maximize/restore (issue #315): transient — the layout controller
183
+ owns the grid takeover, Esc and the restore path. */}
184
+ <button
185
+ type="button"
186
+ className={explorerCss.tabIconBtn}
187
+ style={{ marginLeft: 'auto' }}
188
+ onClick={() => {
189
+ stores.layout.update((prev) => ({ ...prev, maximized: maximizedExplorer ? null : 'explorer' }))
190
+ }}
191
+ title={t(maximizedExplorer ? 'explorer.restore' : 'explorer.maximize')}
192
+ aria-label={t(maximizedExplorer ? 'explorer.restore' : 'explorer.maximize')}
193
+ >
194
+ {maximizedExplorer ? <RestoreIcon size={14} /> : <MaximizeIcon size={14} />}
195
+ </button>
196
+ <button
197
+ type="button"
198
+ className="filemgr-collapse-chevron"
199
+ onClick={onToggleCollapse}
200
+ title={t('explorer.collapse')}
201
+ aria-label={t('explorer.collapse')}
202
+ >
203
+ <ExpandRightIcon size={16} />
204
+ </button>
205
+ </div>
206
+
207
+ {/* Files tab: search + tree (kept mounted; hidden when changes is active). */}
208
+ <div style={{ display: state.activeTab === 'files' ? 'flex' : 'none', flexDirection: 'column', flex: 1, minHeight: 0 }}>
209
+ <SearchArea
210
+ stores={stores}
211
+ searchFocus={searchFocus}
212
+ onFocusChange={setSearchFocus}
213
+ />
214
+ <FileTree stores={stores} onContextMenu={openMenu} onReference={onReference} />
215
+ </div>
216
+
217
+ {/* Changes tab: SCM (mounted on demand; its store outlives the tab). */}
218
+ {state.activeTab === 'changes' && <ScmPanel stores={stores} />}
219
+
220
+ {/* Right-click menu + dialogs (portaled). */}
221
+ <ContextMenu state={menu} onClose={() => setMenu(null)} />
222
+ {prompt !== null && (
223
+ <PromptDialog
224
+ title={t(prompt.kind === 'rename' ? 'explorer.rename.title' : prompt.kind === 'newFolder' ? 'explorer.newFolder.title' : 'explorer.newFile.title')}
225
+ initialValue={prompt.initialValue}
226
+ onConfirm={submitPrompt}
227
+ onCancel={() => setPrompt(null)}
228
+ />
229
+ )}
230
+ {deleteTarget !== null && (
231
+ <ConfirmDialog
232
+ title={t('explorer.deleteConfirmTitle')}
233
+ body={t('explorer.deleteConfirmBody', { name: deleteTarget.name })}
234
+ danger
235
+ onConfirm={() => {
236
+ const target = deleteTarget
237
+ setDeleteTarget(null)
238
+ void stores.explorer.deleteEntry(target.path).then((ok) => {
239
+ if (!ok) toast(t('explorer.opFailed'))
240
+ })
241
+ }}
242
+ onCancel={() => setDeleteTarget(null)}
243
+ />
244
+ )}
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /** The search box + results (the tree stays mounted underneath). */
250
+ function SearchArea({
251
+ stores,
252
+ searchFocus,
253
+ onFocusChange,
254
+ }: {
255
+ stores: PanelStores
256
+ searchFocus: boolean
257
+ onFocusChange: (focused: boolean) => void
258
+ }): JSX.Element {
259
+ const explorer = stores.explorer
260
+ const state = useStore(explorer)
261
+ const search = state.search
262
+ const active = search.query !== ''
263
+ const inputRef = useRef<HTMLInputElement>(null)
264
+
265
+ return (
266
+ <div style={{ display: 'flex', flexDirection: 'column', minHeight: 0, flex: active ? 1 : undefined }}>
267
+ <div className={explorerCss.searchArea}>
268
+ <div
269
+ className={`${explorerCss.searchBox}${searchFocus ? ` ${explorerCss.searchAreaFocus}` : ''}`}
270
+ style={{ borderColor: searchFocus ? 'var(--aion-primary)' : undefined }}
271
+ >
272
+ <span className={explorerCss.searchIcon}><SearchIcon size={14} /></span>
273
+ <input
274
+ ref={inputRef}
275
+ className={explorerCss.searchInput}
276
+ value={search.query}
277
+ placeholder={t('explorer.search.placeholder')}
278
+ aria-label={t('explorer.search.placeholder')}
279
+ onFocus={() => onFocusChange(true)}
280
+ onBlur={() => onFocusChange(false)}
281
+ onChange={(event) => explorer.setSearchQuery(event.target.value)}
282
+ />
283
+ {search.query !== '' && (
284
+ <button
285
+ type="button"
286
+ className={explorerCss.searchClear}
287
+ onClick={() => { explorer.cancelSearch(); inputRef.current?.focus() }}
288
+ aria-label={t('common.close')}
289
+ >
290
+ <CloseIcon size={12} />
291
+ </button>
292
+ )}
293
+ </div>
294
+ </div>
295
+
296
+ {/* Result list replaces the tree while the query is active (the tree
297
+ underneath stays mounted — subscriptions never thrash). */}
298
+ {active ? (
299
+ <SearchResults stores={stores} />
300
+ ) : null}
301
+ </div>
302
+ )
303
+ }
304
+
305
+ /** The flat search-result stream (click = reveal in tree). */
306
+ function SearchResults({ stores }: { stores: PanelStores }): JSX.Element {
307
+ const explorer = stores.explorer
308
+ const state = useStore(explorer)
309
+ const search = state.search
310
+ return (
311
+ <div className={explorerCss.scrollArea}>
312
+ {search.status === 'searching' && search.hits.length === 0 && (
313
+ <div className={explorerCss.searchStatus}>{t('explorer.search.searching')}</div>
314
+ )}
315
+ {search.status === 'error' && <div className={explorerCss.searchStatus}>{t('explorer.search.error')}</div>}
316
+ {search.status === 'done' && search.hits.length === 0 && (
317
+ <div className={explorerCss.searchStatus}>{t('explorer.search.empty')}</div>
318
+ )}
319
+ {search.hits.map((hit) => (
320
+ <div
321
+ key={hit.path}
322
+ className={explorerCss.resultRow}
323
+ role="button"
324
+ tabIndex={0}
325
+ title={hit.path}
326
+ onClick={() => {
327
+ // Reveal: expand the ancestor chain and select — not preview.
328
+ explorer.reveal(hit.path)
329
+ }}
330
+ onKeyDown={activateOnKey(() => { explorer.reveal(hit.path) })}
331
+ >
332
+ <FileTypeIcon name={hit.name} isDir={hit.isDir} expanded={false} />
333
+ <span className={explorerCss.resultName}>{hit.name}</span>
334
+ <span className={explorerCss.resultPath}>{parentRel(hit.path)}</span>
335
+ </div>
336
+ ))}
337
+ {search.truncated && search.hits.length > 0 && (
338
+ <div className={explorerCss.searchStatus}>{t('explorer.search.truncated', { count: search.hits.length })}</div>
339
+ )}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ /** The lazy file tree. */
345
+ function FileTree({
346
+ stores,
347
+ onContextMenu,
348
+ onReference,
349
+ }: {
350
+ stores: PanelStores
351
+ onContextMenu: (event: ReactMouseEvent, entry: FsEntry) => void
352
+ onReference: (path: string, isDir: boolean) => void
353
+ }): JSX.Element {
354
+ const explorer = stores.explorer
355
+ const preview = stores.preview
356
+ const state = useStore(explorer)
357
+ const root = state.root
358
+ // One Set per expansion change: O(1) membership for rows, and the memo
359
+ // comparator below can compare membership instead of array identity.
360
+ // (Hooks precede the early returns below.)
361
+ const expandedSet = useMemo(() => new Set(state.expanded), [state.expanded])
362
+
363
+ if (root === '') return <div className={explorerCss.emptyState}>{t('explorer.tree.empty')}</div>
364
+ const entries = state.dirs['']
365
+ if (entries === undefined) {
366
+ return <div className={explorerCss.searchStatus}>{t('scm.loading')}</div>
367
+ }
368
+ if (entries.length === 0) return <div className={explorerCss.emptyState}>{t('explorer.tree.empty')}</div>
369
+
370
+ return (
371
+ <div className={`${explorerCss.scrollArea} ${explorerCss.tree}`}>
372
+ {entries.map((entry) => (
373
+ <TreeRow
374
+ key={entry.path}
375
+ entry={entry}
376
+ depth={0}
377
+ expanded={expandedSet}
378
+ selected={state.selected}
379
+ dirs={state.dirs}
380
+ root={state.root}
381
+ stores={stores}
382
+ onContextMenu={onContextMenu}
383
+ onReference={onReference}
384
+ />
385
+ ))}
386
+ </div>
387
+ )
388
+ }
389
+
390
+ /** One tree row (recursive for children). */
391
+ function TreeRowBase({
392
+ entry,
393
+ depth,
394
+ expanded,
395
+ selected,
396
+ dirs,
397
+ root,
398
+ stores,
399
+ onContextMenu,
400
+ onReference,
401
+ }: {
402
+ entry: FsEntry
403
+ depth: number
404
+ expanded: ReadonlySet<string>
405
+ selected: string | null
406
+ dirs: Record<string, FsEntry[]>
407
+ root: string
408
+ stores: PanelStores
409
+ onContextMenu: (event: ReactMouseEvent, entry: FsEntry) => void
410
+ onReference: (path: string, isDir: boolean) => void
411
+ }): JSX.Element {
412
+ const explorer = stores.explorer
413
+ const preview = stores.preview
414
+ const isExpanded = expanded.has(entry.path)
415
+ const isSelected = selected === entry.path
416
+ const children = entry.isDir ? dirs[entry.path] : undefined
417
+ const [draggingRow, setDraggingRow] = useState(false)
418
+
419
+ const handleClick = (): void => {
420
+ if (entry.isDir) {
421
+ // Full-row expand/collapse toggle.
422
+ explorer.toggleDir(entry.path)
423
+ return
424
+ }
425
+ // A file: select + open in preview (dedup focuses the open tab).
426
+ explorer.select(entry.path)
427
+ preview.openFile(root, entry.path)
428
+ }
429
+
430
+ // Files are draggable into the composer (the drag MIME carries the
431
+ // workspace-relative path); directory rows stay click-only.
432
+ const onDragStart = (event: DragEvent): void => {
433
+ if (entry.isDir) return
434
+ event.dataTransfer.setData(FILE_DRAG_MIME, entry.path)
435
+ event.dataTransfer.setData('text/plain', entry.path)
436
+ event.dataTransfer.effectAllowed = 'copy'
437
+ setDraggingRow(true)
438
+ }
439
+ const onDragEnd = (): void => {
440
+ setDraggingRow(false)
441
+ }
442
+
443
+ return (
444
+ <>
445
+ <div
446
+ className={`${explorerCss.treeRow}${isSelected ? ` ${explorerCss.treeRowSelected}` : ''}${draggingRow ? ` ${explorerCss.treeRowDragging}` : ''}`}
447
+ style={{ paddingLeft: 12 + 8 + depth * INDENT_STEP }}
448
+ onClick={handleClick}
449
+ onKeyDown={activateOnKey(handleClick)}
450
+ onContextMenu={(event) => onContextMenu(event, entry)}
451
+ onDoubleClick={(event) => {
452
+ // Double-click on a file: same as click (open). Folders: keep toggle.
453
+ event.stopPropagation()
454
+ }}
455
+ draggable={!entry.isDir}
456
+ onDragStart={onDragStart}
457
+ onDragEnd={onDragEnd}
458
+ role="button"
459
+ tabIndex={0}
460
+ aria-expanded={entry.isDir ? isExpanded : undefined}
461
+ title={entry.path}
462
+ >
463
+ {entry.isDir ? (
464
+ <span className={`${explorerCss.treeArrow}${isExpanded ? ` ${explorerCss.treeArrowOpen}` : ''}`}>
465
+ <ChevronRightIcon size={13} />
466
+ </span>
467
+ ) : (
468
+ <span className={explorerCss.treeArrowEmpty} />
469
+ )}
470
+ <FileTypeIcon name={entry.name} isDir={entry.isDir} expanded={isExpanded} />
471
+ <span className={explorerCss.treeName}>{entry.name}</span>
472
+ <button
473
+ type="button"
474
+ className={explorerCss.refBtn}
475
+ title={t('explorer.refToInput')}
476
+ aria-label={t('explorer.refToInput')}
477
+ onClick={(event) => {
478
+ // Insert a file-reference chip (or plain text for folders).
479
+ event.stopPropagation()
480
+ event.preventDefault()
481
+ onReference(entry.path, entry.isDir)
482
+ }}
483
+ onMouseDown={(event) => event.stopPropagation()}
484
+ >
485
+ @
486
+ </button>
487
+ </div>
488
+ {entry.isDir && isExpanded && children !== undefined && (
489
+ <div>
490
+ {children.map((child) => (
491
+ <TreeRow
492
+ key={child.path}
493
+ entry={child}
494
+ depth={depth + 1}
495
+ expanded={expanded}
496
+ selected={selected}
497
+ dirs={dirs}
498
+ root={root}
499
+ stores={stores}
500
+ onContextMenu={onContextMenu}
501
+ onReference={onReference}
502
+ />
503
+ ))}
504
+ </div>
505
+ )}
506
+ </>
507
+ )
508
+ }
509
+
510
+ /**
511
+ * A memoized tree row so the whole tree does not re-render on every explorer
512
+ * state change (search keystrokes, tab switches, fs version bumps). The row
513
+ * takes the `state` fields it actually reads as individual props — `expanded`,
514
+ * `selected`, `dirs` — whose references only change when the corresponding
515
+ * data changed, so the default shallow comparison skips rows whose own entry,
516
+ * ancestor, expansion or selection are unaffected. A `dirs` re-fetch (an fs
517
+ * event that relists the expanded dirs) still re-renders the rows under those
518
+ * dirs — the unavoidable O(open-dirs) cost — but transient UI state no longer
519
+ * invalidates the tree.
520
+ */
521
+ const TreeRow = memo(TreeRowBase, (prev, next) =>
522
+ // The expansion array gets a fresh identity on every toggle; what this row
523
+ // actually reads is its own membership, so compare that instead of the
524
+ // container — plus the other props by identity.
525
+ prev.expanded.has(prev.entry.path) === next.expanded.has(next.entry.path)
526
+ && prev.entry === next.entry
527
+ && prev.depth === next.depth
528
+ && prev.selected === next.selected
529
+ && prev.dirs === next.dirs
530
+ && prev.root === next.root
531
+ && prev.stores === next.stores
532
+ && prev.onContextMenu === next.onContextMenu
533
+ && prev.onReference === next.onReference,
534
+ )
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 16x16 file/folder icons for the tree — a small built-in set (folder
3
+ * open/closed, file variants by kind) using the token colors, so both themes
4
+ * stay consistent without pulling in a vscode-icons package.
5
+ * @module dsh-filemgr/client/components/FileIcon
6
+ */
7
+
8
+ import type { JSX } from 'react'
9
+ import { detectContentType } from '../fileType.ts'
10
+ import {
11
+ FileCodeIcon, FileIcon, FileImageIcon, FileTextIcon, FolderIcon, FolderOpenIcon,
12
+ } from './icons.tsx'
13
+
14
+ /** The icon for one tree entry (16x16, currentColor). */
15
+ export function FileTypeIcon({
16
+ name,
17
+ isDir,
18
+ expanded,
19
+ className,
20
+ }: {
21
+ name: string
22
+ isDir: boolean
23
+ expanded: boolean
24
+ className?: string
25
+ }): JSX.Element {
26
+ if (isDir) {
27
+ return expanded
28
+ ? <FolderOpenIcon size={16} className={className} />
29
+ : <FolderIcon size={16} className={className} />
30
+ }
31
+ const type = detectContentType(name)
32
+ switch (type) {
33
+ case 'image':
34
+ return <FileImageIcon size={16} className={className} />
35
+ case 'markdown':
36
+ case 'text':
37
+ return <FileTextIcon size={16} className={className} />
38
+ case 'code':
39
+ case 'diff':
40
+ case 'csv':
41
+ case 'html':
42
+ return <FileCodeIcon size={16} className={className} />
43
+ default:
44
+ return <FileIcon size={16} className={className} />
45
+ }
46
+ }