diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 36fa5d726a..57f6e18079 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -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") diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c8f72b8d2f..2e6626042e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -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 = (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: [], }), diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 5e9c2c66ea..e7a88ec049 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -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 }, diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 79e8abc0d9..efc8cffe03 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -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, diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index a97010c0af..3e5344f7ac 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -167,10 +167,8 @@ export function createCommentSessionForTest(comments: Record({ comments: {}, }), diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 4c060174ab..353607bf33 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -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 }>({ diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8b..65d219b53b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -53,7 +53,7 @@ function createGlobalSync() { const sessionMeta = new Map() const [projectCache, setProjectCache, projectInit] = persisted( - Persist.global("globalSync.project", ["globalSync.project.v1"]), + Persist.global("globalSync.project"), createStore({ value: [] as Project[] }), ) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f89..0f74288ca6 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -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] }) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 51dc09cd7d..b8bda699bd 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -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, }), diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index aafa4fb66c..e9cb20bdd0 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -123,14 +123,6 @@ const normalizeSessionTabList = (path: ReturnType | 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 => - 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(), } - 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) { const key = createSessionKeyReader(sessionKey, ensureKey) const path = createMemo(() => sessionPath(key())) - const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) + const tabs = createMemo(() => { + 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( diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 84a613c0d2..b88dd4bc74 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -23,27 +23,10 @@ type Saved = { session: Record } -const WORKSPACE_KEY = "__workspace__" const handoff = new Map() 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 - pick?: Record - } - - 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({ session: {}, }), diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 12ec8371ad..c647dd9d86 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -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({ user: [], recent: [], diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 281a1ef33d..fb470a709c 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -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[], }), diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 7556113005..8ecb058062 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -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 = { diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index b206deedff..8c84f97253 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -12,7 +12,7 @@ export function directoryAcceptKey(directory: string) { function accepted(autoAccept: Record, 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, directory: string) { diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 672f84f82a..9c59548256 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -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 - 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, }), @@ -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] }), ) } diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 831fdbca83..c482bfb5ee 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -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 diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1204fba557..66bae52236 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -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, lastProject: {} as Record, }), ) - const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) - const allServers = createMemo((): Array => { 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) } }) } diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts deleted file mode 100644 index 6e07e03124..0000000000 --- a/packages/app/src/context/terminal.test.ts +++ /dev/null @@ -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 }, - ], - }) - }) -}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 17355aab9a..c424dc5992 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -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() - 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 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, dir: string, legacySessionID?: string) { - const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) - +function createWorkspaceTerminalSession(sdk: ReturnType, 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, 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 }, ), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b5a96110f6..15c181bb45 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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 diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 917de35b1f..a6f4663484 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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 failed: Record diff --git a/packages/app/src/theme-preload.test.ts b/packages/app/src/theme-preload.test.ts index 00d7da2394..da557ad813 100644 --- a/packages/app/src/theme-preload.test.ts +++ b/packages/app/src/theme-preload.test.ts @@ -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", () => { diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 3dcbeb7d36..f6d34488bf 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -15,11 +15,8 @@ type PersistedWithReady = [ 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() @@ -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( 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( 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( 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( } 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( 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) => {