dsh-better-sidebar: VSCode-like right sidebar plugin for the DSH web GUI
Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/ write, git status/diff/stage/commit/branch/log/show), /sidebar/file media route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api trust fence (Host-header loopback or connection.trustedHosts). Client half (src/client): portal-mounted right panel with a per-session split-pane workbench (recursive row/col splits, draggable dividers, tab drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal multi-open rules), lazy file explorer rooted at the session cwd, editable text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm terminal with reconnect, and a VSCode-style git panel. Per-session state persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat turn-tail produced-files row (priority -1 chain entry) to open files in the sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives. Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle (externals = module table words; xterm/css inlined). Tests: 19 unit + smoke specs (pure state/tree/git parsers + real git/fs integration).
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* The file explorer: a lazy VSCode-style tree rooted at the session's
|
||||
* working directory. Levels load on expansion (one API call per directory),
|
||||
* directories sort first, hidden entries render dimmed, and the expansion
|
||||
* set lives in the per-session state. Clicking a file opens an editor tab.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, type FsEntry } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
entries?: FsEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Root label: the last path segment (mirror of the host rootLabel). */
|
||||
function baseName(path: string): string {
|
||||
const trimmed = path.replace(/[\\/]+$/, '')
|
||||
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
export function ExplorerView(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
setData(dataRef.current)
|
||||
}, [])
|
||||
|
||||
const loadDir = useCallback((dir: string) => {
|
||||
if (dataRef.current[dir] !== undefined) return
|
||||
storeLevel(dir, {})
|
||||
api.fsTree(sessionId, dir).then((listing) => {
|
||||
storeLevel(dir, { entries: listing.entries })
|
||||
}).catch((error: unknown) => {
|
||||
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
}, [sessionId, storeLevel])
|
||||
|
||||
useEffect(() => {
|
||||
// Load the visible set; already-loaded levels (kept in the cache) are
|
||||
// not refetched. Only the refresh button wipes the cache.
|
||||
const root = cwd
|
||||
if (root === undefined) return
|
||||
loadDir(root)
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
return <div className={css.explorerRow} style={{ paddingLeft: depth * 14 + 8 }}>{t('loading')}</div>
|
||||
}
|
||||
if (level.error !== undefined) {
|
||||
return (
|
||||
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 14 + 8 }}>
|
||||
{level.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
if (entry.isDir) {
|
||||
const isOpen = expanded.includes(entry.path)
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 14 + 8 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
</button>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 14 + 8 }}
|
||||
title={entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorer}>
|
||||
<div className={css.explorerHeader}>
|
||||
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => {
|
||||
dataRef.current = {}
|
||||
setData({})
|
||||
setRefreshTick(tick => tick + 1)
|
||||
}}
|
||||
>
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.explorerBody}>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.explorerRow} style={{ paddingLeft: 8 }}>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
</div>
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user