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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user