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.
@@ -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
+ }