1
0
Эх сурвалжийг харах

feat(desktop): reopen closed tabs and background tab open (#35010)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
usrnk1 1 сар өмнө
parent
commit
82db86e2a1

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

@@ -374,9 +374,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   keybind: "mod+w",
                   keybind: "mod+w",
                   hidden: true,
                   hidden: true,
                   onSelect: () => {
                   onSelect: () => {
-                    tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
+                    tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
                   },
                   },
                 },
                 },
+                {
+                  id: "tab.reopenClosed",
+                  category: language.t("command.category.file"),
+                  title: language.t("command.tab.reopenClosed"),
+                  keybind: "mod+shift+t",
+                  onSelect: () => tabsStoreActions.reopenClosedTab(),
+                },
                 {
                 {
                   id: `tab.prev`,
                   id: `tab.prev`,
                   category: "tab",
                   category: "tab",
@@ -464,7 +471,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   }}
                   }}
                   onClose={(tab) => {
                   onClose={(tab) => {
                     const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
                     const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
-                    if (index !== -1) tabsStoreActions.removeTab(index)
+                    if (index !== -1) tabsStoreActions.closeTab(index)
                   }}
                   }}
                   onReorder={(keys) => tabsStoreActions.reorder(keys)}
                   onReorder={(keys) => tabsStoreActions.reorder(keys)}
                 />
                 />

+ 40 - 0
packages/app/src/context/closed-tabs.ts

@@ -0,0 +1,40 @@
+import type { SessionTab, Tab } from "./tabs"
+
+export type ClosedTab = {
+  tab: SessionTab
+  index: number
+}
+
+const CLOSED_TAB_LIMIT = 25
+
+// Only session tabs are recorded; closing a draft tab deletes its persisted
+// state, so a reopened draft would come back empty anyway.
+export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
+  if (tab.type !== "session") return stack
+  return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
+}
+
+// Pops the most recently closed tab that is not open again,
+// discarding stale entries along the way.
+export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
+  const remaining = [...stack]
+  while (remaining.length) {
+    const entry = remaining.pop()
+    if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining }
+  }
+  return { stack: remaining }
+}
+
+export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) {
+  const removed = new Set(sessionIDs)
+  return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId))
+}
+
+export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
+  if (!active) return undefined
+  return tabs[index + 1] ?? tabs[index - 1] ?? null
+}
+
+function isOpen(tabs: Tab[], tab: SessionTab) {
+  return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
+}

+ 87 - 0
packages/app/src/context/tabs.test.ts

@@ -1,6 +1,21 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import { createRoot, getOwner, onCleanup } from "solid-js"
 import { createRoot, getOwner, onCleanup } from "solid-js"
 import { createTabMemory } from "./tab-memory"
 import { createTabMemory } from "./tab-memory"
+import {
+  nextTabAfterClose,
+  pushClosedTab,
+  removeClosedTabs,
+  takeClosedTab,
+  type ClosedTab,
+} from "./closed-tabs"
+import type { SessionTab, Tab } from "./tabs"
+import type { ServerConnection } from "./server"
+
+const server = "local\nhttp://localhost:4096" as ServerConnection.Key
+
+function sessionTab(sessionId: string): SessionTab {
+  return { type: "session", server, sessionId }
+}
 
 
 describe("tab memory", () => {
 describe("tab memory", () => {
   test("keeps state until its tab is removed", () => {
   test("keeps state until its tab is removed", () => {
@@ -22,3 +37,75 @@ describe("tab memory", () => {
     })
     })
   })
   })
 })
 })
+
+describe("closed tab stack", () => {
+  test("records session tabs with their index", () => {
+    const stack = pushClosedTab([], sessionTab("a"), 2)
+
+    expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
+  })
+
+  test("ignores draft tabs", () => {
+    const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
+
+    expect(pushClosedTab([], draft, 0)).toEqual([])
+  })
+
+  test("caps the stack size", () => {
+    const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
+      (acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
+      [],
+    )
+
+    expect(stack).toHaveLength(25)
+    expect(stack[0]?.tab.sessionId).toBe("s5")
+    expect(stack.at(-1)?.tab.sessionId).toBe("s29")
+  })
+
+  test("pops the most recently closed tab", () => {
+    const stack = [
+      { tab: sessionTab("a"), index: 0 },
+      { tab: sessionTab("b"), index: 1 },
+    ]
+    const result = takeClosedTab(stack, [])
+
+    expect(result.entry?.tab.sessionId).toBe("b")
+    expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
+  })
+
+  test("skips entries whose tab is already open", () => {
+    const stack = [
+      { tab: sessionTab("a"), index: 0 },
+      { tab: sessionTab("b"), index: 1 },
+    ]
+    const result = takeClosedTab(stack, [sessionTab("b")])
+
+    expect(result.entry?.tab.sessionId).toBe("a")
+    expect(result.stack).toEqual([])
+  })
+
+  test("returns no entry when everything is open or empty", () => {
+    expect(takeClosedTab([], []).entry).toBeUndefined()
+
+    const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
+    expect(result.entry).toBeUndefined()
+    expect(result.stack).toEqual([])
+  })
+
+  test("purges removed sessions", () => {
+    const stack = [
+      { tab: sessionTab("a"), index: 0 },
+      { tab: sessionTab("b"), index: 1 },
+    ]
+
+    expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }])
+  })
+
+  test("does not navigate when a background tab closes", () => {
+    const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")]
+
+    expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined()
+    expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c"))
+    expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
+  })
+})

