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

This commit is contained in:
Brendan Allan
2026-07-01 07:02:10 +00:00
committed by GitHub
parent 55552c521f
commit af72cec799
14 changed files with 185 additions and 42 deletions
@@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
}), }),
) )
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([ JSON.stringify([
...sessionIDs.map((sessionId) => ({ ...sessionIDs.map((sessionId) => ({
type: "session", type: "session",
@@ -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("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([ JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA }, { type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB }, { 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("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]), JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
) )
}, },
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
}), }),
) )
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))), JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
) )
}, },
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript( await page.addInitScript(
({ dirBase64, sourceID, targetID }) => { ({ dirBase64, sourceID, targetID }) => {
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify( JSON.stringify(
[sourceID, targetID].map((sessionId) => ({ [sourceID, targetID].map((sessionId) => ({
type: "session", type: "session",
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript( await page.addInitScript(
({ dirBase64, sourceID, targetID }) => { ({ dirBase64, sourceID, targetID }) => {
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.window.browser.dat:tabs",
JSON.stringify( JSON.stringify(
[sourceID, targetID].map((sessionId) => ({ [sourceID, targetID].map((sessionId) => ({
type: "session", type: "session",
+3
View File
@@ -64,6 +64,9 @@ type PlatformBase = {
/** Storage mechanism, defaults to localStorage */ /** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage storage?: (name?: string) => SyncStorage | AsyncStorage
/** Stable platform window identity for window-scoped persistence */
windowID?: string
/** Application-global desktop updater */ /** Application-global desktop updater */
updater?: UpdaterPlatform updater?: UpdaterPlatform
+2 -2
View File
@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const fallback = server.key const fallback = server.key
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ {
...Persist.global("tabs"), ...Persist.window("tabs"),
migrate: (value: unknown) => { migrate: (value: unknown) => {
if (!Array.isArray(value)) return value if (!Array.isArray(value)) return value
return value.map((tab) => { return value.map((tab) => {
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}, },
createStore<Tab[]>([]), 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 params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
+23 -1
View File
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
type PersistTarget = { type PersistTarget = {
storage?: string storage?: string
scope?: "window"
legacyStorageNames?: string[] legacyStorageNames?: string[]
key: string key: string
legacy?: string[] legacy?: string[]
@@ -24,6 +25,7 @@ type PersistTarget = {
const LEGACY_STORAGE = "default.dat" const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat" const GLOBAL_STORAGE = "opencode.global.dat"
const WINDOW_STORAGE = "opencode.window"
const LOCAL_PREFIX = "opencode." const LOCAL_PREFIX = "opencode."
const fallback = new Map<string, boolean>() const fallback = new Map<string, boolean>()
@@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
return `opencode.draft.${head}.${sum}.dat` 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) { function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir)) const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>() const result = new Set<string>()
@@ -467,6 +474,8 @@ export const PersistTesting = {
localStorageWithPrefix, localStorageWithPrefix,
migrateLegacy, migrateLegacy,
normalize, normalize,
resolveTarget,
windowStorage,
workspaceStorage, workspaceStorage,
} }
@@ -474,6 +483,9 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget { global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy } 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 { draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy } 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( export function removePersisted(
target: { storage?: string; legacyStorageNames?: string[]; key: string }, target: { storage?: string; legacyStorageNames?: string[]; key: string },
platform?: Platform, platform?: Platform,
@@ -533,7 +555,7 @@ export function persisted<T>(
store: [Store<T>, SetStoreFunction<T>], store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> { ): PersistedWithReady<T> {
const platform = usePlatform() 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 defaults = snapshot(store[0])
const legacy = config.legacy ?? [] const legacy = config.legacy ?? []
+15 -10
View File
@@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls" import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron" import type { Event } from "electron"
import { app, BrowserWindow } from "electron" import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect" import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu" import contextMenu from "electron-context-menu"
@@ -28,11 +28,13 @@ import {
} from "./server" } from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater" import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import { import {
createMainWindow, getLastFocusedWindow,
registerRendererProtocol, registerRendererProtocol,
setRelaunchHandler, setRelaunchHandler,
setAppQuitting,
setBackgroundColor, setBackgroundColor,
setDockIcon, setDockIcon,
restoreMainWindows,
} from "./windows" } from "./windows"
import { createWslServersController } from "./wsl/servers" import { createWslServersController } from "./wsl/servers"
import { registerWslIpcHandlers } from "./wsl/ipc" import { registerWslIpcHandlers } from "./wsl/ipc"
@@ -53,7 +55,6 @@ const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging> let logger: ReturnType<typeof initLogging>
let mainWindow: BrowserWindow | null = null
let server: SidecarListener | null = null let server: SidecarListener | null = null
const pendingDeepLinks: string[] = [] const pendingDeepLinks: string[] = []
@@ -70,7 +71,8 @@ function useEnvProxy() {
function emitDeepLinks(urls: string[]) { function emitDeepLinks(urls: string[]) {
if (urls.length === 0) return if (urls.length === 0) return
pendingDeepLinks.push(...urls) pendingDeepLinks.push(...urls)
if (mainWindow) sendDeepLinks(mainWindow, urls) const win = getLastFocusedWindow()
if (win) sendDeepLinks(win, urls)
} }
async function killSidecar() { async function killSidecar() {
@@ -194,9 +196,10 @@ const main = Effect.gen(function* () {
logger.log("deep link received via second-instance", { urls }) logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls) emitDeepLinks(urls)
} }
if (mainWindow) { const win = getLastFocusedWindow()
mainWindow.show() if (win) {
mainWindow.focus() win.show()
win.focus()
} }
}) })
@@ -207,10 +210,12 @@ const main = Effect.gen(function* () {
}) })
app.on("before-quit", () => { app.on("before-quit", () => {
setAppQuitting()
void stopSidecars() void stopSidecars()
}) })
app.on("will-quit", () => { app.on("will-quit", () => {
setAppQuitting()
void stopSidecars() void stopSidecars()
}) })
@@ -347,11 +352,11 @@ const main = Effect.gen(function* () {
yield* Fiber.await(loadingTask) yield* Fiber.await(loadingTask)
mainWindow = createMainWindow() const windows = restoreMainWindows()
if (mainWindow) { if (windows.length) {
createMenu({ createMenu({
trigger: (id) => { trigger: (id) => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id) if (win) sendMenuCommand(win, id)
}, },
checkForUpdates: () => { checkForUpdates: () => {
+15 -1
View File
@@ -9,7 +9,13 @@ import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../prel
import { runDesktopMenuAction } from "./desktop-menu-actions" import { runDesktopMenuAction } from "./desktop-menu-actions"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore } from "./store" 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 type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions" 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-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) => { ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false return win?.isFocused() ?? false
+1
View File
@@ -2,3 +2,4 @@ export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers" export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const WINDOW_IDS_KEY = "windowIds"
+73 -3
View File
@@ -2,13 +2,15 @@ import windowState from "electron-window-state"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types" import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json" 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 { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path" import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url" import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types" import type { TitlebarTheme } from "../preload/types"
import { exportDebugLogs, write as writeLog } from "./logging" import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store" 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" import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url)) const root = dirname(fileURLToPath(import.meta.url))
@@ -42,8 +44,12 @@ let relaunchHandler = () => {
app.relaunch() app.relaunch()
app.exit(0) app.exit(0)
} }
let appQuitting = false
let lastFocusedWindowID: string | undefined
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>() const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>() const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const windowIDs = new WeakMap<BrowserWindow, string>()
const windowsByID = new Map<string, BrowserWindow>()
const titlebarHeight = 40 const titlebarHeight = 40
const maxZoomLevel = 10 const maxZoomLevel = 10
const minZoomLevel = 0.2 const minZoomLevel = 0.2
@@ -52,6 +58,10 @@ export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler relaunchHandler = handler
} }
export function setAppQuitting() {
appQuitting = true
}
export function setBackgroundColor(color: string) { export function setBackgroundColor(color: string) {
backgroundColor = color backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color)) BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
@@ -111,14 +121,33 @@ export function getPinchZoomEnabled() {
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true 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() { export function setDockIcon() {
if (process.platform !== "darwin") return if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png")) const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon) if (!icon.isEmpty()) app.dock?.setIcon(icon)
} }
export function createMainWindow() { export function createMainWindow(id: string = randomUUID()) {
const state = windowState({ const state = windowState({
file: windowStateFile(id),
defaultWidth: 1280, defaultWidth: 1280,
defaultHeight: 800, defaultHeight: 800,
}) })
@@ -156,7 +185,7 @@ export function createMainWindow() {
}) })
allowRendererPermissions(win) allowRendererPermissions(win)
wireWindowRecovery(win, "main") wireWindowRecovery(win, id)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details const { requestHeaders } = details
@@ -171,6 +200,7 @@ export function createMainWindow() {
}) })
state.manage(win) state.manage(win)
registerWindow(win, id)
loadWindow(win, "index.html") loadWindow(win, "index.html")
wireZoom(win) wireZoom(win)
@@ -181,6 +211,46 @@ export function createMainWindow() {
return win 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() { export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return if (protocol.isProtocolHandled(rendererProtocol)) return
+1
View File
@@ -73,6 +73,7 @@ const api: ElectronAPI = {
storeLength: (name) => ipcRenderer.invoke("store-length", name), storeLength: (name) => ipcRenderer.invoke("store-length", name),
getWindowCount: () => ipcRenderer.invoke("get-window-count"), getWindowCount: () => ipcRenderer.invoke("get-window-count"),
getWindowID: () => ipcRenderer.invoke("get-window-id"),
onMenuCommand: (cb) => { onMenuCommand: (cb) => {
const handler = (_: unknown, id: string) => cb(id) const handler = (_: unknown, id: string) => cb(id)
ipcRenderer.on("menu-command", handler) ipcRenderer.on("menu-command", handler)
+1
View File
@@ -62,6 +62,7 @@ export type ElectronAPI = {
storeLength: (name: string) => Promise<number> storeLength: (name: string) => Promise<number>
getWindowCount: () => Promise<number> getWindowCount: () => Promise<number>
getWindowID: () => Promise<string>
onMenuCommand: (cb: (id: string) => void) => () => void onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void onDeepLink: (cb: (urls: string[]) => void) => () => void
+45 -19
View File
@@ -63,7 +63,10 @@ const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "di
void window.api.updater.subscribe(setUpdaterState) void window.api.updater.subscribe(setUpdaterState)
const deepLinkEvent = "opencode:deep-link" const deepLinkEvent = "opencode:deep-link"
const lastActiveUrlKey = "opencode.desktop.last-active-url"
type DesktopWindowState = {
id?: string
}
const emitDeepLinks = (urls: string[]) => { const emitDeepLinks = (urls: string[]) => {
if (urls.length === 0) return if (urls.length === 0) return
@@ -78,31 +81,35 @@ const listenForDeepLinks = () => {
return window.api.onDeepLink((urls) => emitDeepLinks(urls)) 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 "/" if (typeof localStorage !== "object") return "/"
try { try {
const value = localStorage.getItem(lastActiveUrlKey) const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
if (value?.startsWith("/") && !value.startsWith("//")) return value if (value?.startsWith("/") && !value.startsWith("//")) return value
} catch {} } catch {}
return "/" return "/"
} }
function setLastActiveUrl(value: string) { function setLastActiveUrl(windowID: string, value: string) {
if (typeof localStorage !== "object") return if (typeof localStorage !== "object") return
try { try {
localStorage.setItem(lastActiveUrlKey, value) localStorage.setItem(windowLastActiveUrlKey(windowID), value)
} catch {} } catch {}
} }
function DesktopMemoryRouter(props: BaseRouterProps) { function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
const history = createMemoryHistory() const history = createMemoryHistory()
const initialUrl = getLastActiveUrl() const initialUrl = getLastActiveUrl(props.windowID)
if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false }) 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} /> return <MemoryRouter {...props} history={history} />
} }
const createPlatform = (): Platform => { const createPlatform = (windowState: DesktopWindowState): Platform => {
const attachmentPaths = new WeakMap<File, string>() const attachmentPaths = new WeakMap<File, string>()
const os = (() => { const os = (() => {
const ua = navigator.userAgent const ua = navigator.userAgent
@@ -161,6 +168,7 @@ const createPlatform = (): Platform => {
platform: "desktop", platform: "desktop",
os, os,
version: pkg.version, version: pkg.version,
windowID: windowState.id,
async openDirectoryPickerDialog(opts) { async openDirectoryPickerDialog(opts) {
return window.api.openDirectoryPicker({ return window.api.openDirectoryPicker({
@@ -307,8 +315,16 @@ window.api.onMenuCommand((id) => {
}) })
listenForDeepLinks() listenForDeepLinks()
render(() => { function LoadingSplash() {
const platform = createPlatform() 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 loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language") const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -328,6 +344,7 @@ render(() => {
const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale) const [locale] = createResource(loadLocale)
const router = (props: BaseRouterProps) => <DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -357,12 +374,6 @@ render(() => {
function App() { function App() {
const wslServers = useWslServers() 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( const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading, () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
) )
@@ -389,10 +400,10 @@ render(() => {
) )
return ( return (
<Show when={ready()} fallback={splash}> <Show when={ready()} fallback={<LoadingSplash />}>
<Show when={effectiveDefaultServer()} keyed> <Show when={effectiveDefaultServer()} keyed>
{(key) => ( {(key) => (
<AppInterface defaultServer={key} servers={servers()} router={DesktopMemoryRouter}> <AppInterface defaultServer={key} servers={servers()} router={router}>
<Inner /> <Inner />
</AppInterface> </AppInterface>
)} )}
@@ -415,4 +426,19 @@ render(() => {
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </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!) }, root!)