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:
+72
-1
@@ -16,7 +16,7 @@ interface FakeContext {
|
||||
register: (route: SidebarWebRoute) => () => void
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
|
||||
}
|
||||
sessions: { get: (id: string) => undefined }
|
||||
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
effect: (fn: () => void | (() => void), label?: string) => void
|
||||
}
|
||||
|
||||
@@ -126,3 +126,74 @@ describe('host plugin smoke', () => {
|
||||
expect(listing.truncated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session cwd resolution over the API route', () => {
|
||||
interface CtxOverrides {
|
||||
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
}
|
||||
|
||||
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: overrides.sessions ?? { get: () => undefined },
|
||||
// The vendored cordis runs registration effects immediately.
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
}
|
||||
|
||||
const invoke = async (route: SidebarWebRoute, payload: unknown): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => {
|
||||
const body = Buffer.from(JSON.stringify(payload))
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: '/sidebar/api/session.cwd',
|
||||
headers: { host: '127.0.0.1:3080' },
|
||||
[Symbol.asyncIterator]: async function* () { yield body },
|
||||
} as never
|
||||
const out: { status: number; body: string } = { status: 200, body: '' }
|
||||
const res = {
|
||||
writeHead: (status: number) => { out.status = status },
|
||||
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
|
||||
} as never
|
||||
await route.handler(req, res)
|
||||
return JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { message: string } }
|
||||
}
|
||||
|
||||
it('uses the client summary cwd while the session is detached', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe('/tmp/summary-cwd')
|
||||
})
|
||||
|
||||
it('falls back to the process cwd with no summary cwd', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, { sessionId: 's-unknown' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe(process.cwd())
|
||||
})
|
||||
|
||||
it('prefers the attached session header over the client summary', async () => {
|
||||
const route = mount({
|
||||
sessions: {
|
||||
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe('/attached-cwd')
|
||||
})
|
||||
|
||||
it('rejects a non-absolute client cwd', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, { sessionId: 's-detached', cwd: 'relative/path' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/invalid working directory/)
|
||||
})
|
||||
})
|
||||
|
||||
+56
-1
@@ -3,8 +3,9 @@ import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import {
|
||||
activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab,
|
||||
resizeSplit, splitPane, toggleExpanded, type SidebarState, type SplitNode,
|
||||
resizeSplit, sanitizeState, splitPane, toggleExpanded, type SidebarState, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
||||
|
||||
@@ -260,3 +261,57 @@ describe('produced-files derivation', () => {
|
||||
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persisted state sanitization', () => {
|
||||
it('accepts a well-formed state unchanged (node environment: no width clamp)', () => {
|
||||
const state = makeDefaultState(400)
|
||||
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
expect(clean).toEqual(state)
|
||||
})
|
||||
|
||||
it('clamps undersized widths to the panel minimum', () => {
|
||||
const state = { ...makeDefaultState(400), width: 10 }
|
||||
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
expect(clean?.width).toBe(280)
|
||||
})
|
||||
|
||||
it('rejects malformed shapes instead of crashing the panel', () => {
|
||||
expect(sanitizeState(null)).toBeUndefined()
|
||||
expect(sanitizeState('nope')).toBeUndefined()
|
||||
expect(sanitizeState({})).toBeUndefined()
|
||||
expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined()
|
||||
expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined()
|
||||
// A split whose sizes do not match its children is rejected.
|
||||
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
|
||||
expect(sanitizeState(withSplit)).toBeUndefined()
|
||||
// Unknown tab types (older layouts) are rejected.
|
||||
const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadTab.splits.tabs[0].type = 'watcher'
|
||||
expect(sanitizeState(withBadTab)).toBeUndefined()
|
||||
// An active id that no tab carries is rejected.
|
||||
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
withBadActive.splits.active = 'ghost-tab'
|
||||
expect(sanitizeState(withBadActive)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor language mapping', () => {
|
||||
it('derives extensions from paths', () => {
|
||||
expect(extOf('/a/b/main.tsx')).toBe('tsx')
|
||||
expect(extOf('README.MD')).toBe('md')
|
||||
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
|
||||
expect(extOf('noext')).toBe('')
|
||||
})
|
||||
|
||||
it('maps common extensions to languages and falls back to plain text', () => {
|
||||
expect(languageKeyForExt('tsx')).toBe('tsx')
|
||||
expect(languageKeyForExt('js')).toBe('js')
|
||||
expect(languageKeyForExt('py')).toBe('python')
|
||||
expect(languageKeyForExt('yaml')).toBe('yaml')
|
||||
expect(languageKeyForExt('sh')).toBe('shell')
|
||||
expect(languageKeyForExt('txt')).toBeNull()
|
||||
expect(languageKeyForExt('log')).toBeNull()
|
||||
expect(languageKeyForExt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user