dsh-plugin-workbench 0.0.1
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +8629 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +179 -0
- package/package.json +71 -0
- package/scripts/patch-layout.mjs +323 -0
- package/src/client/FileExplorer.tsx +374 -0
- package/src/client/FilePreview.tsx +353 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/files.module.css +536 -0
- package/src/client/highlight.ts +88 -0
- package/src/client/index.ts +45 -0
- package/src/client/locales.ts +39 -0
- package/src/client/store.ts +151 -0
- package/src/dsh.d.ts +62 -0
- package/src/index.ts +203 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File tree column, registered into the `explorer` slot declared by the
|
|
3
|
+
* (patched) ui-layout AppFrame. File selection is pushed into the shared
|
|
4
|
+
* selection store so the `explorer.preview` slot can render the split view.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import styles from './files.module.css'
|
|
8
|
+
import type { FilesKey } from './locales'
|
|
9
|
+
import { expandPreview, openFile, setCwd, toggleTheme, useTabsState } from './store'
|
|
10
|
+
|
|
11
|
+
export interface FsListEntry {
|
|
12
|
+
name: string
|
|
13
|
+
path: string
|
|
14
|
+
kind: 'dir' | 'file' | 'other'
|
|
15
|
+
size?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FsListResult {
|
|
19
|
+
root: string
|
|
20
|
+
entries: FsListEntry[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FsReadResult {
|
|
24
|
+
path: string
|
|
25
|
+
content: string
|
|
26
|
+
size: number
|
|
27
|
+
binary: boolean
|
|
28
|
+
truncated: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SessionSummary {
|
|
32
|
+
id: string
|
|
33
|
+
cwd?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SessionListState {
|
|
37
|
+
current?: string
|
|
38
|
+
byId: Record<string, SessionSummary>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FileExplorerProps {
|
|
42
|
+
width: number
|
|
43
|
+
useSessions: <T>(selector: (s: SessionListState) => T) => T
|
|
44
|
+
t: (key: FilesKey, params?: Record<string, unknown>) => string
|
|
45
|
+
listDir: (path: string, signal?: AbortSignal) => Promise<FsListResult>
|
|
46
|
+
openPath: (path: string) => Promise<void>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function basenameOf(path: string): string {
|
|
50
|
+
const trimmed = path.replace(/[\\/]+$/, '')
|
|
51
|
+
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
|
52
|
+
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatSize(bytes: number): string {
|
|
56
|
+
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
|
57
|
+
if (bytes < 1024) return `${bytes} B`
|
|
58
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
59
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
60
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How often the visible tree is re-listed to pick up disk changes. */
|
|
64
|
+
const REFRESH_MS = 2000
|
|
65
|
+
|
|
66
|
+
const EMPTY_EXPANDED = new Set<string>()
|
|
67
|
+
|
|
68
|
+
/** True when two directory listings are identical (name/kind/size). */
|
|
69
|
+
function sameEntries(a: FsListEntry[], b: FsListEntry[]): boolean {
|
|
70
|
+
if (a.length !== b.length) return false
|
|
71
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
72
|
+
if (a[i].name !== b[i].name || a[i].kind !== b[i].kind || a[i].size !== b[i].size) return false
|
|
73
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function FileExplorer({ width, useSessions, t, listDir, openPath }: FileExplorerProps) {
|
|
78
|
+
const sessionList = useSessions((s) => s)
|
|
79
|
+
const currentId = sessionList.current
|
|
80
|
+
const cwd = currentId !== undefined ? sessionList.byId[currentId]?.cwd : undefined
|
|
81
|
+
const { active: activePath, theme } = useTabsState()
|
|
82
|
+
|
|
83
|
+
const rootAbortRef = useRef<AbortController | null>(null)
|
|
84
|
+
|
|
85
|
+
// Expanded directories are kept per workspace so each cwd restores its own.
|
|
86
|
+
const cwdKey = cwd ?? ''
|
|
87
|
+
const [expandedByCwd, setExpandedByCwd] = useState<Record<string, Set<string>>>({})
|
|
88
|
+
const expanded = expandedByCwd[cwdKey] ?? EMPTY_EXPANDED
|
|
89
|
+
|
|
90
|
+
const [root, setRoot] = useState<string | undefined>(undefined)
|
|
91
|
+
const [rootLoading, setRootLoading] = useState(false)
|
|
92
|
+
const [rootError, setRootError] = useState<string | undefined>(undefined)
|
|
93
|
+
const [children, setChildren] = useState<Record<string, FsListEntry[]>>({})
|
|
94
|
+
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set())
|
|
95
|
+
const [dirErrors, setDirErrors] = useState<Record<string, string>>({})
|
|
96
|
+
|
|
97
|
+
// Latest tree snapshot for the polling tick (avoids stale closures).
|
|
98
|
+
const treeRef = useRef({ root, children, expanded, rootLoading })
|
|
99
|
+
treeRef.current = { root, children, expanded, rootLoading }
|
|
100
|
+
|
|
101
|
+
// Expose the explorer width so the maid-atelier fixed chrome (top/bottom
|
|
102
|
+
// trim) can shift past this column instead of covering it.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
document.body.style.setProperty('--dsh-explorer-width', `${width}px`)
|
|
105
|
+
return () => {
|
|
106
|
+
document.body.style.removeProperty('--dsh-explorer-width')
|
|
107
|
+
}
|
|
108
|
+
}, [width])
|
|
109
|
+
|
|
110
|
+
// Reset the whole tree whenever the current session's cwd changes. The tabs
|
|
111
|
+
// and expanded directories live per-workspace in the store / expandedByCwd,
|
|
112
|
+
// so switching back restores them.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
rootAbortRef.current?.abort()
|
|
115
|
+
setRoot(undefined)
|
|
116
|
+
setRootError(undefined)
|
|
117
|
+
setChildren({})
|
|
118
|
+
setLoadingDirs(new Set())
|
|
119
|
+
setDirErrors({})
|
|
120
|
+
setCwd(cwd)
|
|
121
|
+
|
|
122
|
+
if (cwd === undefined) {
|
|
123
|
+
setRootLoading(false)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setRootLoading(true)
|
|
128
|
+
const controller = new AbortController()
|
|
129
|
+
rootAbortRef.current = controller
|
|
130
|
+
listDir(cwd, controller.signal)
|
|
131
|
+
.then((result) => {
|
|
132
|
+
if (controller.signal.aborted) return
|
|
133
|
+
setRoot(result.root)
|
|
134
|
+
setChildren({ [result.root]: result.entries })
|
|
135
|
+
setRootError(undefined)
|
|
136
|
+
// Reload the contents of this workspace's previously-expanded folders
|
|
137
|
+
// right away instead of waiting for the auto-refresh tick.
|
|
138
|
+
for (const dirPath of expanded) {
|
|
139
|
+
if (dirPath !== result.root) void loadDir(dirPath)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
.catch((error) => {
|
|
143
|
+
if (controller.signal.aborted) return
|
|
144
|
+
setRootError(error instanceof Error ? error.message : String(error))
|
|
145
|
+
})
|
|
146
|
+
.finally(() => {
|
|
147
|
+
if (!controller.signal.aborted) setRootLoading(false)
|
|
148
|
+
})
|
|
149
|
+
return () => controller.abort()
|
|
150
|
+
}, [cwd, listDir])
|
|
151
|
+
|
|
152
|
+
// Auto-refresh the visible tree (root + expanded directories) on a timer so
|
|
153
|
+
// disk changes appear without a manual refresh.
|
|
154
|
+
const refreshVisible = useCallback(async (signal: AbortSignal) => {
|
|
155
|
+
const snapshot = treeRef.current
|
|
156
|
+
if (cwd === undefined || snapshot.rootLoading) return
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const result = await listDir(cwd, signal)
|
|
160
|
+
if (signal.aborted) return
|
|
161
|
+
const existing = snapshot.children[result.root]
|
|
162
|
+
if (snapshot.root !== result.root || existing === undefined || !sameEntries(existing, result.entries)) {
|
|
163
|
+
setRoot(result.root)
|
|
164
|
+
setChildren((prev) => ({ ...prev, [result.root]: result.entries }))
|
|
165
|
+
setRootError(undefined)
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (signal.aborted) return
|
|
169
|
+
setRootError(error instanceof Error ? error.message : String(error))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const dirPath of snapshot.expanded) {
|
|
173
|
+
if (signal.aborted) return
|
|
174
|
+
try {
|
|
175
|
+
const result = await listDir(dirPath, signal)
|
|
176
|
+
if (signal.aborted) return
|
|
177
|
+
const existing = snapshot.children[dirPath]
|
|
178
|
+
if (existing === undefined || !sameEntries(existing, result.entries)) {
|
|
179
|
+
setChildren((prev) => ({ ...prev, [dirPath]: result.entries }))
|
|
180
|
+
setDirErrors((prev) => {
|
|
181
|
+
if (!(dirPath in prev)) return prev
|
|
182
|
+
const next = { ...prev }
|
|
183
|
+
delete next[dirPath]
|
|
184
|
+
return next
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (signal.aborted) return
|
|
189
|
+
setDirErrors((prev) => ({ ...prev, [dirPath]: error instanceof Error ? error.message : String(error) }))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}, [cwd, listDir])
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (cwd === undefined) return undefined
|
|
196
|
+
const controller = new AbortController()
|
|
197
|
+
let inFlight = false
|
|
198
|
+
const timer = setInterval(() => {
|
|
199
|
+
if (inFlight) return
|
|
200
|
+
inFlight = true
|
|
201
|
+
void refreshVisible(controller.signal).finally(() => {
|
|
202
|
+
inFlight = false
|
|
203
|
+
})
|
|
204
|
+
}, REFRESH_MS)
|
|
205
|
+
return () => {
|
|
206
|
+
controller.abort()
|
|
207
|
+
clearInterval(timer)
|
|
208
|
+
}
|
|
209
|
+
}, [cwd, refreshVisible])
|
|
210
|
+
|
|
211
|
+
const loadDir = useCallback(async (dirPath: string) => {
|
|
212
|
+
setLoadingDirs((prev) => new Set(prev).add(dirPath))
|
|
213
|
+
const controller = new AbortController()
|
|
214
|
+
try {
|
|
215
|
+
const result = await listDir(dirPath, controller.signal)
|
|
216
|
+
setChildren((prev) => ({ ...prev, [dirPath]: result.entries }))
|
|
217
|
+
setDirErrors((prev) => {
|
|
218
|
+
if (!(dirPath in prev)) return prev
|
|
219
|
+
const next = { ...prev }
|
|
220
|
+
delete next[dirPath]
|
|
221
|
+
return next
|
|
222
|
+
})
|
|
223
|
+
} catch (error) {
|
|
224
|
+
setDirErrors((prev) => ({ ...prev, [dirPath]: error instanceof Error ? error.message : String(error) }))
|
|
225
|
+
} finally {
|
|
226
|
+
setLoadingDirs((prev) => {
|
|
227
|
+
const next = new Set(prev)
|
|
228
|
+
next.delete(dirPath)
|
|
229
|
+
return next
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}, [listDir])
|
|
233
|
+
|
|
234
|
+
const toggleDir = useCallback((dirPath: string) => {
|
|
235
|
+
setExpandedByCwd((prev) => {
|
|
236
|
+
const base = prev[cwdKey] ?? EMPTY_EXPANDED
|
|
237
|
+
const next = new Set(base)
|
|
238
|
+
if (next.has(dirPath)) next.delete(dirPath)
|
|
239
|
+
else next.add(dirPath)
|
|
240
|
+
return { ...prev, [cwdKey]: next }
|
|
241
|
+
})
|
|
242
|
+
if (!children[dirPath]) void loadDir(dirPath)
|
|
243
|
+
}, [cwdKey, children, loadDir])
|
|
244
|
+
|
|
245
|
+
const refreshDir = useCallback((dirPath: string) => {
|
|
246
|
+
setChildren((prev) => {
|
|
247
|
+
const next = { ...prev }
|
|
248
|
+
delete next[dirPath]
|
|
249
|
+
return next
|
|
250
|
+
})
|
|
251
|
+
void loadDir(dirPath)
|
|
252
|
+
}, [loadDir])
|
|
253
|
+
|
|
254
|
+
const onRowClick = useCallback((entry: FsListEntry) => {
|
|
255
|
+
if (entry.kind === 'dir') {
|
|
256
|
+
toggleDir(entry.path)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
openFile(entry.path)
|
|
260
|
+
}, [toggleDir])
|
|
261
|
+
|
|
262
|
+
const onRowDoubleClick = useCallback((entry: FsListEntry) => {
|
|
263
|
+
if (entry.kind !== 'file') return
|
|
264
|
+
void openPath(entry.path)
|
|
265
|
+
}, [openPath])
|
|
266
|
+
|
|
267
|
+
// ---- placeholder: no session / no cwd ----
|
|
268
|
+
if (cwd === undefined) {
|
|
269
|
+
return (
|
|
270
|
+
<div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
|
|
271
|
+
<div className={styles.placeholder}>
|
|
272
|
+
{currentId === undefined ? t('placeholder.noSession') : t('placeholder.noCwd')}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const renderEntries = (entries: FsListEntry[], depth: number) => entries.map((entry) => {
|
|
279
|
+
const isDir = entry.kind === 'dir'
|
|
280
|
+
const isExpanded = isDir && expanded.has(entry.path)
|
|
281
|
+
const isLoading = isDir && loadingDirs.has(entry.path)
|
|
282
|
+
const childEntries = isDir && isExpanded ? children[entry.path] : undefined
|
|
283
|
+
const error = isDir ? dirErrors[entry.path] : undefined
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div key={entry.path}>
|
|
287
|
+
<div
|
|
288
|
+
className={`${styles.row} ${entry.kind === 'file' && activePath === entry.path ? styles.rowSelected : ''}`}
|
|
289
|
+
style={{ paddingLeft: 8 + depth * 14 }}
|
|
290
|
+
onClick={() => onRowClick(entry)}
|
|
291
|
+
onDoubleClick={() => onRowDoubleClick(entry)}
|
|
292
|
+
role="treeitem"
|
|
293
|
+
aria-selected={entry.kind === 'file' && activePath === entry.path}
|
|
294
|
+
aria-expanded={isDir ? isExpanded : undefined}
|
|
295
|
+
title={entry.name}
|
|
296
|
+
>
|
|
297
|
+
<span
|
|
298
|
+
className={styles.chevron}
|
|
299
|
+
onClick={(e) => {
|
|
300
|
+
e.stopPropagation()
|
|
301
|
+
if (isDir) toggleDir(entry.path)
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
{isDir ? (isExpanded ? 'โพ' : 'โธ') : ''}
|
|
305
|
+
</span>
|
|
306
|
+
<span className={styles.icon}>{isDir ? '๐' : entry.kind === 'file' ? '๐' : 'ยท'}</span>
|
|
307
|
+
<span className={styles.name}>{entry.name}</span>
|
|
308
|
+
{entry.kind === 'file' && entry.size !== undefined && (
|
|
309
|
+
<span className={styles.size}>{formatSize(entry.size)}</span>
|
|
310
|
+
)}
|
|
311
|
+
<span className={styles.actions}>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
className={styles.action}
|
|
315
|
+
title={t('action.open')}
|
|
316
|
+
onClick={(e) => {
|
|
317
|
+
e.stopPropagation()
|
|
318
|
+
void openPath(entry.path)
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
โ
|
|
322
|
+
</button>
|
|
323
|
+
{isDir && (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
className={styles.action}
|
|
327
|
+
title={t('action.refresh')}
|
|
328
|
+
onClick={(e) => {
|
|
329
|
+
e.stopPropagation()
|
|
330
|
+
refreshDir(entry.path)
|
|
331
|
+
}}
|
|
332
|
+
>
|
|
333
|
+
โณ
|
|
334
|
+
</button>
|
|
335
|
+
)}
|
|
336
|
+
</span>
|
|
337
|
+
</div>
|
|
338
|
+
{isDir && isExpanded && (
|
|
339
|
+
<div>
|
|
340
|
+
{isLoading && <div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.loading')}</div>}
|
|
341
|
+
{error !== undefined && !isLoading && <div className={styles.rowError} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{error}</div>}
|
|
342
|
+
{childEntries !== undefined && childEntries.length === 0 && !isLoading && (
|
|
343
|
+
<div className={styles.rowHint} style={{ paddingLeft: 8 + (depth + 1) * 14 }}>{t('preview.emptyDir')}</div>
|
|
344
|
+
)}
|
|
345
|
+
{childEntries !== undefined && renderEntries(childEntries, depth + 1)}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<div className={styles.column} style={{ width: width > 0 ? width : undefined }} data-pane="explorer" data-fe-theme={theme}>
|
|
354
|
+
<div className={styles.header}>
|
|
355
|
+
<span className={styles.headerTitle} title={root ?? cwd}>{basenameOf(root ?? cwd)}</span>
|
|
356
|
+
<span className={styles.headerActions}>
|
|
357
|
+
<button type="button" className={styles.action} title={t('action.theme')} onClick={toggleTheme}>
|
|
358
|
+
{theme === 'dark' ? 'โ' : '๐'}
|
|
359
|
+
</button>
|
|
360
|
+
<button type="button" className={styles.action} title={t('action.open')} onClick={() => void openPath(cwd)}>โ</button>
|
|
361
|
+
<button type="button" className={styles.action} title={t('tab.expand')} onClick={expandPreview}>{'>'}</button>
|
|
362
|
+
</span>
|
|
363
|
+
</div>
|
|
364
|
+
<div className={styles.treeArea}>
|
|
365
|
+
{rootLoading && <div className={styles.rowHint}>{t('preview.loading')}</div>}
|
|
366
|
+
{rootError !== undefined && <div className={styles.rowError}>{rootError}</div>}
|
|
367
|
+
{!rootLoading && rootError === undefined && root !== undefined && children[root] !== undefined && children[root].length === 0 && (
|
|
368
|
+
<div className={styles.rowHint}>{t('preview.emptyDir')}</div>
|
|
369
|
+
)}
|
|
370
|
+
{root !== undefined && children[root] !== undefined && renderEntries(children[root], 0)}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
)
|
|
374
|
+
}
|