chore(app): cleanup legacy storage stuff
parent
c2f78224ae
commit
8257a20aa5
|
|
@ -2,7 +2,7 @@
|
||||||
var key = "opencode-theme-id"
|
var key = "opencode-theme-id"
|
||||||
var themeId = localStorage.getItem(key) || "oc-2"
|
var themeId = localStorage.getItem(key) || "oc-2"
|
||||||
|
|
||||||
if (themeId === "oc-1") {
|
if (themeId.slice(0, 3) === "oc-" && themeId !== "oc-2") {
|
||||||
themeId = "oc-2"
|
themeId = "oc-2"
|
||||||
localStorage.setItem(key, themeId)
|
localStorage.setItem(key, themeId)
|
||||||
localStorage.removeItem("opencode-theme-css-light")
|
localStorage.removeItem("opencode-theme-css-light")
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ import {
|
||||||
prependHistoryEntry,
|
prependHistoryEntry,
|
||||||
type PromptHistoryComment,
|
type PromptHistoryComment,
|
||||||
type PromptHistoryEntry,
|
type PromptHistoryEntry,
|
||||||
type PromptHistoryStoredEntry,
|
|
||||||
promptLength,
|
promptLength,
|
||||||
} from "./prompt-input/history"
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||||
|
|
@ -313,17 +312,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const [history, setHistory] = persisted(
|
const [history, setHistory] = persisted(
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
Persist.global("prompt-history.v2"),
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: PromptHistoryStoredEntry[]
|
entries: PromptHistoryEntry[]
|
||||||
}>({
|
}>({
|
||||||
entries: [],
|
entries: [],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const [shellHistory, setShellHistory] = persisted(
|
const [shellHistory, setShellHistory] = persisted(
|
||||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
Persist.global("prompt-history-shell.v2"),
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: PromptHistoryStoredEntry[]
|
entries: PromptHistoryEntry[]
|
||||||
}>({
|
}>({
|
||||||
entries: [],
|
entries: [],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { Prompt } from "@/context/prompt"
|
||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
clonePromptParts,
|
clonePromptParts,
|
||||||
normalizePromptHistoryEntry,
|
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
prependHistoryEntry,
|
prependHistoryEntry,
|
||||||
promptLength,
|
promptLength,
|
||||||
|
|
@ -13,6 +12,7 @@ import {
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
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 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 => ({
|
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||||
id,
|
id,
|
||||||
path: "src/a.ts",
|
path: "src/a.ts",
|
||||||
|
|
@ -42,7 +42,7 @@ describe("prompt-input history", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
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({
|
const up = navigatePromptHistory({
|
||||||
direction: "up",
|
direction: "up",
|
||||||
entries,
|
entries,
|
||||||
|
|
@ -73,12 +73,7 @@ describe("prompt-input history", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("navigatePromptHistory keeps entry comments when moving through history", () => {
|
test("navigatePromptHistory keeps entry comments when moving through history", () => {
|
||||||
const entries = [
|
const entries = [entry("with comment", [comment("c1")])]
|
||||||
{
|
|
||||||
prompt: text("with comment"),
|
|
||||||
comments: [comment("c1")],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const up = navigatePromptHistory({
|
const up = navigatePromptHistory({
|
||||||
direction: "up",
|
direction: "up",
|
||||||
|
|
@ -95,12 +90,6 @@ describe("prompt-input history", () => {
|
||||||
expect(up.entry.comments).toEqual([comment("c1")])
|
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", () => {
|
test("helpers clone prompt and count text content length", () => {
|
||||||
const original: Prompt = [
|
const original: Prompt = [
|
||||||
{ type: "text", content: "one", start: 0, end: 3 },
|
{ type: "text", content: "one", start: 0, end: 3 },
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ export type PromptHistoryEntry = {
|
||||||
comments: PromptHistoryComment[]
|
comments: PromptHistoryComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
|
||||||
|
|
||||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||||
const position = Math.max(0, Math.min(cursor, text.length))
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
const atStart = position === 0
|
const atStart = position === 0
|
||||||
|
|
@ -59,13 +57,7 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
function clonePromptHistoryEntry(entry: PromptHistoryEntry): PromptHistoryEntry {
|
||||||
if (Array.isArray(entry)) {
|
|
||||||
return {
|
|
||||||
prompt: clonePromptParts(entry),
|
|
||||||
comments: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
prompt: clonePromptParts(entry.prompt),
|
prompt: clonePromptParts(entry.prompt),
|
||||||
comments: clonePromptHistoryComments(entry.comments),
|
comments: clonePromptHistoryComments(entry.comments),
|
||||||
|
|
@ -77,7 +69,7 @@ export function promptLength(prompt: Prompt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependHistoryEntry(
|
export function prependHistoryEntry(
|
||||||
entries: PromptHistoryStoredEntry[],
|
entries: PromptHistoryEntry[],
|
||||||
prompt: Prompt,
|
prompt: Prompt,
|
||||||
comments: PromptHistoryComment[] = [],
|
comments: PromptHistoryComment[] = [],
|
||||||
max = MAX_HISTORY,
|
max = MAX_HISTORY,
|
||||||
|
|
@ -112,9 +104,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
function isPromptEqual(entryA: PromptHistoryEntry, entryB: PromptHistoryEntry) {
|
||||||
const entryA = normalizePromptHistoryEntry(promptA)
|
|
||||||
const entryB = normalizePromptHistoryEntry(promptB)
|
|
||||||
if (entryA.prompt.length !== entryB.prompt.length) return false
|
if (entryA.prompt.length !== entryB.prompt.length) return false
|
||||||
for (let i = 0; i < entryA.prompt.length; i++) {
|
for (let i = 0; i < entryA.prompt.length; i++) {
|
||||||
const partA = entryA.prompt[i]
|
const partA = entryA.prompt[i]
|
||||||
|
|
@ -149,7 +139,7 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
|
||||||
|
|
||||||
type HistoryNavInput = {
|
type HistoryNavInput = {
|
||||||
direction: "up" | "down"
|
direction: "up" | "down"
|
||||||
entries: PromptHistoryStoredEntry[]
|
entries: PromptHistoryEntry[]
|
||||||
historyIndex: number
|
historyIndex: number
|
||||||
currentPrompt: Prompt
|
currentPrompt: Prompt
|
||||||
currentComments: PromptHistoryComment[]
|
currentComments: PromptHistoryComment[]
|
||||||
|
|
@ -181,7 +171,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.historyIndex === -1) {
|
if (input.historyIndex === -1) {
|
||||||
const entry = normalizePromptHistoryEntry(input.entries[0])
|
const entry = clonePromptHistoryEntry(input.entries[0])
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: 0,
|
historyIndex: 0,
|
||||||
|
|
@ -196,7 +186,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||||
|
|
||||||
if (input.historyIndex < input.entries.length - 1) {
|
if (input.historyIndex < input.entries.length - 1) {
|
||||||
const next = input.historyIndex + 1
|
const next = input.historyIndex + 1
|
||||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
const entry = clonePromptHistoryEntry(input.entries[next])
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: next,
|
historyIndex: next,
|
||||||
|
|
@ -215,7 +205,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||||
|
|
||||||
if (input.historyIndex > 0) {
|
if (input.historyIndex > 0) {
|
||||||
const next = input.historyIndex - 1
|
const next = input.historyIndex - 1
|
||||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
const entry = clonePromptHistoryEntry(input.entries[next])
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: next,
|
historyIndex: next,
|
||||||
|
|
|
||||||
|
|
@ -167,10 +167,8 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommentSession(dir: string, id: string | undefined) {
|
function createCommentSession(dir: string, id: string | undefined) {
|
||||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.scoped(dir, id, "comments", [legacy]),
|
Persist.scoped(dir, id, "comments"),
|
||||||
createStore<CommentStore>({
|
createStore<CommentStore>({
|
||||||
comments: {},
|
comments: {},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,8 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
|
||||||
}
|
}
|
||||||
|
|
||||||
function createViewSession(dir: string, id: string | undefined) {
|
function createViewSession(dir: string, id: string | undefined) {
|
||||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
|
||||||
|
|
||||||
const [view, setView, _, ready] = persisted(
|
const [view, setView, _, ready] = persisted(
|
||||||
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
|
Persist.scoped(dir, id, "file-view"),
|
||||||
createStore<{
|
createStore<{
|
||||||
file: Record<string, FileViewState>
|
file: Record<string, FileViewState>
|
||||||
}>({
|
}>({
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ function createGlobalSync() {
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const [projectCache, setProjectCache, projectInit] = persisted(
|
const [projectCache, setProjectCache, projectInit] = persisted(
|
||||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
Persist.global("globalSync.project"),
|
||||||
createStore({ value: [] as Project[] }),
|
createStore({ value: [] as Project[] }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,10 +125,7 @@ export function createChildStoreManager(input: {
|
||||||
if (!directory) console.error("No directory provided")
|
if (!directory) console.error("No directory provided")
|
||||||
if (!children[directory]) {
|
if (!children[directory]) {
|
||||||
const vcs = runWithOwner(input.owner, () =>
|
const vcs = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
persisted(Persist.workspace(directory, "vcs"), createStore({ value: undefined as VcsInfo | undefined })),
|
||||||
Persist.workspace(directory, "vcs", ["vcs.v1"]),
|
|
||||||
createStore({ value: undefined as VcsInfo | undefined }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
|
||||||
const vcsStore = vcs[0]
|
const vcsStore = vcs[0]
|
||||||
|
|
@ -136,7 +133,7 @@ export function createChildStoreManager(input: {
|
||||||
|
|
||||||
const meta = runWithOwner(input.owner, () =>
|
const meta = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
persisted(
|
||||||
Persist.workspace(directory, "project", ["project.v1"]),
|
Persist.workspace(directory, "project"),
|
||||||
createStore({ value: undefined as ProjectMeta | undefined }),
|
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] })
|
metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
|
||||||
|
|
||||||
const icon = runWithOwner(input.owner, () =>
|
const icon = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
persisted(Persist.workspace(directory, "icon"), createStore({ value: undefined as string | undefined })),
|
||||||
Persist.workspace(directory, "icon", ["icon.v1"]),
|
|
||||||
createStore({ value: undefined as string | undefined }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
|
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
|
||||||
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||||
init: (props: { locale?: Locale }) => {
|
init: (props: { locale?: Locale }) => {
|
||||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("language", ["language.v1"]),
|
Persist.global("language"),
|
||||||
createStore({
|
createStore({
|
||||||
locale: initial,
|
locale: initial,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -138,96 +130,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
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(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{ ...target, migrate },
|
Persist.global("layout"),
|
||||||
createStore({
|
createStore({
|
||||||
sidebar: {
|
sidebar: {
|
||||||
opened: false,
|
opened: false,
|
||||||
|
|
@ -270,11 +174,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
used: new Map<string, number>(),
|
used: new Map<string, number>(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_STATE_KEYS = [
|
const SESSION_STATE_KEYS = ["comments", "file-view", "prompt", "terminal"] as const
|
||||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
|
||||||
{ key: "terminal", legacy: "terminal", version: "v1" },
|
|
||||||
{ key: "file-view", legacy: "file", version: "v1" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const dropSessionState = (keys: string[]) => {
|
const dropSessionState = (keys: string[]) => {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
|
@ -283,12 +183,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
const session = parts[1]
|
const session = parts[1]
|
||||||
if (!dir) continue
|
if (!dir) continue
|
||||||
|
|
||||||
for (const entry of SESSION_STATE_KEYS) {
|
for (const item of SESSION_STATE_KEYS) {
|
||||||
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
|
void removePersisted(session ? Persist.session(dir, session, item) : Persist.workspace(dir, item), platform)
|
||||||
void removePersisted(target, platform)
|
|
||||||
|
|
||||||
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
|
|
||||||
void removePersisted({ key: legacyKey }, platform)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -876,7 +772,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
tabs(sessionKey: string | Accessor<string>) {
|
tabs(sessionKey: string | Accessor<string>) {
|
||||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||||
const path = createMemo(() => sessionPath(key()))
|
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 normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||||
return {
|
return {
|
||||||
|
|
@ -903,13 +809,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const session = key()
|
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)
|
setStore("sessionTabs", session, next)
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const current = store.sessionTabs[session]
|
if (!store.sessionTabs[session]) return
|
||||||
if (!current) return
|
const current = tabs()
|
||||||
|
|
||||||
if (tab === "review") {
|
if (tab === "review") {
|
||||||
if (current.active !== tab) return
|
if (current.active !== tab) return
|
||||||
|
|
@ -932,8 +838,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
move(tab: string, to: number) {
|
move(tab: string, to: number) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const current = store.sessionTabs[session]
|
if (!store.sessionTabs[session]) return
|
||||||
if (!current) return
|
const current = tabs()
|
||||||
const index = current.all.findIndex((f) => f === tab)
|
const index = current.all.findIndex((f) => f === tab)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
setStore(
|
setStore(
|
||||||
|
|
|
||||||
|
|
@ -23,27 +23,10 @@ type Saved = {
|
||||||
session: Record<string, State | undefined>
|
session: Record<string, State | undefined>
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
|
||||||
const handoff = new Map<string, State>()
|
const handoff = new Map<string, State>()
|
||||||
|
|
||||||
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
|
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) => {
|
const clone = (value: State | undefined) => {
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
return {
|
return {
|
||||||
|
|
@ -66,10 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved] = persisted(
|
const [saved, setSaved] = persisted(
|
||||||
{
|
Persist.workspace(sdk.directory, "model-selection"),
|
||||||
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
|
|
||||||
migrate,
|
|
||||||
},
|
|
||||||
createStore<Saved>({
|
createStore<Saved>({
|
||||||
session: {},
|
session: {},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("model", ["model.v1"]),
|
Persist.global("model"),
|
||||||
createStore<Store>({
|
createStore<Store>({
|
||||||
user: [],
|
user: [],
|
||||||
recent: [],
|
recent: [],
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
const currentSession = createMemo(() => params.id)
|
const currentSession = createMemo(() => params.id)
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("notification", ["notification.v1"]),
|
Persist.global("notification"),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as Notification[],
|
list: [] as Notification[],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,6 @@ describe("autoRespondsPermission", () => {
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
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", () => {
|
test("defaults to requiring approval when no lineage override exists", () => {
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||||
const autoAccept = {
|
const autoAccept = {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export function directoryAcceptKey(directory: string) {
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
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) {
|
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||||
|
|
|
||||||
|
|
@ -59,23 +59,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
Persist.global("permission"),
|
||||||
...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
|
|
||||||
: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createStore({
|
createStore({
|
||||||
autoAccept: {} as Record<string, boolean>,
|
autoAccept: {} as Record<string, boolean>,
|
||||||
}),
|
}),
|
||||||
|
|
@ -206,7 +190,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.autoAccept[key] = true
|
draft.autoAccept[key] = true
|
||||||
delete draft.autoAccept[sessionID]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -230,8 +213,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.autoAccept[key] = false
|
draft.autoAccept[key] = false
|
||||||
if (!directory) return
|
|
||||||
delete draft.autoAccept[sessionID]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,10 +162,8 @@ type PromptCacheEntry = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPromptSession(dir: string, id: string | undefined) {
|
function createPromptSession(dir: string, id: string | undefined) {
|
||||||
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.scoped(dir, id, "prompt", [legacy]),
|
Persist.scoped(dir, id, "prompt"),
|
||||||
createStore<{
|
createStore<{
|
||||||
prompt: Prompt
|
prompt: Prompt
|
||||||
cursor?: number
|
cursor?: number
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useCheckServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
type StoredProject = { worktree: string; expanded: boolean }
|
type StoredProject = { worktree: string; expanded: boolean }
|
||||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
|
||||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||||
|
|
||||||
export function normalizeServerUrl(input: string) {
|
export function normalizeServerUrl(input: string) {
|
||||||
|
|
@ -102,35 +101,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.global("server", ["server.v3"]),
|
Persist.global("server"),
|
||||||
createStore({
|
createStore({
|
||||||
list: [] as StoredServer[],
|
list: [] as ServerConnection.Http[],
|
||||||
projects: {} as Record<string, StoredProject[]>,
|
projects: {} as Record<string, StoredProject[]>,
|
||||||
lastProject: {} as Record<string, string>,
|
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 allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||||
const servers = [
|
const servers = [
|
||||||
...(props.servers ?? []),
|
...(props.servers ?? []),
|
||||||
...store.list.map((value) =>
|
...store.list.filter(
|
||||||
typeof value === "string"
|
(value): value is ServerConnection.Http => !!value && typeof value === "object" && value.type === "http",
|
||||||
? {
|
|
||||||
type: "http" as const,
|
|
||||||
http: { url: value },
|
|
||||||
}
|
|
||||||
: value,
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
const deduped = new Map(
|
const deduped = new Map(servers.map((value) => [ServerConnection.key(value), value] as const))
|
||||||
servers.map((value) => {
|
|
||||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
|
||||||
return [ServerConnection.key(conn), conn]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return [...deduped.values()]
|
return [...deduped.values()]
|
||||||
})
|
})
|
||||||
|
|
@ -176,7 +163,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||||
if (!url_) return
|
if (!url_) return
|
||||||
const conn = { ...input, http: { ...input.http, url: url_ } }
|
const conn = { ...input, http: { ...input.http, url: url_ } }
|
||||||
return batch(() => {
|
return batch(() => {
|
||||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
const existing = store.list.findIndex((x) => x.http.url === url_)
|
||||||
if (existing !== -1) {
|
if (existing !== -1) {
|
||||||
setStore("list", existing, conn)
|
setStore("list", existing, conn)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -188,12 +175,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(key: ServerConnection.Key) {
|
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(() => {
|
batch(() => {
|
||||||
setStore("list", list)
|
setStore("list", list)
|
||||||
if (state.active === key) {
|
if (state.active === key) {
|
||||||
const next = list[0]
|
const next = list[0]
|
||||||
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
|
setState("active", next ? ServerConnection.key(next) : props.defaultServer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
import type { Platform } from "./platform"
|
||||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
import { defaultTitle } from "./terminal-title"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
|
|
||||||
export type LocalPTY = {
|
export type LocalPTY = {
|
||||||
|
|
@ -33,64 +33,28 @@ function num(value: unknown) {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberFromTitle(title: string) {
|
function pty(value: unknown): value is LocalPTY {
|
||||||
return titleNumber(title, MAX_TERMINAL_SESSIONS)
|
if (!record(value)) return false
|
||||||
}
|
|
||||||
|
|
||||||
function pty(value: unknown): LocalPTY | undefined {
|
|
||||||
if (!record(value)) return
|
|
||||||
|
|
||||||
const id = text(value.id)
|
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 number = num(value.titleNumber)
|
||||||
const rows = num(value.rows)
|
if (!title) return false
|
||||||
const cols = num(value.cols)
|
if (!number || number <= 0) return false
|
||||||
const buffer = text(value.buffer)
|
if (value.rows !== undefined && num(value.rows) === undefined) return false
|
||||||
const scrollY = num(value.scrollY)
|
if (value.cols !== undefined && num(value.cols) === undefined) return false
|
||||||
const cursor = num(value.cursor)
|
if (value.buffer !== undefined && text(value.buffer) === undefined) return false
|
||||||
|
if (value.scrollY !== undefined && num(value.scrollY) === undefined) return false
|
||||||
return {
|
if (value.cursor !== undefined && num(value.cursor) === undefined) return false
|
||||||
id,
|
return true
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||||
return `${dir}:${WORKSPACE_KEY}`
|
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 TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
|
||||||
|
|
||||||
type TerminalCacheEntry = {
|
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)
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
for (const cache of caches) {
|
for (const cache of caches) {
|
||||||
const entry = cache.get(key)
|
const entry = cache.get(key)
|
||||||
|
|
@ -118,26 +82,11 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||||
}
|
}
|
||||||
|
|
||||||
removePersisted(Persist.workspace(dir, "terminal"), platform)
|
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) {
|
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string) {
|
||||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
Persist.workspace(dir, "terminal"),
|
||||||
...Persist.workspace(dir, "terminal", legacy),
|
|
||||||
migrate: migrateTerminalState,
|
|
||||||
},
|
|
||||||
createStore<{
|
createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
all: LocalPTY[]
|
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 pickNextTerminalNumber = () => {
|
||||||
const existingTitleNumbers = new Set(
|
const existingTitleNumbers = new Set(store.all.flatMap((pty) => (pty.titleNumber > 0 ? [pty.titleNumber] : [])))
|
||||||
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]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
|
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.
|
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||||
const key = getWorkspaceTerminalCacheKey(dir)
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
|
|
@ -393,7 +346,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = createRoot((dispose) => ({
|
const entry = createRoot((dispose) => ({
|
||||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
value: createWorkspaceTerminalSession(sdk, dir),
|
||||||
dispose,
|
dispose,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -402,7 +355,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
return entry.value
|
return entry.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
const workspace = createMemo(() => loadWorkspace(params.dir!))
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
|
|
@ -411,7 +364,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
if (!prev?.dir) return
|
if (!prev?.dir) return
|
||||||
if (next.dir === prev.dir && next.id === prev.id) return
|
if (next.dir === prev.dir && next.id === prev.id) return
|
||||||
if (next.dir === prev.dir && next.id) return
|
if (next.dir === prev.dir && next.id) return
|
||||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
loadWorkspace(prev.dir).trimAll()
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const [store, setStore, , ready] = persisted(
|
const [store, setStore, , ready] = persisted(
|
||||||
Persist.global("layout.page", ["layout.page.v1"]),
|
Persist.global("layout.page"),
|
||||||
createStore({
|
createStore({
|
||||||
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
|
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
|
||||||
activeProject: undefined as string | undefined,
|
activeProject: undefined as string | undefined,
|
||||||
|
|
@ -1567,11 +1567,7 @@ export default function Layout(props: ParentProps) {
|
||||||
.then((x) => x.data ?? [])
|
.then((x) => x.data ?? [])
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
|
|
||||||
clearWorkspaceTerminals(
|
clearWorkspaceTerminals(directory, platform)
|
||||||
directory,
|
|
||||||
sessions.map((s) => s.id),
|
|
||||||
platform,
|
|
||||||
)
|
|
||||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||||
|
|
||||||
const result = await globalSDK.client.worktree
|
const result = await globalSDK.client.worktree
|
||||||
|
|
|
||||||
|
|
@ -516,7 +516,7 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const [followup, setFollowup] = persisted(
|
const [followup, setFollowup] = persisted(
|
||||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
Persist.workspace(sdk.directory, "followup"),
|
||||||
createStore<{
|
createStore<{
|
||||||
items: Record<string, FollowupItem[] | undefined>
|
items: Record<string, FollowupItem[] | undefined>
|
||||||
failed: Record<string, string | undefined>
|
failed: Record<string, string | undefined>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ beforeEach(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("theme preload", () => {
|
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-id", "oc-1")
|
||||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
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.theme).toBe("oc-2")
|
||||||
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
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-light")).toBeNull()
|
||||||
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
|
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
|
||||||
expect(document.getElementById("oc-theme-preload")).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps cached css for non-default themes", () => {
|
test("keeps cached css for non-default themes", () => {
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,8 @@ type PersistedWithReady<T> = [
|
||||||
type PersistTarget = {
|
type PersistTarget = {
|
||||||
storage?: string
|
storage?: string
|
||||||
key: string
|
key: string
|
||||||
legacy?: string[]
|
|
||||||
migrate?: (value: unknown) => unknown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_STORAGE = "default.dat"
|
|
||||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||||
const LOCAL_PREFIX = "opencode."
|
const LOCAL_PREFIX = "opencode."
|
||||||
const fallback = new Map<string, boolean>()
|
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)
|
const parsed = parse(raw)
|
||||||
if (parsed === undefined) return
|
if (parsed === undefined) return
|
||||||
const migrated = migrate ? migrate(parsed) : parsed
|
const merged = merge(defaults, parsed)
|
||||||
const merged = merge(defaults, migrated)
|
|
||||||
return JSON.stringify(merged)
|
return JSON.stringify(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,18 +305,18 @@ export const PersistTesting = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Persist = {
|
export const Persist = {
|
||||||
global(key: string, legacy?: string[]): PersistTarget {
|
global(key: string): PersistTarget {
|
||||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
return { storage: GLOBAL_STORAGE, key }
|
||||||
},
|
},
|
||||||
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
|
workspace(dir: string, key: string): PersistTarget {
|
||||||
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
|
return { storage: workspaceStorage(dir), key: `workspace:${key}` }
|
||||||
},
|
},
|
||||||
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
session(dir: string, session: string, key: string): PersistTarget {
|
||||||
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
|
return { storage: workspaceStorage(dir), key: `session:${session}:${key}` }
|
||||||
},
|
},
|
||||||
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
|
scoped(dir: string, session: string | undefined, key: string): PersistTarget {
|
||||||
if (session) return Persist.session(dir, session, key, legacy)
|
if (session) return Persist.session(dir, session, key)
|
||||||
return Persist.workspace(dir, key, legacy)
|
return Persist.workspace(dir, key)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,7 +343,6 @@ export function persisted<T>(
|
||||||
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
const config: PersistTarget = typeof target === "string" ? { key: target } : target
|
||||||
|
|
||||||
const defaults = snapshot(store[0])
|
const defaults = snapshot(store[0])
|
||||||
const legacy = config.legacy ?? []
|
|
||||||
|
|
||||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||||
|
|
||||||
|
|
@ -357,22 +352,15 @@ export function persisted<T>(
|
||||||
return localStorageWithPrefix(config.storage)
|
return localStorageWithPrefix(config.storage)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const legacyStorage = (() => {
|
|
||||||
if (!isDesktop) return localStorageDirect()
|
|
||||||
if (!config.storage) return platform.storage?.()
|
|
||||||
return platform.storage?.(LEGACY_STORAGE)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const storage = (() => {
|
const storage = (() => {
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
const current = currentStorage as SyncStorage
|
const current = currentStorage as SyncStorage
|
||||||
const legacyStore = legacyStorage as SyncStorage
|
|
||||||
|
|
||||||
const api: SyncStorage = {
|
const api: SyncStorage = {
|
||||||
getItem: (key) => {
|
getItem: (key) => {
|
||||||
const raw = current.getItem(key)
|
const raw = current.getItem(key)
|
||||||
if (raw !== null) {
|
if (raw !== null) {
|
||||||
const next = normalize(defaults, raw, config.migrate)
|
const next = normalize(defaults, raw)
|
||||||
if (next === undefined) {
|
if (next === undefined) {
|
||||||
current.removeItem(key)
|
current.removeItem(key)
|
||||||
return null
|
return null
|
||||||
|
|
@ -381,20 +369,6 @@ export function persisted<T>(
|
||||||
return next
|
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
|
return null
|
||||||
},
|
},
|
||||||
setItem: (key, value) => {
|
setItem: (key, value) => {
|
||||||
|
|
@ -409,13 +383,12 @@ export function persisted<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = currentStorage as AsyncStorage
|
const current = currentStorage as AsyncStorage
|
||||||
const legacyStore = legacyStorage as AsyncStorage | undefined
|
|
||||||
|
|
||||||
const api: AsyncStorage = {
|
const api: AsyncStorage = {
|
||||||
getItem: async (key) => {
|
getItem: async (key) => {
|
||||||
const raw = await current.getItem(key)
|
const raw = await current.getItem(key)
|
||||||
if (raw !== null) {
|
if (raw !== null) {
|
||||||
const next = normalize(defaults, raw, config.migrate)
|
const next = normalize(defaults, raw)
|
||||||
if (next === undefined) {
|
if (next === undefined) {
|
||||||
await current.removeItem(key).catch(() => undefined)
|
await current.removeItem(key).catch(() => undefined)
|
||||||
return null
|
return null
|
||||||
|
|
@ -424,22 +397,6 @@ export function persisted<T>(
|
||||||
return next
|
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
|
return null
|
||||||
},
|
},
|
||||||
setItem: async (key, value) => {
|
setItem: async (key, value) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue