chore: refactoring and tests (#12468)

This commit is contained in:
Adam
2026-02-06 09:37:49 -06:00
committed by GitHub
parent c07077f96c
commit a4bc883595
39 changed files with 3804 additions and 1494 deletions

View File

@@ -0,0 +1,111 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import type { LineComment } from "./comments"
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./comments")
createCommentSessionForTest = mod.createCommentSessionForTest
})
function line(file: string, id: string, time: number): LineComment {
return {
id,
file,
comment: id,
time,
selection: { start: 1, end: 1 },
}
}
describe("comments session indexing", () => {
test("keeps file list behavior and aggregate chronological order", () => {
createRoot((dispose) => {
const now = Date.now()
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
"b.ts": [line("b.ts", "b-mid", now + 10_000)],
})
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
const next = comments.add({
file: "b.ts",
comment: "next",
selection: { start: 2, end: 2 },
})
expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
expect(comments.all().map((item) => item.time)).toEqual(
comments
.all()
.map((item) => item.time)
.slice()
.sort((a, b) => a - b),
)
dispose()
})
})
test("remove updates file and aggregate indexes consistently", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
"b.ts": [line("b.ts", "shared", 30)],
})
comments.setFocus({ file: "a.ts", id: "shared" })
comments.setActive({ file: "a.ts", id: "shared" })
comments.remove("a.ts", "shared")
expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
expect(
comments
.all()
.filter((item) => item.id === "shared")
.map((item) => item.file),
).toEqual(["b.ts"])
expect(comments.focus()).toBeNull()
expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
dispose()
})
})
test("clear resets file and aggregate indexes plus focus state", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a1", 10)],
})
const next = comments.add({
file: "b.ts",
comment: "next",
selection: { start: 2, end: 2 },
})
comments.setActive({ file: "b.ts", id: next.id })
comments.clear()
expect(comments.list("a.ts")).toEqual([])
expect(comments.list("b.ts")).toEqual([])
expect(comments.all()).toEqual([])
expect(comments.focus()).toBeNull()
expect(comments.active()).toBeNull()
dispose()
})
})
})

View File

@@ -1,8 +1,9 @@
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
import type { SelectedLineRange } from "@/context/file"
export type LineComment = {
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20
type CommentSession = ReturnType<typeof createCommentSession>
type CommentCacheEntry = {
value: CommentSession
dispose: VoidFunction
type CommentStore = {
comments: Record<string, LineComment[]>
}
function createCommentSession(dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
function aggregate(comments: Record<string, LineComment[]>) {
return Object.keys(comments)
.flatMap((file) => comments[file] ?? [])
.slice()
.sort((a, b) => a.time - b.time)
}
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "comments", [legacy]),
createStore<{
comments: Record<string, LineComment[]>
}>({
comments: {},
}),
)
function insert(items: LineComment[], next: LineComment) {
const index = items.findIndex((item) => item.time > next.time)
if (index < 0) return [...items, next]
return [...items.slice(0, index), next, ...items.slice(index)]
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
active: null as CommentFocus | null,
all: aggregate(store.comments),
})
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
@@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) {
batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next])
setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id })
})
@@ -66,37 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
}
const remove = (file: string, id: string) => {
setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
setFocus((current) => (current?.id === id ? null : current))
batch(() => {
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
setFocus((current) => (current?.id === id ? null : current))
})
}
const clear = () => {
batch(() => {
setStore("comments", {})
setStore("comments", reconcile({}))
setState("all", [])
setFocus(null)
setActive(null)
})
}
const all = createMemo(() => {
const files = Object.keys(store.comments)
const items = files.flatMap((file) => store.comments[file] ?? [])
return items.slice().sort((a, b) => a.time - b.time)
return {
list,
all: () => state.all,
add,
remove,
clear,
focus: () => state.focus,
setFocus,
clearFocus: () => setFocus(null),
active: () => state.active,
setActive,
clearActive: () => setActive(null),
reindex: () => setState("all", aggregate(store.comments)),
}
}
export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
const [store, setStore] = createStore<CommentStore>({ comments })
return createCommentSessionState(store, setStore)
}
function createCommentSession(dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "comments", [legacy]),
createStore<CommentStore>({
comments: {},
}),
)
const session = createCommentSessionState(store, setStore)
createEffect(() => {
if (!ready()) return
session.reindex()
})
return {
ready,
list,
all,
add,
remove,
clear,
focus: createMemo(() => state.focus),
setFocus,
clearFocus: () => setFocus(null),
active: createMemo(() => state.active),
setActive,
clearActive: () => setActive(null),
list: session.list,
all: session.all,
add: session.add,
remove: session.remove,
clear: session.clear,
focus: session.focus,
setFocus: session.setFocus,
clearFocus: session.clearFocus,
active: session.active,
setActive: session.setActive,
clearActive: session.clearActive,
}
}
@@ -105,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
const cache = new Map<string, CommentCacheEntry>()
const cache = createScopedCache(
(key) => {
const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
dispose,
}))
},
{
maxEntries: MAX_COMMENT_SESSIONS,
dispose: (entry) => entry.dispose(),
},
)
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
}
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_COMMENT_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createCommentSession(dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
return cache.get(key).value
}
const session = createMemo(() => load(params.dir!, params.id))

