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

fix(app): file tree not staying in sync across projects/sessions

Adam 2 недель назад
Родитель
Сommit
c8622df762

+ 46 - 0
packages/app/src/context/layout.tsx

@@ -33,6 +33,8 @@ type SessionTabs = {
 type SessionView = {
   scroll: Record<string, SessionScroll>
   reviewOpen?: string[]
+  pendingMessage?: string
+  pendingMessageAt?: number
 }
 
 type TabHandoff = {
@@ -128,6 +130,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     )
 
     const MAX_SESSION_KEYS = 50
+    const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
     const meta = { active: undefined as string | undefined, pruned: false }
     const used = new Map<string, number>()
 
@@ -555,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("mobileSidebar", "opened", (x) => !x)
         },
       },
+      pendingMessage: {
+        set(sessionKey: string, messageID: string) {
+          const at = Date.now()
+          touch(sessionKey)
+          const current = store.sessionView[sessionKey]
+          if (!current) {
+            setStore("sessionView", sessionKey, {
+              scroll: {},
+              pendingMessage: messageID,
+              pendingMessageAt: at,
+            })
+            prune(meta.active ?? sessionKey)
+            return
+          }
+
+          setStore(
+            "sessionView",
+            sessionKey,
+            produce((draft) => {
+              draft.pendingMessage = messageID
+              draft.pendingMessageAt = at
+            }),
+          )
+        },
+        consume(sessionKey: string) {
+          const current = store.sessionView[sessionKey]
+          const message = current?.pendingMessage
+          const at = current?.pendingMessageAt
+          if (!message || !at) return
+
+          setStore(
+            "sessionView",
+            sessionKey,
+            produce((draft) => {
+              delete draft.pendingMessage
+              delete draft.pendingMessageAt
+            }),
+          )
+
+          if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
+          return message
+        },
+      },
       view(sessionKey: string | Accessor<string>) {
         const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
 

+ 4 - 1
packages/app/src/pages/layout.tsx

@@ -1864,7 +1864,10 @@ export default function Layout(props: ParentProps) {
                   getLabel={messageLabel}
                   onMessageSelect={(message) => {
                     if (!isActive()) {
-                      sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
+                      layout.pendingMessage.set(
+                        `${base64Encode(props.session.directory)}/${props.session.id}`,
+                        message.id,
+                      )
                       navigate(`${props.slug}/session/${props.session.id}`)
                       return
                     }

+ 104 - 55
packages/app/src/pages/session.tsx

@@ -76,10 +76,31 @@ import { same } from "@/utils/same"
 
 type DiffStyle = "unified" | "split"
 
+type HandoffSession = {
+  prompt: string
+  files: Record<string, SelectedLineRange | null>
+}
+
+const HANDOFF_MAX = 40
+
 const handoff = {
-  prompt: "",
-  terminals: [] as string[],
-  files: {} as Record<string, SelectedLineRange | null>,
+  session: new Map<string, HandoffSession>(),
+  terminal: new Map<string, string[]>(),
+}
+
+const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
+  map.delete(key)
+  map.set(key, value)
+  while (map.size > HANDOFF_MAX) {
+    const first = map.keys().next().value
+    if (first === undefined) return
+    map.delete(first)
+  }
+}
+
+const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
+  const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
+  touch(handoff.session, key, { ...prev, ...patch })
 }
 
 interface SessionReviewTabProps {
@@ -793,8 +814,10 @@ export default function Page() {
   const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
 
   createEffect(() => {
-    if (!params.id) return
-    sync.session.sync(params.id)
+    sdk.directory
+    const id = params.id
+    if (!id) return
+    sync.session.sync(id)
   })
 
   createEffect(() => {
@@ -862,10 +885,22 @@ export default function Page() {
 
   createEffect(
     on(
-      () => params.id,
+      sessionKey,
       () => {
         setStore("messageId", undefined)
         setStore("expanded", {})
+        setUi("autoCreated", false)
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => params.dir,
+      (dir) => {
+        if (!dir) return
+        setStore("newSessionWorktree", "main")
       },
       { defer: true },
     ),
@@ -1373,12 +1408,15 @@ export default function Page() {
     activeDiff: undefined as string | undefined,
   })
 
-  const reviewScroll = () => tree.reviewScroll
-  const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
-  const pendingDiff = () => tree.pendingDiff
-  const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
-  const activeDiff = () => tree.activeDiff
-  const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
+  createEffect(
+    on(
+      sessionKey,
+      () => {
+        setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
+      },
+      { defer: true },
+    ),
+  )
 
   const showAllFiles = () => {
     if (fileTreeTab() !== "changes") return
@@ -1399,8 +1437,8 @@ export default function Page() {
                 view={view}
                 diffStyle={layout.review.diffStyle()}
                 onDiffStyleChange={layout.review.setDiffStyle}
-                onScrollRef={setReviewScroll}
-                focusedFile={activeDiff()}
+                onScrollRef={(el) => setTree("reviewScroll", el)}
+                focusedFile={tree.activeDiff}
                 onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
                 comments={comments.all()}
                 focusedComment={comments.focus()}
@@ -1450,7 +1488,7 @@ export default function Page() {
   }
 
   const reviewDiffTop = (path: string) => {
-    const root = reviewScroll()
+    const root = tree.reviewScroll
     if (!root) return
 
     const id = reviewDiffId(path)
@@ -1466,7 +1504,7 @@ export default function Page() {
   }
 
   const scrollToReviewDiff = (path: string) => {
-    const root = reviewScroll()
+    const root = tree.reviewScroll
     if (!root) return false
 
     const top = reviewDiffTop(path)
@@ -1480,24 +1518,23 @@ export default function Page() {
   const focusReviewDiff = (path: string) => {
     const current = view().review.open() ?? []
     if (!current.includes(path)) view().review.setOpen([...current, path])
-    setActiveDiff(path)
-    setPendingDiff(path)
+    setTree({ activeDiff: path, pendingDiff: path })
   }
 
   createEffect(() => {
-    const pending = pendingDiff()
+    const pending = tree.pendingDiff
     if (!pending) return
-    if (!reviewScroll()) return
+    if (!tree.reviewScroll) return
     if (!diffsReady()) return
 
     const attempt = (count: number) => {
-      if (pendingDiff() !== pending) return
+      if (tree.pendingDiff !== pending) return
       if (count > 60) {
-        setPendingDiff(undefined)
+        setTree("pendingDiff", undefined)
         return
       }
 
-      const root = reviewScroll()
+      const root = tree.reviewScroll
       if (!root) {
         requestAnimationFrame(() => attempt(count + 1))
         return
@@ -1515,7 +1552,7 @@ export default function Page() {
       }
 
       if (Math.abs(root.scrollTop - top) <= 1) {
-        setPendingDiff(undefined)
+        setTree("pendingDiff", undefined)
         return
       }
 
@@ -1558,13 +1595,17 @@ export default function Page() {
     void sync.session.diff(id)
   })
 
+  let treeDir: string | undefined
   createEffect(() => {
+    const dir = sdk.directory
     if (!isDesktop()) return
     if (!layout.fileTree.opened()) return
     if (sync.status === "loading") return
 
     fileTreeTab()
-    void file.tree.list("")
+    const refresh = treeDir !== dir
+    treeDir = dir
+    void (refresh ? file.tree.refresh("") : file.tree.list(""))
   })
 
   const autoScroll = createAutoScroll({
@@ -1599,6 +1640,18 @@ export default function Page() {
   let scrollSpyFrame: number | undefined
   let scrollSpyTarget: HTMLDivElement | undefined
 
+  createEffect(
+    on(
+      sessionKey,
+      () => {
+        if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
+        scrollSpyFrame = undefined
+        scrollSpyTarget = undefined
+      },
+      { defer: true },
+    ),
+  )
+
   const anchor = (id: string) => `message-${id}`
 
   const setScrollRef = (el: HTMLDivElement | undefined) => {
@@ -1713,20 +1766,14 @@ export default function Page() {
     window.history.replaceState(null, "", `#${anchor(id)}`)
   }
 
-  createEffect(() => {
-    const sessionID = params.id
-    if (!sessionID) return
-    const raw = sessionStorage.getItem("opencode.pendingMessage")
-    if (!raw) return
-    const parts = raw.split("|")
-    const pendingSessionID = parts[0]
-    const messageID = parts[1]
-    if (!pendingSessionID || !messageID) return
-    if (pendingSessionID !== sessionID) return
-
-    sessionStorage.removeItem("opencode.pendingMessage")
-    setUi("pendingMessage", messageID)
-  })
+  createEffect(
+    on(sessionKey, (key) => {
+      if (!params.id) return
+      const messageID = layout.pendingMessage.consume(key)
+      if (!messageID) return
+      setUi("pendingMessage", messageID)
+    }),
+  )
 
   const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
     const root = scroller
@@ -1940,7 +1987,7 @@ export default function Page() {
 
   createEffect(() => {
     if (!prompt.ready()) return
-    handoff.prompt = previewPrompt()
+    setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
   })
 
   createEffect(() => {
@@ -1960,20 +2007,22 @@ export default function Page() {
       return language.t("terminal.title")
     }
 
-    handoff.terminals = terminal.all().map(label)
+    touch(handoff.terminal, params.dir!, terminal.all().map(label))
   })
 
   createEffect(() => {
     if (!file.ready()) return
-    handoff.files = Object.fromEntries(
-      tabs()
-        .all()
-        .flatMap((tab) => {
-          const path = file.pathFromTab(tab)
-          if (!path) return []
-          return [[path, file.selectedLines(path) ?? null] as const]
-        }),
-    )
+    setSessionHandoff(sessionKey(), {
+      files: Object.fromEntries(
+        tabs()
+          .all()
+          .flatMap((tab) => {
+            const path = file.pathFromTab(tab)
+            if (!path) return []
+            return [[path, file.selectedLines(path) ?? null] as const]
+          }),
+      ),
+    })
   })
 
   onCleanup(() => {
@@ -2049,7 +2098,7 @@ export default function Page() {
                                 diffs={diffs}
                                 view={view}
                                 diffStyle="unified"
-                                focusedFile={activeDiff()}
+                                focusedFile={tree.activeDiff}
                                 onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
                                 comments={comments.all()}
                                 focusedComment={comments.focus()}
@@ -2483,7 +2532,7 @@ export default function Page() {
                 when={prompt.ready()}
                 fallback={
                   <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
-                    {handoff.prompt || language.t("prompt.loading")}
+                    {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
                   </div>
                 }
               >
@@ -2734,7 +2783,7 @@ export default function Page() {
                             const p = path()
                             if (!p) return null
                             if (file.ready()) return file.selectedLines(p) ?? null
-                            return handoff.files[p] ?? null
+                            return handoff.session.get(sessionKey())?.files[p] ?? null
                           })
 
                           let wrap: HTMLDivElement | undefined
@@ -3228,7 +3277,7 @@ export default function Page() {
                               allowed={diffFiles()}
                               kinds={kinds()}
                               draggable={false}
-                              active={activeDiff()}
+                              active={tree.activeDiff}
                               onFileClick={(node) => focusReviewDiff(node.path)}
                             />
                           </Show>
@@ -3288,7 +3337,7 @@ export default function Page() {
             fallback={
               <div class="flex flex-col h-full pointer-events-none">
                 <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
-                  <For each={handoff.terminals}>
+                  <For each={handoff.terminal.get(params.dir!) ?? []}>
                     {(title) => (
                       <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
                         {title}