persist.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import { usePlatform } from "@/context/platform"
  2. import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
  3. import { checksum } from "@opencode-ai/util/encode"
  4. import { createResource, type Accessor } from "solid-js"
  5. import type { SetStoreFunction, Store } from "solid-js/store"
  6. type InitType = Promise<string> | string | null
  7. type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
  8. type PersistTarget = {
  9. storage?: string
  10. key: string
  11. legacy?: string[]
  12. migrate?: (value: unknown) => unknown
  13. }
  14. const LEGACY_STORAGE = "default.dat"
  15. const GLOBAL_STORAGE = "opencode.global.dat"
  16. function snapshot(value: unknown) {
  17. return JSON.parse(JSON.stringify(value)) as unknown
  18. }
  19. function isRecord(value: unknown): value is Record<string, unknown> {
  20. return typeof value === "object" && value !== null && !Array.isArray(value)
  21. }
  22. function merge(defaults: unknown, value: unknown): unknown {
  23. if (value === undefined) return defaults
  24. if (value === null) return value
  25. if (Array.isArray(defaults)) {
  26. if (Array.isArray(value)) return value
  27. return defaults
  28. }
  29. if (isRecord(defaults)) {
  30. if (!isRecord(value)) return defaults
  31. const result: Record<string, unknown> = { ...defaults }
  32. for (const key of Object.keys(value)) {
  33. if (key in defaults) {
  34. result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
  35. } else {
  36. result[key] = (value as Record<string, unknown>)[key]
  37. }
  38. }
  39. return result
  40. }
  41. return value
  42. }
  43. function parse(value: string) {
  44. try {
  45. return JSON.parse(value) as unknown
  46. } catch {
  47. return undefined
  48. }
  49. }
  50. function workspaceStorage(dir: string) {
  51. const head = dir.slice(0, 12) || "workspace"
  52. const sum = checksum(dir) ?? "0"
  53. return `opencode.workspace.${head}.${sum}.dat`
  54. }
  55. function localStorageWithPrefix(prefix: string): SyncStorage {
  56. const base = `${prefix}:`
  57. return {
  58. getItem: (key) => localStorage.getItem(base + key),
  59. setItem: (key, value) => localStorage.setItem(base + key, value),
  60. removeItem: (key) => localStorage.removeItem(base + key),
  61. }
  62. }
  63. export const Persist = {
  64. global(key: string, legacy?: string[]): PersistTarget {
  65. return { storage: GLOBAL_STORAGE, key, legacy }
  66. },
  67. workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
  68. return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
  69. },
  70. session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
  71. return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
  72. },
  73. scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
  74. if (session) return Persist.session(dir, session, key, legacy)
  75. return Persist.workspace(dir, key, legacy)
  76. },
  77. }
  78. export function removePersisted(target: { storage?: string; key: string }) {
  79. const platform = usePlatform()
  80. const isDesktop = platform.platform === "desktop" && !!platform.storage
  81. if (isDesktop) {
  82. return platform.storage?.(target.storage)?.removeItem(target.key)
  83. }
  84. if (!target.storage) {
  85. localStorage.removeItem(target.key)
  86. return
  87. }
  88. localStorageWithPrefix(target.storage).removeItem(target.key)
  89. }
  90. export function persisted<T>(
  91. target: string | PersistTarget,
  92. store: [Store<T>, SetStoreFunction<T>],
  93. ): PersistedWithReady<T> {
  94. const platform = usePlatform()
  95. const config: PersistTarget = typeof target === "string" ? { key: target } : target
  96. const defaults = snapshot(store[0])
  97. const legacy = config.legacy ?? []
  98. const isDesktop = platform.platform === "desktop" && !!platform.storage
  99. const currentStorage = (() => {
  100. if (isDesktop) return platform.storage?.(config.storage)
  101. if (!config.storage) return localStorage
  102. return localStorageWithPrefix(config.storage)
  103. })()
  104. const legacyStorage = (() => {
  105. if (!isDesktop) return localStorage
  106. if (!config.storage) return platform.storage?.()
  107. return platform.storage?.(LEGACY_STORAGE)
  108. })()
  109. const storage = (() => {
  110. if (!isDesktop) {
  111. const current = currentStorage as SyncStorage
  112. const legacyStore = legacyStorage as SyncStorage
  113. const api: SyncStorage = {
  114. getItem: (key) => {
  115. const raw = current.getItem(key)
  116. if (raw !== null) {
  117. const parsed = parse(raw)
  118. if (parsed === undefined) return raw
  119. const migrated = config.migrate ? config.migrate(parsed) : parsed
  120. const merged = merge(defaults, migrated)
  121. const next = JSON.stringify(merged)
  122. if (raw !== next) current.setItem(key, next)
  123. return next
  124. }
  125. for (const legacyKey of legacy) {
  126. const legacyRaw = legacyStore.getItem(legacyKey)
  127. if (legacyRaw === null) continue
  128. current.setItem(key, legacyRaw)
  129. legacyStore.removeItem(legacyKey)
  130. const parsed = parse(legacyRaw)
  131. if (parsed === undefined) return legacyRaw
  132. const migrated = config.migrate ? config.migrate(parsed) : parsed
  133. const merged = merge(defaults, migrated)
  134. const next = JSON.stringify(merged)
  135. if (legacyRaw !== next) current.setItem(key, next)
  136. return next
  137. }
  138. return null
  139. },
  140. setItem: (key, value) => {
  141. current.setItem(key, value)
  142. },
  143. removeItem: (key) => {
  144. current.removeItem(key)
  145. },
  146. }
  147. return api
  148. }
  149. const current = currentStorage as AsyncStorage
  150. const legacyStore = legacyStorage as AsyncStorage | undefined
  151. const api: AsyncStorage = {
  152. getItem: async (key) => {
  153. const raw = await current.getItem(key)
  154. if (raw !== null) {
  155. const parsed = parse(raw)
  156. if (parsed === undefined) return raw
  157. const migrated = config.migrate ? config.migrate(parsed) : parsed
  158. const merged = merge(defaults, migrated)
  159. const next = JSON.stringify(merged)
  160. if (raw !== next) await current.setItem(key, next)
  161. return next
  162. }
  163. if (!legacyStore) return null
  164. for (const legacyKey of legacy) {
  165. const legacyRaw = await legacyStore.getItem(legacyKey)
  166. if (legacyRaw === null) continue
  167. await current.setItem(key, legacyRaw)
  168. await legacyStore.removeItem(legacyKey)
  169. const parsed = parse(legacyRaw)
  170. if (parsed === undefined) return legacyRaw
  171. const migrated = config.migrate ? config.migrate(parsed) : parsed
  172. const merged = merge(defaults, migrated)
  173. const next = JSON.stringify(merged)
  174. if (legacyRaw !== next) await current.setItem(key, next)
  175. return next
  176. }
  177. return null
  178. },
  179. setItem: async (key, value) => {
  180. await current.setItem(key, value)
  181. },
  182. removeItem: async (key) => {
  183. await current.removeItem(key)
  184. },
  185. }
  186. return api
  187. })()
  188. const [state, setState, init] = makePersisted(store, { name: config.key, storage })
  189. const isAsync = init instanceof Promise
  190. const [ready] = createResource(
  191. () => init,
  192. async (initValue) => {
  193. if (initValue instanceof Promise) await initValue
  194. return true
  195. },
  196. { initialValue: !isAsync },
  197. )
  198. return [state, setState, init, () => ready() === true]
  199. }