Преглед на файлове

feat(desktop): show migration progress (#42495)

Luke Parker преди 2 дни
родител
ревизия
72d37cda81
променени са 3 файла, в които са добавени 113 реда и са изтрити 2 реда
  1. 1 0
      packages/app/src/i18n/en.ts
  2. 6 2
      packages/desktop/src/renderer/index.tsx
  3. 106 0
      packages/desktop/src/renderer/migration-status.tsx

+ 1 - 0
packages/app/src/i18n/en.ts

@@ -579,6 +579,7 @@ export const dict = {
 
   "toast.session.listFailed.title": "Failed to load sessions for {{project}}",
   "toast.project.reloadFailed.title": "Failed to reload {{project}}",
+  "toast.migration.failed.title": "Data migration failed",
 
   "toast.update.title": "Update available",
   "toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.",

+ 6 - 2
packages/desktop/src/renderer/index.tsx

@@ -29,6 +29,7 @@ import { DesktopFirstLaunchOnboarding } from "./onboarding"
 import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
 import { windowFullscreen } from "./window-fullscreen"
 import { availableStartupServer, readyWslConnections } from "./wsl/connections"
+import { MigrationStatus } from "./migration-status"
 import "./styles.css"
 import { Splash } from "@opencode-ai/ui/logo"
 import { useTheme } from "@opencode-ai/ui/theme/context"
@@ -357,7 +358,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
   )
   const onboarding = Promise.withResolvers<void>()
 
-  function Inner() {
+  function DesktopEffects() {
     const cmd = useCommand()
     menuTrigger = (id) => cmd.trigger(id)
 
@@ -412,7 +413,10 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
                 onLoaded={onboarding.resolve}
                 serverKey={key}
               />
-              <Inner />
+              <DesktopEffects />
+              <Show when={initializationData(sidecar)} keyed>
+                {(server) => <MigrationStatus server={server} />}
+              </Show>
             </AppInterface>
           )}
         </Show>

+ 106 - 0
packages/desktop/src/renderer/migration-status.tsx

@@ -0,0 +1,106 @@
+import { OpenCode, type MigrationV1StatusOutput } from "@opencode-ai/client/promise"
+import { useLanguage } from "@opencode-ai/app"
+import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
+import { showToastV2, toasterV2, ToastV2 } from "@opencode-ai/ui/v2/toast-v2"
+import { createRoot, createSignal, onCleanup, onMount } from "solid-js"
+import type { ServerReadyData } from "../preload/types"
+
+type Progress = Extract<MigrationV1StatusOutput, { status: "running" }>["progress"]
+
+export function MigrationStatus(props: { server: ServerReadyData }) {
+  const language = useLanguage()
+  const [progress, setProgress] = createSignal<Progress>()
+  const abort = new AbortController()
+  let toastID: number | undefined
+  let disposeToast: (() => void) | undefined
+
+  const hide = () => {
+    if (toastID !== undefined) toasterV2.dismiss(toastID)
+    toastID = undefined
+    disposeToast?.()
+    disposeToast = undefined
+  }
+
+  const show = () => {
+    if (toastID !== undefined) return
+    toastID = toasterV2.show(
+      ({ toastId }) =>
+        createRoot((dispose) => {
+          disposeToast?.()
+          disposeToast = dispose
+          return (
+            <ToastV2 toastId={toastId}>
+              <div data-slot="toast-v2-header" class="col-span-full">
+                <ToastV2.Icon>
+                  <LoaderV2 />
+                </ToastV2.Icon>
+                <ToastV2.Content>
+                  <ToastV2.Title dir="auto">{format(progress())}</ToastV2.Title>
+                </ToastV2.Content>
+              </div>
+            </ToastV2>
+          )
+        }),
+      { persistent: true },
+    )
+  }
+
+  onMount(async () => {
+    await wait(1_000, abort.signal)
+    if (abort.signal.aborted) return
+
+    const client = OpenCode.make({
+      baseUrl: props.server.url,
+      headers: props.server.password
+        ? { Authorization: `Basic ${btoa(`${props.server.username ?? "opencode"}:${props.server.password}`)}` }
+        : undefined,
+    })
+
+    void (async () => {
+      while (true) {
+        const status = await client.migration.v1.status({ signal: abort.signal })
+        setProgress(status.status === "running" ? status.progress : undefined)
+        if (status.status === "running") show()
+        else hide()
+        if (status.status === "completed") return
+        if (status.status === "error") throw new Error(status.error)
+        await wait(1_000, abort.signal)
+      }
+    })().catch((error) => {
+      if (abort.signal.aborted) return
+      hide()
+      showToastV2({
+        variant: "error",
+        title: language.t("toast.migration.failed.title"),
+        description: error instanceof Error ? error.message : String(error),
+        duration: 10_000,
+      })
+    })
+  })
+
+  onCleanup(() => {
+    abort.abort()
+    hide()
+  })
+
+  return null
+}
+
+function format(progress: Progress | undefined) {
+  if (!progress) return ""
+  if (progress.numerator === undefined) return progress.label
+  if (progress.denominator === undefined) return `${progress.label} ${progress.numerator}`
+  return `${progress.label} ${progress.numerator}/${progress.denominator}`
+}
+
+function wait(delay: number, signal: AbortSignal) {
+  return new Promise<void>((resolve) => {
+    const timer = setTimeout(done, delay)
+    signal.addEventListener("abort", done, { once: true })
+    function done() {
+      clearTimeout(timer)
+      signal.removeEventListener("abort", done)
+      resolve()
+    }
+  })
+}