@jonsoc/app 1.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
@@ -0,0 +1,10 @@
1
+ /* This file is auto-generated by SST. Do not edit. */
2
+ /* tslint:disable */
3
+ /* eslint-disable */
4
+ /// <reference types="vite/client" />
5
+ interface ImportMetaEnv {
6
+
7
+ }
8
+ interface ImportMeta {
9
+ readonly env: ImportMetaEnv
10
+ }
@@ -0,0 +1,51 @@
1
+ export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
2
+ const r = document.createRange()
3
+ r.selectNodeContents(lineElement)
4
+ r.setEnd(targetNode, offset)
5
+ return r.toString().length
6
+ }
7
+
8
+ export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
9
+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
10
+ let remaining = Math.max(0, charIndex)
11
+ let lastText: Node | null = null
12
+ let lastLen = 0
13
+ let node: Node | null
14
+ while ((node = walker.nextNode())) {
15
+ const len = node.textContent?.length || 0
16
+ lastText = node
17
+ lastLen = len
18
+ if (remaining <= len) return { node, offset: remaining }
19
+ remaining -= len
20
+ }
21
+ if (lastText) return { node: lastText, offset: lastLen }
22
+ if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
23
+ return null
24
+ }
25
+
26
+ export function getSelectionInContainer(
27
+ container: HTMLElement,
28
+ ): { sl: number; sch: number; el: number; ech: number } | null {
29
+ const s = window.getSelection()
30
+ if (!s || s.rangeCount === 0) return null
31
+ const r = s.getRangeAt(0)
32
+ const sc = r.startContainer
33
+ const ec = r.endContainer
34
+ const getLineElement = (n: Node) =>
35
+ (n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
36
+ const sle = getLineElement(sc)
37
+ const ele = getLineElement(ec)
38
+ if (!sle || !ele) return null
39
+ if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
40
+ const cc = container.querySelector("code") as HTMLElement | null
41
+ if (!cc) return null
42
+ const lines = Array.from(cc.querySelectorAll(".line"))
43
+ const sli = lines.indexOf(sle as Element)
44
+ const eli = lines.indexOf(ele as Element)
45
+ if (sli === -1 || eli === -1) return null
46
+ const sl = sli + 1
47
+ const el = eli + 1
48
+ const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
49
+ const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
50
+ return { sl, sch, el, ech }
51
+ }
@@ -0,0 +1,99 @@
1
+ import z from "zod"
2
+
3
+ const prefixes = {
4
+ session: "ses",
5
+ message: "msg",
6
+ permission: "per",
7
+ user: "usr",
8
+ part: "prt",
9
+ pty: "pty",
10
+ } as const
11
+
12
+ const LENGTH = 26
13
+ let lastTimestamp = 0
14
+ let counter = 0
15
+
16
+ type Prefix = keyof typeof prefixes
17
+ export namespace Identifier {
18
+ export function schema(prefix: Prefix) {
19
+ return z.string().startsWith(prefixes[prefix])
20
+ }
21
+
22
+ export function ascending(prefix: Prefix, given?: string) {
23
+ return generateID(prefix, false, given)
24
+ }
25
+
26
+ export function descending(prefix: Prefix, given?: string) {
27
+ return generateID(prefix, true, given)
28
+ }
29
+ }
30
+
31
+ function generateID(prefix: Prefix, descending: boolean, given?: string): string {
32
+ if (!given) {
33
+ return create(prefix, descending)
34
+ }
35
+
36
+ if (!given.startsWith(prefixes[prefix])) {
37
+ throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
38
+ }
39
+
40
+ return given
41
+ }
42
+
43
+ function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
44
+ const currentTimestamp = timestamp ?? Date.now()
45
+
46
+ if (currentTimestamp !== lastTimestamp) {
47
+ lastTimestamp = currentTimestamp
48
+ counter = 0
49
+ }
50
+
51
+ counter += 1
52
+
53
+ let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
54
+
55
+ if (descending) {
56
+ now = ~now
57
+ }
58
+
59
+ const timeBytes = new Uint8Array(6)
60
+ for (let i = 0; i < 6; i += 1) {
61
+ timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
62
+ }
63
+
64
+ return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
65
+ }
66
+
67
+ function bytesToHex(bytes: Uint8Array): string {
68
+ let hex = ""
69
+ for (let i = 0; i < bytes.length; i += 1) {
70
+ hex += bytes[i].toString(16).padStart(2, "0")
71
+ }
72
+ return hex
73
+ }
74
+
75
+ function randomBase62(length: number): string {
76
+ const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
77
+ const bytes = getRandomBytes(length)
78
+ let result = ""
79
+ for (let i = 0; i < length; i += 1) {
80
+ result += chars[bytes[i] % 62]
81
+ }
82
+ return result
83
+ }
84
+
85
+ function getRandomBytes(length: number): Uint8Array {
86
+ const bytes = new Uint8Array(length)
87
+ const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
88
+
89
+ if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
90
+ cryptoObj.getRandomValues(bytes)
91
+ return bytes
92
+ }
93
+
94
+ for (let i = 0; i < length; i += 1) {
95
+ bytes[i] = Math.floor(Math.random() * 256)
96
+ }
97
+
98
+ return bytes
99
+ }
@@ -0,0 +1 @@
1
+ export * from "./dom"
@@ -0,0 +1,135 @@
1
+ type Nav = {
2
+ id: string
3
+ dir?: string
4
+ from?: string
5
+ to: string
6
+ trigger?: string
7
+ start: number
8
+ marks: Record<string, number>
9
+ logged: boolean
10
+ timer?: ReturnType<typeof setTimeout>
11
+ }
12
+
13
+ const dev = import.meta.env.DEV
14
+
15
+ const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
16
+
17
+ const now = () => performance.now()
18
+
19
+ const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
20
+
21
+ const navs = new Map<string, Nav>()
22
+ const pending = new Map<string, string>()
23
+ const active = new Map<string, string>()
24
+
25
+ const required = [
26
+ "session:params",
27
+ "session:data-ready",
28
+ "session:first-turn-mounted",
29
+ "storage:prompt-ready",
30
+ "storage:terminal-ready",
31
+ "storage:file-view-ready",
32
+ ]
33
+
34
+ function flush(id: string, reason: "complete" | "timeout") {
35
+ if (!dev) return
36
+ const nav = navs.get(id)
37
+ if (!nav) return
38
+ if (nav.logged) return
39
+
40
+ nav.logged = true
41
+ if (nav.timer) clearTimeout(nav.timer)
42
+
43
+ const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
44
+ const base = nav.marks[baseName] ?? nav.start
45
+
46
+ const ms = Object.fromEntries(
47
+ Object.entries(nav.marks)
48
+ .slice()
49
+ .sort(([a], [b]) => a.localeCompare(b))
50
+ .map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
51
+ )
52
+
53
+ console.log(
54
+ "perf.session-nav " +
55
+ JSON.stringify({
56
+ type: "perf.session-nav.v0",
57
+ id: nav.id,
58
+ dir: nav.dir,
59
+ from: nav.from,
60
+ to: nav.to,
61
+ trigger: nav.trigger,
62
+ base: baseName,
63
+ reason,
64
+ ms,
65
+ }),
66
+ )
67
+
68
+ navs.delete(id)
69
+ }
70
+
71
+ function maybeFlush(id: string) {
72
+ if (!dev) return
73
+ const nav = navs.get(id)
74
+ if (!nav) return
75
+ if (nav.logged) return
76
+ if (!required.every((name) => nav.marks[name] !== undefined)) return
77
+ flush(id, "complete")
78
+ }
79
+
80
+ function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
81
+ const existing = navs.get(id)
82
+ if (existing) return existing
83
+
84
+ const nav: Nav = {
85
+ ...data,
86
+ marks: {},
87
+ logged: false,
88
+ }
89
+ nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
90
+ navs.set(id, nav)
91
+ return nav
92
+ }
93
+
94
+ export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
95
+ if (!dev) return
96
+
97
+ const id = uid()
98
+ const start = now()
99
+ const nav = ensure(id, { ...input, id, start })
100
+ nav.marks["navigate:start"] = start
101
+
102
+ pending.set(key(input.dir, input.to), id)
103
+ return id
104
+ }
105
+
106
+ export function navParams(input: { dir?: string; from?: string; to: string }) {
107
+ if (!dev) return
108
+
109
+ const k = key(input.dir, input.to)
110
+ const pendingId = pending.get(k)
111
+ if (pendingId) pending.delete(k)
112
+ const id = pendingId ?? uid()
113
+
114
+ const start = now()
115
+ const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
116
+ nav.marks["session:params"] = start
117
+
118
+ active.set(k, id)
119
+ maybeFlush(id)
120
+ return id
121
+ }
122
+
123
+ export function navMark(input: { dir?: string; to: string; name: string }) {
124
+ if (!dev) return
125
+
126
+ const id = active.get(key(input.dir, input.to))
127
+ if (!id) return
128
+
129
+ const nav = navs.get(id)
130
+ if (!nav) return
131
+ if (nav.marks[input.name] !== undefined) return
132
+
133
+ nav.marks[input.name] = now()
134
+ maybeFlush(id)
135
+ }
@@ -0,0 +1,377 @@
1
+ import { usePlatform } from "@/context/platform"
2
+ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
3
+ import { checksum } from "@jonsoc/util/encode"
4
+ import { createResource, type Accessor } from "solid-js"
5
+ import type { SetStoreFunction, Store } from "solid-js/store"
6
+
7
+ type InitType = Promise<string> | string | null
8
+ type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
9
+
10
+ type PersistTarget = {
11
+ storage?: string
12
+ key: string
13
+ legacy?: string[]
14
+ migrate?: (value: unknown) => unknown
15
+ }
16
+
17
+ const LEGACY_STORAGE = "default.dat"
18
+ const GLOBAL_STORAGE = "jonsoc.global.dat"
19
+ const LOCAL_PREFIX = "jonsoc."
20
+ const fallback = { disabled: false }
21
+ const cache = new Map<string, string>()
22
+
23
+ function quota(error: unknown) {
24
+ if (error instanceof DOMException) {
25
+ if (error.name === "QuotaExceededError") return true
26
+ if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
27
+ if (error.name === "QUOTA_EXCEEDED_ERR") return true
28
+ if (error.code === 22 || error.code === 1014) return true
29
+ return false
30
+ }
31
+
32
+ if (!error || typeof error !== "object") return false
33
+ const name = (error as { name?: string }).name
34
+ if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
35
+ if (name && /quota/i.test(name)) return true
36
+
37
+ const code = (error as { code?: number }).code
38
+ if (code === 22 || code === 1014) return true
39
+
40
+ const message = (error as { message?: string }).message
41
+ if (typeof message !== "string") return false
42
+ if (/quota/i.test(message)) return true
43
+ return false
44
+ }
45
+
46
+ type Evict = { key: string; size: number }
47
+
48
+ function evict(storage: Storage, keep: string, value: string) {
49
+ const total = storage.length
50
+ const indexes = Array.from({ length: total }, (_, index) => index)
51
+ const items: Evict[] = []
52
+
53
+ for (const index of indexes) {
54
+ const name = storage.key(index)
55
+ if (!name) continue
56
+ if (!name.startsWith(LOCAL_PREFIX)) continue
57
+ if (name === keep) continue
58
+ const stored = storage.getItem(name)
59
+ items.push({ key: name, size: stored?.length ?? 0 })
60
+ }
61
+
62
+ items.sort((a, b) => b.size - a.size)
63
+
64
+ for (const item of items) {
65
+ storage.removeItem(item.key)
66
+
67
+ try {
68
+ storage.setItem(keep, value)
69
+ return true
70
+ } catch (error) {
71
+ if (!quota(error)) throw error
72
+ }
73
+ }
74
+
75
+ return false
76
+ }
77
+
78
+ function write(storage: Storage, key: string, value: string) {
79
+ try {
80
+ storage.setItem(key, value)
81
+ return true
82
+ } catch (error) {
83
+ if (!quota(error)) throw error
84
+ }
85
+
86
+ try {
87
+ storage.removeItem(key)
88
+ storage.setItem(key, value)
89
+ return true
90
+ } catch (error) {
91
+ if (!quota(error)) throw error
92
+ }
93
+
94
+ return evict(storage, key, value)
95
+ }
96
+
97
+ function snapshot(value: unknown) {
98
+ return JSON.parse(JSON.stringify(value)) as unknown
99
+ }
100
+
101
+ function isRecord(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === "object" && value !== null && !Array.isArray(value)
103
+ }
104
+
105
+ function merge(defaults: unknown, value: unknown): unknown {
106
+ if (value === undefined) return defaults
107
+ if (value === null) return value
108
+
109
+ if (Array.isArray(defaults)) {
110
+ if (Array.isArray(value)) return value
111
+ return defaults
112
+ }
113
+
114
+ if (isRecord(defaults)) {
115
+ if (!isRecord(value)) return defaults
116
+
117
+ const result: Record<string, unknown> = { ...defaults }
118
+ for (const key of Object.keys(value)) {
119
+ if (key in defaults) {
120
+ result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
121
+ } else {
122
+ result[key] = (value as Record<string, unknown>)[key]
123
+ }
124
+ }
125
+ return result
126
+ }
127
+
128
+ return value
129
+ }
130
+
131
+ function parse(value: string) {
132
+ try {
133
+ return JSON.parse(value) as unknown
134
+ } catch {
135
+ return undefined
136
+ }
137
+ }
138
+
139
+ function workspaceStorage(dir: string) {
140
+ const head = dir.slice(0, 12) || "workspace"
141
+ const sum = checksum(dir) ?? "0"
142
+ return `jonsoc.workspace.${head}.${sum}.dat`
143
+ }
144
+
145
+ function localStorageWithPrefix(prefix: string): SyncStorage {
146
+ const base = `${prefix}:`
147
+ const item = (key: string) => base + key
148
+ return {
149
+ getItem: (key) => {
150
+ const name = item(key)
151
+ const cached = cache.get(name)
152
+ if (fallback.disabled && cached !== undefined) return cached
153
+
154
+ const stored = localStorage.getItem(name)
155
+ if (stored === null) return cached ?? null
156
+ cache.set(name, stored)
157
+ return stored
158
+ },
159
+ setItem: (key, value) => {
160
+ const name = item(key)
161
+ cache.set(name, value)
162
+ if (fallback.disabled) return
163
+ try {
164
+ if (write(localStorage, name, value)) return
165
+ } catch {
166
+ fallback.disabled = true
167
+ return
168
+ }
169
+ fallback.disabled = true
170
+ },
171
+ removeItem: (key) => {
172
+ const name = item(key)
173
+ cache.delete(name)
174
+ if (fallback.disabled) return
175
+ localStorage.removeItem(name)
176
+ },
177
+ }
178
+ }
179
+
180
+ function localStorageDirect(): SyncStorage {
181
+ return {
182
+ getItem: (key) => {
183
+ const cached = cache.get(key)
184
+ if (fallback.disabled && cached !== undefined) return cached
185
+
186
+ const stored = localStorage.getItem(key)
187
+ if (stored === null) return cached ?? null
188
+ cache.set(key, stored)
189
+ return stored
190
+ },
191
+ setItem: (key, value) => {
192
+ cache.set(key, value)
193
+ if (fallback.disabled) return
194
+ try {
195
+ if (write(localStorage, key, value)) return
196
+ } catch {
197
+ fallback.disabled = true
198
+ return
199
+ }
200
+ fallback.disabled = true
201
+ },
202
+ removeItem: (key) => {
203
+ cache.delete(key)
204
+ if (fallback.disabled) return
205
+ localStorage.removeItem(key)
206
+ },
207
+ }
208
+ }
209
+
210
+ export const Persist = {
211
+ global(key: string, legacy?: string[]): PersistTarget {
212
+ return { storage: GLOBAL_STORAGE, key, legacy }
213
+ },
214
+ workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
215
+ return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
216
+ },
217
+ session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
218
+ return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
219
+ },
220
+ scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
221
+ if (session) return Persist.session(dir, session, key, legacy)
222
+ return Persist.workspace(dir, key, legacy)
223
+ },
224
+ }
225
+
226
+ export function removePersisted(target: { storage?: string; key: string }) {
227
+ const platform = usePlatform()
228
+ const isDesktop = platform.platform === "desktop" && !!platform.storage
229
+
230
+ if (isDesktop) {
231
+ return platform.storage?.(target.storage)?.removeItem(target.key)
232
+ }
233
+
234
+ if (!target.storage) {
235
+ localStorageDirect().removeItem(target.key)
236
+ return
237
+ }
238
+
239
+ localStorageWithPrefix(target.storage).removeItem(target.key)
240
+ }
241
+
242
+ export function persisted<T>(
243
+ target: string | PersistTarget,
244
+ store: [Store<T>, SetStoreFunction<T>],
245
+ ): PersistedWithReady<T> {
246
+ const platform = usePlatform()
247
+ const config: PersistTarget = typeof target === "string" ? { key: target } : target
248
+
249
+ const defaults = snapshot(store[0])
250
+ const legacy = config.legacy ?? []
251
+
252
+ const isDesktop = platform.platform === "desktop" && !!platform.storage
253
+
254
+ const currentStorage = (() => {
255
+ if (isDesktop) return platform.storage?.(config.storage)
256
+ if (!config.storage) return localStorageDirect()
257
+ return localStorageWithPrefix(config.storage)
258
+ })()
259
+
260
+ const legacyStorage = (() => {
261
+ if (!isDesktop) return localStorageDirect()
262
+ if (!config.storage) return platform.storage?.()
263
+ return platform.storage?.(LEGACY_STORAGE)
264
+ })()
265
+
266
+ const storage = (() => {
267
+ if (!isDesktop) {
268
+ const current = currentStorage as SyncStorage
269
+ const legacyStore = legacyStorage as SyncStorage
270
+
271
+ const api: SyncStorage = {
272
+ getItem: (key) => {
273
+ const raw = current.getItem(key)
274
+ if (raw !== null) {
275
+ const parsed = parse(raw)
276
+ if (parsed === undefined) return raw
277
+
278
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
279
+ const merged = merge(defaults, migrated)
280
+ const next = JSON.stringify(merged)
281
+ if (raw !== next) current.setItem(key, next)
282
+ return next
283
+ }
284
+
285
+ for (const legacyKey of legacy) {
286
+ const legacyRaw = legacyStore.getItem(legacyKey)
287
+ if (legacyRaw === null) continue
288
+
289
+ current.setItem(key, legacyRaw)
290
+ legacyStore.removeItem(legacyKey)
291
+
292
+ const parsed = parse(legacyRaw)
293
+ if (parsed === undefined) return legacyRaw
294
+
295
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
296
+ const merged = merge(defaults, migrated)
297
+ const next = JSON.stringify(merged)
298
+ if (legacyRaw !== next) current.setItem(key, next)
299
+ return next
300
+ }
301
+
302
+ return null
303
+ },
304
+ setItem: (key, value) => {
305
+ current.setItem(key, value)
306
+ },
307
+ removeItem: (key) => {
308
+ current.removeItem(key)
309
+ },
310
+ }
311
+
312
+ return api
313
+ }
314
+
315
+ const current = currentStorage as AsyncStorage
316
+ const legacyStore = legacyStorage as AsyncStorage | undefined
317
+
318
+ const api: AsyncStorage = {
319
+ getItem: async (key) => {
320
+ const raw = await current.getItem(key)
321
+ if (raw !== null) {
322
+ const parsed = parse(raw)
323
+ if (parsed === undefined) return raw
324
+
325
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
326
+ const merged = merge(defaults, migrated)
327
+ const next = JSON.stringify(merged)
328
+ if (raw !== next) await current.setItem(key, next)
329
+ return next
330
+ }
331
+
332
+ if (!legacyStore) return null
333
+
334
+ for (const legacyKey of legacy) {
335
+ const legacyRaw = await legacyStore.getItem(legacyKey)
336
+ if (legacyRaw === null) continue
337
+
338
+ await current.setItem(key, legacyRaw)
339
+ await legacyStore.removeItem(legacyKey)
340
+
341
+ const parsed = parse(legacyRaw)
342
+ if (parsed === undefined) return legacyRaw
343
+
344
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
345
+ const merged = merge(defaults, migrated)
346
+ const next = JSON.stringify(merged)
347
+ if (legacyRaw !== next) await current.setItem(key, next)
348
+ return next
349
+ }
350
+
351
+ return null
352
+ },
353
+ setItem: async (key, value) => {
354
+ await current.setItem(key, value)
355
+ },
356
+ removeItem: async (key) => {
357
+ await current.removeItem(key)
358
+ },
359
+ }
360
+
361
+ return api
362
+ })()
363
+
364
+ const [state, setState, init] = makePersisted(store, { name: config.key, storage })
365
+
366
+ const isAsync = init instanceof Promise
367
+ const [ready] = createResource(
368
+ () => init,
369
+ async (initValue) => {
370
+ if (initValue instanceof Promise) await initValue
371
+ return true
372
+ },
373
+ { initialValue: !isAsync },
374
+ )
375
+
376
+ return [state, setState, init, () => ready() === true]
377
+ }