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

chore: localStorage -> tauri store

Adam 3 месяцев назад
Родитель
Сommit
4a3ba58f65

+ 3 - 1
bun.lock

@@ -137,7 +137,7 @@
         "@solid-primitives/media": "2.3.3",
         "@solid-primitives/resize-observer": "2.1.3",
         "@solid-primitives/scroll": "2.1.3",
-        "@solid-primitives/storage": "4.3.3",
+        "@solid-primitives/storage": "catalog:",
         "@solid-primitives/websocket": "1.3.1",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
@@ -355,6 +355,7 @@
       "version": "1.0.164",
       "dependencies": {
         "@opencode-ai/desktop": "workspace:*",
+        "@solid-primitives/storage": "catalog:",
         "@tauri-apps/api": "^2",
         "@tauri-apps/plugin-dialog": "~2",
         "@tauri-apps/plugin-opener": "^2",
@@ -474,6 +475,7 @@
     "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
     "@pierre/diffs": "1.0.0-beta.3",
+    "@solid-primitives/storage": "4.3.3",
     "@solidjs/meta": "0.29.4",
     "@solidjs/router": "0.15.4",
     "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",

+ 1 - 0
package.json

@@ -32,6 +32,7 @@
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
       "@pierre/diffs": "1.0.0-beta.3",
+      "@solid-primitives/storage": "4.3.3",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "ai": "5.0.97",

+ 1 - 1
packages/desktop/package.json

@@ -40,7 +40,7 @@
     "@solid-primitives/media": "2.3.3",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",
-    "@solid-primitives/storage": "4.3.3",
+    "@solid-primitives/storage": "catalog:",
     "@solid-primitives/websocket": "1.3.1",
     "@solidjs/meta": "catalog:",
     "@solidjs/router": "catalog:",

+ 1 - 1
packages/desktop/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
-import { Show } from "solid-js"
+import { Show, Suspense } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"

+ 3 - 5
packages/desktop/src/components/prompt-input.tsx

@@ -1,7 +1,6 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
 import { createStore, produce } from "solid-js/store"
-import { makePersisted } from "@solid-primitives/storage"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
@@ -21,6 +20,7 @@ import { DialogSelectModel } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
 import { useCommand, formatKeybind } from "@/context/command"
+import { persisted } from "@/utils/persist"
 
 const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
 const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -109,15 +109,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   })
 
   const MAX_HISTORY = 100
