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:
+79
-37
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]!()
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>()
|
||||
|
||||
+25
-9
@@ -63,14 +63,28 @@ function trustedHostsOf(ctx: Context): string[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** Resolve a session's authoritative working directory or throw not-found. */
|
||||
function sessionCwdOf(ctx: Context, sessionId: string): string {
|
||||
/**
|
||||
* Resolve a session's authoritative working directory. The attached session
|
||||
* header wins; while the session is still hydrating from persistence (the
|
||||
* web client attaches the current conversation a moment after page load, so
|
||||
* the very first sidebar requests can arrive detached) the caller's own
|
||||
* list-summary cwd is used; the process cwd is the last resort (blank
|
||||
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
|
||||
* explorer/git/terminal work from first paint instead of surfacing
|
||||
* "session ... has no working directory".
|
||||
*/
|
||||
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
const cwd = session?.header.cwd
|
||||
if (cwd === undefined) {
|
||||
throw new SidebarError('not-found', `session "${sessionId}" has no working directory`, 404)
|
||||
const headerCwd = session?.header.cwd
|
||||
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
|
||||
if (clientCwd !== undefined && clientCwd !== '') {
|
||||
try {
|
||||
return requireAbsolute(clientCwd)
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
|
||||
}
|
||||
}
|
||||
return cwd
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
/** Text read of a file with the size cap; binary detection via NUL probe. */
|
||||
@@ -104,7 +118,9 @@ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMethod> {
|
||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId) }
|
||||
const record = payload as { cwd?: unknown } | null
|
||||
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
@@ -256,7 +272,7 @@ export function apply(ctx: Context): void {
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const raw = url.searchParams.get('path')
|
||||
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
|
||||
const cwd = sessionCwdOf(ctx, sessionId)
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const path = requireAbsolute(raw)
|
||||
if (!path.startsWith(cwd)) {
|
||||
// Only files under the session cwd are served as media (the editor
|
||||
@@ -311,7 +327,7 @@ async function attachTerminal(
|
||||
ws.close(1008, 'sessionId and tab are required')
|
||||
return
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId)
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
|
||||
// Replay the transcript, then follow live output.
|
||||
if (handle.transcript !== '') ws.send(handle.transcript)
|
||||
|
||||
Reference in New Issue
Block a user