mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
fix(desktop): smooth startup hydration (#44377)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
|
||||
export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
|
||||
@@ -65,7 +65,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
|
||||
const [info, setInfo, , infoReady] = persisted(
|
||||
Persist.window("tabs.info"),
|
||||
createStore<Record<string, TabInfo>>({}),
|
||||
)
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
|
||||
const params = useParams()
|
||||
@@ -378,6 +381,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, info, ready, recentReady }
|
||||
return { ...actions, store, info, ready, infoReady, recentReady }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,28 +2,13 @@ import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { WindowRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import {
|
||||
getPinchZoomEnabled,
|
||||
getWindowID,
|
||||
setPinchZoomEnabled,
|
||||
setTitlebar,
|
||||
setWindowThemeReady,
|
||||
updateTitlebar,
|
||||
} from "../windows"
|
||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const windowHandlers = WindowRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return WindowRpcs.of({
|
||||
WindowGetId: (_args, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (!win) throw new Error("Window not found")
|
||||
const id = getWindowID(win)
|
||||
if (!id) throw new Error("Window ID not found")
|
||||
return id
|
||||
}),
|
||||
WindowThemeReady: (_args, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
|
||||
@@ -7,6 +7,7 @@ import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { forgetStore, getStore } from "../storage/store"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import {
|
||||
getBackgroundColor,
|
||||
getPinchZoomEnabled,
|
||||
@@ -24,7 +25,6 @@ import { createWindowRegistry } from "./registry"
|
||||
import { makeWindowRecovery } from "./recovery"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
|
||||
const windowIDs = new WeakMap<BrowserWindow, string>()
|
||||
const themeReady = new WeakMap<BrowserWindow, () => void>()
|
||||
const registry = createWindowRegistry<BrowserWindow>({
|
||||
read: () => getStore().get(WINDOW_IDS_KEY),
|
||||
@@ -59,10 +59,6 @@ export function setAppQuitting(quitting = true) {
|
||||
registry.setQuitting(quitting)
|
||||
}
|
||||
|
||||
export function getWindowID(win: BrowserWindow) {
|
||||
return windowIDs.get(win)
|
||||
}
|
||||
|
||||
export function getLastFocusedWindow() {
|
||||
const focused = BrowserWindow.getFocusedWindow()
|
||||
if (focused) return focused
|
||||
@@ -89,6 +85,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
|
||||
const create = (id: string = randomUUID()) => {
|
||||
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
|
||||
const appearance = windowAppearance(path, paths)
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
@@ -96,7 +93,11 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
height: state.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...windowAppearance(path, paths),
|
||||
...appearance,
|
||||
webPreferences: {
|
||||
...appearance.webPreferences,
|
||||
additionalArguments: [windowIDArgument(id)],
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
@@ -132,7 +133,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
}
|
||||
|
||||
const register = (win: BrowserWindow, id: string) => {
|
||||
windowIDs.set(win, id)
|
||||
registry.register(id, win)
|
||||
win.on("focus", () => registry.focused(id))
|
||||
// Windows emits session-end, but not before-quit, during shutdown and logoff.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { windowIDFromArguments } from "../shared/window-bootstrap"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
const port = event.ports[0]
|
||||
@@ -7,5 +8,6 @@ ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
windowID: windowIDFromArguments(process.argv),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export type ElectronNative = {
|
||||
windowID: string
|
||||
getPathForFile(file: File): string
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ElectronAPI = {
|
||||
draftDelete(key: string): Promise<void>
|
||||
draftBlobPut(data: ArrayBuffer): Promise<string>
|
||||
draftBlobGet(id: string): Promise<ArrayBuffer | null>
|
||||
getWindowID(): Promise<string>
|
||||
getWindowID(): string
|
||||
themeReady(): Promise<void>
|
||||
onMenuCommand(cb: (id: string) => void): () => void
|
||||
onDeepLink(cb: (urls: string[]) => void): () => void
|
||||
|
||||
@@ -86,7 +86,7 @@ export const api: ElectronAPI = {
|
||||
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
|
||||
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
|
||||
|
||||
getWindowID: () => invoke("WindowGetId"),
|
||||
getWindowID: () => window.electron.windowID,
|
||||
themeReady: () => invoke("WindowThemeReady"),
|
||||
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
|
||||
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
|
||||
|
||||
@@ -4,16 +4,22 @@
|
||||
import {
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
currentRoute,
|
||||
PlatformProvider,
|
||||
preloadRoute,
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
useCurrentRoute,
|
||||
useLanguage,
|
||||
useTabs,
|
||||
useWslServers,
|
||||
type LayoutRoute,
|
||||
type UpdaterPlatform,
|
||||
} from "@opencode-ai/app/desktop"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import type { BaseRouterProps } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
|
||||
@@ -28,19 +34,51 @@ import { availableStartupServer, readyWslConnections } from "./wsl/connections"
|
||||
const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus })))
|
||||
|
||||
export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
|
||||
const [windowState] = createResource(() => props.api.getWindowID().then((id) => ({ id, version: props.version })))
|
||||
const windowState = { id: props.api.getWindowID(), version: props.version }
|
||||
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
|
||||
const route = currentRoute(url.pathname, url.search)
|
||||
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
|
||||
ready: false,
|
||||
visible: true,
|
||||
route,
|
||||
})
|
||||
return (
|
||||
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
|
||||
{(state) => <DesktopWindow api={props.api} updater={props.updater} windowState={state} />}
|
||||
</Show>
|
||||
<>
|
||||
<DesktopWindow
|
||||
api={props.api}
|
||||
updater={props.updater}
|
||||
windowState={windowState}
|
||||
onReady={() => setStartup("ready", true)}
|
||||
onRoute={(route) => setStartup("route", route)}
|
||||
/>
|
||||
<Show when={startup.visible}>
|
||||
<div
|
||||
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
|
||||
classList={{ "pointer-events-none opacity-0": startup.ready }}
|
||||
onTransitionEnd={(event) => {
|
||||
if (event.target !== event.currentTarget || !startup.ready) return
|
||||
setStartup("visible", false)
|
||||
}}
|
||||
>
|
||||
<LoadingSplash deep={startup.route.type === "draft"} />
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
|
||||
function DesktopWindow(props: {
|
||||
api: ElectronAPI
|
||||
updater: UpdaterPlatform
|
||||
windowState: DesktopWindowState
|
||||
onReady: () => void
|
||||
onRoute: (route: LayoutRoute) => void
|
||||
}) {
|
||||
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
|
||||
const [sidecar] = createResource(() => props.api.awaitInitialization())
|
||||
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
|
||||
const [locale] = createResource(() => preloadStoredLocale(platform))
|
||||
const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id)))
|
||||
const router = (routerProps: BaseRouterProps) => (
|
||||
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
|
||||
)
|
||||
@@ -74,10 +112,15 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={ready()} fallback={<LoadingSplash />}>
|
||||
<Show when={ready()}>
|
||||
<Show when={effectiveDefaultServer()} keyed>
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<DesktopStartupReady
|
||||
routeReady={() => !initialRoute.loading}
|
||||
onReady={props.onReady}
|
||||
onRoute={props.onRoute}
|
||||
/>
|
||||
<DesktopFirstLaunchOnboarding
|
||||
api={props.api}
|
||||
initialUrl={getLastActiveUrl(props.windowState.id)}
|
||||
@@ -109,6 +152,21 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
|
||||
)
|
||||
}
|
||||
|
||||
function DesktopStartupReady(props: {
|
||||
routeReady: () => boolean
|
||||
onReady: () => void
|
||||
onRoute: (route: LayoutRoute) => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const route = useCurrentRoute()
|
||||
createEffect(() => props.onRoute(route()))
|
||||
createEffect(() => {
|
||||
if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return
|
||||
props.onReady()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function DesktopEffects(props: { api: ElectronAPI }) {
|
||||
const command = useCommand()
|
||||
bindDesktopMenu((id) => command.trigger(id))
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
|
||||
export function LoadingSplash() {
|
||||
export function LoadingSplash(props: { deep: boolean }) {
|
||||
return (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<div
|
||||
class="h-dvh w-screen flex flex-col items-center justify-center"
|
||||
classList={{
|
||||
"bg-v2-background-bg-deep": props.deep,
|
||||
"bg-v2-background-bg-base": !props.deep,
|
||||
}}
|
||||
>
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
|
||||
export const WindowThemeReady = Rpc.make("WindowThemeReady")
|
||||
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
|
||||
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
|
||||
@@ -26,7 +25,6 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
|
||||
},
|
||||
})
|
||||
export const WindowRpcs = RpcGroup.make(
|
||||
WindowGetId,
|
||||
WindowThemeReady,
|
||||
WindowGetFocused,
|
||||
WindowGetFullscreen,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
|
||||
|
||||
describe("window bootstrap", () => {
|
||||
test("round-trips the window ID through renderer arguments", () => {
|
||||
const id = "window/id with spaces"
|
||||
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
|
||||
})
|
||||
|
||||
test("requires a window ID argument", () => {
|
||||
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
const windowIDPrefix = "--opencode-window-id="
|
||||
|
||||
export function windowIDArgument(id: string) {
|
||||
return windowIDPrefix + encodeURIComponent(id)
|
||||
}
|
||||
|
||||
export function windowIDFromArguments(args: readonly string[]) {
|
||||
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
|
||||
if (!value) throw new Error("Window ID argument not found")
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
Reference in New Issue
Block a user