feat(desktop): scope tabs to windows (#34669)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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!)
|
||||||
|
|||||||
Reference in New Issue
Block a user