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

This commit is contained in:
Brendan Allan 2026-07-01 15:02:10 +08:00 committed by GitHub
commit af72cec799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 185 additions and 42 deletions

View file

@ -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: () => {

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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!)