View File

@@ -0,0 +1,85 @@
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"
let evictContentLru: (keep: Set<string> | undefined, evict: (path: string) => void) => void
let getFileContentBytesTotal: () => number
let getFileContentEntryCount: () => number
let removeFileContentBytes: (path: string) => void
let resetFileContentLru: () => void
let setFileContentBytes: (path: string, bytes: number) => void
let touchFileContent: (path: string, bytes?: number) => void
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./file")
evictContentLru = mod.evictContentLru
getFileContentBytesTotal = mod.getFileContentBytesTotal
getFileContentEntryCount = mod.getFileContentEntryCount
removeFileContentBytes = mod.removeFileContentBytes
resetFileContentLru = mod.resetFileContentLru
setFileContentBytes = mod.setFileContentBytes
touchFileContent = mod.touchFileContent
})
describe("file content eviction accounting", () => {
afterEach(() => {
resetFileContentLru()
})
test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
setFileContentBytes("a", 10)
setFileContentBytes("b", 15)
expect(getFileContentBytesTotal()).toBe(25)
expect(getFileContentEntryCount()).toBe(2)
setFileContentBytes("a", 5)
expect(getFileContentBytesTotal()).toBe(20)
expect(getFileContentEntryCount()).toBe(2)
touchFileContent("a")
expect(getFileContentBytesTotal()).toBe(20)
removeFileContentBytes("b")
expect(getFileContentBytesTotal()).toBe(5)
expect(getFileContentEntryCount()).toBe(1)
resetFileContentLru()
expect(getFileContentBytesTotal()).toBe(0)
expect(getFileContentEntryCount()).toBe(0)
})
test("evicts by entry cap using LRU order", () => {
for (const i of Array.from({ length: 41 }, (_, n) => n)) {
setFileContentBytes(`f-${i}`, 1)
}
const evicted: string[] = []
evictContentLru(undefined, (path) => evicted.push(path))
expect(evicted).toEqual(["f-0"])
expect(getFileContentEntryCount()).toBe(40)
expect(getFileContentBytesTotal()).toBe(40)
})
test("evicts by byte cap while preserving protected entries", () => {
const chunk = 8 * 1024 * 1024
setFileContentBytes("a", chunk)
setFileContentBytes("b", chunk)
setFileContentBytes("c", chunk)
const evicted: string[] = []
evictContentLru(new Set(["a"]), (path) => evicted.push(path))
expect(evicted).toEqual(["b"])
expect(getFileContentEntryCount()).toBe(2)
expect(getFileContentBytesTotal()).toBe(chunk * 2)
})
})

View File

