Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { ServerConnection, ServerProvider, serverName, useServer } from "@/conte
import { SettingsProvider, useSettings } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { TerminalProvider, TerminalRegistryProvider } from "@/context/terminal"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
Expand Down Expand Up @@ -378,15 +379,17 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
)
}

// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
// Drafts share their workspace with the terminal, files, and prompt context. A new chat can
// therefore open the same bottom terminal before its first prompt is submitted.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}

Expand All @@ -413,11 +416,13 @@ export function AppBaseProviders(
}}
>
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
<TerminalRegistryProvider>
<WslServersProvider>
<DialogProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
</TerminalRegistryProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/context/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ServerScope } from "@/utils/server-scope"
let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
let createWorkspaceTerminalRegistry: typeof import("./terminal").createWorkspaceTerminalRegistry

beforeAll(async () => {
mock.module("@solidjs/router", () => ({
Expand All @@ -22,6 +23,7 @@ beforeAll(async () => {
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
createWorkspaceTerminalRegistry = mod.createWorkspaceTerminalRegistry
})

describe("getWorkspaceTerminalCacheKey", () => {
Expand Down Expand Up @@ -89,3 +91,39 @@ describe("migrateTerminalState", () => {
})
})
})

describe("createWorkspaceTerminalRegistry", () => {
test("reuses one terminal session across chats in the same workspace", () => {
const registry = createWorkspaceTerminalRegistry<{ id: string }>()
const disposed: string[] = []
const first = registry.get("server\u0000repo-a", () => ({
value: { id: "terminal-a" },
dispose: () => disposed.push("terminal-a"),
}))
const second = registry.get("server\u0000repo-a", () => ({
value: { id: "replacement" },
dispose: () => disposed.push("replacement"),
}))

expect(second).toBe(first)
expect(disposed).toEqual([])
registry.dispose()
expect(disposed).toEqual(["terminal-a"])
})

test("keeps workspaces separate and evicts the least recently used entry", () => {
const registry = createWorkspaceTerminalRegistry<{ id: string }>(2)
const disposed: string[] = []
const create = (id: string) => ({ value: { id }, dispose: () => disposed.push(id) })

expect(registry.get("server\u0000repo-a", () => create("a")).id).toBe("a")
expect(registry.get("server\u0000repo-b", () => create("b")).id).toBe("b")
expect(registry.get("server\u0000repo-a", () => create("replacement")).id).toBe("a")
expect(registry.get("server\u0000repo-c", () => create("c")).id).toBe("c")

expect(registry.peek("server\u0000repo-a")?.id).toBe("a")
expect(registry.peek("server\u0000repo-b")).toBeUndefined()
expect(registry.peek("server\u0000repo-c")?.id).toBe("c")
expect(disposed).toEqual(["b"])
})
})
104 changes: 57 additions & 47 deletions packages/app/src/context/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,41 @@ export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: stri

type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>

type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
export function createWorkspaceTerminalRegistry<T>(limit = MAX_TERMINAL_SESSIONS) {
const cache = new Map<string, { value: T; dispose: VoidFunction }>()

return {
get(key: string, create: () => { value: T; dispose: VoidFunction }) {
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}

const entry = create()
cache.set(key, entry)
while (cache.size > limit) {
const first = cache.keys().next().value
if (!first) break
cache.get(first)?.dispose()
cache.delete(first)
}
return entry.value
},
peek(key: string) {
return cache.get(key)?.value
},
dispose() {
for (const entry of cache.values()) entry.dispose()
cache.clear()
},
}
}

const caches = new Set<Map<string, TerminalCacheEntry>>()
type TerminalRegistry = ReturnType<typeof createWorkspaceTerminalRegistry<TerminalSession>>

const registries = new Set<TerminalRegistry>()

const trimTerminal = (pty: LocalPTY) => {
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
Expand All @@ -124,10 +153,7 @@ export function clearWorkspaceTerminals(
scope: ServerScopeValue = ServerScope.local,
) {
const key = getWorkspaceTerminalCacheKey(dir, scope)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
for (const registry of registries) registry.peek(key)?.clear()

void removePersisted(terminalPersistTarget(scope, dir), platform)

Expand All @@ -143,6 +169,20 @@ export function clearWorkspaceTerminals(
}
}

export const { use: useTerminalRegistry, provider: TerminalRegistryProvider } = createSimpleContext({
name: "TerminalRegistry",
gate: false,
init: () => {
const registry = createWorkspaceTerminalRegistry<TerminalSession>()
registries.add(registry)
onCleanup(() => {
registries.delete(registry)
registry.dispose()
})
return registry
},
})

function createWorkspaceTerminalSession(
sdk: DirectorySDK,
dir: string,
Expand Down Expand Up @@ -460,51 +500,21 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
init: () => {
const sdk = useSDK()
const serverSDK = useServerSDK()
const registry = useTerminalRegistry()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))

caches.add(cache)
onCleanup(() => caches.delete(cache))

const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
}

onCleanup(disposeAll)

const prune = () => {
while (cache.size > MAX_TERMINAL_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}

const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
// The app-level registry outlives chat routes, while each entry keeps the DirectorySDK from
// the workspace that created it. The server scope in the key prevents cross-server reuse.
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}

const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
dispose,
}))

cache.set(key, entry)
prune()
return entry.value
return registry.get(key, () =>
createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk(), dir, serverScope, legacySessionID),
dispose,
})),
)
}

const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
Expand Down
Loading