diff options
| author | Adam <[email protected]> | 2026-02-06 09:37:49 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 09:37:49 -0600 |
| commit | a4bc883595df9ea0f752079519081bc602408553 (patch) | |
| tree | 583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/context | |
| parent | c07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff) | |
| download | opencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz opencode-a4bc883595df9ea0f752079519081bc602408553.zip | |
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/comments.test.ts | 111 | ||||
| -rw-r--r-- | packages/app/src/context/comments.tsx | 152 | ||||
| -rw-r--r-- | packages/app/src/context/file-content-eviction-accounting.test.ts | 85 | ||||
| -rw-r--r-- | packages/app/src/context/file.tsx | 143 | ||||
| -rw-r--r-- | packages/app/src/context/layout.test.ts | 69 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 94 | ||||
| -rw-r--r-- | packages/app/src/context/terminal.test.ts | 38 | ||||
| -rw-r--r-- | packages/app/src/context/terminal.tsx | 24 |
8 files changed, 538 insertions, 178 deletions
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts new file mode 100644 index 000000000..13cb132c4 --- /dev/null +++ b/packages/app/src/context/comments.test.ts @@ -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() + }) + }) +}) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index d51c16352..d43f3705b 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -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 { - ready, list, - all, + all: () => state.all, add, remove, clear, - focus: createMemo(() => state.focus), + focus: () => state.focus, setFocus, clearFocus: () => setFocus(null), - active: createMemo(() => state.active), + 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: 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 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) - } - } + 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(), + }, + ) + + 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)) diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts new file mode 100644 index 000000000..9a455e2af --- /dev/null +++ b/packages/app/src/context/file-content-eviction-accounting.test.ts @@ -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) + }) +}) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 3ed1b1ae4..164da726f 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -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 + setContentBytes(path, bytes ?? prev ?? 0) +} + +function removeContentBytes(path: string) { + const prev = contentLru.get(path) + if (prev === undefined) return contentLru.delete(path) - contentLru.set(path, value) + contentBytesTotal -= prev +} + +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) } -type ViewSession = ReturnType<typeof createViewSession> +export function removeFileContentBytes(path: string) { + removeContentBytes(path) +} + +export function touchFileContent(path: string, bytes?: number) { + touchContent(path, bytes) +} -type ViewCacheEntry = { - value: ViewSession - dispose: VoidFunction +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 { diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts new file mode 100644 index 000000000..582d5edbd --- /dev/null +++ b/packages/app/src/context/layout.test.ts @@ -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([]) + }) +}) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 95a2006ea..8d9c865f8 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -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, diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts new file mode 100644 index 000000000..d8c8cfcd4 --- /dev/null +++ b/packages/app/src/context/terminal.test.ts @@ -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", + ]) + }) +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0c383a78d..76e8cf0f7 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -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(), |