+ 56 - 3
packages/app/src/context/tabs.tsx

@@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid"
 import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
 import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
 import { sessionHref } from "@/utils/session-route"
 import { sessionHref } from "@/utils/session-route"
 import { createTabMemory } from "./tab-memory"
 import { createTabMemory } from "./tab-memory"
+import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
 
 
 export type SessionTab = {
 export type SessionTab = {
   type: "session"
   type: "session"
@@ -63,6 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       createStore<Tab[]>([]),
       createStore<Tab[]>([]),
     )
     )
     const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
     const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
+    const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
 
 
     const params = useParams()
     const params = useParams()
     const navigate = useNavigate()
     const navigate = useNavigate()
@@ -87,6 +89,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       })
       })
     }
     }
 
 
+    const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
+      const apply = () => setClosed((stack) => update(stack))
+      if (closedReady()) {
+        apply()
+        return
+      }
+      void closedReady.promise?.then(apply)
+    }
+
     const removeDraftPersisted = (draftID: string) => {
     const removeDraftPersisted = (draftID: string) => {
       for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
       for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
     }
     }
@@ -106,6 +117,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
       if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
     })
     })
 
 
+    createEffect(() => {
+      if (!closedReady()) return
+      const servers = new Set(server.list.map(ServerConnection.key))
+      const next = closed.filter((entry) => servers.has(entry.tab.server))
+      if (next.length !== closed.length) setClosed(() => next)
+    })
+
     const navigateTab = (tab: Tab) => {
     const navigateTab = (tab: Tab) => {
       const href = tabHref(tab)
       const href = tabHref(tab)
       setRecentKey(tabKey(tab))
       setRecentKey(tabKey(tab))
@@ -117,7 +135,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       if (!tab) return
       if (!tab) return
       const key = tabKey(tab)
       const key = tabKey(tab)
       const draftID = tab.type === "draft" ? tab.draftID : undefined
       const draftID = tab.type === "draft" ? tab.draftID : undefined
-      const nextTab = store[index + 1] ?? store[index - 1]
+      const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
       closing.add(key)
       closing.add(key)
       void startTransition(() => {
       void startTransition(() => {
         setStore(
         setStore(
@@ -125,9 +143,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
             tabs.splice(index, 1)
             tabs.splice(index, 1)
           }),
           }),
         )
         )
-        if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
+        if (nextTab === null) {
+          setRecentKey(undefined)
+          navigate("/")
+        }
         if (nextTab) navigateTab(nextTab)
         if (nextTab) navigateTab(nextTab)
-        else navigate("/")
       }).finally(() => closing.delete(key))
       }).finally(() => closing.delete(key))
       memory.remove(key)
       memory.remove(key)
       if (draftID) removeDraftPersisted(draftID)
       if (draftID) removeDraftPersisted(draftID)
@@ -201,13 +221,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
         removeDraftPersisted(draftID)
         removeDraftPersisted(draftID)
       },
       },
       removeTab,
       removeTab,
