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
+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}>