diff options
| author | usvimal <[email protected]> | 2026-01-07 15:19:01 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-07 15:19:01 +0800 |
| commit | a8c2928a8778eff5928d11ab52f9f8526cc2a7a1 (patch) | |
| tree | 1e6ffdfeaae9b04f32a538543e11602d8f229419 /packages | |
| parent | 79f6910697df9a6021b56d9c045493c0ae2f3b3d (diff) | |
| download | opencode-a8c2928a8778eff5928d11ab52f9f8526cc2a7a1.tar.gz opencode-a8c2928a8778eff5928d11ab52f9f8526cc2a7a1.zip | |
perf: show window immediately during desktop startup (#6734)
Co-authored-by: Brendan Allan <[email protected]>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/app/src/app.tsx | 132 | ||||
| -rw-r--r-- | packages/app/src/entry.tsx | 6 | ||||
| -rw-r--r-- | packages/app/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/Cargo.lock | 18 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/lib.rs | 227 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 43 |
7 files changed, 246 insertions, 183 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a2f1aa401..13d9d147e 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show, Suspense, lazy, type ParentProps } from "solid-js" +import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -20,10 +20,12 @@ import { FileProvider } from "@/context/file" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" +import { Logo } from "@opencode-ai/ui/logo" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" +import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -31,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex declare global { interface Window { - __OPENCODE__?: { updaterEnabled?: boolean; port?: number } + __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean } } } @@ -47,16 +49,7 @@ const defaultServerUrl = iife(() => { return window.location.origin }) -function ServerKey(props: ParentProps) { - const server = useServer() - return ( - <Show when={server.url} keyed> - {props.children} - </Show> - ) -} - -export function App() { +export function AppBaseProviders(props: ParentProps) { return ( <MetaProvider> <Font /> @@ -65,57 +58,7 @@ export function App() { <DialogProvider> <MarkedProvider> <DiffComponentProvider component={Diff}> - <CodeComponentProvider component={Code}> - <ServerProvider defaultUrl={defaultServerUrl}> - <ServerKey> - <GlobalSDKProvider> - <GlobalSyncProvider> - <Router - root={(props) => ( - <PermissionProvider> - <LayoutProvider> - <NotificationProvider> - <CommandProvider> - <Layout>{props.children}</Layout> - </CommandProvider> - </NotificationProvider> - </LayoutProvider> - </PermissionProvider> - )} - > - <Route - path="/" - component={() => ( - <Suspense fallback={<Loading />}> - <Home /> - </Suspense> - )} - /> - <Route path="/:dir" component={DirectoryLayout}> - <Route path="/" component={() => <Navigate href="session" />} /> - <Route - path="/session/:id?" - component={(p) => ( - <Show when={p.params.id ?? "new"} keyed> - <TerminalProvider> - <FileProvider> - <PromptProvider> - <Suspense fallback={<Loading />}> - <Session /> - </Suspense> - </PromptProvider> - </FileProvider> - </TerminalProvider> - </Show> - )} - /> - </Route> - </Router> - </GlobalSyncProvider> - </GlobalSDKProvider> - </ServerKey> - </ServerProvider> - </CodeComponentProvider> + <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider> </DiffComponentProvider> </MarkedProvider> </DialogProvider> @@ -124,3 +67,66 @@ export function App() { </MetaProvider> ) } + +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + <Show when={server.url} keyed> + {props.children} + </Show> + ) +} + +export function AppInterface() { + return ( + <ServerProvider defaultUrl={defaultServerUrl}> + <ServerKey> + <GlobalSDKProvider> + <GlobalSyncProvider> + <Router + root={(props) => ( + <PermissionProvider> + <LayoutProvider> + <NotificationProvider> + <CommandProvider> + <Layout>{props.children}</Layout> + </CommandProvider> + </NotificationProvider> + </LayoutProvider> + </PermissionProvider> + )} + > + <Route + path="/" + component={() => ( + <Suspense fallback={<Loading />}> + <Home /> + </Suspense> + )} + /> + <Route path="/:dir" component={DirectoryLayout}> + <Route path="/" component={() => <Navigate href="session" />} /> + <Route + path="/session/:id?" + component={(p) => ( + <Show when={p.params.id ?? "new"} keyed> + <TerminalProvider> + <FileProvider> + <PromptProvider> + <Suspense fallback={<Loading />}> + <Session /> + </Suspense> + </PromptProvider> + </FileProvider> + </TerminalProvider> + </Show> + )} + /> + </Route> + </Router> + </GlobalSyncProvider> + </GlobalSDKProvider> + </ServerKey> + </ServerProvider> + ) +} diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index a915aa25b..28741098c 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,6 +1,6 @@ // @refresh reload import { render } from "solid-js/web" -import { App } from "@/app" +import { AppBaseProviders, AppInterface } from "@/app" import { Platform, PlatformProvider } from "@/context/platform" import pkg from "../package.json" @@ -55,7 +55,9 @@ const platform: Platform = { render( () => ( <PlatformProvider value={platform}> - <App /> + <AppBaseProviders> + <AppInterface /> + </AppBaseProviders> </PlatformProvider> ), root!, diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index cf5be9f51..df3181133 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,2 +1,2 @@ export { PlatformProvider, type Platform } from "./context/platform" -export { App } from "./app" +export { AppBaseProviders, AppInterface } from "./app" diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index a35db0b3a..c533bf9e9 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -1178,12 +1178,28 @@ dependencies = [ ] [[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1251,6 +1267,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2775,6 +2792,7 @@ dependencies = [ name = "opencode-desktop" version = "0.0.0" dependencies = [ + "futures", "gtk", "listeners", "semver", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 63aafe280..af8adc119 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ serde_json = "1" tokio = "1.48.0" listeners = "0.3" tauri-plugin-os = "2" +futures = "0.3.31" semver = "1.0.27" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 5d1610fa3..c96abd9f4 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod cli; mod window_customizer; use cli::{get_sidecar_path, install_cli, sync_cli}; +use futures::FutureExt; use std::{ collections::VecDeque, net::{SocketAddr, TcpListener}, @@ -9,10 +10,9 @@ use std::{ time::{Duration, Instant}, }; use tauri::{ - path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, + path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, + WebviewWindow, }; -use tauri_plugin_clipboard_manager::ClipboardExt; -use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; use tokio::net::TcpSocket; @@ -20,7 +20,26 @@ use tokio::net::TcpSocket; use crate::window_customizer::PinchZoomDisablePlugin; #[derive(Clone)] -struct ServerState(Arc<Mutex<Option<CommandChild>>>); +struct ServerState { + child: Arc<Mutex<Option<CommandChild>>>, + status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>, +} + +impl ServerState { + pub fn new( + child: Option<CommandChild>, + status: tokio::sync::oneshot::Receiver<Result<(), String>>, + ) -> Self { + Self { + child: Arc::new(Mutex::new(child)), + status: status.shared(), + } + } + + pub fn set_child(&self, child: Option<CommandChild>) { + *self.child.lock().unwrap() = child; + } +} #[derive(Clone)] struct LogState(Arc<Mutex<VecDeque<String>>>); @@ -35,7 +54,7 @@ fn kill_sidecar(app: AppHandle) { }; let Some(server_state) = server_state - .0 + .child .lock() .expect("Failed to acquire mutex lock") .take() @@ -49,8 +68,7 @@ fn kill_sidecar(app: AppHandle) { println!("Killed server"); } -#[tauri::command] -async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> { +async fn get_logs(app: AppHandle) -> Result<String, String> { let log_state = app.try_state::<LogState>().ok_or("Log state not found")?; let logs = log_state @@ -58,25 +76,16 @@ async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> { .lock() .map_err(|_| "Failed to acquire log lock")?; - let log_text = logs.iter().cloned().collect::<Vec<_>>().join(""); - - app.clipboard() - .write_text(log_text) - .map_err(|e| format!("Failed to copy to clipboard: {}", e))?; - - Ok(()) + Ok(logs.iter().cloned().collect::<Vec<_>>().join("")) } #[tauri::command] -async fn get_logs(app: AppHandle) -> Result<String, String> { - let log_state = app.try_state::<LogState>().ok_or("Log state not found")?; - - let logs = log_state - .0 - .lock() - .map_err(|_| "Failed to acquire log lock")?; - - Ok(logs.iter().cloned().collect::<Vec<_>>().join("")) +async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> { + state + .status + .clone() + .await + .map_err(|_| "Failed to get server status".to_string())? } fn get_sidecar_port() -> u32 { @@ -209,9 +218,8 @@ pub fn run() { .plugin(PinchZoomDisablePlugin) .invoke_handler(tauri::generate_handler![ kill_sidecar, - copy_logs_to_clipboard, - get_logs, - install_cli + install_cli, + ensure_server_started ]) .setup(move |app| { let app = app.handle().clone(); @@ -219,94 +227,93 @@ pub fn run() { // Initialize log state app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); + // Get port and create window immediately for faster perceived startup + let port = get_sidecar_port(); + + let primary_monitor = app.primary_monitor().ok().flatten(); + let size = primary_monitor + .map(|m| m.size().to_logical(m.scale_factor())) + .unwrap_or(LogicalSize::new(1920, 1080)); + + // Create window immediately with serverReady = false + let mut window_builder = + WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) + .title("OpenCode") + .inner_size(size.width as f64, size.height as f64) + .decorations(true) + .zoom_hotkeys_enabled(true) + .disable_drag_drop_handler() + .initialization_script(format!( + r#" + window.__OPENCODE__ ??= {{}}; + window.__OPENCODE__.updaterEnabled = {updater_enabled}; + window.__OPENCODE__.port = {port}; + "# + )); + + #[cfg(target_os = "macos")] { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let port = get_sidecar_port(); - - let should_spawn_sidecar = !is_server_running(port).await; - - let child = if should_spawn_sidecar { - let child = spawn_sidecar(&app, port); - - let timestamp = Instant::now(); - loop { - if timestamp.elapsed() > Duration::from_secs(7) { - let res = app.dialog() - .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") - .title("Startup Failed") - .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string())) - .blocking_show_with_result(); - - if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { - match copy_logs_to_clipboard(app.clone()).await { - Ok(()) => println!("Logs copied to clipboard successfully"), - Err(e) => println!("Failed to copy logs to clipboard: {}", e), - } - } - - app.exit(1); - - return; - } - - tokio::time::sleep(Duration::from_millis(10)).await; - - if is_server_running(port).await { - // give the server a little bit more time to warm up - tokio::time::sleep(Duration::from_millis(10)).await; - - break; - } - } - - println!("Server ready after {:?}", timestamp.elapsed()); - - Some(child) - } else { - None - }; - - let primary_monitor = app.primary_monitor().ok().flatten(); - let size = primary_monitor - .map(|m| m.size().to_logical(m.scale_factor())) - .unwrap_or(LogicalSize::new(1920, 1080)); - - let mut window_builder = - WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) - .title("OpenCode") - .inner_size(size.width as f64, size.height as f64) - .decorations(true) - .zoom_hotkeys_enabled(true) - .disable_drag_drop_handler() - .initialization_script(format!( - r#" - window.__OPENCODE__ ??= {{}}; - window.__OPENCODE__.updaterEnabled = {updater_enabled}; - window.__OPENCODE__.port = {port}; - "# - )); - - #[cfg(target_os = "macos")] - { - window_builder = window_builder - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - } - - window_builder.build().expect("Failed to create window"); - - app.manage(ServerState(Arc::new(Mutex::new(child)))); - }); + window_builder = window_builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); } + let window = window_builder.build().expect("Failed to create window"); + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.manage(ServerState::new(None, rx)); + { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(e) = sync_cli(app) { - eprintln!("Failed to sync CLI: {e}"); - } - }); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let should_spawn_sidecar = !is_server_running(port).await; + + let (child, res) = if should_spawn_sidecar { + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + let res = loop { + if timestamp.elapsed() > Duration::from_secs(7) { + break Err(format!( + "Failed to spawn OpenCode Server. Logs:\n{}", + get_logs(app.clone()).await.unwrap() + )); + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + // give the server a little bit more time to warm up + tokio::time::sleep(Duration::from_millis(10)).await; + + break Ok(()); + } + }; + + println!("Server ready after {:?}", timestamp.elapsed()); + + (Some(child), res) + } else { + (None, Ok(())) + }; + + app.state::<ServerState>().set_child(child); + + if res.is_ok() { + let _ = window.eval("window.__OPENCODE__.serverReady = true;"); + } + + let _ = tx.send(res); + }); + } + + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = sync_cli(app) { + eprintln!("Failed to sync CLI: {e}"); + } + }); } Ok(()) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index aee2c8249..94cb887f5 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,20 +1,22 @@ // @refresh reload import { render } from "solid-js/web" -import { App, PlatformProvider, Platform } from "@opencode-ai/app" +import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" +import { check, Update } from "@tauri-apps/plugin-updater" +import { invoke } from "@tauri-apps/api/core" +import { getCurrentWindow } from "@tauri-apps/api/window" +import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" +import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" +import { Logo } from "@opencode-ai/ui/logo" +import { Suspense, createResource, ParentProps } from "solid-js" import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" -import { check, Update } from "@tauri-apps/plugin-updater" -import { invoke } from "@tauri-apps/api/core" -import { getCurrentWindow } from "@tauri-apps/api/window" -import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" -import { relaunch } from "@tauri-apps/plugin-process" import pkg from "../package.json" const root = document.getElementById("root") @@ -269,7 +271,34 @@ render(() => { {ostype() === "macos" && ( <div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region /> )} - <App /> + <AppBaseProviders> + <ServerGate> + <AppInterface /> + </ServerGate> + </AppBaseProviders> </PlatformProvider> ) }, root!) + +// Gate component that waits for the server to be ready +function ServerGate(props: ParentProps) { + const [status] = createResource(async () => { + if (window.__OPENCODE__?.serverReady) return + return await invoke("ensure_server_started") + }) + + return ( + <Suspense + fallback={ + <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base"> + <Logo class="w-xl opacity-12 animate-pulse" /> + <div class="mt-8 text-14-regular text-text-weak">Starting server...</div> + </div> + } + > + {/* Triggers suspense/error boundaries without rendering the returned value */} + {(status(), null)} + <Suspense>{props.children}</Suspense> + </Suspense> + ) +} |