+      // User-initiated close: records the tab so it can be reopened.
+      // Cleanup paths (missing sessions, archive, server removal) go through
+      // removeTab and friends directly and are not recorded.
+      closeTab(index: number) {
+        const tab = store[index]
+        if (!tab) return
+        if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
+        removeTab(index)
+      },
+      reopenClosedTab() {
+        if (!closedReady()) {
+          void closedReady.promise?.then(() => actions.reopenClosedTab())
+          return
+        }
+        const result = takeClosedTab(closed, store)
+        if (result.stack.length === closed.length) return
+        setClosed(() => result.stack)
+        const entry = result.entry
+        if (!entry) return
+        const index = Math.min(entry.index, store.length)
+        void startTransition(() => {
+          setStore(
+            produce((tabs) => {
+              if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
+              tabs.splice(index, 0, entry.tab)
+            }),
+          )
+          navigateTab(entry.tab)
+        })
+      },
       removeSessionTab(input: Omit<SessionTab, "type">) {
       removeSessionTab(input: Omit<SessionTab, "type">) {
+        updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
         const index = store.findIndex(
         const index = store.findIndex(
           (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
           (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
         )
         )
         if (index !== -1) removeTab(index)
         if (index !== -1) removeTab(index)
       },
       },
       removeServer(key: ServerConnection.Key) {
       removeServer(key: ServerConnection.Key) {
+        updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
         const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
         const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
         const removed = store.filter((tab) => tab.server === key).map(tabKey)
         const removed = store.filter((tab) => tab.server === key).map(tabKey)
         setStore((tabs) => tabs.filter((tab) => tab.server !== key))
         setStore((tabs) => tabs.filter((tab) => tab.server !== key))
@@ -218,6 +270,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       },
       },
       removeSessions: (input: SessionTabsRemovedDetail) => {
       removeSessions: (input: SessionTabsRemovedDetail) => {
         const targetServer = input.server ?? server.key
         const targetServer = input.server ?? server.key
+        updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
         const removed = store
         const removed = store
           .filter(
           .filter(
             (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
             (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),

+ 1 - 0
packages/app/src/i18n/en.ts

@@ -48,6 +48,7 @@ export const dict = {
   "command.session.new": "New session",
   "command.session.new": "New session",
   "command.file.open": "Open file",
   "command.file.open": "Open file",
   "command.tab.close": "Close tab",
   "command.tab.close": "Close tab",
+  "command.tab.reopenClosed": "Reopen closed tab",
   "command.context.addSelection": "Add selection to context",
   "command.context.addSelection": "Add selection to context",
   "command.context.addSelection.description": "Add selected lines from the current file",
   "command.context.addSelection.description": "Add selected lines from the current file",
   "command.input.focus": "Focus input",
   "command.input.focus": "Focus input",

+ 12 - 0
packages/app/src/pages/home-session-open.test.ts

@@ -0,0 +1,12 @@
+import { describe, expect, test } from "bun:test"
+import { shouldOpenSessionInBackground } from "./home-session-open"
+
+describe("shouldOpenSessionInBackground", () => {
+  test("requires only the platform primary modifier", () => {
+    expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
+    expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
+    expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
+    expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
+    expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
+  })
+})

+ 11 - 0
packages/app/src/pages/home-session-open.ts

@@ -0,0 +1,11 @@
+export function shouldOpenSessionInBackground(input: {
+  mac: boolean
+  meta: boolean
+  ctrl: boolean
+  shift: boolean
+  alt: boolean
+}) {
+  if (input.shift || input.alt) return false
+  if (input.mac) return input.meta && !input.ctrl
+  return input.ctrl && !input.meta
+}

+ 31 - 10
packages/app/src/pages/home.tsx

@@ -66,6 +66,7 @@ import { Persist, persisted } from "@/utils/persist"
 import { useMarked } from "@opencode-ai/ui/context/marked"
 import { useMarked } from "@opencode-ai/ui/context/marked"
 import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
 import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
 import { archiveHomeSession } from "./home-session-archive"
 import { archiveHomeSession } from "./home-session-archive"
+import { shouldOpenSessionInBackground } from "./home-session-open"
 import { showToast } from "@/utils/toast"
 import { showToast } from "@/utils/toast"
 
 
 const HOME_SESSION_LIMIT = 64
 const HOME_SESSION_LIMIT = 64
@@ -238,6 +239,20 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
   return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
   return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
 }
 }
 
 
+// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
+// background without navigating, matching browser conventions.
+function isBackgroundOpen(event: MouseEvent) {
+  return shouldOpenSessionInBackground({
+    mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
+    meta: event.metaKey,
+    ctrl: event.ctrlKey,
+    shift: event.shiftKey,
+    alt: event.altKey,
+  })
+}
+
+type OpenSessionOptions = { background?: boolean }
+
 export function NewHome() {
 export function NewHome() {
   const sync = useServerSync()
   const sync = useServerSync()
   const layout = useLayout()
   const layout = useLayout()
@@ -377,9 +392,11 @@ export function NewHome() {
     setState("searchFocused", false)
     setState("searchFocused", false)
   }
   }
 
 
-  function selectSearchSession(session: Session) {
-    openSession(session)
-    closeSearch()
+  function selectSearchSession(session: Session, options?: OpenSessionOptions) {
+    openSession(session, options)
+    // Background opens keep the search visible so several results can be
+    // opened in a row.
+    if (!options?.background) closeSearch()
   }
   }
 
 
   command.register("home", () => [
   command.register("home", () => [
@@ -464,13 +481,17 @@ export function NewHome() {
       .forEach((directory) => state.project.markViewed(directory))
       .forEach((directory) => state.project.markViewed(directory))
   }
   }
 
 
-  function openSession(session: Session) {
+  function openSession(session: Session, options?: OpenSessionOptions) {
     const project = projectForSession(session, projects(), projectByID())
     const project = projectForSession(session, projects(), projectByID())
     const conn = focusedServer()
     const conn = focusedServer()
     if (!conn) return
     if (!conn) return
     const directory = project?.worktree ?? session.directory
     const directory = project?.worktree ?? session.directory
     const ctx = global.ensureServerCtx(conn)
     const ctx = global.ensureServerCtx(conn)
     ctx.projects.open(directory)
     ctx.projects.open(directory)
+    if (options?.background) {
+      tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
+      return
+    }
     ctx.projects.touch(directory)
     ctx.projects.touch(directory)
     startTransition(() => {
     startTransition(() => {
       const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
       const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
@@ -1130,7 +1151,7 @@ function HomeSessionSearch(props: {
   onInput: (value: string) => void
   onInput: (value: string) => void
   onFocus: () => void
   onFocus: () => void
   onClose: () => void
   onClose: () => void
-  onSelect: (session: Session) => void
+  onSelect: (session: Session, options?: OpenSessionOptions) => void
 }) {
 }) {
   const language = useLanguage()
   const language = useLanguage()
   const [store, setStore] = createStore({ active: "" })
   const [store, setStore] = createStore({ active: "" })
@@ -1244,7 +1265,7 @@ function HomeSessionSearch(props: {
                                 server={props.server}
                                 server={props.server}
                                 selected={store.active === homeSessionSearchKey(record)}
                                 selected={store.active === homeSessionSearchKey(record)}
                                 onHighlight={() => setStore("active", homeSessionSearchKey(record))}
                                 onHighlight={() => setStore("active", homeSessionSearchKey(record))}
-                                onSelect={(session) => props.onSelect(session)}
+                                onSelect={(session, options) => props.onSelect(session, options)}
                               />
                               />
                             )}
                             )}
                           </For>
                           </For>
@@ -1324,7 +1345,7 @@ function HomeSessionSearchResultRow(props: {
   server: ServerConnection.Key
   server: ServerConnection.Key
   selected: boolean
   selected: boolean
   onHighlight: () => void
   onHighlight: () => void
-  onSelect: (session: Session) => void
+  onSelect: (session: Session, options?: OpenSessionOptions) => void
 }) {
 }) {
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
   const showProjectName = () => props.showProjectName && props.record.projectName
   const showProjectName = () => props.showProjectName && props.record.projectName
@@ -1345,7 +1366,7 @@ function HomeSessionSearchResultRow(props: {
         group: !!showProjectName(),
         group: !!showProjectName(),
       }}
       }}
       onMouseEnter={() => props.onHighlight()}
       onMouseEnter={() => props.onHighlight()}
-      onClick={() => props.onSelect(props.record.session)}
+      onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
     >
     >
       <HomeSessionLeading
       <HomeSessionLeading
         project={props.record.project}
         project={props.record.project}
@@ -1389,7 +1410,7 @@ function HomeSessionRow(props: {
   record: HomeSessionRecord
   record: HomeSessionRecord
   showProjectName: boolean
   showProjectName: boolean
   server: ServerConnection.Key
   server: ServerConnection.Key
-  openSession: (session: Session) => void
+  openSession: (session: Session, options?: OpenSessionOptions) => void
   archiveSession: (session: Session) => Promise<void>
   archiveSession: (session: Session) => Promise<void>
 }) {
 }) {
   const language = useLanguage()
   const language = useLanguage()
@@ -1405,7 +1426,7 @@ function HomeSessionRow(props: {
         type="button"
         type="button"
         data-component="home-session-row"
         data-component="home-session-row"
         class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
         class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
-        onClick={() => props.openSession(props.record.session)}
+        onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
       >
       >
         <HomeSessionLeading
         <HomeSessionLeading
           project={props.record.project}
           project={props.record.project}