Răsfoiți Sursa

feat(desktop): scope tabs to windows (#34669)

Brendan Allan 1 lună în urmă
părinte
comite
af72cec799

+ 1 - 1
packages/app/e2e/performance/timeline/timeline-test-helpers.ts

@@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
         }),
       )
       localStorage.setItem(
-        "opencode.global.dat:tabs",
+        "opencode.window.browser.dat:tabs",
         JSON.stringify([
           ...sessionIDs.map((sessionId) => ({
             type: "session",

+ 2 - 2
packages/app/e2e/regression/cross-server-tab-close.spec.ts

@@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
       localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
       localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
       localStorage.setItem(
-        "opencode.global.dat:tabs",
+        "opencode.window.browser.dat:tabs",
         JSON.stringify([
           { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
           { type: "session", server: serverB, sessionId: sessionB },
@@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
       localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
       localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
       localStorage.setItem(
-        "opencode.global.dat:tabs",
+        "opencode.window.browser.dat:tabs",
         JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
       )
     },

+ 1 - 1
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts

@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
         }),
       )
       localStorage.setItem(
-        "opencode.global.dat:tabs",
+        "opencode.window.browser.dat:tabs",
         JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
       )
     },

+ 2 - 2
packages/app/e2e/smoke/session-timeline.spec.ts

@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
     await page.addInitScript(
       ({ dirBase64, sourceID, targetID }) => {
         localStorage.setItem(
-          "opencode.global.dat:tabs",
+          "opencode.window.browser.dat:tabs",
           JSON.stringify(
             [sourceID, targetID].map((sessionId) => ({
               type: "session",
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
     await page.addInitScript(
       ({ dirBase64, sourceID, targetID }) => {
         localStorage.setItem(
-          "opencode.global.dat:tabs",
+          "opencode.window.browser.dat:tabs",
           JSON.stringify(
             [sourceID, targetID].map((sessionId) => ({
               type: "session",

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -64,6 +64,9 @@ type PlatformBase = {
   /** Storage mechanism, defaults to localStorage */
   storage?: (name?: string) => SyncStorage | AsyncStorage
 
+  /** Stable platform window identity for window-scoped persistence */
+  windowID?: string
+
   /** Application-global desktop updater */
   updater?: UpdaterPlatform
 

+ 2 - 2
packages/app/src/context/tabs.tsx

@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
     const fallback = server.key
     const [store, setStore, _, ready] = persisted(
       {
-        ...Persist.global("tabs"),
+        ...Persist.window("tabs"),
         migrate: (value: unknown) => {
           if (!Array.isArray(value)) return value
           return value.map((tab) => {
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
       },
       createStore<Tab[]>([]),
     )
-    const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
+    const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
 
     const params = useParams()
     const navigate = useNavigate()

+ 23 - 1
packages/app/src/utils/persist.ts

@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
 
 type PersistTarget = {
   storage?: string
+  scope?: "window"
   legacyStorageNames?: string[]
   key: string
   legacy?: string[]
@@ -24,6 +25,7 @@ type PersistTarget = {
 
 const LEGACY_STORAGE = "default.dat"
 const GLOBAL_STORAGE = "opencode.global.dat"
+const WINDOW_STORAGE = "opencode.window"
 const LOCAL_PREFIX = "opencode."
 const fallback = new Map<string, boolean>()
 
@@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
   return `opencode.draft.${head}.${sum}.dat`
 }
 
+function windowStorage(windowID: string) {
+  const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-")
+  return `${WINDOW_STORAGE}.${safe}.dat`
+}
+
 function legacyWorkspaceStorage(dir: string) {
   const storage = workspaceStorage(pathKey(dir))
   const result = new Set<string>()
@@ -467,6 +474,8 @@ export const PersistTesting = {
   localStorageWithPrefix,
   migrateLegacy,
   normalize,
+  resolveTarget,
+  windowStorage,
   workspaceStorage,
 }
 
@@ -474,6 +483,9 @@ export const Persist = {
   global(key: string, legacy?: string[]): PersistTarget {
     return { storage: GLOBAL_STORAGE, key, legacy }
   },
+  window(key: string, legacy?: string[]): PersistTarget {
+    return { scope: "window", key, legacy }
+  },
   draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
     return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
   },
@@ -503,6 +515,16 @@ export const Persist = {
   },
 }
 
+function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
+  if (target.scope !== "window") return target
+  if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
+  const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
+  return {
+    ...target,
+    storage: windowStorage(windowID),
+  }
+}
+
 export function removePersisted(
   target: { storage?: string; legacyStorageNames?: string[]; key: string },
   platform?: Platform,
@@ -533,7 +555,7 @@ export function persisted<T>(
   store: [Store<T>, SetStoreFunction<T>],
 ): PersistedWithReady<T> {
   const platform = usePlatform()
-  const config: PersistTarget = typeof target === "string" ? { key: target } : target
+  const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
 
   const defaults = snapshot(store[0])
   const legacy = config.legacy ?? []

+ 15 - 10
packages/desktop/src/main/index.ts

@@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os"
 import { join } from "node:path"
 import { getCACertificates, setDefaultCACertificates } from "node:tls"
 import type { Event } from "electron"
-import { app, BrowserWindow } from "electron"
+import { app } from "electron"
 
 import { Deferred, Effect, Fiber } from "effect"
 import contextMenu from "electron-context-menu"
@@ -28,11 +28,13 @@ import {
 } from "./server"
 import { setupAutoUpdater, showUpdaterDialog } from "./updater"
 import {
-  createMainWindow,
+  getLastFocusedWindow,
   registerRendererProtocol,
   setRelaunchHandler,
+  setAppQuitting,
   setBackgroundColor,
   setDockIcon,
+  restoreMainWindows,
 } from "./windows"
 import { createWslServersController } from "./wsl/servers"
 import { registerWslIpcHandlers } from "./wsl/ipc"
@@ -53,7 +55,6 @@ const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
 const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
 
 let logger: ReturnType<typeof initLogging>
-let mainWindow: BrowserWindow | null = null
 let server: SidecarListener | null = null
 
 const pendingDeepLinks: string[] = []
@@ -70,7 +71,8 @@ function useEnvProxy() {
 function emitDeepLinks(urls: string[]) {
   if (urls.length === 0) return
   pendingDeepLinks.push(...urls)
-  if (mainWindow) sendDeepLinks(mainWindow, urls)
+  const win = getLastFocusedWindow()
+  if (win) sendDeepLinks(win, urls)
 }
 
 async function killSidecar() {
@@ -194,9 +196,10 @@ const main = Effect.gen(function* () {
       logger.log("deep link received via second-instance", { urls })
       emitDeepLinks(urls)
     }
-    if (mainWindow) {
-      mainWindow.show()
-      mainWindow.focus()
+    const win = getLastFocusedWindow()
+    if (win) {
+      win.show()
+      win.focus()
     }
   })
 
@@ -207,10 +210,12 @@ const main = Effect.gen(function* () {
   })
 
   app.on("before-quit", () => {
+    setAppQuitting()
     void stopSidecars()
   })
 
   app.on("will-quit", () => {
+    setAppQuitting()
     void stopSidecars()
   })
 
@@ -347,11 +352,11 @@ const main = Effect.gen(function* () {
 
   yield* Fiber.await(loadingTask)
 
-  mainWindow = createMainWindow()
-  if (mainWindow) {
+  const windows = restoreMainWindows()
+  if (windows.length) {
     createMenu({
       trigger: (id) => {
-        const win = BrowserWindow.getFocusedWindow() ?? mainWindow
+        const win = getLastFocusedWindow()
         if (win) sendMenuCommand(win, id)
       },
       checkForUpdates: () => {

+ 15 - 1
packages/desktop/src/main/ipc.ts

@@ -9,7 +9,13 @@ import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../prel
 import { runDesktopMenuAction } from "./desktop-menu-actions"
 import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
 import { getStore } from "./store"
-import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
+import {
+  getPinchZoomEnabled,
+  getWindowID,
+  setPinchZoomEnabled,
+  setTitlebar,
+  updateTitlebar,
+} from "./windows"
 import type { UpdaterController } from "./updater-controller"
 import { createUpdaterSubscriptions } from "./updater-subscriptions"
 
@@ -190,6 +196,14 @@ export function registerIpcHandlers(deps: Deps) {
 
   ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
 
+  ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
+    const win = BrowserWindow.fromWebContents(event.sender)
+    if (!win) throw new Error("Window not found")
+    const id = getWindowID(win)
+    if (!id) throw new Error("Window ID not found")
+    return id
+  })
+
   ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
     const win = BrowserWindow.fromWebContents(event.sender)
     return win?.isFocused() ?? false

+ 1 - 0
packages/desktop/src/main/store-keys.ts

@@ -2,3 +2,4 @@ export const SETTINGS_STORE = "opencode.settings"
 export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
 export const WSL_SERVERS_KEY = "wslServers"
 export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
+export const WINDOW_IDS_KEY = "windowIds"

+ 73 - 3
packages/desktop/src/main/windows.ts

@@ -2,13 +2,15 @@ import windowState from "electron-window-state"
 import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
 import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
 import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
+import { randomUUID } from "node:crypto"
+import { rmSync } from "node:fs"
 import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
 import { dirname, isAbsolute, join, relative, resolve } from "node:path"
 import { fileURLToPath, pathToFileURL } from "node:url"
 import type { TitlebarTheme } from "../preload/types"
 import { exportDebugLogs, write as writeLog } from "./logging"
 import { getStore } from "./store"
-import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
+import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
 import { createUnresponsiveSampler } from "./unresponsive"
 
 const root = dirname(fileURLToPath(import.meta.url))
@@ -42,8 +44,12 @@ let relaunchHandler = () => {
   app.relaunch()
   app.exit(0)
 }
+let appQuitting = false
+let lastFocusedWindowID: string | undefined
 const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
 const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
+const windowIDs = new WeakMap<BrowserWindow, string>()
+const windowsByID = new Map<string, BrowserWindow>()
 const titlebarHeight = 40
 const maxZoomLevel = 10
 const minZoomLevel = 0.2
@@ -52,6 +58,10 @@ export function setRelaunchHandler(handler: () => void) {
   relaunchHandler = handler
 }
 
+export function setAppQuitting() {
+  appQuitting = true
+}
+
 export function setBackgroundColor(color: string) {
   backgroundColor = color
   BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
@@ -111,14 +121,33 @@ export function getPinchZoomEnabled() {
   return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
 }
 
+export function getWindowID(win: BrowserWindow) {
+  return windowIDs.get(win)
+}
+
+export function getLastFocusedWindow() {
+  const focused = BrowserWindow.getFocusedWindow()
+  if (focused) return focused
+  if (!lastFocusedWindowID) return null
+  const win = windowsByID.get(lastFocusedWindowID)
+  if (!win || win.isDestroyed()) return null
+  return win
+}
+
+export function restoreMainWindows() {
+  const ids = readWindowIDs()
+  return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
+}
+
 export function setDockIcon() {
   if (process.platform !== "darwin") return
   const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
   if (!icon.isEmpty()) app.dock?.setIcon(icon)
 }
 
-export function createMainWindow() {
+export function createMainWindow(id: string = randomUUID()) {
   const state = windowState({
+    file: windowStateFile(id),
     defaultWidth: 1280,
     defaultHeight: 800,
   })
@@ -156,7 +185,7 @@ export function createMainWindow() {
   })
 
   allowRendererPermissions(win)
-  wireWindowRecovery(win, "main")
+  wireWindowRecovery(win, id)
 
   win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
     const { requestHeaders } = details
@@ -171,6 +200,7 @@ export function createMainWindow() {
   })
 
   state.manage(win)
+  registerWindow(win, id)
   loadWindow(win, "index.html")
   wireZoom(win)
 
@@ -181,6 +211,46 @@ export function createMainWindow() {
   return win
 }
 
+function registerWindow(win: BrowserWindow, id: string) {
+  windowIDs.set(win, id)
+  windowsByID.set(id, win)
+  persistWindowID(id)
+
+  win.on("focus", () => {
+    lastFocusedWindowID = id
+  })
+  win.on("closed", () => {
+    windowsByID.delete(id)
+    if (lastFocusedWindowID === id) lastFocusedWindowID = windowsByID.keys().next().value
+    if (!appQuitting) removeWindowID(id)
+  })
+}
+
+function readWindowIDs() {
+  const value = getStore().get(WINDOW_IDS_KEY)
+  if (!Array.isArray(value)) return []
+  return value.filter((id): id is string => typeof id === "string" && id.length > 0)
+}
+
+function writeWindowIDs(ids: string[]) {
+  getStore().set(WINDOW_IDS_KEY, [...new Set(ids)])
+}
+
+function persistWindowID(id: string) {
+  const ids = readWindowIDs()
+  if (ids.includes(id)) return
+  writeWindowIDs([...ids, id])
+}
+
+function removeWindowID(id: string) {
+  writeWindowIDs(readWindowIDs().filter((item) => item !== id))
+  rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
+}
+
+function windowStateFile(id: string) {
+  return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
+}
+
 export function registerRendererProtocol() {
   if (protocol.isProtocolHandled(rendererProtocol)) return
 

+ 1 - 0
packages/desktop/src/preload/index.ts

@@ -73,6 +73,7 @@ const api: ElectronAPI = {
   storeLength: (name) => ipcRenderer.invoke("store-length", name),
 
   getWindowCount: () => ipcRenderer.invoke("get-window-count"),
+  getWindowID: () => ipcRenderer.invoke("get-window-id"),
   onMenuCommand: (cb) => {
     const handler = (_: unknown, id: string) => cb(id)
     ipcRenderer.on("menu-command", handler)

+ 1 - 0
packages/desktop/src/preload/types.ts

@@ -62,6 +62,7 @@ export type ElectronAPI = {
   storeLength: (name: string) => Promise<number>
 
   getWindowCount: () => Promise<number>
+  getWindowID: () => Promise<string>
   onMenuCommand: (cb: (id: string) => void) => () => void
   onDeepLink: (cb: (urls: string[]) => void) => () => void
 

+ 45 - 19
packages/desktop/src/renderer/index.tsx

@@ -63,7 +63,10 @@ const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "di
 void window.api.updater.subscribe(setUpdaterState)
 
 const deepLinkEvent = "opencode:deep-link"
-const lastActiveUrlKey = "opencode.desktop.last-active-url"
+
+type DesktopWindowState = {
+  id?: string
+}
 
 const emitDeepLinks = (urls: string[]) => {
   if (urls.length === 0) return
@@ -78,31 +81,35 @@ const listenForDeepLinks = () => {
   return window.api.onDeepLink((urls) => emitDeepLinks(urls))
 }
 
-function getLastActiveUrl() {
+function windowLastActiveUrlKey(windowID: string) {
+  return `opencode.desktop.window.${windowID}.last-active-url`
+}
+
+function getLastActiveUrl(windowID: string) {
   if (typeof localStorage !== "object") return "/"
   try {
-    const value = localStorage.getItem(lastActiveUrlKey)
+    const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
     if (value?.startsWith("/") && !value.startsWith("//")) return value
   } catch {}
   return "/"
 }
 
-function setLastActiveUrl(value: string) {
+function setLastActiveUrl(windowID: string, value: string) {
   if (typeof localStorage !== "object") return
   try {
-    localStorage.setItem(lastActiveUrlKey, value)
+    localStorage.setItem(windowLastActiveUrlKey(windowID), value)
   } catch {}
 }
 
-function DesktopMemoryRouter(props: BaseRouterProps) {
+function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
   const history = createMemoryHistory()
-  const initialUrl = getLastActiveUrl()
+  const initialUrl = getLastActiveUrl(props.windowID)
   if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
-  onCleanup(history.listen(setLastActiveUrl))
+  onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
   return <MemoryRouter {...props} history={history} />
 }
 
-const createPlatform = (): Platform => {
+const createPlatform = (windowState: DesktopWindowState): Platform => {
   const attachmentPaths = new WeakMap<File, string>()
   const os = (() => {
     const ua = navigator.userAgent
@@ -161,6 +168,7 @@ const createPlatform = (): Platform => {
     platform: "desktop",
     os,
     version: pkg.version,
+    windowID: windowState.id,
 
     async openDirectoryPickerDialog(opts) {
       return window.api.openDirectoryPicker({
@@ -307,8 +315,16 @@ window.api.onMenuCommand((id) => {
 })
 listenForDeepLinks()
 
-render(() => {
-  const platform = createPlatform()
+function LoadingSplash() {
+  return (
+    <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
+      <Splash class="w-16 h-20 opacity-50 animate-pulse" />
+    </div>
+  )
+}
+
+function DesktopRoot(props: { windowState: DesktopWindowState }) {
+  const platform = createPlatform(props.windowState)
   const loadLocale = async () => {
     const current = await platform.storage?.("opencode.global.dat").getItem("language")
     const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -328,6 +344,7 @@ render(() => {
 
   const [defaultServer] = createResource(() => platform.getDefaultServer?.())
   const [locale] = createResource(loadLocale)
+  const router = (props: BaseRouterProps) => <DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
 
   function handleClick(e: MouseEvent) {
     const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -357,12 +374,6 @@ render(() => {
 
   function App() {
     const wslServers = useWslServers()
-    const splash = (
-      <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
-        <Splash class="w-16 h-20 opacity-50 animate-pulse" />
-      </div>
-    )
-
     const ready = createMemo(
       () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
     )
@@ -389,10 +400,10 @@ render(() => {
     )
 
     return (
-      <Show when={ready()} fallback={splash}>
+      <Show when={ready()} fallback={<LoadingSplash />}>
         <Show when={effectiveDefaultServer()} keyed>
           {(key) => (
-            <AppInterface defaultServer={key} servers={servers()} router={DesktopMemoryRouter}>
+            <AppInterface defaultServer={key} servers={servers()} router={router}>
               <Inner />
             </AppInterface>
           )}
@@ -415,4 +426,19 @@ render(() => {
       </AppBaseProviders>
     </PlatformProvider>
   )
+}
+
+render(() => {
+  const [windowState] = createResource(async () => {
+    const api = window.api as typeof window.api & {
+      getWindowID?: () => Promise<string>
+    }
+    return { id: await api.getWindowID?.() }
+  })
+
+  return (
+    <Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
+      {(state) => <DesktopRoot windowState={state} />}
+    </Show>
+  )
 }, root!)