@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.
- package/LICENSE +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- 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
|
+
}
|