summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/context
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-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.ts111
-rw-r--r--packages/app/src/context/comments.tsx152
-rw-r--r--packages/app/src/context/file-content-eviction-accounting.test.ts85
-rw-r--r--packages/app/src/context/file.tsx143
-rw-r--r--packages/app/src/context/layout.test.ts69
-rw-r--r--packages/app/src/context/layout.tsx94
-rw-r--r--packages/app/src/context/terminal.test.ts38
-rw-r--r--packages/app/src/context/terminal.tsx24
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(),