Skip to content

Commit 7945329

Browse files
committed
fix(app): terminal state corruption
1 parent 7b773c6 commit 7945329

2 files changed

Lines changed: 112 additions & 29 deletions

File tree

packages/app/src/context/terminal.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
22

33
let getWorkspaceTerminalCacheKey: (dir: string) => string
44
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
5+
let migrateTerminalState: (value: unknown) => unknown
56

67
beforeAll(async () => {
78
mock.module("@solidjs/router", () => ({
@@ -17,6 +18,7 @@ beforeAll(async () => {
1718
const mod = await import("./terminal")
1819
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
1920
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
21+
migrateTerminalState = mod.migrateTerminalState
2022
})
2123

2224
describe("getWorkspaceTerminalCacheKey", () => {
@@ -37,3 +39,44 @@ describe("getLegacyTerminalStorageKeys", () => {
3739
])
3840
})
3941
})
42+
43+
describe("migrateTerminalState", () => {
44+
test("drops invalid terminals and restores a valid active terminal", () => {
45+
expect(
46+
migrateTerminalState({
47+
active: "missing",
48+
all: [
49+
null,
50+
{ id: "one", title: "Terminal 2" },
51+
{ id: "one", title: "duplicate", titleNumber: 9 },
52+
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
53+
{ title: "no-id" },
54+
],
55+
}),
56+
).toEqual({
57+
active: "one",
58+
all: [
59+
{ id: "one", title: "Terminal 2", titleNumber: 2 },
60+
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
61+
],
62+
})
63+
})
64+
65+
test("keeps a valid active id", () => {
66+
expect(
67+
migrateTerminalState({
68+
active: "two",
69+
all: [
70+
{ id: "one", title: "Terminal 1" },
71+
{ id: "two", title: "shell", titleNumber: 7 },
72+
],
73+
}),
74+
).toEqual({
75+
active: "two",
76+
all: [
77+
{ id: "one", title: "Terminal 1", titleNumber: 1 },
78+
{ id: "two", title: "shell", titleNumber: 7 },
79+
],
80+
})
81+
})
82+
})

packages/app/src/context/terminal.tsx

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,71 @@ export type LocalPTY = {
2020
const WORKSPACE_KEY = "__workspace__"
2121
const MAX_TERMINAL_SESSIONS = 20
2222

23+
function record(value: unknown): value is Record<string, unknown> {
24+
return typeof value === "object" && value !== null && !Array.isArray(value)
25+
}
26+
27+
function text(value: unknown) {
28+
return typeof value === "string" ? value : undefined
29+
}
30+
31+
function num(value: unknown) {
32+
return typeof value === "number" && Number.isFinite(value) ? value : undefined
33+
}
34+
35+
function numberFromTitle(title: string) {
36+
const match = title.match(/^Terminal (\d+)$/)
37+
if (!match) return
38+
const value = Number(match[1])
39+
if (!Number.isFinite(value) || value <= 0) return
40+
return value
41+
}
42+
43+
function pty(value: unknown): LocalPTY | undefined {
44+
if (!record(value)) return
45+
46+
const id = text(value.id)
47+
if (!id) return
48+
49+
const title = text(value.title) ?? ""
50+
const number = num(value.titleNumber)
51+
const rows = num(value.rows)
52+
const cols = num(value.cols)
53+
const buffer = text(value.buffer)
54+
const scrollY = num(value.scrollY)
55+
const cursor = num(value.cursor)
56+
57+
return {
58+
id,
59+
title,
60+
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
61+
...(rows !== undefined ? { rows } : {}),
62+
...(cols !== undefined ? { cols } : {}),
63+
...(buffer !== undefined ? { buffer } : {}),
64+
...(scrollY !== undefined ? { scrollY } : {}),
65+
...(cursor !== undefined ? { cursor } : {}),
66+
}
67+
}
68+
69+
export function migrateTerminalState(value: unknown) {
70+
if (!record(value)) return value
71+
72+
const seen = new Set<string>()
73+
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
74+
const next = pty(item)
75+
if (!next || seen.has(next.id)) return []
76+
seen.add(next.id)
77+
return [next]
78+
})
79+
80+
const active = text(value.active)
81+
82+
return {
83+
active: active && seen.has(active) ? active : all[0]?.id,
84+
all,
85+
}
86+
}
87+
2388
export function getWorkspaceTerminalCacheKey(dir: string) {
2489
return `${dir}:${WORKSPACE_KEY}`
2590
}
@@ -71,16 +136,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
71136
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
72137
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
73138

74-
const numberFromTitle = (title: string) => {
75-
const match = title.match(/^Terminal (\d+)$/)
76-
if (!match) return
77-
const value = Number(match[1])
78-
if (!Number.isFinite(value) || value <= 0) return
79-
return value
80-
}
81-
82139
const [store, setStore, _, ready] = persisted(
83-
Persist.workspace(dir, "terminal", legacy),
140+
{
141+
...Persist.workspace(dir, "terminal", legacy),
142+
migrate: migrateTerminalState,
143+
},
84144
createStore<{
85145
active?: string
86146
all: LocalPTY[]
@@ -128,26 +188,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
128188
})
129189
onCleanup(unsub)
130190

131-
const meta = { migrated: false }
132-
133-
createEffect(() => {
134-
if (!ready()) return
135-
if (meta.migrated) return
136-
meta.migrated = true
137-
138-
setStore("all", (all) => {
139-
const next = all.map((pty) => {
140-
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
141-
if (direct !== undefined) return pty
142-
const parsed = numberFromTitle(pty.title)
143-
if (parsed === undefined) return pty
144-
return { ...pty, titleNumber: parsed }
145-
})
146-
if (next.every((pty, index) => pty === all[index])) return all
147-
return next
148-
})
149-
})
150-
151191
return {
152192
ready,
153193
all: createMemo(() => store.all),

0 commit comments

Comments
 (0)