Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
Expand Down
25 changes: 25 additions & 0 deletions packages/desktop/electron.vite.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@ test.each(["build", "serve"] as const)("configures minification for %s", async (
expect(result?.config.renderer?.build?.sourcemap).toBe(true)
})

test("onboarding preview is enabled only by the development test flag", async () => {
const previous = process.env.OPENCODE_TEST_ONBOARDING
try {
process.env.OPENCODE_TEST_ONBOARDING = "1"
for (const command of ["build", "serve"] as const) {
const result = await loadConfigFromFile(
{ command, mode: command === "build" ? "production" : "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe(
JSON.stringify(command === "serve"),
)
}
delete process.env.OPENCODE_TEST_ONBOARDING
const result = await loadConfigFromFile(
{ command: "serve", mode: "development" },
`${import.meta.dirname}/electron.vite.config.ts`,
)
expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe("false")
} finally {
delete process.env.OPENCODE_TEST_ONBOARDING
if (previous !== undefined) process.env.OPENCODE_TEST_ONBOARDING = previous
}
})

test("does not package external copies of bundled dependencies", () => {
for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) {
expect(Object.keys(pkg.dependencies)).not.toContain(name)
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify(
command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1",
),
},
plugins: [pickerPlugin(), appPlugin, sentry],
publicDir: "../../../app/public",
Expand Down
7 changes: 5 additions & 2 deletions packages/desktop/src/main/lifecycle/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export const configureApplication = Effect.fn("Application.configure")(function*

const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
if (testRoot) {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
})

export function acquireApplicationLock() {
Expand Down Expand Up @@ -99,7 +102,7 @@ const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
if (!root) return undefined
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session"],
["data", "config", "cache", "state", "desktop", "session", "documents"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
Expand Down
4 changes: 4 additions & 0 deletions packages/desktop/src/main/windows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") {
if (process.platform === "darwin") app.focus({ steal: true })
win.focus()
}
runFork(Effect.logInfo("main window visible", { window: id }))
}
const ready = () => {
Expand Down
107 changes: 61 additions & 46 deletions packages/desktop/src/renderer/desktop-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { createDesktopPlatform } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
Expand All @@ -37,61 +37,55 @@ const MigrationStatus = lazy(() => import("./migration-status").then((module) =>

export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) {
const windowState = { id: props.api.getWindowID(), version: props.version }
const url = new URL(getLastActiveUrl(windowState.id), "http://localhost")
const initialUrl = getLastActiveUrl(windowState.id)
const url = new URL(initialUrl, "http://localhost")
const route = currentRoute(url.pathname, url.search)
const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({
const [startup, setStartup] = createStore({
ready: false,
visible: true,
themeReady: false,
onboardingReady: false,
drawingReady: false,
route,
})
return (
<>
<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>
</>
const [firstLaunch] = createResource(() =>
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
)
}

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 platform = createDesktopPlatform(props.api, windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = 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} />
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
preloadRoute,
)
const router = (routerProps: BaseRouterProps) => <DesktopMemoryRouter {...routerProps} windowID={windowState.id} />
const readyToReveal = () =>
startup.ready &&
(!import.meta.env.OPENCODE_TEST_ONBOARDING || !firstLaunch() || initialUrl !== "/" || startup.drawingReady)

// Reveal only after the theme and the first-launch splash choice are both resolved.
createEffect(() => {
if (!startup.themeReady || firstLaunch.loading) return
void props.api.themeReady()
})

function ReadyApp() {
const wslServers = useWslServers()
const ssh = useSsh()
const sshConnections = createSshConnections(props.api.sshServers)
const language = useLanguage()
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading && !ssh.loading,
() =>
!firstLaunch.loading &&
!defaultServer.loading &&
!sidecar.loading &&
!locale.loading &&
!wslServers.isLoading &&
!ssh.loading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
Expand Down Expand Up @@ -119,14 +113,16 @@ function DesktopWindow(props: {
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={() => !initialRoute.loading}
onReady={props.onReady}
onRoute={props.onRoute}
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={getLastActiveUrl(props.windowState.id)}
initialUrl={initialUrl}
serverKey={key}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
Expand All @@ -148,25 +144,44 @@ function DesktopWindow(props: {
onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)}
onThemeApplied={(mode, scheme) => {
void props.api.setTitlebar({ mode, scheme })
void props.api.themeReady()
setStartup("themeReady", true)
}}
>
<Show when={true}>{(_) => <ReadyApp />}</Show>
<Show when={!firstLaunch.loading && startup.visible}>
<div
data-component="startup-overlay"
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": readyToReveal() }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !readyToReveal()) return
setStartup("visible", false)
}}
>
<LoadingSplash
firstLaunch={!!firstLaunch() && initialUrl === "/"}
deep={startup.route.type === "draft"}
platform={platform}
preview={import.meta.env.OPENCODE_TEST_ONBOARDING}
onDrawEnd={() => setStartup("drawingReady", true)}
/>
</div>
</Show>
</AppBaseProviders>
</PlatformProvider>
)
}

function DesktopStartupReady(props: {
routeReady: () => boolean
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
if (!props.routeReady || !tabs.ready() || !tabs.infoReady()) return
props.onReady()
})
return null
Expand Down
4 changes: 4 additions & 0 deletions packages/desktop/src/renderer/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { ElectronNative } from "../preload/types"

declare global {
interface ImportMetaEnv {
readonly OPENCODE_TEST_ONBOARDING: boolean
}

interface Window {
electron: ElectronNative
__OPENCODE__?: {
Expand Down
36 changes: 25 additions & 11 deletions packages/desktop/src/renderer/onboarding/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,38 @@
import { ServerConnection, useServers, useTabs } from "@opencode/app/desktop"
import { onMount } from "solid-js"
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
import { createResource } from "solid-js"
import type { ElectronAPI } from "../api-types"

export function DesktopFirstLaunchOnboarding(props: {
api: ElectronAPI
serverKey: ServerConnection.Key
initialUrl: string
pending: boolean
onReady: () => void
}) {
const server = useServers()
const global = useGlobal()
const tabs = useTabs()
const route = useCurrentRoute()

onMount(() => {
void runFirstLaunchOnboarding()
const [completed] = createResource(async () => {
await runFirstLaunchOnboarding()
return null
})

async function runFirstLaunchOnboarding() {
try {
const pending = await props.api.isFirstLaunchOnboardingPending()
if (!pending) return
if (!props.pending) return

await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))

const shouldTrigger =
props.initialUrl === "/" && tabs.store.length === 0 && server.list.every(ServerConnection.builtin)
props.initialUrl === "/" &&
route().type === "home" &&
tabs.store.length === 0 &&
server.list.every(ServerConnection.builtin)

console.info("[desktop-onboarding] first launch onboarding evaluated", {
pending,
pending: props.pending,
shouldTrigger,
initialUrl: props.initialUrl,
tabs: tabs.store.length,
Expand All @@ -39,11 +46,18 @@ export function DesktopFirstLaunchOnboarding(props: {
const projects = server.projects.forServer(props.serverKey)
projects.open(directory)
projects.touch(directory)
const connection = server.list.find((connection) => ServerConnection.key(connection) === props.serverKey)
if (connection) {
const data = global.ensureServerCtx(connection).data
// Load the initial provider/model state before the draft transition exposes the composer.
await Promise.all([data.location.provider.sync({ directory }), data.location.model.sync({ directory })])
}
tabs.select(await tabs.newDraft({ server: props.serverKey, directory }))
} catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error)
} finally {
props.onReady()
}
}

return null
// Let startup failures reach the app's recovery screen, including its splash boundary.
return <>{completed()}</>
}
25 changes: 25 additions & 0 deletions packages/desktop/src/renderer/startup/splash.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[data-component="first-launch-splash"] path {
/* Outline paths use a normalized length so the drawing scales with the wordmark. */
stroke-dasharray: 1;
animation: first-launch-wordmark-draw 1000ms ease-out both;
}

[data-component="first-launch-splash"][data-drawing="pending"] path {
animation-play-state: paused;
}

@keyframes first-launch-wordmark-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}

@media (prefers-reduced-motion: reduce) {
[data-component="first-launch-splash"] path {
animation: none;
stroke-dasharray: none;
}
}
Loading
Loading