chore: merge dev into v2 (#35591)

Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
Aiden Cline
2026-07-06 16:05:29 -05:00
committed by GitHub
co-authored by Frank Aarav Sareen Brendan Allan opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Jack Brendan Allan Shoubhit Dash opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> James Long Dustin Deus starptech Luke Parker 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 Dax usrnk1 Jay runvip opencode Julian Coy Vladimir Glafirov Adam Kit Langton Simon Klee Jay David Hill
parent f87998f37f
commit 9e0d3976e1
332 changed files with 24650 additions and 4497 deletions
+5
View File
@@ -19,6 +19,7 @@ import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
import {
getDefaultServerUrl,
preferAppEnv,
@@ -159,6 +160,7 @@ const main = Effect.gen(function* () {
wslServers.stopAll()
}
const relaunch = () => {
setAppQuitting()
void stopSidecars().finally(() => {
app.relaunch()
app.exit(0)
@@ -234,6 +236,7 @@ const main = Effect.gen(function* () {
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
setAppQuitting()
void stopSidecars().finally(() => app.exit(0))
})
}
@@ -275,6 +278,8 @@ const main = Effect.gen(function* () {
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
isFirstLaunchOnboardingPending,
finishFirstLaunchOnboarding,
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
+6
View File
@@ -27,6 +27,8 @@ type Deps = {
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
@@ -50,6 +52,10 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending())
ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
deps.finishFirstLaunchOnboarding(createDefaultProject),
)
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
+28
View File
@@ -0,0 +1,28 @@
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { app } from "electron"
import { getStore } from "./store"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
import { write as writeLog } from "./logging"
const DEFAULT_PROJECT_DIR = "New OpenCode Project"
export function isFirstLaunchOnboardingPending() {
const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
writeLog("onboarding", "first launch onboarding pending checked", { pending })
return pending
}
export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) {
if (!isFirstLaunchOnboardingPending()) {
writeLog("onboarding", "first launch onboarding already completed")
return null
}
const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
if (defaultProject) await mkdir(defaultProject, { recursive: true })
getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true)
writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject })
return defaultProject
}
+1
View File
@@ -1,5 +1,6 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const WINDOW_IDS_KEY = "windowIds"
+7
View File
@@ -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)
}
+18 -1
View File
@@ -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)
@@ -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"])
})
})
@@ -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)
},
}
}
+27 -40
View File
@@ -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
+3
View File
@@ -59,6 +59,9 @@ const api: ElectronAPI = {
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
finishFirstLaunchOnboarding: (createDefaultProject) =>
ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
+2
View File
@@ -49,6 +49,8 @@ export type ElectronAPI = {
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
isFirstLaunchOnboardingPending: () => Promise<boolean>
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
+14 -2
View File
@@ -23,6 +23,7 @@ import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
import "./styles.css"
@@ -347,6 +348,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const router = (props: BaseRouterProps) => (
<DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
)
const onboarding = Promise.withResolvers<void>()
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -400,12 +402,22 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
const effectiveDefaultServer = createMemo(() =>
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
)
return (
<Show when={ready()} fallback={<LoadingSplash />}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<AppInterface
defaultServer={key}
servers={servers()}
router={router}
startup={onboarding.promise}
serverScoped={
<DesktopFirstLaunchOnboarding
initialUrl={getLastActiveUrl(platform.windowID ?? "browser")}
onLoaded={onboarding.resolve}
/>
}
>
<Inner />
</AppInterface>
)}
@@ -0,0 +1,79 @@
import {
ServerConnection,
useLayout,
useProviders,
useServer,
useServerSDK,
useServerSync,
useTabs,
} from "@opencode-ai/app"
import { onMount, startTransition } from "solid-js"
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
const server = useServer()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const layout = useLayout()
const providers = useProviders()
const tabs = useTabs()
onMount(() => {
void runFirstLaunchOnboarding().finally(props.onLoaded)
})
async function runFirstLaunchOnboarding() {
try {
await Promise.all(
[server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map(
(p) => p ?? Promise.resolve(),
),
)
if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending()
if (!pending) return
const sessions = await serverSDK()
.client.session.list()
.then((x) => x.data ?? [])
.catch(() => undefined)
const connectedProviders = providers.connected()
const paidProviders = providers.paid()
const persistedProjects = layout.projects.list()
const shouldTrigger =
props.initialUrl === "/" &&
sessions?.length === 0 &&
paidProviders.length === 0 &&
persistedProjects.length === 0 &&
tabs.store.length === 0 &&
server.list.every(ServerConnection.builtin)
console.info("[desktop-onboarding] first launch onboarding evaluated", {
pending,
shouldTrigger,
initialUrl: props.initialUrl,
sessions: sessions?.length,
connectedProviders: connectedProviders.length,
paidProviders: paidProviders.length,
serverProjects: serverSync().data.project.length,
persistedProjects: persistedProjects.length,
tabs: tabs.store.length,
servers: server.list.map(ServerConnection.key),
})
const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger)
if (!shouldTrigger || !directory) return
console.info("[desktop-onboarding] starting first launch draft", { directory })
server.projects.open(directory)
server.projects.touch(directory)
await startTransition(() => {
tabs.newDraft({ server: server.key, directory })
})
} catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error)
}
}
return null
}