mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 05:43:55 +00:00
desktop: multi-window support in electron (#17155)
This commit is contained in:
parent
d9dd33aeeb
commit
84df96eaef
@ -159,7 +159,7 @@ const effectMinDuration =
|
||||
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||
|
||||
function ConnectionGate(props: ParentProps) {
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
@ -168,21 +168,23 @@ function ConnectionGate(props: ParentProps) {
|
||||
// performs repeated health check with a grace period for
|
||||
// non-http connections, otherwise fails instantly
|
||||
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||
Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
props.disableHealthCheck
|
||||
? true
|
||||
: Effect.gen(function* () {
|
||||
if (!server.current) return true
|
||||
const { http, type } = server.current
|
||||
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
while (true) {
|
||||
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||
if (res.healthy) return true
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
@ -261,10 +263,11 @@ export function AppInterface(props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ConnectionGate>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Dynamic
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
// @refresh reload
|
||||
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
@ -132,7 +131,11 @@ if (root instanceof HTMLElement) {
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||
servers={[server]}
|
||||
disableHealthCheck
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@ -5,7 +5,7 @@ import { createServer } from "node:net"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Event } from "electron"
|
||||
import { app, type BrowserWindow, dialog } from "electron"
|
||||
import { app, BrowserWindow, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
@ -32,7 +32,7 @@ import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
|
||||
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
@ -156,12 +156,9 @@ async function initialize() {
|
||||
|
||||
const globals = {
|
||||
updaterEnabled: UPDATER_ENABLED,
|
||||
wsl: getWslConfig().enabled,
|
||||
deepLinks: pendingDeepLinks,
|
||||
}
|
||||
|
||||
wireMenu()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
@ -178,6 +175,7 @@ async function initialize() {
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow(globals)
|
||||
wireMenu()
|
||||
|
||||
overlay?.close()
|
||||
}
|
||||
@ -231,6 +229,7 @@ registerIpcHandlers({
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
|
||||
@ -24,6 +24,7 @@ type Deps = {
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
@ -53,6 +54,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
|
||||
ipcMain.handle("check-update", () => deps.checkUpdate())
|
||||
ipcMain.handle("install-update", () => deps.installUpdate())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
@ -140,6 +142,8 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
new Notification({ title, body }).show()
|
||||
})
|
||||
|
||||
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
|
||||
|
||||
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { BrowserWindow, Menu, shell } from "electron"
|
||||
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createMainWindow } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
@ -48,6 +49,11 @@ export function createMenu(deps: Deps) {
|
||||
submenu: [
|
||||
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
|
||||
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "Cmd+Shift+N",
|
||||
click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "close" },
|
||||
],
|
||||
|
||||
@ -6,12 +6,21 @@ import type { TitlebarTheme } from "../preload/types"
|
||||
|
||||
type Globals = {
|
||||
updaterEnabled: boolean
|
||||
wsl: boolean
|
||||
deepLinks?: string[]
|
||||
}
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
let backgroundColor: string | undefined
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
function iconsDir() {
|
||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
||||
}
|
||||
@ -59,6 +68,7 @@ export function createMainWindow(globals: Globals) {
|
||||
show: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@ -95,6 +105,7 @@ export function createLoadingWindow(globals: Globals) {
|
||||
center: true,
|
||||
show: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
@ -131,7 +142,6 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
|
||||
const deepLinks = globals.deepLinks ?? []
|
||||
const data = {
|
||||
updaterEnabled: globals.updaterEnabled,
|
||||
wsl: globals.wsl,
|
||||
deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
|
||||
}
|
||||
void win.webContents.executeJavaScript(
|
||||
|
||||
@ -28,6 +28,7 @@ const api: ElectronAPI = {
|
||||
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
|
||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||
|
||||
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
|
||||
onSqliteMigrationProgress: (cb) => {
|
||||
const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
|
||||
ipcRenderer.on("sqlite-migration-progress", handler)
|
||||
@ -62,6 +63,7 @@ const api: ElectronAPI = {
|
||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||
installUpdate: () => ipcRenderer.invoke("install-update"),
|
||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
|
||||
@ -36,6 +36,7 @@ export type ElectronAPI = {
|
||||
storeKeys: (name: string) => Promise<string[]>
|
||||
storeLength: (name: string) => Promise<number>
|
||||
|
||||
getWindowCount: () => Promise<number>
|
||||
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||
@ -66,4 +67,5 @@ export type ElectronAPI = {
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void>
|
||||
setBackgroundColor: (color: string) => Promise<void>
|
||||
}
|
||||
|
||||
@ -10,14 +10,15 @@ import {
|
||||
useCommand,
|
||||
} from "@opencode-ai/app"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@ -226,7 +227,9 @@ const createPlatform = (): Platform => {
|
||||
const image = await window.api.readClipboardImage().catch(() => null)
|
||||
if (!image) return null
|
||||
const blob = new Blob([image.buffer], { type: "image/png" })
|
||||
return new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })
|
||||
return new File([blob], `pasted-image-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -240,6 +243,8 @@ listenForDeepLinks()
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
|
||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||
|
||||
// Fetch sidecar credentials (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||
|
||||
@ -276,6 +281,18 @@ render(() => {
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
createEffect(() => {
|
||||
theme.themeId()
|
||||
theme.mode()
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
|
||||
if (bg) {
|
||||
void window.api.setBackgroundColor(bg)
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@ -289,13 +306,14 @@ render(() => {
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading}>
|
||||
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
disableHealthCheck={(windowCount() ?? 0) > 1}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
import { commands } from "./bindings"
|
||||
import { installCli } from "./cli"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { commands } from "./bindings"
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
|
||||
export async function createMenu(trigger: (id: string) => void) {
|
||||
if (ostype() !== "macos") return
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { DesktopTheme } from "./types"
|
||||
import { resolveThemeVariant, themeToCss } from "./resolve"
|
||||
import { DEFAULT_THEMES } from "./default-themes"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { DEFAULT_THEMES } from "./default-themes"
|
||||
import { resolveThemeVariant, themeToCss } from "./resolve"
|
||||
import type { DesktopTheme } from "./types"
|
||||
|
||||
export type ColorScheme = "light" | "dark" | "system"
|
||||
|
||||
@ -87,6 +87,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
previewScheme: null as ColorScheme | null,
|
||||
})
|
||||
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue)
|
||||
if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) {
|
||||
setStore("colorScheme", e.newValue as ColorScheme)
|
||||
setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any))
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handler = () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user