-  const [history, setHistory] = makePersisted(
+  const [history, setHistory] = persisted(
+    "prompt-history.v1",
     createStore<{
       entries: Prompt[]
     }>({
       entries: [],
     }),
-    {
-      name: "prompt-history.v1",
-    },
   )
 
   const clonePromptParts = (prompt: Prompt): Prompt =>

+ 4 - 5
packages/desktop/src/context/layout.tsx

@@ -1,10 +1,10 @@
 import { createStore, produce } from "solid-js/store"
 import { batch, createMemo, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
 import { Project } from "@opencode-ai/sdk/v2"
+import { persisted } from "@/utils/persist"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -32,7 +32,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
   init: () => {
     const globalSdk = useGlobalSDK()
     const globalSync = useGlobalSync()
-    const [store, setStore] = makePersisted(
+    const [store, setStore, _, ready] = persisted(
+      "layout.v3",
       createStore({
         projects: [] as { worktree: string; expanded: boolean }[],
         sidebar: {
@@ -48,9 +49,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
         sessionTabs: {} as Record<string, SessionTabs>,
       }),
-      {
-        name: "layout.v3",
-      },
     )
 
     const usedColors = new Set<AvatarColorKey>()
@@ -93,6 +91,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     })
 
     return {
+      ready,
       projects: {
         list,
         open(directory: string) {

+ 4 - 3
packages/desktop/src/context/local.tsx

@@ -7,8 +7,8 @@ import { useSDK } from "./sdk"
 import { useSync } from "./sync"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useProviders } from "@/hooks/use-providers"
-import { makePersisted } from "@solid-primitives/storage"
 import { DateTime } from "luxon"
+import { persisted } from "@/utils/persist"
 
 export type LocalFile = FileNode &
   Partial<{
@@ -110,7 +110,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     })()
 
     const model = (() => {
-      const [store, setStore] = makePersisted(
+      const [store, setStore, _, modelReady] = persisted(
+        "model.v1",
         createStore<{
           user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
           recent: ModelKey[]
@@ -118,7 +119,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           user: [],
           recent: [],
         }),
-        { name: "model.v1" },
       )
 
       const [ephemeral, setEphemeral] = createStore<{
@@ -242,6 +242,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
 
       return {
+        ready: modelReady,
         current,
         recent,
         list,

+ 4 - 5
packages/desktop/src/context/notification.tsx

@@ -1,6 +1,5 @@
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSync } from "./global-sync"
 import { Binary } from "@opencode-ai/util/binary"
@@ -8,6 +7,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { makeAudioPlayer } from "@solid-primitives/audio"
 import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
 import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
+import { persisted } from "@/utils/persist"
 
 type NotificationBase = {
   directory?: string
@@ -44,13 +44,11 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     const globalSDK = useGlobalSDK()
     const globalSync = useGlobalSync()
 
-    const [store, setStore] = makePersisted(
+    const [store, setStore, _, ready] = persisted(
+      "notification.v1",
       createStore({
         list: [] as Notification[],
       }),
-      {
-        name: "notification.v1",
-      },
     )
 
     globalSDK.event.listen((e) => {
@@ -101,6 +99,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     })
 
     return {
+      ready,
       session: {
         all(session: string) {
           return store.list.filter((n) => n.session === session)

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

@@ -1,4 +1,5 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
+import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
 
 export type Platform = {
   /** Platform discriminator */
@@ -15,6 +16,9 @@ export type Platform = {
 
   /** Open a URL in the default browser */
   openLink(url: string): void
+
+  /** Storage mechanism, defaults to localStorage */
+  storage?: (name?: string) => SyncStorage | AsyncStorage
 }
 
 export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

+ 4 - 5
packages/desktop/src/context/prompt.tsx

@@ -1,9 +1,9 @@
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createMemo } from "solid-js"
-import { makePersisted } from "@solid-primitives/storage"
 import { useParams } from "@solidjs/router"
 import { TextSelection } from "./local"
+import { persisted } from "@/utils/persist"
 
 interface PartBase {
   content: string
@@ -77,7 +77,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     const params = useParams()
     const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
 
-    const [store, setStore] = makePersisted(
+    const [store, setStore, _, ready] = persisted(
+      name(),
       createStore<{
         prompt: Prompt
         cursor?: number
@@ -85,12 +86,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
         prompt: clonePrompt(DEFAULT_PROMPT),
         cursor: undefined,
       }),
-      {
-        name: name(),
-      },
     )
 
     return {
+      ready,
       current: createMemo(() => store.prompt),
       cursor: createMemo(() => store.cursor),
       dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),

+ 4 - 5
packages/desktop/src/context/terminal.tsx

@@ -1,9 +1,9 @@
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createMemo } from "solid-js"
-import { makePersisted } from "@solid-primitives/storage"
 import { useParams } from "@solidjs/router"
 import { useSDK } from "./sdk"
+import { persisted } from "@/utils/persist"
 
 export type LocalPTY = {
   id: string
@@ -21,19 +21,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
     const params = useParams()
     const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
 
-    const [store, setStore] = makePersisted(
+    const [store, setStore, _, ready] = persisted(
+      name(),
       createStore<{
         active?: string
         all: LocalPTY[]
       }>({
         all: [],
       }),
-      {
-        name: name(),
-      },
     )
 
     return {
+      ready,
       all: createMemo(() => Object.values(store.all)),
       active: createMemo(() => store.active),
       new() {

+ 26 - 0
packages/desktop/src/utils/persist.ts

@@ -0,0 +1,26 @@
+import { usePlatform } from "@/context/platform"
+import { makePersisted } from "@solid-primitives/storage"
+import { createResource, type Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+type InitType = Promise<string> | string | null
+type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
+
+export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
+  const platform = usePlatform()
+  const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
+
+  // Create a resource that resolves when the store is initialized
+  // This integrates with Suspense and provides a ready signal
+  const isAsync = init instanceof Promise
+  const [ready] = createResource(
+    () => init,
+    async (initValue) => {
+      if (initValue instanceof Promise) await initValue
+      return true
+    },
+    { initialValue: !isAsync },
+  )
+
+  return [state, setState, init, () => ready() === true]
+}

+ 1 - 0
packages/tauri/package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@opencode-ai/desktop": "workspace:*",
+    "@solid-primitives/storage": "catalog:",
     "@tauri-apps/api": "^2",
     "@tauri-apps/plugin-dialog": "~2",
     "@tauri-apps/plugin-opener": "^2",

+ 18 - 0
packages/tauri/src/index.tsx

@@ -5,6 +5,7 @@ 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 { createMenu } from "./menu"
@@ -48,6 +49,23 @@ const platform: Platform = {
   openLink(url: string) {
     shellOpen(url)
   },
+
+  storage: (name = "default.dat") => {
+    const api: AsyncStorage = {
+      _store: null,
+      _getStore: async () => api._store || (api._store = (await import("@tauri-apps/plugin-store")).Store.load(name)),
+      getItem: async (key: string) => (await (await api._getStore()).get(key)) ?? null,
+      setItem: async (key: string, value: string) => await (await api._getStore()).set(key, value),
+      removeItem: async (key: string) => await (await api._getStore()).delete(key),
+      clear: async () => await (await api._getStore()).clear(),
+      key: async (index: number) => (await (await api._getStore()).keys())[index],
+      getLength: async () => (await api._getStore()).length(),
+      get length() {
+        return api.getLength()
+      },
+    }
+    return api
+  },
 }
 
 createMenu()

+ 3 - 1
packages/ui/src/components/session-turn.tsx

@@ -102,7 +102,9 @@ export function SessionTurn(
     setState("autoScrolled", true)
     requestAnimationFrame(() => {
       scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
-      setState("autoScrolled", false)
+      requestAnimationFrame(() => {
+        setState("autoScrolled", false)
+      })
     })
   }
 

+ 8 - 3
packages/ui/src/context/helper.tsx

@@ -1,4 +1,4 @@
-import { createContext, Show, useContext, type ParentProps } from "solid-js"
+import { createContext, createMemo, Show, useContext, type ParentProps, type Accessor } from "solid-js"
 
 export function createSimpleContext<T, Props extends Record<string, any>>(input: {
   name: string
@@ -9,9 +9,14 @@ export function createSimpleContext<T, Props extends Record<string, any>>(input:
   return {
     provider: (props: ParentProps<Props>) => {
       const init = input.init(props)
-      return (
+      // Access init.ready inside the memo to make it reactive for getter properties
+      const isReady = createMemo(() => {
         // @ts-expect-error
-        <Show when={init.ready === undefined || init.ready === true}>
+        const ready = init.ready as Accessor<boolean> | boolean | undefined
+        return ready === undefined || (typeof ready === "function" ? ready() : ready)
+      })
+      return (
+        <Show when={isReady()}>
           <ctx.Provider value={init}>{props.children}</ctx.Provider>
         </Show>
       )