fix: session-cwd attach race, terminal failure surfacing, white-screen hardening; CodeMirror editor

- cwd resolution falls back (attached header → client summary cwd → process
  cwd) across the JSON API, media route and terminal upgrade, so explorer/
  git/terminal work from first paint instead of 'session ... has no working
  directory' while the web client hydrates the session (refresh-only fix).
- terminal client threads the summary cwd into the WS URL and stops the
  reconnect loop after three unreasoned failures, showing the close code
  with a manual retry; a 1011+reason refusal already showed the reason.
- white screen: persisted state is structurally sanitized on load (malformed
  shapes fall back to default) and the restored width is clamped to the
  viewport; the layout margin is capped at the viewport; the client entry
  wraps every lifecycle step in try/catch and pins a visible diagnostic bar
  on load/mount/interception failure instead of failing silently.
- editor: textarea replaced with CodeMirror 6 — line wrapping, per-extension
  syntax highlighting (js/ts/jsx/tsx, json, python, html, css, xml, yaml,
  sql, java, c/c++, rust, go, php, shell, toml, nginx, dockerfile,
  properties), one-dark theme, dirty dot + Ctrl/Cmd+S save kept.
- tests: +9 (cwd fallback chain over the API route, state sanitization,
  language mapping); 36 pass, typecheck + build + vm materialize clean.
