@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,308 @@
1
+ import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { createSimpleContext } from "@jonsoc/ui/context"
4
+ import { useDialog } from "@jonsoc/ui/context/dialog"
5
+ import { useLanguage } from "@/context/language"
6
+ import { useSettings } from "@/context/settings"
7
+ import { Persist, persisted } from "@/utils/persist"
8
+
9
+ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
10
+
11
+ const PALETTE_ID = "command.palette"
12
+ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
13
+ const SUGGESTED_PREFIX = "suggested."
14
+
15
+ function actionId(id: string) {
16
+ if (!id.startsWith(SUGGESTED_PREFIX)) return id
17
+ return id.slice(SUGGESTED_PREFIX.length)
18
+ }
19
+
20
+ function normalizeKey(key: string) {
21
+ if (key === ",") return "comma"
22
+ if (key === "+") return "plus"
23
+ if (key === " ") return "space"
24
+ return key.toLowerCase()
25
+ }
26
+
27
+ export type KeybindConfig = string
28
+
29
+ export interface Keybind {
30
+ key: string
31
+ ctrl: boolean
32
+ meta: boolean
33
+ shift: boolean
34
+ alt: boolean
35
+ }
36
+
37
+ export interface CommandOption {
38
+ id: string
39
+ title: string
40
+ description?: string
41
+ category?: string
42
+ keybind?: KeybindConfig
43
+ slash?: string
44
+ suggested?: boolean
45
+ disabled?: boolean
46
+ onSelect?: (source?: "palette" | "keybind" | "slash") => void
47
+ onHighlight?: () => (() => void) | void
48
+ }
49
+
50
+ export type CommandCatalogItem = {
51
+ title: string
52
+ description?: string
53
+ category?: string
54
+ keybind?: KeybindConfig
55
+ slash?: string
56
+ }
57
+
58
+ export function parseKeybind(config: string): Keybind[] {
59
+ if (!config || config === "none") return []
60
+
61
+ return config.split(",").map((combo) => {
62
+ const parts = combo.trim().toLowerCase().split("+")
63
+ const keybind: Keybind = {
64
+ key: "",
65
+ ctrl: false,
66
+ meta: false,
67
+ shift: false,
68
+ alt: false,
69
+ }
70
+
71
+ for (const part of parts) {
72
+ switch (part) {
73
+ case "ctrl":
74
+ case "control":
75
+ keybind.ctrl = true
76
+ break
77
+ case "meta":
78
+ case "cmd":
79
+ case "command":
80
+ keybind.meta = true
81
+ break
82
+ case "mod":
83
+ if (IS_MAC) keybind.meta = true
84
+ else keybind.ctrl = true
85
+ break
86
+ case "alt":
87
+ case "option":
88
+ keybind.alt = true
89
+ break
90
+ case "shift":
91
+ keybind.shift = true
92
+ break
93
+ default:
94
+ keybind.key = part
95
+ break
96
+ }
97
+ }
98
+
99
+ return keybind
100
+ })
101
+ }
102
+
103
+ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
104
+ const eventKey = normalizeKey(event.key)
105
+
106
+ for (const kb of keybinds) {
107
+ const keyMatch = kb.key === eventKey
108
+ const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
109
+ const metaMatch = kb.meta === (event.metaKey || false)
110
+ const shiftMatch = kb.shift === (event.shiftKey || false)
111
+ const altMatch = kb.alt === (event.altKey || false)
112
+
113
+ if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
114
+ return true
115
+ }
116
+ }
117
+
118
+ return false
119
+ }
120
+
121
+ export function formatKeybind(config: string): string {
122
+ if (!config || config === "none") return ""
123
+
124
+ const keybinds = parseKeybind(config)
125
+ if (keybinds.length === 0) return ""
126
+
127
+ const kb = keybinds[0]
128
+ const parts: string[] = []
129
+
130
+ if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
131
+ if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
132
+ if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
133
+ if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
134
+
135
+ if (kb.key) {
136
+ const keys: Record<string, string> = {
137
+ arrowup: "↑",
138
+ arrowdown: "↓",
139
+ arrowleft: "←",
140
+ arrowright: "→",
141
+ comma: ",",
142
+ plus: "+",
143
+ space: "Space",
144
+ }
145
+ const key = kb.key.toLowerCase()
146
+ const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
147
+ parts.push(displayKey)
148
+ }
149
+
150
+ return IS_MAC ? parts.join("") : parts.join("+")
151
+ }
152
+
153
+ export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
154
+ name: "Command",
155
+ init: () => {
156
+ const dialog = useDialog()
157
+ const settings = useSettings()
158
+ const language = useLanguage()
159
+ const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
160
+ const [suspendCount, setSuspendCount] = createSignal(0)
161
+
162
+ const [catalog, setCatalog, _, catalogReady] = persisted(
163
+ Persist.global("command.catalog.v1"),
164
+ createStore<Record<string, CommandCatalogItem>>({}),
165
+ )
166
+
167
+ const bind = (id: string, def: KeybindConfig | undefined) => {
168
+ const custom = settings.keybinds.get(actionId(id))
169
+ const config = custom ?? def
170
+ if (!config || config === "none") return
171
+ return config
172
+ }
173
+
174
+ const registered = createMemo(() => {
175
+ const seen = new Set<string>()
176
+ const all: CommandOption[] = []
177
+
178
+ for (const reg of registrations()) {
179
+ for (const opt of reg()) {
180
+ if (seen.has(opt.id)) continue
181
+ seen.add(opt.id)
182
+ all.push(opt)
183
+ }
184
+ }
185
+
186
+ return all
187
+ })
188
+
189
+ createEffect(() => {
190
+ if (!catalogReady()) return
191
+
192
+ for (const opt of registered()) {
193
+ const id = actionId(opt.id)
194
+ setCatalog(id, {
195
+ title: opt.title,
196
+ description: opt.description,
197
+ category: opt.category,
198
+ keybind: opt.keybind,
199
+ slash: opt.slash,
200
+ })
201
+ }
202
+ })
203
+
204
+ const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
205
+
206
+ const options = createMemo(() => {
207
+ const resolved = registered().map((opt) => ({
208
+ ...opt,
209
+ keybind: bind(opt.id, opt.keybind),
210
+ }))
211
+
212
+ const suggested = resolved.filter((x) => x.suggested && !x.disabled)
213
+
214
+ return [
215
+ ...suggested.map((x) => ({
216
+ ...x,
217
+ id: SUGGESTED_PREFIX + x.id,
218
+ category: language.t("command.category.suggested"),
219
+ })),
220
+ ...resolved,
221
+ ]
222
+ })
223
+
224
+ const suspended = () => suspendCount() > 0
225
+
226
+ const run = (id: string, source?: "palette" | "keybind" | "slash") => {
227
+ for (const option of options()) {
228
+ if (option.id === id || option.id === "suggested." + id) {
229
+ option.onSelect?.(source)
230
+ return
231
+ }
232
+ }
233
+ }
234
+
235
+ const showPalette = () => {
236
+ run("file.open", "palette")
237
+ }
238
+
239
+ const handleKeyDown = (event: KeyboardEvent) => {
240
+ if (suspended() || dialog.active) return
241
+
242
+ const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
243
+ if (matchKeybind(paletteKeybinds, event)) {
244
+ event.preventDefault()
245
+ showPalette()
246
+ return
247
+ }
248
+
249
+ for (const option of options()) {
250
+ if (option.disabled) continue
251
+ if (!option.keybind) continue
252
+
253
+ const keybinds = parseKeybind(option.keybind)
254
+ if (matchKeybind(keybinds, event)) {
255
+ event.preventDefault()
256
+ option.onSelect?.("keybind")
257
+ return
258
+ }
259
+ }
260
+ }
261
+
262
+ onMount(() => {
263
+ document.addEventListener("keydown", handleKeyDown)
264
+ })
265
+
266
+ onCleanup(() => {
267
+ document.removeEventListener("keydown", handleKeyDown)
268
+ })
269
+
270
+ return {
271
+ register(cb: () => CommandOption[]) {
272
+ const results = createMemo(cb)
273
+ setRegistrations((arr) => [results, ...arr])
274
+ onCleanup(() => {
275
+ setRegistrations((arr) => arr.filter((x) => x !== results))
276
+ })
277
+ },
278
+ trigger(id: string, source?: "palette" | "keybind" | "slash") {
279
+ run(id, source)
280
+ },
281
+ keybind(id: string) {
282
+ if (id === PALETTE_ID) {
283
+ return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
284
+ }
285
+
286
+ const base = actionId(id)
287
+ const option = options().find((x) => actionId(x.id) === base)
288
+ if (option?.keybind) return formatKeybind(option.keybind)
289
+
290
+ const meta = catalog[base]
291
+ const config = bind(base, meta?.keybind)
292
+ if (!config) return ""
293
+ return formatKeybind(config)
294
+ },
295
+ show: showPalette,
296
+ keybinds(enabled: boolean) {
297
+ setSuspendCount((count) => count + (enabled ? -1 : 1))
298
+ },
299
+ suspended,
300
+ get catalog() {
301
+ return catalogOptions()
302
+ },
303
+ get options() {
304
+ return options()
305
+ },
306
+ }
307
+ },
308
+ })
@@ -0,0 +1,140 @@
1
+ import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { createSimpleContext } from "@jonsoc/ui/context"
4
+ import { useParams } from "@solidjs/router"
5
+ import { Persist, persisted } from "@/utils/persist"
6
+ import type { SelectedLineRange } from "@/context/file"
7
+
8
+ export type LineComment = {
9
+ id: string
10
+ file: string
11
+ selection: SelectedLineRange
12
+ comment: string
13
+ time: number
14
+ }
15
+
16
+ type CommentFocus = { file: string; id: string }
17
+
18
+ const WORKSPACE_KEY = "__workspace__"
19
+ const MAX_COMMENT_SESSIONS = 20
20
+
21
+ type CommentSession = ReturnType<typeof createCommentSession>
22
+
23
+ type CommentCacheEntry = {
24
+ value: CommentSession
25
+ dispose: VoidFunction
26
+ }
27
+
28
+ function createCommentSession(dir: string, id: string | undefined) {
29
+ const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
30
+
31
+ const [store, setStore, _, ready] = persisted(
32
+ Persist.scoped(dir, id, "comments", [legacy]),
33
+ createStore<{
34
+ comments: Record<string, LineComment[]>
35
+ }>({
36
+ comments: {},
37
+ }),
38
+ )
39
+
40
+ const [focus, setFocus] = createSignal<CommentFocus | null>(null)
41
+
42
+ const list = (file: string) => store.comments[file] ?? []
43
+
44
+ const add = (input: Omit<LineComment, "id" | "time">) => {
45
+ const next: LineComment = {
46
+ id: crypto.randomUUID(),
47
+ time: Date.now(),
48
+ ...input,
49
+ }
50
+
51
+ batch(() => {
52
+ setStore("comments", input.file, (items) => [...(items ?? []), next])
53
+ setFocus({ file: input.file, id: next.id })
54
+ })
55
+
56
+ return next
57
+ }
58
+
59
+ const remove = (file: string, id: string) => {
60
+ setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
61
+ setFocus((current) => (current?.id === id ? null : current))
62
+ }
63
+
64
+ const all = createMemo(() => {
65
+ const files = Object.keys(store.comments)
66
+ const items = files.flatMap((file) => store.comments[file] ?? [])
67
+ return items.slice().sort((a, b) => a.time - b.time)
68
+ })
69
+
70
+ return {
71
+ ready,
72
+ list,
73
+ all,
74
+ add,
75
+ remove,
76
+ focus: createMemo(() => focus()),
77
+ setFocus,
78
+ clearFocus: () => setFocus(null),
79
+ }
80
+ }
81
+
82
+ export const { use: useComments, provider: CommentsProvider } = createSimpleContext({
83
+ name: "Comments",
84
+ gate: false,
85
+ init: () => {
86
+ const params = useParams()
87
+ const cache = new Map<string, CommentCacheEntry>()
88
+
89
+ const disposeAll = () => {
90
+ for (const entry of cache.values()) {
91
+ entry.dispose()
92
+ }
93
+ cache.clear()
94
+ }
95
+
96
+ onCleanup(disposeAll)
97
+
98
+ const prune = () => {
99
+ while (cache.size > MAX_COMMENT_SESSIONS) {
100
+ const first = cache.keys().next().value
101
+ if (!first) return
102
+ const entry = cache.get(first)
103
+ entry?.dispose()
104
+ cache.delete(first)
105
+ }
106
+ }
107
+
108
+ const load = (dir: string, id: string | undefined) => {
109
+ const key = `${dir}:${id ?? WORKSPACE_KEY}`
110
+ const existing = cache.get(key)
111
+ if (existing) {
112
+ cache.delete(key)
113
+ cache.set(key, existing)
114
+ return existing.value
115
+ }
116
+
117
+ const entry = createRoot((dispose) => ({
118
+ value: createCommentSession(dir, id),
119
+ dispose,
120
+ }))
121
+
122
+ cache.set(key, entry)
123
+ prune()
124
+ return entry.value
125
+ }
126
+
127
+ const session = createMemo(() => load(params.dir!, params.id))
128
+
129
+ return {
130
+ ready: () => session().ready(),
131
+ list: (file: string) => session().list(file),
132
+ all: () => session().all(),
133
+ add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
134
+ remove: (file: string, id: string) => session().remove(file, id),
135
+ focus: () => session().focus(),
136
+ setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
137
+ clearFocus: () => session().clearFocus(),
138
+ }
139
+ },
140
+ })