Ver Fonte

desktop: multi-window support in electron (#17155)

Brendan Allan há 1 mês atrás
pai
commit
84df96eaef

+ 19 - 16
packages/app/src/app.tsx

@@ -159,7 +159,7 @@ const effectMinDuration =
   <A, E, R>(e: Effect.Effect<A, E, R>) =>
     Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
 
-function ConnectionGate(props: ParentProps) {
+function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
   const server = useServer()
   const checkServerHealth = useCheckServerHealth()
 
@@ -168,21 +168,23 @@ function ConnectionGate(props: ParentProps) {
   // performs repeated health check with a grace period for
   // non-http connections, otherwise fails instantly
   const [startupHealthCheck, healthCheckActions] = createResource(() =>
-    Effect.gen(function* () {
-      if (!server.current) return true
-      const { http, type } = server.current
+    props.disableHealthCheck
+      ? true
+      : Effect.gen(function* () {
+          if (!server.current) return true
+          const { http, type } = server.current
 
-      while (true) {
-        const res = yield* Effect.promise(() => checkServerHealth(http))
-        if (res.healthy) return true
-        if (checkMode() === "background" || type === "http") return false
-      }
-    }).pipe(
-      effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
-      Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
-      Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
-      Effect.runPromise,
-    ),
+          while (true) {
+            const res = yield* Effect.promise(() => checkServerHealth(http))
+            if (res.healthy) return true
+            if (checkMode() === "background" || type === "http") return false
+          }
+        }).pipe(
+          effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
+          Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
+          Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
+          Effect.runPromise,
+        ),
   )
 
   return (
@@ -261,10 +263,11 @@ export function AppInterface(props: {
   defaultServer: ServerConnection.Key
   servers?: Array<ServerConnection.Any>
   router?: Component<BaseRouterProps>
+  disableHealthCheck?: boolean
 }) {
   return (
     <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
-      <ConnectionGate>
+      <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
         <GlobalSDKProvider>
           <GlobalSyncProvider>
             <Dynamic

+ 5 - 2
packages/app/src/entry.tsx

@@ -1,6 +1,5 @@
 // @refresh reload
 
-import { iife } from "@opencode-ai/util/iife"
 import { render } from "solid-js/web"
 import { AppBaseProviders, AppInterface } from "@/app"
 import { type Platform, PlatformProvider } from "@/context/platform"
@@ -132,7 +131,11 @@ if (root instanceof HTMLElement) {
     () => (
       <PlatformProvider value={platform}>
         <AppBaseProviders>
-          <AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
+          <AppInterface
+            defaultServer={ServerConnection.Key.make(getDefaultUrl())}
+            servers={[server]}
+            disableHealthCheck
+          />
         </AppBaseProviders>
       </PlatformProvider>
     ),

+ 4 - 5
packages/desktop-electron/src/main/index.ts

@@ -5,7 +5,7 @@ import { createServer } from "node:net"
 import { homedir } from "node:os"
 import { join } from "node:path"
 import type { Event } from "electron"
-import { app, type BrowserWindow, dialog } from "electron"
+import { app, BrowserWindow, dialog } from "electron"
 import pkg from "electron-updater"
 
 const APP_NAMES: Record<string, string> = {
@@ -32,7 +32,7 @@ import { initLogging } from "./logging"
 import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
 import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
-import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
+import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
 
 const initEmitter = new EventEmitter()
 let initStep: InitStep = { phase: "server_waiting" }
@@ -156,12 +156,9 @@ async function initialize() {
 
   const globals = {
     updaterEnabled: UPDATER_ENABLED,
-    wsl: getWslConfig().enabled,
     deepLinks: pendingDeepLinks,
   }
 
-  wireMenu()
-
   if (needsMigration) {
     const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
     if (show) {
@@ -178,6 +175,7 @@ async function initialize() {
   }
 
   mainWindow = createMainWindow(globals)
+  wireMenu()
 
   overlay?.close()
 }
@@ -231,6 +229,7 @@ registerIpcHandlers({
   runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
   checkUpdate: async () => checkUpdate(),
   installUpdate: async () => installUpdate(),
+  setBackgroundColor: (color) => setBackgroundColor(color),
 })
 
 function killSidecar() {

+ 4 - 0
packages/desktop-electron/src/main/ipc.ts

@@ -24,6 +24,7 @@ type Deps = {
   runUpdater: (alertOnFail: boolean) => Promise<void> | void
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
   installUpdate: () => Promise<void> | void
+  setBackgroundColor: (color: string) => void
 }
 
 export function registerIpcHandlers(deps: Deps) {
@@ -53,6 +54,7 @@ export function registerIpcHandlers(deps: Deps) {
   ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
   ipcMain.handle("check-update", () => deps.checkUpdate())
   ipcMain.handle("install-update", () => deps.installUpdate())
+  ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
   ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
     const store = getStore(name)
     const value = store.get(key)
@@ -140,6 +142,8 @@ export function registerIpcHandlers(deps: Deps) {
     new Notification({ title, body }).show()
   })
 
+  ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
+
   ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
     const win = BrowserWindow.fromWebContents(event.sender)
     return win?.isFocused() ?? false

+ 6 - 0
packages/desktop-electron/src/main/menu.ts

@@ -1,6 +1,7 @@
 import { BrowserWindow, Menu, shell } from "electron"
 
 import { UPDATER_ENABLED } from "./constants"
+import { createMainWindow } from "./windows"
 
 type Deps = {
   trigger: (id: string) => void
@@ -48,6 +49,11 @@ export function createMenu(deps: Deps) {
       submenu: [
         { label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
         { label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
+        {
+          label: "New Window",
+          accelerator: "Cmd+Shift+N",
+          click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
+        },
         { type: "separator" },
         { role: "close" },
       ],

+ 12 - 2
packages/desktop-electron/src/main/windows.ts

@@ -6,12 +6,21 @@ import type { TitlebarTheme } from "../preload/types"
 
 type Globals = {
   updaterEnabled: boolean
-  wsl: boolean
   deepLinks?: string[]
 }
 
 const root = dirname(fileURLToPath(import.meta.url))
 
+let backgroundColor: string | undefined
+
+export function setBackgroundColor(color: string) {
+  backgroundColor = color
+}
+
+export function getBackgroundColor(): string | undefined {
+  return backgroundColor
+}
+
 function iconsDir() {
   return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
 }
@@ -59,6 +68,7 @@ export function createMainWindow(globals: Globals) {
     show: true,
     title: "OpenCode",
     icon: iconPath(),
+    backgroundColor,
     ...(process.platform === "darwin"
       ? {
           titleBarStyle: "hidden" as const,
@@ -95,6 +105,7 @@ export function createLoadingWindow(globals: Globals) {
     center: true,
     show: true,
     icon: iconPath(),
+    backgroundColor,
     ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
     ...(process.platform === "win32"
       ? {
@@ -131,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
     const deepLinks = globals.deepLinks ?? []
     const data = {
       updaterEnabled: globals.updaterEnabled,
-      wsl: globals.wsl,
       deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
     }
     void win.webContents.executeJavaScript(

+ 2 - 0
packages/desktop-electron/src/preload/index.ts

@@ -28,6 +28,7 @@ const api: ElectronAPI = {
   storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
   storeLength: (name) => ipcRenderer.invoke("store-length", name),
 
+  getWindowCount: () => ipcRenderer.invoke("get-window-count"),
   onSqliteMigrationProgress: (cb) => {
     const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
     ipcRenderer.on("sqlite-migration-progress", handler)
@@ -62,6 +63,7 @@ const api: ElectronAPI = {
   runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
   checkUpdate: () => ipcRenderer.invoke("check-update"),
   installUpdate: () => ipcRenderer.invoke("install-update"),
+  setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
 }
 
 contextBridge.exposeInMainWorld("api", api)

+ 2 - 0
packages/desktop-electron/src/preload/types.ts

@@ -36,6 +36,7 @@ export type ElectronAPI = {
   storeKeys: (name: string) => Promise<string[]>
   storeLength: (name: string) => Promise<number>
 
+  getWindowCount: () => Promise<number>
   onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
   onMenuCommand: (cb: (id: string) => void) => () => void
   onDeepLink: (cb: (urls: string[]) => void) => () => void
@@ -66,4 +67,5 @@ export type ElectronAPI = {
   runUpdater: (alertOnFail: boolean) => Promise<void>
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
   installUpdate: () => Promise<void>
+  setBackgroundColor: (color: string) => Promise<void>
 }

+ 22 - 4
packages/desktop-electron/src/renderer/index.tsx

@@ -10,14 +10,15 @@ import {
   useCommand,
 } from "@opencode-ai/app"
 import type { AsyncStorage } from "@solid-primitives/storage"
-import { createResource, onCleanup, onMount, Show } from "solid-js"
-import { render } from "solid-js/web"
 import { MemoryRouter } from "@solidjs/router"
+import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
+import { render } from "solid-js/web"
 import pkg from "../../package.json"
 import { initI18n, t } from "./i18n"
 import { UPDATER_ENABLED } from "./updater"
 import { webviewZoom } from "./webview-zoom"
 import "./styles.css"
+import { useTheme } from "@opencode-ai/ui/theme"
 
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -226,7 +227,9 @@ const createPlatform = (): Platform => {
       const image = await window.api.readClipboardImage().catch(() => null)
       if (!image) return null
       const blob = new Blob([image.buffer], { type: "image/png" })
-      return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })
+      return new File([blob], `pasted-image-${Date.now()}.png`, {
+        type: "image/png",
+      })
     },
   }
 }
@@ -240,6 +243,8 @@ listenForDeepLinks()
 render(() => {
   const platform = createPlatform()
 
+  const [windowCount] = createResource(() => window.api.getWindowCount())
+
   // Fetch sidecar credentials (available immediately, before health check)
   const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
 
@@ -276,6 +281,18 @@ render(() => {
   function Inner() {
     const cmd = useCommand()
     menuTrigger = (id) => cmd.trigger(id)
+
+    const theme = useTheme()
+
+    createEffect(() => {
+      theme.themeId()
+      theme.mode()
+      const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
+      if (bg) {
+        void window.api.setBackgroundColor(bg)
+      }
+    })
+
     return null
   }
 
@@ -289,13 +306,14 @@ render(() => {
   return (
     <PlatformProvider value={platform}>
       <AppBaseProviders>
-        <Show when={!defaultServer.loading && !sidecar.loading}>
+        <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
           {(_) => {
             return (
               <AppInterface
                 defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
                 servers={servers()}
                 router={MemoryRouter}
+                disableHealthCheck={(windowCount() ?? 0) > 1}
               >
                 <Inner />
               </AppInterface>

+ 3 - 4
packages/desktop/src/menu.ts

@@ -1,12 +1,11 @@
 import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
+import { openUrl } from "@tauri-apps/plugin-opener"
 import { type as ostype } from "@tauri-apps/plugin-os"
 import { relaunch } from "@tauri-apps/plugin-process"
-import { openUrl } from "@tauri-apps/plugin-opener"
-
-import { runUpdater, UPDATER_ENABLED } from "./updater"
+import { commands } from "./bindings"
 import { installCli } from "./cli"
 import { initI18n, t } from "./i18n"
-import { commands } from "./bindings"
+import { runUpdater, UPDATER_ENABLED } from "./updater"
 
 export async function createMenu(trigger: (id: string) => void) {
   if (ostype() !== "macos") return

+ 12 - 4
packages/ui/src/theme/context.tsx

@@ -1,9 +1,9 @@
-import { onMount, onCleanup, createEffect } from "solid-js"
+import { createEffect, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
-import type { DesktopTheme } from "./types"
-import { resolveThemeVariant, themeToCss } from "./resolve"
-import { DEFAULT_THEMES } from "./default-themes"
 import { createSimpleContext } from "../context/helper"
+import { DEFAULT_THEMES } from "./default-themes"
+import { resolveThemeVariant, themeToCss } from "./resolve"
+import type { DesktopTheme } from "./types"
 
 export type ColorScheme = "light" | "dark" | "system"
 
@@ -87,6 +87,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       previewScheme: null as ColorScheme | null,
     })
 
+    window.addEventListener("storage", (e) => {
+      if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue)
+      if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
+        setStore("colorScheme", e.newValue as ColorScheme)
+        setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any))
+      }
+    })
+
     onMount(() => {
       const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
       const handler = () => {