Просмотр исходного кода

feat(desktop): custom update toast

Adam 2 месяцев назад
Родитель
Сommit
e37a75a411

+ 47 - 44
packages/desktop/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
-import { Show } from "solid-js"
+import { ErrorBoundary, Show } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"
@@ -20,6 +20,7 @@ import Layout from "@/pages/layout"
 import Home from "@/pages/home"
 import DirectoryLayout from "@/pages/directory-layout"
 import Session from "@/pages/session"
+import { ErrorPage } from "./pages/error"
 
 declare global {
   interface Window {
@@ -38,48 +39,50 @@ const url =
 
 export function App() {
   return (
-    <MetaProvider>
-      <Font />
-      <DialogProvider>
-        <MarkedProvider>
-          <DiffComponentProvider component={Diff}>
-            <CodeComponentProvider component={Code}>
-              <GlobalSDKProvider url={url}>
-                <GlobalSyncProvider>
-                  <LayoutProvider>
-                    <NotificationProvider>
-                      <Router
-                        root={(props) => (
-                          <CommandProvider>
-                            <Layout>{props.children}</Layout>
-                          </CommandProvider>
-                        )}
-                      >
-                        <Route path="/" component={Home} />
-                        <Route path="/:dir" component={DirectoryLayout}>
-                          <Route path="/" component={() => <Navigate href="session" />} />
-                          <Route
-                            path="/session/:id?"
-                            component={(p) => (
-                              <Show when={p.params.id || true} keyed>
-                                <TerminalProvider>
-                                  <PromptProvider>
-                                    <Session />
-                                  </PromptProvider>
-                                </TerminalProvider>
-                              </Show>
-                            )}
-                          />
-                        </Route>
-                      </Router>
-                    </NotificationProvider>
-                  </LayoutProvider>
-                </GlobalSyncProvider>
-              </GlobalSDKProvider>
-            </CodeComponentProvider>
-          </DiffComponentProvider>
-        </MarkedProvider>
-      </DialogProvider>
-    </MetaProvider>
+    <ErrorBoundary fallback={ErrorPage}>
+      <MetaProvider>
+        <Font />
+        <DialogProvider>
+          <MarkedProvider>
+            <DiffComponentProvider component={Diff}>
+              <CodeComponentProvider component={Code}>
+                <GlobalSDKProvider url={url}>
+                  <GlobalSyncProvider>
+                    <LayoutProvider>
+                      <NotificationProvider>
+                        <Router
+                          root={(props) => (
+                            <CommandProvider>
+                              <Layout>{props.children}</Layout>
+                            </CommandProvider>
+                          )}
+                        >
+                          <Route path="/" component={Home} />
+                          <Route path="/:dir" component={DirectoryLayout}>
+                            <Route path="/" component={() => <Navigate href="session" />} />
+                            <Route
+                              path="/session/:id?"
+                              component={(p) => (
+                                <Show when={p.params.id || true} keyed>
+                                  <TerminalProvider>
+                                    <PromptProvider>
+                                      <Session />
+                                    </PromptProvider>
+                                  </TerminalProvider>
+                                </Show>
+                              )}
+                            />
+                          </Route>
+                        </Router>
+                      </NotificationProvider>
+                    </LayoutProvider>
+                  </GlobalSyncProvider>
+                </GlobalSDKProvider>
+              </CodeComponentProvider>
+            </DiffComponentProvider>
+          </MarkedProvider>
+        </DialogProvider>
+      </MetaProvider>
+    </ErrorBoundary>
   )
 }

+ 6 - 0
packages/desktop/src/context/platform.tsx

@@ -19,6 +19,12 @@ export type Platform = {
 
   /** Storage mechanism, defaults to localStorage */
   storage?: (name?: string) => SyncStorage | AsyncStorage
+
+  /** Check for updates (Tauri only) */
+  checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
+
+  /** Install updates (Tauri only) */
+  update?(): Promise<void>
 }
 
 export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

+ 45 - 10
packages/desktop/src/pages/layout.tsx

@@ -1,4 +1,15 @@
-import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Match,
+  onMount,
+  ParentProps,
+  Show,
+  Switch,
+  type JSX,
+} from "solid-js"
 import { DateTime } from "luxon"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, getAvatarColors } from "@/context/layout"
@@ -28,7 +39,7 @@ import {
 } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
 import { useProviders } from "@/hooks/use-providers"
-import { Toast } from "@opencode-ai/ui/toast"
+import { showToast, Toast } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useNotification } from "@/context/notification"
 import { Binary } from "@opencode-ai/util/binary"
@@ -46,14 +57,6 @@ export default function Layout(props: ParentProps) {
 
   let scrollContainerRef: HTMLDivElement | undefined
 
-  function scrollToSession(sessionId: string) {
-    if (!scrollContainerRef) return
-    const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
-    if (element) {
-      element.scrollIntoView({ block: "center", behavior: "smooth" })
-    }
-  }
-
   const params = useParams()
   const globalSDK = useGlobalSDK()
   const globalSync = useGlobalSync()
@@ -65,6 +68,30 @@ export default function Layout(props: ParentProps) {
   const dialog = useDialog()
   const command = useCommand()
 
+  onMount(async () => {
+    if (platform.checkUpdate && platform.update) {
+      const { updateAvailable, version } = await platform.checkUpdate()
+      if (updateAvailable) {
+        showToast({
+          persistent: true,
+          icon: "download",
+          title: "Update available",
+          description: `A new version of OpenCode (${version}) is now available to install.`,
+          actions: [
+            {
+              label: "Install and restart",
+              onClick: () => platform!.update!(),
+            },
+            {
+              label: "Not yet",
+              onClick: "dismiss",
+            },
+          ],
+        })
+      }
+    }
+  })
+
   function flattenSessions(sessions: Session[]): Session[] {
     const childrenMap = new Map<string, Session[]>()
     for (const session of sessions) {
@@ -87,6 +114,14 @@ export default function Layout(props: ParentProps) {
     return result
   }
 
+  function scrollToSession(sessionId: string) {
+    if (!scrollContainerRef) return
+    const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
+    if (element) {
+      element.scrollIntoView({ block: "center", behavior: "smooth" })
+    }
+  }
+
   const currentSessions = createMemo(() => {
     if (!params.dir) return []
     const directory = base64Decode(params.dir)

+ 21 - 6
packages/tauri/src/index.tsx

@@ -1,14 +1,16 @@
 // @refresh reload
 import { render } from "solid-js/web"
 import { App, PlatformProvider, Platform } from "@opencode-ai/desktop"
-import { onMount } from "solid-js"
 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 { AsyncStorage } from "@solid-primitives/storage"
 
-import { runUpdater, UPDATER_ENABLED } from "./updater"
+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 { relaunch } from "@tauri-apps/plugin-process"
 
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -17,6 +19,8 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
   )
 }
 
+let update: Update | null = null
+
 const platform: Platform = {
   platform: "tauri",
 
@@ -66,15 +70,26 @@ const platform: Platform = {
     }
     return api
   },
+
+  checkUpdate: async () => {
+    if (!UPDATER_ENABLED) return { updateAvailable: false }
+    update = await check()
+    if (!update) return { updateAvailable: false }
+    await update.download()
+    return { updateAvailable: true, version: update.version }
+  },
+
+  update: async () => {
+    if (!UPDATER_ENABLED || !update) return
+    await update.install()
+    await invoke("kill_sidecar")
+    await relaunch()
+  },
 }
 
 createMenu()
 
 render(() => {
-  onMount(() => {
-    if (UPDATER_ENABLED) runUpdater({ alertOnFail: false })
-  })
-
   return (
     <PlatformProvider value={platform}>
       {ostype() === "macos" && (

+ 1 - 0
packages/ui/src/components/icon.tsx

@@ -53,6 +53,7 @@ const icons = {
   check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
   photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
   share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
+  download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
 }
 
 export interface IconProps extends ComponentProps<"svg"> {

+ 3 - 3
packages/ui/src/components/toast.css

@@ -134,7 +134,7 @@
     padding: 0;
     cursor: pointer;
 
-    color: var(--text-invert-strong);
+    color: var(--text-invert-weak);
     font-family: var(--font-family-sans);
     font-size: var(--font-size-base);
     font-weight: var(--font-weight-medium);
@@ -145,8 +145,8 @@
       text-decoration: underline;
     }
 
-    &:last-child {
-      color: var(--text-invert-weak);
+    &:first-child {
+      color: var(--text-invert-strong);
     }
   }
 

+ 12 - 3
packages/ui/src/components/toast.tsx

@@ -91,7 +91,7 @@ export type ToastVariant = "default" | "success" | "error" | "loading"
 
 export interface ToastAction {
   label: string
-  onClick: () => void
+  onClick: "dismiss" | (() => void)
 }
 
 export interface ToastOptions {
@@ -100,13 +100,19 @@ export interface ToastOptions {
   icon?: IconProps["name"]
   variant?: ToastVariant
   duration?: number
+  persistent?: boolean
   actions?: ToastAction[]
 }
 
 export function showToast(options: ToastOptions | string) {
   const opts = typeof options === "string" ? { description: options } : options
   return toaster.show((props) => (
-    <Toast toastId={props.toastId} duration={opts.duration} data-variant={opts.variant ?? "default"}>
+    <Toast
+      toastId={props.toastId}
+      duration={opts.duration}
+      persistent={opts.persistent}
+      data-variant={opts.variant ?? "default"}
+    >
       <Show when={opts.icon}>
         <Toast.Icon name={opts.icon!} />
       </Show>
@@ -120,7 +126,10 @@ export function showToast(options: ToastOptions | string) {
         <Show when={opts.actions?.length}>
           <Toast.Actions>
             {opts.actions!.map((action) => (
-              <button data-slot="toast-action" onClick={action.onClick}>
+              <button
+                data-slot="toast-action"
+                onClick={typeof action.onClick === "function" ? action.onClick : () => toaster.dismiss(props.toastId)}
+              >
                 {action.label}
               </button>
             ))}