chore: merge dev into v2
This commit is contained in:
commit
6cd5812ca1
304 changed files with 24678 additions and 4507 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
packages/desktop/src/main/onboarding.ts
Normal file
28
packages/desktop/src/main/onboarding.ts
Normal 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,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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
79
packages/desktop/src/renderer/onboarding.tsx
Normal file
79
packages/desktop/src/renderer/onboarding.tsx
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue