@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,409 @@
1
+ import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
2
+ import { createStore, produce } from "solid-js/store"
3
+ import { createSimpleContext } from "@jonsoc/ui/context"
4
+ import type { FileContent } from "@jonsoc/sdk/v2"
5
+ import { showToast } from "@jonsoc/ui/toast"
6
+ import { useParams } from "@solidjs/router"
7
+ import { getFilename } from "@jonsoc/util/path"
8
+ import { useSDK } from "./sdk"
9
+ import { useSync } from "./sync"
10
+ import { useLanguage } from "@/context/language"
11
+ import { Persist, persisted } from "@/utils/persist"
12
+
13
+ export type FileSelection = {
14
+ startLine: number
15
+ startChar: number
16
+ endLine: number
17
+ endChar: number
18
+ }
19
+
20
+ export type SelectedLineRange = {
21
+ start: number
22
+ end: number
23
+ side?: "additions" | "deletions"
24
+ endSide?: "additions" | "deletions"
25
+ }
26
+
27
+ export type FileViewState = {
28
+ scrollTop?: number
29
+ scrollLeft?: number
30
+ selectedLines?: SelectedLineRange | null
31
+ }
32
+
33
+ export type FileState = {
34
+ path: string
35
+ name: string
36
+ loaded?: boolean
37
+ loading?: boolean
38
+ error?: string
39
+ content?: FileContent
40
+ }
41
+
42
+ function stripFileProtocol(input: string) {
43
+ if (!input.startsWith("file://")) return input
44
+ return input.slice("file://".length)
45
+ }
46
+
47
+ function stripQueryAndHash(input: string) {
48
+ const hashIndex = input.indexOf("#")
49
+ const queryIndex = input.indexOf("?")
50
+
51
+ if (hashIndex !== -1 && queryIndex !== -1) {
52
+ return input.slice(0, Math.min(hashIndex, queryIndex))
53
+ }
54
+
55
+ if (hashIndex !== -1) return input.slice(0, hashIndex)
56
+ if (queryIndex !== -1) return input.slice(0, queryIndex)
57
+ return input
58
+ }
59
+
60
+ export function selectionFromLines(range: SelectedLineRange): FileSelection {
61
+ const startLine = Math.min(range.start, range.end)
62
+ const endLine = Math.max(range.start, range.end)
63
+ return {
64
+ startLine,
65
+ endLine,
66
+ startChar: 0,
67
+ endChar: 0,
68
+ }
69
+ }
70
+
71
+ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
72
+ if (range.start <= range.end) return range
73
+
74
+ const startSide = range.side
75
+ const endSide = range.endSide ?? startSide
76
+
77
+ return {
78
+ ...range,
79
+ start: range.end,
80
+ end: range.start,
81
+ side: endSide,
82
+ endSide: startSide !== endSide ? startSide : undefined,
83
+ }
84
+ }
85
+
86
+ const WORKSPACE_KEY = "__workspace__"
87
+ const MAX_FILE_VIEW_SESSIONS = 20
88
+ const MAX_VIEW_FILES = 500
89
+
90
+ type ViewSession = ReturnType<typeof createViewSession>
91
+
92
+ type ViewCacheEntry = {
93
+ value: ViewSession
94
+ dispose: VoidFunction
95
+ }
96
+
97
+ function createViewSession(dir: string, id: string | undefined) {
98
+ const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
99
+
100
+ const [view, setView, _, ready] = persisted(
101
+ Persist.scoped(dir, id, "file-view", [legacyViewKey]),
102
+ createStore<{
103
+ file: Record<string, FileViewState>
104
+ }>({
105
+ file: {},
106
+ }),
107
+ )
108
+
109
+ const meta = { pruned: false }
110
+
111
+ const pruneView = (keep?: string) => {
112
+ const keys = Object.keys(view.file)
113
+ if (keys.length <= MAX_VIEW_FILES) return
114
+
115
+ const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
116
+ if (drop.length === 0) return
117
+
118
+ setView(
119
+ produce((draft) => {
120
+ for (const key of drop) {
121
+ delete draft.file[key]
122
+ }
123
+ }),
124
+ )
125
+ }
126
+
127
+ createEffect(() => {
128
+ if (!ready()) return
129
+ if (meta.pruned) return
130
+ meta.pruned = true
131
+ pruneView()
132
+ })
133
+
134
+ const scrollTop = (path: string) => view.file[path]?.scrollTop
135
+ const scrollLeft = (path: string) => view.file[path]?.scrollLeft
136
+ const selectedLines = (path: string) => view.file[path]?.selectedLines
137
+
138
+ const setScrollTop = (path: string, top: number) => {
139
+ setView("file", path, (current) => {
140
+ if (current?.scrollTop === top) return current
141
+ return {
142
+ ...(current ?? {}),
143
+ scrollTop: top,
144
+ }
145
+ })
146
+ pruneView(path)
147
+ }
148
+
149
+ const setScrollLeft = (path: string, left: number) => {
150
+ setView("file", path, (current) => {
151
+ if (current?.scrollLeft === left) return current
152
+ return {
153
+ ...(current ?? {}),
154
+ scrollLeft: left,
155
+ }
156
+ })
157
+ pruneView(path)
158
+ }
159
+
160
+ const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
161
+ const next = range ? normalizeSelectedLines(range) : null
162
+ setView("file", path, (current) => {
163
+ if (current?.selectedLines === next) return current
164
+ return {
165
+ ...(current ?? {}),
166
+ selectedLines: next,
167
+ }
168
+ })
169
+ pruneView(path)
170
+ }
171
+
172
+ return {
173
+ ready,
174
+ scrollTop,
175
+ scrollLeft,
176
+ selectedLines,
177
+ setScrollTop,
178
+ setScrollLeft,
179
+ setSelectedLines,
180
+ }
181
+ }
182
+
183
+ export const { use: useFile, provider: FileProvider } = createSimpleContext({
184
+ name: "File",
185
+ gate: false,
186
+ init: () => {
187
+ const sdk = useSDK()
188
+ const sync = useSync()
189
+ const params = useParams()
190
+ const language = useLanguage()
191
+
192
+ const scope = createMemo(() => sdk.directory)
193
+
194
+ const directory = createMemo(() => sync.data.path.directory)
195
+
196
+ function normalize(input: string) {
197
+ const root = directory()
198
+ const prefix = root.endsWith("/") ? root : root + "/"
199
+
200
+ let path = stripQueryAndHash(stripFileProtocol(input))
201
+
202
+ if (path.startsWith(prefix)) {
203
+ path = path.slice(prefix.length)
204
+ }
205
+
206
+ if (path.startsWith(root)) {
207
+ path = path.slice(root.length)
208
+ }
209
+
210
+ if (path.startsWith("./")) {
211
+ path = path.slice(2)
212
+ }
213
+
214
+ if (path.startsWith("/")) {
215
+ path = path.slice(1)
216
+ }
217
+
218
+ return path
219
+ }
220
+
221
+ function tab(input: string) {
222
+ const path = normalize(input)
223
+ return `file://${path}`
224
+ }
225
+
226
+ function pathFromTab(tabValue: string) {
227
+ if (!tabValue.startsWith("file://")) return
228
+ return normalize(tabValue)
229
+ }
230
+
231
+ const inflight = new Map<string, Promise<void>>()
232
+
233
+ const [store, setStore] = createStore<{
234
+ file: Record<string, FileState>
235
+ }>({
236
+ file: {},
237
+ })
238
+
239
+ createEffect(() => {
240
+ scope()
241
+ inflight.clear()
242
+ setStore("file", {})
243
+ })
244
+
245
+ const viewCache = new Map<string, ViewCacheEntry>()
246
+
247
+ const disposeViews = () => {
248
+ for (const entry of viewCache.values()) {
249
+ entry.dispose()
250
+ }
251
+ viewCache.clear()
252
+ }
253
+
254
+ const pruneViews = () => {
255
+ while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
256
+ const first = viewCache.keys().next().value
257
+ if (!first) return
258
+ const entry = viewCache.get(first)
259
+ entry?.dispose()
260
+ viewCache.delete(first)
261
+ }
262
+ }
263
+
264
+ const loadView = (dir: string, id: string | undefined) => {
265
+ const key = `${dir}:${id ?? WORKSPACE_KEY}`
266
+ const existing = viewCache.get(key)
267
+ if (existing) {
268
+ viewCache.delete(key)
269
+ viewCache.set(key, existing)
270
+ return existing.value
271
+ }
272
+
273
+ const entry = createRoot((dispose) => ({
274
+ value: createViewSession(dir, id),
275
+ dispose,
276
+ }))
277
+
278
+ viewCache.set(key, entry)
279
+ pruneViews()
280
+ return entry.value
281
+ }
282
+
283
+ const view = createMemo(() => loadView(params.dir!, params.id))
284
+
285
+ function ensure(path: string) {
286
+ if (!path) return
287
+ if (store.file[path]) return
288
+ setStore("file", path, { path, name: getFilename(path) })
289
+ }
290
+
291
+ function load(input: string, options?: { force?: boolean }) {
292
+ const path = normalize(input)
293
+ if (!path) return Promise.resolve()
294
+
295
+ const directory = scope()
296
+ const key = `${directory}\n${path}`
297
+ const client = sdk.client
298
+
299
+ ensure(path)
300
+
301
+ const current = store.file[path]
302
+ if (!options?.force && current?.loaded) return Promise.resolve()
303
+
304
+ const pending = inflight.get(key)
305
+ if (pending) return pending
306
+
307
+ setStore(
308
+ "file",
309
+ path,
310
+ produce((draft) => {
311
+ draft.loading = true
312
+ draft.error = undefined
313
+ }),
314
+ )
315
+
316
+ const promise = client.file
317
+ .read({ path })
318
+ .then((x) => {
319
+ if (scope() !== directory) return
320
+ setStore(
321
+ "file",
322
+ path,
323
+ produce((draft) => {
324
+ draft.loaded = true
325
+ draft.loading = false
326
+ draft.content = x.data
327
+ }),
328
+ )
329
+ })
330
+ .catch((e) => {
331
+ if (scope() !== directory) return
332
+ setStore(
333
+ "file",
334
+ path,
335
+ produce((draft) => {
336
+ draft.loading = false
337
+ draft.error = e.message
338
+ }),
339
+ )
340
+ showToast({
341
+ variant: "error",
342
+ title: language.t("toast.file.loadFailed.title"),
343
+ description: e.message,
344
+ })
345
+ })
346
+ .finally(() => {
347
+ inflight.delete(key)
348
+ })
349
+
350
+ inflight.set(key, promise)
351
+ return promise
352
+ }
353
+
354
+ const stop = sdk.event.listen((e) => {
355
+ const event = e.details
356
+ if (event.type !== "file.watcher.updated") return
357
+ const path = normalize(event.properties.file)
358
+ if (!path) return
359
+ if (path.startsWith(".git/")) return
360
+ if (!store.file[path]) return
361
+ load(path, { force: true })
362
+ })
363
+
364
+ const get = (input: string) => store.file[normalize(input)]
365
+
366
+ const scrollTop = (input: string) => view().scrollTop(normalize(input))
367
+ const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
368
+ const selectedLines = (input: string) => view().selectedLines(normalize(input))
369
+
370
+ const setScrollTop = (input: string, top: number) => {
371
+ const path = normalize(input)
372
+ view().setScrollTop(path, top)
373
+ }
374
+
375
+ const setScrollLeft = (input: string, left: number) => {
376
+ const path = normalize(input)
377
+ view().setScrollLeft(path, left)
378
+ }
379
+
380
+ const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
381
+ const path = normalize(input)
382
+ view().setSelectedLines(path, range)
383
+ }
384
+
385
+ onCleanup(() => {
386
+ stop()
387
+ disposeViews()
388
+ })
389
+
390
+ return {
391
+ ready: () => view().ready(),
392
+ normalize,
393
+ tab,
394
+ pathFromTab,
395
+ get,
396
+ load,
397
+ scrollTop,
398
+ scrollLeft,
399
+ setScrollTop,
400
+ setScrollLeft,
401
+ selectedLines,
402
+ setSelectedLines,
403
+ searchFiles: (query: string) =>
404
+ sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)),
405
+ searchFilesAndDirectories: (query: string) =>
406
+ sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)),
407
+ }
408
+ },
409
+ })
@@ -0,0 +1,106 @@
1
+ import { createOpencodeClient, type Event } from "@jonsoc/sdk/v2/client"
2
+ import { createSimpleContext } from "@jonsoc/ui/context"
3
+ import { createGlobalEmitter } from "@solid-primitives/event-bus"
4
+ import { batch, onCleanup } from "solid-js"
5
+ import { usePlatform } from "./platform"
6
+ import { useServer } from "./server"
7
+
8
+ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
9
+ name: "GlobalSDK",
10
+ init: () => {
11
+ const server = useServer()
12
+ const platform = usePlatform()
13
+ const abort = new AbortController()
14
+
15
+ const eventSdk = createOpencodeClient({
16
+ baseUrl: server.url,
17
+ signal: abort.signal,
18
+ fetch: platform.fetch,
19
+ })
20
+ const emitter = createGlobalEmitter<{
21
+ [key: string]: Event
22
+ }>()
23
+
24
+ type Queued = { directory: string; payload: Event }
25
+
26
+ let queue: Array<Queued | undefined> = []
27
+ const coalesced = new Map<string, number>()
28
+ let timer: ReturnType<typeof setTimeout> | undefined
29
+ let last = 0
30
+
31
+ const key = (directory: string, payload: Event) => {
32
+ if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
33
+ if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
34
+ if (payload.type === "message.part.updated") {
35
+ const part = payload.properties.part
36
+ return `message.part.updated:${directory}:${part.messageID}:${part.id}`
37
+ }
38
+ }
39
+
40
+ const flush = () => {
41
+ if (timer) clearTimeout(timer)
42
+ timer = undefined
43
+
44
+ const events = queue
45
+ queue = []
46
+ coalesced.clear()
47
+ if (events.length === 0) return
48
+
49
+ last = Date.now()
50
+ batch(() => {
51
+ for (const event of events) {
52
+ if (!event) continue
53
+ emitter.emit(event.directory, event.payload)
54
+ }
55
+ })
56
+ }
57
+
58
+ const schedule = () => {
59
+ if (timer) return
60
+ const elapsed = Date.now() - last
61
+ timer = setTimeout(flush, Math.max(0, 16 - elapsed))
62
+ }
63
+
64
+ const stop = () => {
65
+ flush()
66
+ }
67
+
68
+ void (async () => {
69
+ const events = await eventSdk.global.event()
70
+ let yielded = Date.now()
71
+ for await (const event of events.stream) {
72
+ const directory = event.directory ?? "global"
73
+ const payload = event.payload
74
+ const k = key(directory, payload)
75
+ if (k) {
76
+ const i = coalesced.get(k)
77
+ if (i !== undefined) {
78
+ queue[i] = undefined
79
+ }
80
+ coalesced.set(k, queue.length)
81
+ }
82
+ queue.push({ directory, payload })
83
+ schedule()
84
+
85
+ if (Date.now() - yielded < 8) continue
86
+ yielded = Date.now()
87
+ await new Promise<void>((resolve) => setTimeout(resolve, 0))
88
+ }
89
+ })()
90
+ .finally(stop)
91
+ .catch(() => undefined)
92
+
93
+ onCleanup(() => {
94
+ abort.abort()
95
+ stop()
96
+ })
97
+
98
+ const sdk = createOpencodeClient({
99
+ baseUrl: server.url,
100
+ fetch: platform.fetch,
101
+ throwOnError: true,
102
+ })
103
+
104
+ return { url: server.url, client: sdk, event: emitter }
105
+ },
106
+ })