Compare commits

...

1 Commits

Author SHA1 Message Date
Adam
8257a20aa5 chore(app): cleanup legacy storage stuff 2026-03-30 11:00:28 -05:00
24 changed files with 107 additions and 470 deletions

View File

@@ -2,7 +2,7 @@
var key = "opencode-theme-id"
var themeId = localStorage.getItem(key) || "oc-2"
if (themeId === "oc-1") {
if (themeId.slice(0, 3) === "oc-" && themeId !== "oc-2") {
themeId = "oc-2"
localStorage.setItem(key, themeId)
localStorage.removeItem("opencode-theme-css-light")

View File

@@ -45,7 +45,6 @@ import {
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryEntry,
type PromptHistoryStoredEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
@@ -313,17 +312,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
Persist.global("prompt-history.v2"),
createStore<{
entries: PromptHistoryStoredEntry[]
entries: PromptHistoryEntry[]
}>({
entries: [],
}),
)
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
Persist.global("prompt-history-shell.v2"),
createStore<{
entries: PromptHistoryStoredEntry[]
entries: PromptHistoryEntry[]
}>({
entries: [],
}),

View File

@@ -3,7 +3,6 @@ import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
normalizePromptHistoryEntry,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
@@ -13,6 +12,7 @@ import {
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
const entry = (value: string, comments: PromptHistoryComment[] = []) => ({ prompt: text(value), comments })
const comment = (id: string, value = "note"): PromptHistoryComment => ({
id,
path: "src/a.ts",
@@ -42,7 +42,7 @@ describe("prompt-input history", () => {
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
const entries = [text("third"), text("second"), text("first")]
const entries = [entry("third"), entry("second"), entry("first")]
const up = navigatePromptHistory({
direction: "up",
entries,
@@ -73,12 +73,7 @@ describe("prompt-input history", () => {
})
test("navigatePromptHistory keeps entry comments when moving through history", () => {
const entries = [
{
prompt: text("with comment"),
comments: [comment("c1")],
},
]
const entries = [entry("with comment", [comment("c1")])]
const up = navigatePromptHistory({
direction: "up",
@@ -95,12 +90,6 @@ describe("prompt-input history", () => {
expect(up.entry.comments).toEqual([comment("c1")])
})
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
const entry = normalizePromptHistoryEntry(text("legacy"))
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
expect(entry.comments).toEqual([])
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },

View File

@@ -20,8 +20,6 @@ export type PromptHistoryEntry = {
comments: PromptHistoryComment[]
}
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0
@@ -59,13 +57,7 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
}))
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
if (Array.isArray(entry)) {
return {
prompt: clonePromptParts(entry),
comments: [],
}
}
function clonePromptHistoryEntry(entry: PromptHistoryEntry): PromptHistoryEntry {
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
@@ -77,7 +69,7 @@ export function promptLength(prompt: Prompt) {
}
export function prependHistoryEntry(
entries: PromptHistoryStoredEntry[],
entries: PromptHistoryEntry[],
prompt: Prompt,
comments: PromptHistoryComment[] = [],
max = MAX_HISTORY,
@@ -112,9 +104,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
)
}
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
const entryA = normalizePromptHistoryEntry(promptA)
const entryB = normalizePromptHistoryEntry(promptB)
function isPromptEqual(entryA: PromptHistoryEntry, entryB: PromptHistoryEntry) {
if (entryA.prompt.length !== entryB.prompt.length) return false
for (let i = 0; i < entryA.prompt.length; i++) {
const partA = entryA.prompt[i]
@@ -149,7 +139,7 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
type HistoryNavInput = {
direction: "up" | "down"
entries: PromptHistoryStoredEntry[]
entries: PromptHistoryEntry[]
historyIndex: number
currentPrompt: Prompt
currentComments: PromptHistoryComment[]
@@ -181,7 +171,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
}
if (input.historyIndex === -1) {
const entry = normalizePromptHistoryEntry(input.entries[0])
const entry = clonePromptHistoryEntry(input.entries[0])
return {
handled: true,
historyIndex: 0,
@@ -196,7 +186,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
const entry = normalizePromptHistoryEntry(input.entries[next])
const entry = clonePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
@@ -215,7 +205,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
const entry = normalizePromptHistoryEntry(input.entries[next])
const entry = clonePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,

View File

@@ -167,10 +167,8 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
}
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]),
Persist.scoped(dir, id, "comments"),
createStore<CommentStore>({
comments: {},
}),

View File

@@ -34,10 +34,8 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
Persist.scoped(dir, id, "file-view"),
createStore<{
file: Record<string, FileViewState>
}>({

View File

@@ -53,7 +53,7 @@ function createGlobalSync() {
const sessionMeta = new Map<string, { limit: number }>()
const [projectCache, setProjectCache, projectInit] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
Persist.global("globalSync.project"),
createStore({ value: [] as Project[] }),
)

View File

@@ -125,10 +125,7 @@ export function createChildStoreManager(input: {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
persisted(Persist.workspace(directory, "vcs"), createStore({ value: undefined as VcsInfo | undefined })),
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0]
@@ -136,7 +133,7 @@ export function createChildStoreManager(input: {
const meta = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "project", ["project.v1"]),
Persist.workspace(directory, "project"),
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
@@ -144,10 +141,7 @@ export function createChildStoreManager(input: {
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "icon", ["icon.v1"]),
createStore({ value: undefined as string | undefined }),
),
persisted(Persist.workspace(directory, "icon"), createStore({ value: undefined as string | undefined })),
)
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })

View File

@@ -195,7 +195,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]),
Persist.global("language"),
createStore({
locale: initial,
}),

View File

@@ -123,14 +123,6 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un
})
}
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
const path = sessionPath(key)
return {
all: normalizeSessionTabList(path, tabs.all),
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
}
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -138,96 +130,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const platform = usePlatform()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const migrate = (value: unknown) => {
if (!isRecord(value)) return value
const sidebar = value.sidebar
const migratedSidebar = (() => {
if (!isRecord(sidebar)) return sidebar
if (typeof sidebar.workspaces !== "boolean") return sidebar
return {
...sidebar,
workspaces: {},
workspacesDefault: sidebar.workspaces,
}
})()
const review = value.review
const fileTree = value.fileTree
const migratedFileTree = (() => {
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_FILE_TREE_WIDTH
return {
...fileTree,
opened: true,
width: width === 260 ? DEFAULT_FILE_TREE_WIDTH : width,
tab: "changes",
}
})()
const migratedReview = (() => {
if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
return {
...review,
panelOpened: opened,
}
})()
const sessionTabs = value.sessionTabs
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
let changed = false
const next = Object.fromEntries(
Object.entries(sessionTabs).map(([key, tabs]) => {
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
const current = {
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
active: typeof tabs.active === "string" ? tabs.active : undefined,
}
const normalized = normalizeStoredSessionTabs(key, current)
if (current.all.length !== tabs.all.length) changed = true
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
return [key, normalized]
}),
)
if (!changed) return sessionTabs
return next
})()
if (
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === sessionTabs
) {
return value
}
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
}
}
const target = Persist.global("layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
Persist.global("layout"),
createStore({
sidebar: {
opened: false,
@@ -270,11 +174,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
used: new Map<string, number>(),
}
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
{ key: "terminal", legacy: "terminal", version: "v1" },
{ key: "file-view", legacy: "file", version: "v1" },
] as const
const SESSION_STATE_KEYS = ["comments", "file-view", "prompt", "terminal"] as const
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
@@ -283,12 +183,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target, platform)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey }, platform)
for (const item of SESSION_STATE_KEYS) {
void removePersisted(session ? Persist.session(dir, session, item) : Persist.workspace(dir, item), platform)
}
}
}
@@ -876,7 +772,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
tabs(sessionKey: string | Accessor<string>) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const path = createMemo(() => sessionPath(key()))
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
const tabs = createMemo<SessionTabs>(() => {
const current = store.sessionTabs[key()]
if (!current || !Array.isArray(current.all)) return { all: [] }
return {
all: normalizeSessionTabList(
path(),
current.all.filter((tab): tab is string => typeof tab === "string"),
),
active: typeof current.active === "string" ? normalizeSessionTab(path(), current.active) : undefined,
}
})
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
return {
@@ -903,13 +809,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
const next = nextSessionTabsForOpen(store.sessionTabs[session] ? tabs() : undefined, normalize(tab))
setStore("sessionTabs", session, next)
},
close(tab: string) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (!store.sessionTabs[session]) return
const current = tabs()
if (tab === "review") {
if (current.active !== tab) return
@@ -932,8 +838,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
move(tab: string, to: number) {
const session = key()
const current = store.sessionTabs[session]
if (!current) return
if (!store.sessionTabs[session]) return
const current = tabs()
const index = current.all.findIndex((f) => f === tab)
if (index === -1) return
setStore(

View File

@@ -23,27 +23,10 @@ type Saved = {
session: Record<string, State | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
if (!item.pick || typeof item.pick !== "object") return { session: {} }
return {
session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
}
}
const clone = (value: State | undefined) => {
if (!value) return undefined
return {
@@ -66,10 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
Persist.workspace(sdk.directory, "model-selection"),
createStore<Saved>({
session: {},
}),

View File

@@ -28,7 +28,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
const providers = useProviders()
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
Persist.global("model"),
createStore<Store>({
user: [],
recent: [],

View File

@@ -124,7 +124,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
Persist.global("notification"),
createStore({
list: [] as Notification[],
}),

View File

@@ -25,12 +25,6 @@ describe("autoRespondsPermission", () => {
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
})
test("uses a parent session's legacy auto-accept key", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
})
test("defaults to requiring approval when no lineage override exists", () => {
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
const autoAccept = {

View File

@@ -12,7 +12,7 @@ export function directoryAcceptKey(directory: string) {
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
return autoAccept[key] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
}
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {

View File

@@ -59,23 +59,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
const [store, setStore, _, ready] = persisted(
{
...Persist.global("permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value
const data = value as Record<string, unknown>
if (data.autoAccept) return value
return {
...data,
autoAccept:
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
? data.autoAcceptEdits
: {},
}
},
},
Persist.global("permission"),
createStore({
autoAccept: {} as Record<string, boolean>,
}),
@@ -206,7 +190,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
setStore(
produce((draft) => {
draft.autoAccept[key] = true
delete draft.autoAccept[sessionID]
}),
)
@@ -230,8 +213,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
setStore(
produce((draft) => {
draft.autoAccept[key] = false
if (!directory) return
delete draft.autoAccept[sessionID]
}),
)
}

View File

@@ -162,10 +162,8 @@ type PromptCacheEntry = {
}
function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
Persist.scoped(dir, id, "prompt"),
createStore<{
prompt: Prompt
cursor?: number

View File

@@ -5,7 +5,6 @@ import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
@@ -102,35 +101,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
Persist.global("server"),
createStore({
list: [] as StoredServer[],
list: [] as ServerConnection.Http[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
...store.list.filter(
(value): value is ServerConnection.Http => !!value && typeof value === "object" && value.type === "http",
),
]
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
const deduped = new Map(servers.map((value) => [ServerConnection.key(value), value] as const))
return [...deduped.values()]
})
@@ -176,7 +163,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!url_) return
const conn = { ...input, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
const existing = store.list.findIndex((x) => x.http.url === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
@@ -188,12 +175,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => url(x) !== key)
const list = store.list.filter((x) => ServerConnection.key(x) !== key)
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
setState("active", next ? ServerConnection.key(next) : props.defaultServer)
}
})
}

View File

@@ -1,82 +0,0 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
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",
])
})
})
describe("migrateTerminalState", () => {
test("drops invalid terminals and restores a valid active terminal", () => {
expect(
migrateTerminalState({
active: "missing",
all: [
null,
{ id: "one", title: "Terminal 2" },
{ id: "one", title: "duplicate", titleNumber: 9 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
{ title: "no-id" },
],
}),
).toEqual({
active: "one",
all: [
{ id: "one", title: "Terminal 2", titleNumber: 2 },
{ id: "two", title: "logs", titleNumber: 4, rows: 24, cols: 80 },
],
})
})
test("keeps a valid active id", () => {
expect(
migrateTerminalState({
active: "two",
all: [
{ id: "one", title: "Terminal 1" },
{ id: "two", title: "shell", titleNumber: 7 },
],
}),
).toEqual({
active: "two",
all: [
{ id: "one", title: "Terminal 1", titleNumber: 1 },
{ id: "two", title: "shell", titleNumber: 7 },
],
})
})
})

View File

@@ -4,7 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { defaultTitle } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
export type LocalPTY = {
@@ -33,64 +33,28 @@ function num(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
function numberFromTitle(title: string) {
return titleNumber(title, MAX_TERMINAL_SESSIONS)
}
function pty(value: unknown): LocalPTY | undefined {
if (!record(value)) return
function pty(value: unknown): value is LocalPTY {
if (!record(value)) return false
const id = text(value.id)
if (!id) return
if (!id) return false
const title = text(value.title) ?? ""
const title = text(value.title)
const number = num(value.titleNumber)
const rows = num(value.rows)
const cols = num(value.cols)
const buffer = text(value.buffer)
const scrollY = num(value.scrollY)
const cursor = num(value.cursor)
return {
id,
title,
titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
...(rows !== undefined ? { rows } : {}),
...(cols !== undefined ? { cols } : {}),
...(buffer !== undefined ? { buffer } : {}),
...(scrollY !== undefined ? { scrollY } : {}),
...(cursor !== undefined ? { cursor } : {}),
}
}
export function migrateTerminalState(value: unknown) {
if (!record(value)) return value
const seen = new Set<string>()
const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
const next = pty(item)
if (!next || seen.has(next.id)) return []
seen.add(next.id)
return [next]
})
const active = text(value.active)
return {
active: active && seen.has(active) ? active : all[0]?.id,
all,
}
if (!title) return false
if (!number || number <= 0) return false
if (value.rows !== undefined && num(value.rows) === undefined) return false
if (value.cols !== undefined && num(value.cols) === undefined) return false
if (value.buffer !== undefined && text(value.buffer) === undefined) return false
if (value.scrollY !== undefined && num(value.scrollY) === undefined) return false
if (value.cursor !== undefined && num(value.cursor) === undefined) return false
return true
}
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 = {
@@ -110,7 +74,7 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
export function clearWorkspaceTerminals(dir: string, platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
const entry = cache.get(key)
@@ -118,26 +82,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
removePersisted(Persist.workspace(dir, "terminal"), platform)
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
legacy.add(key)
}
}
for (const key of legacy) {
removePersisted({ key }, platform)
}
}
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string) {
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
Persist.workspace(dir, "terminal"),
createStore<{
active?: string
all: LocalPTY[]
@@ -146,16 +95,20 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}),
)
createEffect(() => {
if (!ready()) return
const all = store.all.filter(pty)
const active =
typeof store.active === "string" && all.some((item) => item.id === store.active) ? store.active : all[0]?.id
if (all.length === store.all.length && active === store.active) return
batch(() => {
setStore("all", all)
if (active !== store.active) setStore("active", active)
})
})
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return [direct]
const parsed = numberFromTitle(pty.title)
if (parsed === undefined) return []
return [parsed]
}),
)
const existingTitleNumbers = new Set(store.all.flatMap((pty) => (pty.titleNumber > 0 ? [pty.titleNumber] : [])))
return (
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
@@ -382,7 +335,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID?: string) => {
const loadWorkspace = (dir: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
@@ -393,7 +346,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
value: createWorkspaceTerminalSession(sdk, dir),
dispose,
}))
@@ -402,7 +355,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
const workspace = createMemo(() => loadWorkspace(params.dir!))
createEffect(
on(
@@ -411,7 +364,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
loadWorkspace(prev.dir).trimAll()
},
{ defer: true },
),