This commit is contained in:
Menghuan1918
2026-08-08 15:12:56 +08:00
parent 8950e2ae69
commit 47ebffcf9b
17 changed files with 1063 additions and 140 deletions
+79 -37
View File
@@ -1,46 +1,48 @@
/**
* The file editor tab: text editing with a dirty dot and Ctrl/Cmd+S save,
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
* image viewing through the media route, Markdown preview through the shared
* MarkdownText component, and a plain notice for binary files. Reads cap at
* the host's 512KB bound (a banner marks truncation).
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { oneDark } from '@codemirror/theme-one-dark'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api, mediaUrl } from './api.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { languageForPath } from './lang.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
const MD_EXT = ['.md', '.markdown']
function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
| { status: 'binary' }
export function EditorView(props: { sessionId: string; path: string; title: string }) {
const { sessionId, path, title } = props
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
const [draft, setDraft] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<CodeMirrorView | null>(null)
const savingRef = useRef(false)
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
setDraft(null)
setDirty(false)
setSaveState('idle')
api.fsRead(sessionId, path).then((result) => {
api.fsRead(scope, path).then((result) => {
if (cancelled) return
const ext = extOf(path)
const ext = extOfPath(path)
if (result.kind === 'binary') {
setLoad({ status: 'binary' })
return
@@ -60,20 +62,66 @@ export function EditorView(props: { sessionId: string; path: string; title: stri
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [sessionId, path])
}, [scope.sessionId, scope.cwd, path])
// Create the CodeMirror editor once the text is loaded. The view owns the
// document; React only tracks dirty/save state through the update listener.
useEffect(() => {
if (load.status !== 'ready' || load.kind !== 'text') return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
const state = EditorState.create({
doc: load.content,
extensions: [
CodeMirrorView.lineWrapping,
history(),
EditorState.tabSize.of(2),
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
CodeMirrorView.theme({
'&': { height: '100%', fontSize: '13px', backgroundColor: 'transparent' },
'.cm-scroller': { overflow: 'auto', fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' },
'.cm-content': { caretColor: 'var(--dsw-alias-label-primary)' },
}),
...(language !== null ? [language] : []),
oneDark,
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) setDirty(true)
}),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => { save(); return true },
},
...defaultKeymap,
...historyKeymap,
]),
],
})
const view = new CodeMirrorView({ state, parent: host })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
}, [load])
const save = (): void => {
if (load.status !== 'ready' || load.kind !== 'text' || draft === null || saveState === 'saving') return
const view = viewRef.current
if (view === null || savingRef.current) return
savingRef.current = true
setSaveState('saving')
api.fsWrite(sessionId, path, draft).then(() => {
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
savingRef.current = false
setDirty(false)
setSaveState('saved')
setDraft(null)
}).catch(() => {
savingRef.current = false
setSaveState('failed')
})
}
const dirty = load.status === 'ready' && load.kind === 'text' && draft !== null
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
@@ -100,26 +148,12 @@ export function EditorView(props: { sessionId: string; path: string; title: stri
{load.status === 'ready' && load.kind === 'text' && (
<>
{load.truncated && <div className={css.editorBanner}>{t('truncation')}</div>}
<textarea
className={css.editorTextarea}
spellCheck={false}
value={draft ?? load.content}
onChange={(event) => {
setDraft(event.target.value)
setSaveState('idle')
}}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault()
save()
}
}}
/>
<div className={css.editorCm} ref={hostRef} />
</>
)}
{load.status === 'ready' && load.kind === 'image' && (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={mediaUrl(sessionId, path)} alt={title} />
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
</div>
)}
{load.status === 'ready' && load.kind === 'md' && (
@@ -130,3 +164,11 @@ export function EditorView(props: { sessionId: string; path: string; title: stri
</div>
)
}
/** The lowercased file extension of a path ('' when none). */
function extOfPath(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
+2 -2
View File
@@ -45,12 +45,12 @@ export function ExplorerView(props: {
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
storeLevel(dir, {})
api.fsTree(sessionId, dir).then((listing) => {
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
storeLevel(dir, { entries: listing.entries })
}).catch((error: unknown) => {
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
})
}, [sessionId, storeLevel])
}, [sessionId, cwd, storeLevel])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
+16 -16
View File
@@ -11,7 +11,7 @@ import {
DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16,
Input,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult } from './api.ts'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -25,8 +25,8 @@ function badgeOf(entry: GitStatusEntry): string {
return '?'
}
export function GitView(props: { sessionId: string }) {
const { sessionId } = props
export function GitView(props: { scope: SessionScope }) {
const { scope } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -47,9 +47,9 @@ export function GitView(props: { sessionId: string }) {
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(sessionId),
api.gitBranch(sessionId).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(sessionId).catch(() => [] as GitLogEntry[]),
api.gitStatus(scope),
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(scope).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
@@ -59,7 +59,7 @@ export function GitView(props: { sessionId: string }) {
} finally {
setLoading(false)
}
}, [sessionId])
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
@@ -69,12 +69,12 @@ export function GitView(props: { sessionId: string }) {
setDiffStaged(staged)
setDiffOld(null)
setDiffNew('')
const oldPromise = api.gitShow(sessionId, 'HEAD', entry.path)
const oldPromise = api.gitShow(scope, 'HEAD', entry.path)
.then(result => result.content)
.catch(() => null)
const newPromise = staged
? api.gitShow(sessionId, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(sessionId, entry.path)
? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(scope, entry.path)
.then(result => (result.kind === 'text' ? result.content : ''))
.catch(() => '')
const [oldText, newText] = await Promise.all([oldPromise, newPromise])
@@ -85,8 +85,8 @@ export function GitView(props: { sessionId: string }) {
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(sessionId, entry.path)
else await api.gitStage(sessionId, entry.path)
if (staged) await api.gitUnstage(scope, entry.path)
else await api.gitStage(scope, entry.path)
setDiffPath(null)
await refresh()
} finally {
@@ -97,8 +97,8 @@ export function GitView(props: { sessionId: string }) {
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(sessionId)
else await api.gitStage(sessionId)
if (staged) await api.gitUnstage(scope)
else await api.gitStage(scope)
setDiffPath(null)
await refresh()
} finally {
@@ -112,7 +112,7 @@ export function GitView(props: { sessionId: string }) {
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(sessionId, message)
await api.gitCommit(scope, message)
setCommitMsg('')
setDiffPath(null)
await refresh()
@@ -128,7 +128,7 @@ export function GitView(props: { sessionId: string }) {
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(sessionId, branch)
await api.gitCheckout(scope, branch)
setDiffPath(null)
await refresh()
} catch (reason) {
+29 -7
View File
@@ -27,6 +27,7 @@ import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
import { openSidebarFile } from './intercept.tsx'
import css from './sidebar.module.css'
@@ -40,6 +41,7 @@ function TabContent(props: {
ctx: Context
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, ctx } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
return (
@@ -52,11 +54,11 @@ function TabContent(props: {
/>
)
case 'git':
return <GitView sessionId={sessionId} />
return <GitView scope={scope} />
case 'terminal':
return <TerminalView sessionId={sessionId} tabId={tab.id} />
return <TerminalView scope={scope} tabId={tab.id} />
case 'editor':
return <EditorView sessionId={sessionId} path={tab.path ?? ''} title={tab.title} />
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
}
}
@@ -95,7 +97,23 @@ export function Sidebar(props: { ctx: Context }) {
const state = snapshot.state
const sessionId = snapshot.sessionId
const cwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
setFetchedCwd(undefined)
if (sessionId === undefined || summaryCwd !== undefined) return
let cancelled = false
api.sessionCwd({ sessionId })
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
.catch(() => { /* the explorer/git rows surface their own errors */ })
return () => { cancelled = true }
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
@@ -103,9 +121,13 @@ export function Sidebar(props: { ctx: Context }) {
// Layout push: the app shell gives up the panel's width while it is open
// (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. Dragging disables the layout transition.
// instead of covered. The margin is capped at the viewport so a stale
// persisted width (e.g. fullscreen on a bigger window) can never crush
// the app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const width = snapshot.state?.panelOpen === true ? snapshot.state.width : 0
const width = snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width])
useEffect(() => {
@@ -215,7 +237,7 @@ export function Sidebar(props: { ctx: Context }) {
*/}
<div
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: state.width }}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
>
<div
+22 -5
View File
@@ -5,17 +5,22 @@
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, so the banner never spins forever.
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import css from './sidebar.module.css'
export function TerminalView(props: { sessionId: string; tabId: string }) {
const { sessionId, tabId } = props
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
export function TerminalView(props: { scope: SessionScope; tabId: string }) {
const { scope, tabId } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
@@ -40,10 +45,12 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
let failures = 0
const wsUrl = (): string => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const params = new URLSearchParams({ sessionId, tab: tabId })
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `${proto}//${location.host}/sidebar/ws/terminal?${params.toString()}`
}
@@ -57,6 +64,7 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
if (closed) return
socket = new WebSocket(wsUrl())
socket.onopen = () => {
failures = 0
setConnected(true)
setFatal(null)
sendResize()
@@ -72,6 +80,15 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
setFatal(event.reason)
return
}
// Unreasoned drops (upgrade rejected, host down, mid-handshake
// refusal) normally recover on the next attempt; after a few
// consecutive failures stop spinning and show the close code.
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
@@ -109,7 +126,7 @@ export function TerminalView(props: { sessionId: string; tabId: string }) {
term.dispose()
connectRef.current = null
}
}, [sessionId, tabId])
}, [scope.sessionId, scope.cwd, tabId])
return (
<div className={css.terminalWrap}>
+48 -31
View File
@@ -1,7 +1,10 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId; failures surface as
* {@link SidebarApiError} with the wire error code.
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
* cwd from the client's own list summary. The host prefers its attached
* session header and uses the summary cwd only while the session is still
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
/** One wire failure. */
@@ -71,37 +74,51 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
return parsed.value as T
}
/** The sidebar API surface (sessionId is threaded through every call). */
/** One request's session scope: the conversation id plus its cwd when known. */
export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (sessionId: string, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', { sessionId }, signal),
fsTree: (sessionId: string, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', { sessionId, path }, signal),
fsRead: (sessionId: string, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', { sessionId, path }, signal),
fsWrite: (sessionId: string, path: string, content: string) =>
call<{ ok: true }>('fs.write', { sessionId, path, content }),
gitStatus: (sessionId: string, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', { sessionId }, signal),
gitDiff: (sessionId: string, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', { sessionId, ...(path !== undefined ? { path } : {}), staged }, signal),
gitShow: (sessionId: string, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', { sessionId, rev, path }, signal),
gitStage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.stage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitUnstage: (sessionId: string, path?: string) =>
call<{ ok: true }>('git.unstage', { sessionId, ...(path !== undefined ? { path } : {}) }),
gitCommit: (sessionId: string, message: string) =>
call<{ ok: true }>('git.commit', { sessionId, message }),
gitBranch: (sessionId: string, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', { sessionId }, signal),
gitCheckout: (sessionId: string, branch: string) =>
call<{ ok: true }>('git.checkout', { sessionId, branch }),
gitLog: (sessionId: string, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', { sessionId }, signal),
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitShow: (scope: SessionScope, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', scopePayload(scope, { rev, path }), signal),
gitStage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitCommit: (scope: SessionScope, message: string) =>
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
gitCheckout: (scope: SessionScope, branch: string) =>
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
gitLog: (scope: SessionScope, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {}), signal),
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(sessionId: string, path: string): string {
return `/sidebar/file?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`
export function mediaUrl(scope: SessionScope, path: string): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `/sidebar/file?${params.toString()}`
}
+46 -14
View File
@@ -57,20 +57,52 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
ctx.effect(() => {
const host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
return () => {
root.unmount()
host.remove()
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}, 'dsh-better-sidebar: sidebar mount')
}
try {
ctx.effect(() => {
try {
const host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
const root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
return () => {
root.unmount()
host.remove()
}
} catch (error) {
fail('mount', error)
return undefined
}
}, 'dsh-better-sidebar: sidebar mount')
ctx.effect(
() => registerTurnTailInterception(ctx),
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: turn-tail interception',
)
} catch (error) {
fail('load', error)
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Syntax highlighting for the file editor: extension → CodeMirror language
* mapping. The key derivation is pure and unit-tested; the factories pull in
* the CodeMirror language packages (bundled into the client).
*/
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { xml } from '@codemirror/lang-xml'
import { yaml } from '@codemirror/lang-yaml'
import { sql } from '@codemirror/lang-sql'
import { java } from '@codemirror/lang-java'
import { cpp } from '@codemirror/lang-cpp'
import { rust } from '@codemirror/lang-rust'
import { go } from '@codemirror/lang-go'
import { php } from '@codemirror/lang-php'
import { shell } from '@codemirror/legacy-modes/mode/shell'
import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/** Language key for an extension, or null for plain text. Pure (tested). */
export function languageKeyForExt(ext: string): string | null {
switch (ext) {
case 'js': case 'mjs': case 'cjs': return 'js'
case 'jsx': return 'jsx'
case 'ts': case 'mts': case 'cts': return 'ts'
case 'tsx': return 'tsx'
case 'json': case 'jsonc': return 'json'
case 'py': case 'pyw': return 'python'
case 'html': case 'htm': return 'html'
case 'css': return 'css'
case 'xml': case 'xsl': return 'xml'
case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql'
case 'java': return 'java'
case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust'
case 'go': return 'go'
case 'php': return 'php'
case 'sh': case 'bash': case 'zsh': return 'shell'
case 'toml': return 'toml'
case 'nginx': case 'conf': return 'nginx'
case 'dockerfile': case 'docker': return 'dockerfile'
case 'properties': case 'env': return 'properties'
default: return null
}
}
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
js: () => javascript({ jsx: true }),
jsx: () => javascript({ jsx: true }),
ts: () => javascript({ typescript: true }),
tsx: () => javascript({ typescript: true, jsx: true }),
json: () => json(),
python: () => python(),
html: () => html(),
css: () => css(),
xml: () => xml(),
yaml: () => yaml(),
sql: () => sql(),
java: () => java(),
c: () => cpp(),
cpp: () => cpp(),
rust: () => rust(),
go: () => go(),
php: () => php(),
shell: () => StreamLanguage.define(shell),
toml: () => StreamLanguage.define(toml),
nginx: () => StreamLanguage.define(nginx),
dockerfile: () => StreamLanguage.define(dockerFile),
properties: () => StreamLanguage.define(properties),
}
/** The CodeMirror language support for a path, or null for plain text. */
export function languageForPath(path: string): Language | LanguageSupport | null {
const key = languageKeyForExt(extOf(path))
return key === null ? null : FACTORIES[key]!()
}
+2
View File
@@ -21,6 +21,7 @@ const zh = {
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
refresh: '刷新',
save: '保存',
@@ -82,6 +83,7 @@ const en: Record<keyof typeof zh, string> = {
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
refresh: 'Refresh',
save: 'Save',
+12 -10
View File
@@ -538,19 +538,21 @@
background: var(--dsw-alias-state-warn-tertiary);
}
.editorTextarea {
.editorCm {
flex: 1;
min-height: 0;
width: 100%;
padding: 10px 12px;
border: none;
outline: none;
resize: none;
overflow: hidden;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code-block-small);
tab-size: 2;
white-space: pre;
}
/* CodeMirror fills the pane; the theme supplies its own token palette
(a designed dark theme, same policy as the xterm palette). */
.editorCm :global(.cm-editor) {
height: 100%;
}
.editorCm :global(.cm-editor.cm-focused) {
outline: none;
}
.editorImageWrap {
+82 -4
View File
@@ -366,10 +366,8 @@ function loadState(sessionId: string): SidebarState {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
if (raw !== null) {
const parsed = JSON.parse(raw) as SidebarState
if (parsed !== null && typeof parsed === 'object' && 'splits' in parsed && 'width' in parsed) {
return parsed
}
const sanitized = sanitizeState(JSON.parse(raw) as unknown)
if (sanitized !== undefined) return sanitized
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
@@ -383,6 +381,86 @@ function loadState(sessionId: string): SidebarState {
return makeDefaultState(contentHalf)
}
/**
* Structural validation of one persisted state. A malformed or stale shape
* (older layouts, hand-edited storage) must fall back to the default instead
* of crashing the panel on every reload; the restored width is also clamped
* to the current viewport so a stale fullscreen width can never crush the
* app shell (margin-right larger than the window) or cover the whole screen.
* @returns a clean state, or undefined to fall back to the default.
*/
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record<string, unknown>
if (typeof record.panelOpen !== 'boolean') return undefined
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
const splits = sanitizeNode(record.splits)
if (splits === undefined) return undefined
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
activePane: typeof record.activePane === 'string' ? record.activePane : null,
nextTerminal: record.nextTerminal,
expanded: record.expanded as string[],
splits,
}
}
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
function sanitizeNode(node: unknown): SplitNode | undefined {
if (node === null || typeof node !== 'object') return undefined
const record = node as Record<string, unknown>
if (record.kind === 'leaf') {
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
const tabs: SidebarTab[] = []
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
if (
candidate.type !== 'explorer' && candidate.type !== 'git'
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
) {
return undefined
}
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
})
}
const active = typeof record.active === 'string' ? record.active : null
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
return { kind: 'leaf', id: record.id, tabs, active }
}
if (record.kind === 'split') {
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
const children: SplitNode[] = []
for (const child of record.children) {
const clean = sanitizeNode(child)
if (clean === undefined) return undefined
children.push(clean)
}
if (children.length < 2) return undefined
if (
record.sizes.length !== children.length
|| record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
) {
return undefined
}
return { kind: 'split', id: record.id, dir: record.dir, sizes: record.sizes as number[], children }
}
return undefined
}
/** The session-scoped store: one state per conversation, localStorage-backed. */
export class SidebarStore {
private readonly bySession = new Map<string, SidebarState>()