@@ -9,6 +9,7 @@ import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
export type FileSelection = {
startLine: number
@@ -155,6 +156,7 @@ const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
const contentLru = new Map<string, number>()
let contentBytesTotal = 0
function approxBytes(content: FileContent) {
const patchBytes =
@@ -165,19 +167,72 @@ function approxBytes(content: FileContent) {
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
}
function setContentBytes(path: string, nextBytes: number) {
const prev = contentLru.get(path)
if (prev !== undefined) contentBytesTotal -= prev
contentLru.delete(path)
contentLru.set(path, nextBytes)
contentBytesTotal += nextBytes
}
function touchContent(path: string, bytes?: number) {
const prev = contentLru.get(path)
if (prev === undefined && bytes === undefined) return
const value = bytes ?? prev ?? 0
contentLru.delete(path)
contentLru.set(path, value)
setContentBytes(path, bytes ?? prev ?? 0)
}
type ViewSession = ReturnType<typeof createViewSession>
function removeContentBytes(path: string) {
const prev = contentLru.get(path)
if (prev === undefined) return
contentLru.delete(path)
contentBytesTotal -= prev
}
type ViewCacheEntry = {
value: ViewSession
dispose: VoidFunction
function resetContentBytes() {
contentLru.clear()
contentBytesTotal = 0
}
export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
const protectedSet = keep ?? new Set<string>()
while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || contentBytesTotal > MAX_FILE_CONTENT_BYTES) {
const path = contentLru.keys().next().value
if (!path) return
if (protectedSet.has(path)) {
touchContent(path)
if (contentLru.size <= protectedSet.size) return
continue
}
removeContentBytes(path)
evict(path)
}
}
export function resetFileContentLru() {
resetContentBytes()
}
export function setFileContentBytes(path: string, bytes: number) {
setContentBytes(path, bytes)
}
export function removeFileContentBytes(path: string) {
removeContentBytes(path)
}
export function touchFileContent(path: string, bytes?: number) {
touchContent(path, bytes)
}
export function getFileContentBytesTotal() {
return contentBytesTotal
}
export function getFileContentEntryCount() {
return contentLru.size
}
function createViewSession(dir: string, id: string | undefined) {
@@ -336,23 +391,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
const evictContent = (keep?: Set<string>) => {
const protectedSet = keep ?? new Set<string>()
const total = () => {
return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
}
while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
const path = contentLru.keys().next().value
if (!path) return
if (protectedSet.has(path)) {
touchContent(path)
if (contentLru.size <= protectedSet.size) return
continue
}
contentLru.delete(path)
if (!store.file[path]) continue
evictContentLru(keep, (path) => {
if (!store.file[path]) return
setStore(
"file",
path,
@@ -361,14 +401,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
draft.loaded = false
}),
)
}
})
}
createEffect(() => {
scope()
inflight.clear()
treeInflight.clear()
contentLru.clear()
resetContentBytes()
batch(() => {
setStore("file", reconcile({}))
@@ -378,42 +418,25 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = new Map<string, ViewCacheEntry>()
const disposeViews = () => {
for (const entry of viewCache.values()) {
entry.dispose()
}
viewCache.clear()
}
const pruneViews = () => {
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
const first = viewCache.keys().next().value
if (!first) return
const entry = viewCache.get(first)
entry?.dispose()
viewCache.delete(first)
}
}
const viewCache = createScopedCache(
(key) => {
const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
dispose,
}))
},
{
maxEntries: MAX_FILE_VIEW_SESSIONS,
dispose: (entry) => entry.dispose(),
},
)
const loadView = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = viewCache.get(key)
if (existing) {
viewCache.delete(key)
viewCache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createViewSession(dir, id),
dispose,
}))
viewCache.set(key, entry)
pruneViews()
return entry.value
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
return viewCache.get(key).value
}
const view = createMemo(() => loadView(scope(), params.id))
@@ -690,7 +713,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
onCleanup(() => {
stop()
disposeViews()
viewCache.clear()
})
return {

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
describe("layout session-key helpers", () => {
test("couples touch and scroll seed in order", () => {
const calls: string[] = []
const result = ensureSessionKey(
"dir/a",
(key) => calls.push(`touch:${key}`),
(key) => calls.push(`seed:${key}`),
)
expect(result).toBe("dir/a")
expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
})
test("reads dynamic accessor keys lazily", () => {
const seen: string[] = []
createRoot((dispose) => {
const [key, setKey] = createSignal("dir/one")
const read = createSessionKeyReader(key, (value) => seen.push(value))
expect(read()).toBe("dir/one")
setKey("dir/two")
expect(read()).toBe("dir/two")
dispose()
})
expect(seen).toEqual(["dir/one", "dir/two"])
})
})
describe("pruneSessionKeys", () => {
test("keeps active key and drops lowest-used keys", () => {
const drop = pruneSessionKeys({
keep: "k4",
max: 3,
used: new Map([
["k1", 1],
["k2", 2],
["k3", 3],
["k4", 4],
]),
view: ["k1", "k2", "k4"],
tabs: ["k1", "k3", "k4"],
})
expect(drop).toEqual(["k1"])
expect(drop.includes("k4")).toBe(false)
})
test("does not prune without keep key", () => {
const drop = pruneSessionKeys({
keep: undefined,
max: 1,
used: new Map([
["k1", 1],
["k2", 2],
]),
view: ["k1"],
tabs: ["k2"],
})
expect(drop).toEqual([])
})
})

View File

@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
@@ -47,6 +47,43 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type ReviewDiffStyle = "unified" | "split"
export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
touch(key)
seed(key)
return key
}
export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
return () => {
const value = key()
ensure(value)
return value
}
}
export function pruneSessionKeys(input: {
keep?: string
max: number
used: Map<string, number>
view: string[]
tabs: string[]
}) {
if (!input.keep) return []
const keys = new Set<string>([...input.view, ...input.tabs])
if (keys.size <= input.max) return []
const score = (key: string) => {
if (key === input.keep) return Number.MAX_SAFE_INTEGER
return input.used.get(key) ?? 0
}
return Array.from(keys)
.sort((a, b) => score(b) - score(a))
.slice(input.max)
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -172,20 +209,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function prune(keep?: string) {
if (!keep) return
const keys = new Set<string>()
for (const key of Object.keys(store.sessionView)) keys.add(key)
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
if (keys.size <= MAX_SESSION_KEYS) return
const score = (key: string) => {
if (key === keep) return Number.MAX_SAFE_INTEGER
return used.get(key) ?? 0
}
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
const drop = ordered.slice(MAX_SESSION_KEYS)
const drop = pruneSessionKeys({
keep,
max: MAX_SESSION_KEYS,
used,
view: Object.keys(store.sessionView),
tabs: Object.keys(store.sessionTabs),
})
if (drop.length === 0) return
setStore(
@@ -233,6 +263,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
})
const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
@@ -616,22 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
touch(key())
scroll.seed(key())
createEffect(
on(
key,
(value) => {
touch(value)
scroll.seed(value)
},
{ defer: true },
),
)
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
@@ -711,20 +728,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
touch(key())
createEffect(
on(
key,
(value) => {
touch(value)
},
{ defer: true },
),
)
const key = createSessionKeyReader(sessionKey, ensureKey)
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
return {
tabs,

View File

@@ -0,0 +1,38 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
})
describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
})
describe("getLegacyTerminalStorageKeys", () => {
test("keeps workspace storage path when no legacy session id", () => {
expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
})
test("includes legacy session path before workspace path", () => {
expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
"/repo/terminal/session-123.v1",
"/repo/terminal.v1",
])
})
})

View File

@@ -19,15 +19,24 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
type TerminalSession = ReturnType<typeof createTerminalSession>
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
if (!legacySessionID) return [`${dir}/terminal.v1`]
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
}
type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const numberFromTitle = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
@@ -235,8 +244,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const load = (dir: string, session?: string) => {
const key = `${dir}:${WORKSPACE_KEY}`
const loadWorkspace = (dir: string, legacySessionID?: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -245,7 +255,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, session),
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose,
}))
@@ -254,7 +264,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => load(params.dir!, params.id))
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
return {
ready: () => workspace().ready(),