View File

@@ -89,7 +89,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
Persist.global("layout.page"),
createStore({
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
activeProject: undefined as string | undefined,
@@ -1567,11 +1567,7 @@ export default function Layout(props: ParentProps) {
.then((x) => x.data ?? [])
.catch(() => [])
clearWorkspaceTerminals(
directory,
sessions.map((s) => s.id),
platform,
)
clearWorkspaceTerminals(directory, platform)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
const result = await globalSDK.client.worktree

View File

@@ -516,7 +516,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
Persist.workspace(sdk.directory, "followup"),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>

View File

@@ -19,7 +19,7 @@ beforeEach(() => {
})
describe("theme preload", () => {
test("migrates legacy oc-1 to oc-2 before mount", () => {
test("resets unsupported built-in theme ids before mount", () => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
@@ -28,10 +28,9 @@ describe("theme preload", () => {
expect(document.documentElement.dataset.theme).toBe("oc-2")
expect(document.documentElement.dataset.colorScheme).toBe("light")
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
expect(document.getElementById("oc-theme-preload")).toBeNull()
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
expect(document.getElementById("oc-theme-preload")).toBeNull()
})
test("keeps cached css for non-default themes", () => {

View File

@@ -15,11 +15,8 @@ type PersistedWithReady<T> = [
type PersistTarget = {
storage?: string
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
}
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const LOCAL_PREFIX = "opencode."
const fallback = new Map<string, boolean>()
@@ -200,11 +197,10 @@ function parse(value: string) {
}
}
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
function normalize(defaults: unknown, raw: string) {
const parsed = parse(raw)
if (parsed === undefined) return
const migrated = migrate ? migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const merged = merge(defaults, parsed)
return JSON.stringify(merged)
}
@@ -309,18 +305,18 @@ export const PersistTesting = {
}
export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
global(key: string): PersistTarget {
return { storage: GLOBAL_STORAGE, key }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
workspace(dir: string, key: string): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}` }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
session(dir: string, session: string, key: string): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}` }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
scoped(dir: string, session: string | undefined, key: string): PersistTarget {
if (session) return Persist.session(dir, session, key)
return Persist.workspace(dir, key)
},
}
@@ -347,7 +343,6 @@ export function persisted<T>(
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
@@ -357,22 +352,15 @@ export function persisted<T>(
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorageDirect()
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
const next = normalize(defaults, raw)
if (next === undefined) {
current.removeItem(key)
return null
@@ -381,20 +369,6 @@ export function persisted<T>(
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
legacyStore.removeItem(legacyKey)
continue
}
current.setItem(key, next)
legacyStore.removeItem(legacyKey)
return next
}
return null
},
setItem: (key, value) => {
@@ -409,13 +383,12 @@ export function persisted<T>(
}
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
const next = normalize(defaults, raw)
if (next === undefined) {
await current.removeItem(key).catch(() => undefined)
return null
@@ -424,22 +397,6 @@ export function persisted<T>(
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
await legacyStore.removeItem(legacyKey).catch(() => undefined)
continue
}
await current.setItem(key, next)
await legacyStore.removeItem(legacyKey)
return next
}
return null
},
setItem: async (key, value) => {