feat(app): add draft tab support to tabs store (#31343)
This commit is contained in:
parent
161247c70d
commit
f565ff3c09
4 changed files with 100 additions and 5 deletions
|
|
@ -447,6 +447,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
refreshTabsAreOverflowing()
|
refreshTabsAreOverflowing()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (tab.type !== "session") return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{i() !== 0 && (
|
{i() !== 0 && (
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { createEffect, startTransition } from "solid-js"
|
import { createEffect, startTransition } from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
|
import { usePlatform } from "./platform"
|
||||||
|
import { uuid } from "@/utils/uuid"
|
||||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
|
|
@ -15,10 +17,23 @@ export type SessionTab = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tab = SessionTab
|
export type DraftTab = {
|
||||||
|
type: "draft"
|
||||||
|
draftID: string
|
||||||
|
server: ServerConnection.Key
|
||||||
|
directory: string
|
||||||
|
worktree?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
|
export type Tab = SessionTab | DraftTab
|
||||||
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
|
|
||||||
|
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||||
|
|
||||||
|
export const tabHref = (tab: Tab) =>
|
||||||
|
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
|
||||||
|
|
||||||
|
export const tabKey = (tab: Tab) =>
|
||||||
|
tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`
|
||||||
|
|
||||||
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
|
||||||
const dirBase64 = base64Encode(session.directory)
|
const dirBase64 = base64Encode(session.directory)
|
||||||
|
|
@ -33,6 +48,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const platform = usePlatform()
|
||||||
const fallback = server.key
|
const fallback = server.key
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
|
|
@ -53,6 +69,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
|
|
||||||
const closing = new Set<string>()
|
const closing = new Set<string>()
|
||||||
|
|
||||||
|
const removeDraftPersisted = (draftID: string) => {
|
||||||
|
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
const servers = new Set(server.list.map(ServerConnection.key))
|
const servers = new Set(server.list.map(ServerConnection.key))
|
||||||
|
|
@ -83,10 +103,42 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
draft(draftID: string) {
|
||||||
|
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
|
||||||
|
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||||
|
return tab
|
||||||
|
},
|
||||||
|
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||||
|
const draftID = uuid()
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
tabs.push({ type: "draft", draftID, ...draft })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||||
|
},
|
||||||
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
|
setStore(
|
||||||
|
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||||
|
produce((tab) => Object.assign(tab, draft)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||||
|
const active = `${location.pathname}${location.search}` === draftHref(draftID)
|
||||||
|
setStore(
|
||||||
|
produce((tabs) => {
|
||||||
|
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||||
|
if (index !== -1) tabs[index] = { type: "session", ...session }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (active) navigateTab({ type: "session", ...session })
|
||||||
|
removeDraftPersisted(draftID)
|
||||||
|
},
|
||||||
removeTab: (index: number) => {
|
removeTab: (index: number) => {
|
||||||
const tab = store[index]
|
const tab = store[index]
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
const key = tabKey(tab)
|
const key = tabKey(tab)
|
||||||
|
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||||
const nextTab = store[index + 1] ?? store[index - 1]
|
const nextTab = store[index + 1] ?? store[index - 1]
|
||||||
closing.add(key)
|
closing.add(key)
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
|
|
@ -98,9 +150,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
if (nextTab) navigateTab(nextTab)
|
if (nextTab) navigateTab(nextTab)
|
||||||
else navigate("/")
|
else navigate("/")
|
||||||
}).finally(() => closing.delete(key))
|
}).finally(() => closing.delete(key))
|
||||||
|
if (draftID) removeDraftPersisted(draftID)
|
||||||
},
|
},
|
||||||
removeServer(key: ServerConnection.Key) {
|
removeServer(key: ServerConnection.Key) {
|
||||||
|
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||||
|
for (const draftID of drafts) removeDraftPersisted(draftID)
|
||||||
if (server.key === key) navigate("/")
|
if (server.key === key) navigate("/")
|
||||||
},
|
},
|
||||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||||
|
|
@ -110,7 +165,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
const sessionIDs = new Set(input.sessionIDs)
|
const sessionIDs = new Set(input.sessionIDs)
|
||||||
const currentHref =
|
const currentHref =
|
||||||
params.dir && params.id
|
params.dir && params.id
|
||||||
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
|
? tabHref({
|
||||||
|
type: "session",
|
||||||
|
server: server.key,
|
||||||
|
dirBase64: params.dir,
|
||||||
|
sessionId: params.id,
|
||||||
|
})
|
||||||
: undefined
|
: undefined
|
||||||
const currentIndex = currentHref
|
const currentIndex = currentHref
|
||||||
? tabs.findIndex(
|
? tabs.findIndex(
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => {
|
||||||
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
|
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("draft target isolates storage per draft and namespaces keys", () => {
|
||||||
|
const a = Persist.draft("draft-a", "prompt")
|
||||||
|
const b = Persist.draft("draft-b", "prompt")
|
||||||
|
|
||||||
|
expect(a.key).toBe("draft:prompt")
|
||||||
|
expect(a.storage).not.toBe(b.storage)
|
||||||
|
expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes draft storage when removing persisted target", () => {
|
||||||
|
const target = Persist.draft("draft-a", "prompt")
|
||||||
|
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
|
||||||
|
|
||||||
|
removePersisted(target)
|
||||||
|
|
||||||
|
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test("server workspace target preserves local storage and isolates remote storage", () => {
|
test("server workspace target preserves local storage and isolates remote storage", () => {
|
||||||
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
|
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
|
||||||
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
|
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,12 @@ function workspaceStorage(dir: string) {
|
||||||
return `opencode.workspace.${head}.${sum}.dat`
|
return `opencode.workspace.${head}.${sum}.dat`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftStorage(draftID: string) {
|
||||||
|
const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
const sum = checksum(draftID) ?? "0"
|
||||||
|
return `opencode.draft.${head}.${sum}.dat`
|
||||||
|
}
|
||||||
|
|
||||||
function legacyWorkspaceStorage(dir: string) {
|
function legacyWorkspaceStorage(dir: string) {
|
||||||
const storage = workspaceStorage(pathKey(dir))
|
const storage = workspaceStorage(pathKey(dir))
|
||||||
const result = new Set<string>()
|
const result = new Set<string>()
|
||||||
|
|
@ -450,6 +456,12 @@ function localStorageDirect(): SyncStorage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
|
||||||
|
|
||||||
|
export function draftPersistedKeys() {
|
||||||
|
return DRAFT_PERSISTED_KEYS
|
||||||
|
}
|
||||||
|
|
||||||
export const PersistTesting = {
|
export const PersistTesting = {
|
||||||
localStorageDirect,
|
localStorageDirect,
|
||||||
localStorageWithPrefix,
|
localStorageWithPrefix,
|
||||||
|
|
@ -462,6 +474,9 @@ export const Persist = {
|
||||||
global(key: string, legacy?: string[]): PersistTarget {
|
global(key: string, legacy?: string[]): PersistTarget {
|
||||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||||
},
|
},
|
||||||
|
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
|
||||||
|
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
|
||||||
|
},
|
||||||
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
|
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
|
||||||
if (scope === ServerScope.local) return Persist.global(key, legacy)
|
if (scope === ServerScope.local) return Persist.global(key, legacy)
|
||||||
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
|
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue