@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.
- package/AGENTS.md +30 -0
- package/README.md +51 -0
- package/bunfig.toml +2 -0
- package/e2e/context.spec.ts +45 -0
- package/e2e/file-open.spec.ts +23 -0
- package/e2e/file-viewer.spec.ts +35 -0
- package/e2e/fixtures.ts +40 -0
- package/e2e/home.spec.ts +21 -0
- package/e2e/model-picker.spec.ts +43 -0
- package/e2e/navigation.spec.ts +9 -0
- package/e2e/palette.spec.ts +15 -0
- package/e2e/prompt-mention.spec.ts +26 -0
- package/e2e/prompt-slash-open.spec.ts +22 -0
- package/e2e/prompt.spec.ts +62 -0
- package/e2e/session.spec.ts +21 -0
- package/e2e/settings.spec.ts +44 -0
- package/e2e/sidebar.spec.ts +21 -0
- package/e2e/terminal-init.spec.ts +25 -0
- package/e2e/terminal.spec.ts +16 -0
- package/e2e/tsconfig.json +8 -0
- package/e2e/utils.ts +38 -0
- package/happydom.ts +75 -0
- package/index.html +23 -0
- package/package.json +72 -0
- package/playwright.config.ts +43 -0
- package/public/_headers +17 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/oc-theme-preload.js +28 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/script/e2e-local.ts +143 -0
- package/src/addons/serialize.test.ts +319 -0
- package/src/addons/serialize.ts +591 -0
- package/src/app.tsx +150 -0
- package/src/components/dialog-connect-provider.tsx +428 -0
- package/src/components/dialog-edit-project.tsx +259 -0
- package/src/components/dialog-fork.tsx +104 -0
- package/src/components/dialog-manage-models.tsx +59 -0
- package/src/components/dialog-select-directory.tsx +208 -0
- package/src/components/dialog-select-file.tsx +196 -0
- package/src/components/dialog-select-mcp.tsx +96 -0
- package/src/components/dialog-select-model-unpaid.tsx +130 -0
- package/src/components/dialog-select-model.tsx +162 -0
- package/src/components/dialog-select-provider.tsx +70 -0
- package/src/components/dialog-select-server.tsx +249 -0
- package/src/components/dialog-settings.tsx +112 -0
- package/src/components/file-tree.tsx +112 -0
- package/src/components/link.tsx +17 -0
- package/src/components/model-tooltip.tsx +91 -0
- package/src/components/prompt-input.tsx +2076 -0
- package/src/components/session/index.ts +5 -0
- package/src/components/session/session-context-tab.tsx +428 -0
- package/src/components/session/session-header.tsx +343 -0
- package/src/components/session/session-new-view.tsx +93 -0
- package/src/components/session/session-sortable-tab.tsx +56 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
- package/src/components/session-context-usage.tsx +113 -0
- package/src/components/session-lsp-indicator.tsx +42 -0
- package/src/components/session-mcp-indicator.tsx +34 -0
- package/src/components/settings-agents.tsx +15 -0
- package/src/components/settings-commands.tsx +15 -0
- package/src/components/settings-general.tsx +306 -0
- package/src/components/settings-keybinds.tsx +437 -0
- package/src/components/settings-mcp.tsx +15 -0
- package/src/components/settings-models.tsx +15 -0
- package/src/components/settings-permissions.tsx +234 -0
- package/src/components/settings-providers.tsx +15 -0
- package/src/components/terminal.tsx +315 -0
- package/src/components/titlebar.tsx +156 -0
- package/src/context/command.tsx +308 -0
- package/src/context/comments.tsx +140 -0
- package/src/context/file.tsx +409 -0
- package/src/context/global-sdk.tsx +106 -0
- package/src/context/global-sync.tsx +898 -0
- package/src/context/language.tsx +161 -0
- package/src/context/layout-scroll.test.ts +73 -0
- package/src/context/layout-scroll.ts +118 -0
- package/src/context/layout.tsx +648 -0
- package/src/context/local.tsx +578 -0
- package/src/context/notification.tsx +173 -0
- package/src/context/permission.tsx +167 -0
- package/src/context/platform.tsx +59 -0
- package/src/context/prompt.tsx +245 -0
- package/src/context/sdk.tsx +48 -0
- package/src/context/server.tsx +214 -0
- package/src/context/settings.tsx +166 -0
- package/src/context/sync.tsx +320 -0
- package/src/context/terminal.tsx +267 -0
- package/src/custom-elements.d.ts +17 -0
- package/src/entry.tsx +76 -0
- package/src/env.d.ts +8 -0
- package/src/hooks/use-providers.ts +31 -0
- package/src/i18n/ar.ts +656 -0
- package/src/i18n/br.ts +667 -0
- package/src/i18n/da.ts +582 -0
- package/src/i18n/de.ts +591 -0
- package/src/i18n/en.ts +665 -0
- package/src/i18n/es.ts +585 -0
- package/src/i18n/fr.ts +592 -0
- package/src/i18n/ja.ts +579 -0
- package/src/i18n/ko.ts +580 -0
- package/src/i18n/no.ts +602 -0
- package/src/i18n/pl.ts +661 -0
- package/src/i18n/ru.ts +664 -0
- package/src/i18n/zh.ts +574 -0
- package/src/i18n/zht.ts +570 -0
- package/src/index.css +57 -0
- package/src/index.ts +2 -0
- package/src/pages/directory-layout.tsx +57 -0
- package/src/pages/error.tsx +290 -0
- package/src/pages/home.tsx +125 -0
- package/src/pages/layout.tsx +2599 -0
- package/src/pages/session.tsx +2505 -0
- package/src/sst-env.d.ts +10 -0
- package/src/utils/dom.ts +51 -0
- package/src/utils/id.ts +99 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/perf.ts +135 -0
- package/src/utils/persist.ts +377 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/solid-dnd.tsx +55 -0
- package/src/utils/sound.ts +110 -0
- package/src/utils/speech.ts +302 -0
- package/src/utils/worktree.ts +58 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +15 -0
- package/vite.js +26 -0
package/src/sst-env.d.ts
ADDED
package/src/utils/dom.ts
ADDED
|
@@ -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
|
+
}
|
package/src/utils/id.ts
ADDED
|
@@ -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
|
+
}
|