mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
fix(desktop): keep window tabs across app close (#34807)
This commit is contained in:
parent
3df5556223
commit
f266e829cf
6 changed files with 192 additions and 41 deletions
|
|
@ -159,6 +159,7 @@ const main = Effect.gen(function* () {
|
|||
wslServers.stopAll()
|
||||
}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
|
|
@ -234,6 +235,7 @@ const main = Effect.gen(function* () {
|
|||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import Store from "electron-store"
|
||||
import electron from "electron"
|
||||
import { rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { SETTINGS_STORE } from "./store-keys"
|
||||
import { deleteStoreFileIfEmpty } from "./store-cleanup"
|
||||
|
|
@ -26,3 +28,8 @@ export function getStore(name = SETTINGS_STORE) {
|
|||
export async function removeStoreFileIfEmpty(name: string) {
|
||||
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
||||
}
|
||||
|
||||
export function removeStoreFile(name: string) {
|
||||
rmSync(join(electron.app.getPath("userData"), name), { force: true })
|
||||
cache.delete(name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { UPDATER_ENABLED } from "./constants"
|
|||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { getLogger } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
import { setAppQuitting } from "./windows"
|
||||
|
||||
const { autoUpdater } = pkg
|
||||
const key = "ready"
|
||||
|
|
@ -27,7 +28,23 @@ export function setupAutoUpdater(stop: () => Promise<void>) {
|
|||
return createUpdaterController({
|
||||
enabled: UPDATER_ENABLED,
|
||||
currentVersion: app.getVersion(),
|
||||
backend: autoUpdater,
|
||||
backend: {
|
||||
checkForUpdates: () => autoUpdater.checkForUpdates(),
|
||||
downloadUpdate: () => autoUpdater.downloadUpdate(),
|
||||
quitAndInstall: () => {
|
||||
// quitAndInstall closes all windows before emitting before-quit, so
|
||||
// flag the quit first to keep window ids persisted for restore.
|
||||
setAppQuitting()
|
||||
try {
|
||||
autoUpdater.quitAndInstall()
|
||||
} catch (error) {
|
||||
// The install failed and the app keeps running; clear the flag so
|
||||
// deliberate window closes prune ids again.
|
||||
setAppQuitting(false)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
persistence: {
|
||||
get() {
|
||||
const value = store.get(key)
|
||||
|
|
|
|||
91
packages/desktop/src/main/window-registry.test.ts
Normal file
91
packages/desktop/src/main/window-registry.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { createWindowRegistry } from "./window-registry"
|
||||
|
||||
function setup(initial: unknown = []) {
|
||||
const state = { stored: initial }
|
||||
const cleaned: string[] = []
|
||||
const registry = createWindowRegistry<{ name: string }>({
|
||||
read: () => state.stored,
|
||||
write: (ids) => {
|
||||
state.stored = ids
|
||||
},
|
||||
cleanup: (id) => cleaned.push(id),
|
||||
})
|
||||
return { registry, state, cleaned }
|
||||
}
|
||||
|
||||
describe("window registry", () => {
|
||||
test("restores persisted ids and ignores malformed entries", () => {
|
||||
expect(setup(["a", "", 42, "b"]).registry.persisted()).toEqual(["a", "b"])
|
||||
expect(setup("junk").registry.persisted()).toEqual([])
|
||||
expect(setup(undefined).registry.persisted()).toEqual([])
|
||||
})
|
||||
|
||||
test("registers windows and persists each id once", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
expect(app.state.stored).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("forgets a deliberately closed window while others remain open", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.closed("a")
|
||||
expect(app.state.stored).toEqual(["b"])
|
||||
expect(app.cleaned).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("keeps the id when the last window closes so relaunch restores it", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.closed("a")
|
||||
expect(app.state.stored).toEqual(["a"])
|
||||
expect(app.cleaned).toEqual([])
|
||||
|
||||
const restarted = createWindowRegistry<{ name: string }>({
|
||||
read: () => app.state.stored,
|
||||
write: (ids) => {
|
||||
app.state.stored = ids
|
||||
},
|
||||
cleanup: () => {},
|
||||
})
|
||||
expect(restarted.persisted()).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("keeps every id when windows close during quit", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.setQuitting()
|
||||
app.registry.closed("a")
|
||||
app.registry.closed("b")
|
||||
expect(app.state.stored).toEqual(["a", "b"])
|
||||
expect(app.cleaned).toEqual([])
|
||||
})
|
||||
|
||||
test("tracks the last focused window and falls back on close", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.focused("a")
|
||||
expect(app.registry.lastFocused()).toEqual({ name: "a" })
|
||||
app.registry.closed("a")
|
||||
expect(app.registry.lastFocused()).toEqual({ name: "b" })
|
||||
app.registry.closed("b")
|
||||
expect(app.registry.lastFocused()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("resumes forgetting closed windows after the quit flag resets", () => {
|
||||
const app = setup()
|
||||
app.registry.register("a", { name: "a" })
|
||||
app.registry.register("b", { name: "b" })
|
||||
app.registry.setQuitting()
|
||||
app.registry.setQuitting(false)
|
||||
app.registry.closed("a")
|
||||
expect(app.state.stored).toEqual(["b"])
|
||||
expect(app.cleaned).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
47
packages/desktop/src/main/window-registry.ts
Normal file
47
packages/desktop/src/main/window-registry.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Tracks open windows and the persisted window id list used to restore
|
||||
// windows (and their per-window persisted state) across app launches.
|
||||
export function createWindowRegistry<W>(persistence: {
|
||||
read: () => unknown
|
||||
write: (ids: string[]) => void
|
||||
cleanup: (id: string) => void
|
||||
}) {
|
||||
const windows = new Map<string, W>()
|
||||
let quitting = false
|
||||
let lastFocusedID: string | undefined
|
||||
|
||||
const persisted = () => {
|
||||
const value = persistence.read()
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
}
|
||||
|
||||
return {
|
||||
persisted,
|
||||
setQuitting(value = true) {
|
||||
quitting = value
|
||||
},
|
||||
register(id: string, window: W) {
|
||||
windows.set(id, window)
|
||||
const ids = persisted()
|
||||
if (!ids.includes(id)) persistence.write([...ids, id])
|
||||
},
|
||||
focused(id: string) {
|
||||
lastFocusedID = id
|
||||
},
|
||||
lastFocused() {
|
||||
if (!lastFocusedID) return
|
||||
return windows.get(lastFocusedID)
|
||||
},
|
||||
closed(id: string) {
|
||||
windows.delete(id)
|
||||
if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
|
||||
// Only a deliberate close (app keeps running with other windows open)
|
||||
// forgets a window. Closing the last window quits the app and fires
|
||||
// `closed` before `before-quit`, so treat it as a quit and keep the id
|
||||
// for restore on next launch.
|
||||
if (quitting || windows.size === 0) return
|
||||
persistence.write(persisted().filter((item) => item !== id))
|
||||
persistence.cleanup(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,10 @@ 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 { getStore, removeStoreFile } from "./store"
|
||||
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
|
||||
import { createUnresponsiveSampler } from "./unresponsive"
|
||||
import { createWindowRegistry } from "./window-registry"
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const rendererRoot = join(root, "../renderer")
|
||||
|
|
@ -41,15 +42,21 @@ protocol.registerSchemesAsPrivileged([
|
|||
|
||||
let backgroundColor: string | undefined
|
||||
let relaunchHandler = () => {
|
||||
setAppQuitting()
|
||||
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 registry = createWindowRegistry<BrowserWindow>({
|
||||
read: () => getStore().get(WINDOW_IDS_KEY),
|
||||
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
|
||||
cleanup: (id) => {
|
||||
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
|
||||
removeStoreFile(windowDataFile(id))
|
||||
},
|
||||
})
|
||||
const titlebarHeight = 40
|
||||
const maxZoomLevel = 10
|
||||
const minZoomLevel = 0.2
|
||||
|
|
@ -58,8 +65,8 @@ export function setRelaunchHandler(handler: () => void) {
|
|||
relaunchHandler = handler
|
||||
}
|
||||
|
||||
export function setAppQuitting() {
|
||||
appQuitting = true
|
||||
export function setAppQuitting(quitting = true) {
|
||||
registry.setQuitting(quitting)
|
||||
}
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
|
|
@ -128,14 +135,13 @@ export function getWindowID(win: BrowserWindow) {
|
|||
export function getLastFocusedWindow() {
|
||||
const focused = BrowserWindow.getFocusedWindow()
|
||||
if (focused) return focused
|
||||
if (!lastFocusedWindowID) return null
|
||||
const win = windowsByID.get(lastFocusedWindowID)
|
||||
const win = registry.lastFocused()
|
||||
if (!win || win.isDestroyed()) return null
|
||||
return win
|
||||
}
|
||||
|
||||
export function restoreMainWindows() {
|
||||
const ids = readWindowIDs()
|
||||
const ids = registry.persisted()
|
||||
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
|
||||
}
|
||||
|
||||
|
|
@ -213,44 +219,25 @@ export function createMainWindow(id: string = randomUUID()) {
|
|||
|
||||
function registerWindow(win: BrowserWindow, id: string) {
|
||||
windowIDs.set(win, id)
|
||||
windowsByID.set(id, win)
|
||||
persistWindowID(id)
|
||||
registry.register(id, win)
|
||||
|
||||
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 })
|
||||
win.on("focus", () => registry.focused(id))
|
||||
// Windows never emits before-quit on OS shutdown/logoff, but each window
|
||||
// gets session-end before it closes; flag the quit so ids stay persisted.
|
||||
win.on("session-end", () => registry.setQuitting())
|
||||
win.on("closed", () => registry.closed(id))
|
||||
}
|
||||
|
||||
function windowStateFile(id: string) {
|
||||
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
// Mirrors windowStorage() in packages/app/src/utils/persist.ts, which names
|
||||
// the per-window renderer store this window persists its tabs into.
|
||||
function windowDataFile(id: string) {
|
||||
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
|
||||
}
|
||||
|
||||
export function registerRendererProtocol() {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue