Просмотр исходного кода

feat(app): add draft tab support to tabs store (#31343)

Brendan Allan 2 месяцев назад
Родитель
Сommit
f565ff3c09

+ 2 - 0
packages/app/src/components/titlebar.tsx

@@ -447,6 +447,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                           refreshTabsAreOverflowing()
                         })
 
+                        if (tab.type !== "session") return null
+
                         return (
                           <>
                             {i() !== 0 && (

+ 65 - 5
packages/app/src/context/tabs.tsx

@@ -2,10 +2,12 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 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 { createEffect, startTransition } from "solid-js"
 import { useNavigate, useParams } from "@solidjs/router"
+import { usePlatform } from "./platform"
+import { uuid } from "@/utils/uuid"
 import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
 
 export type SessionTab = {
@@ -15,10 +17,23 @@ export type SessionTab = {
   sessionId: string
 }
 
-export type Tab = SessionTab
+export type DraftTab = {
+  type: "draft"
+  draftID: string
+  server: ServerConnection.Key
+  directory: string
+  worktree?: string
+}
+
+export type Tab = SessionTab | DraftTab
 
-export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
-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) {
   const dirBase64 = base64Encode(session.directory)
@@ -33,6 +48,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
   gate: false,
   init: () => {
     const server = useServer()
+    const platform = usePlatform()
     const fallback = server.key
     const [store, setStore, _, ready] = persisted(
       {
@@ -53,6 +69,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
 
     const closing = new Set<string>()
 
+    const removeDraftPersisted = (draftID: string) => {
+      for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
+    }
+
     createEffect(() => {
       if (!ready()) return
       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) => {
         const tab = store[index]
         if (!tab) return
         const key = tabKey(tab)
+        const draftID = tab.type === "draft" ? tab.draftID : undefined
         const nextTab = store[index + 1] ?? store[index - 1]
         closing.add(key)
         void startTransition(() => {
@@ -98,9 +150,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
           if (nextTab) navigateTab(nextTab)
           else navigate("/")
         }).finally(() => closing.delete(key))
+        if (draftID) removeDraftPersisted(draftID)
       },
       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))
+        for (const draftID of drafts) removeDraftPersisted(draftID)
         if (server.key === key) navigate("/")
       },
       removeSessions: (input: SessionTabsRemovedDetail) => {
@@ -110,7 +165,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
               const sessionIDs = new Set(input.sessionIDs)
               const currentHref =
                 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
               const currentIndex = currentHref
                 ? tabs.findIndex(

+ 18 - 0
packages/app/src/utils/persist.test.ts

@@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => {
     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", () => {
     const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
     const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")

+ 15 - 0
packages/app/src/utils/persist.ts

@@ -341,6 +341,12 @@ function workspaceStorage(dir: string) {
   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) {
   const storage = workspaceStorage(pathKey(dir))
   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 = {
   localStorageDirect,
   localStorageWithPrefix,
@@ -462,6 +474,9 @@ export const Persist = {
   global(key: string, legacy?: string[]): PersistTarget {
     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 {
     if (scope === ServerScope.local) return Persist.global(key, legacy)
     return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }