Sfoglia il codice sorgente

perf: show window immediately during desktop startup (#6734)

Co-authored-by: Brendan Allan <[email protected]>
usvimal 1 mese fa
parent
commit
a8c2928a87

+ 69 - 63
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>
+  )
+}

+ 4 - 2
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!,

+ 1 - 1
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"

+ 18 - 0
packages/desktop/src-tauri/Cargo.lock

@@ -1177,6 +1177,21 @@ dependencies = [
  "new_debug_unreachable",
 ]
 
+[[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"
@@ -1184,6 +1199,7 @@ 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",

+ 1 - 0
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]

+ 117 - 110
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(())

+ 36 - 7
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>
+  )
+}