@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,259 @@
1
+ import { Button } from "@jonsoc/ui/button"
2
+ import { useDialog } from "@jonsoc/ui/context/dialog"
3
+ import { Dialog } from "@jonsoc/ui/dialog"
4
+ import { TextField } from "@jonsoc/ui/text-field"
5
+ import { Icon } from "@jonsoc/ui/icon"
6
+ import { createMemo, createSignal, For, Show } from "solid-js"
7
+ import { createStore } from "solid-js/store"
8
+ import { useGlobalSDK } from "@/context/global-sdk"
9
+ import { useGlobalSync } from "@/context/global-sync"
10
+ import { type LocalProject, getAvatarColors } from "@/context/layout"
11
+ import { getFilename } from "@jonsoc/util/path"
12
+ import { Avatar } from "@jonsoc/ui/avatar"
13
+ import { useLanguage } from "@/context/language"
14
+
15
+ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
16
+
17
+ export function DialogEditProject(props: { project: LocalProject }) {
18
+ const dialog = useDialog()
19
+ const globalSDK = useGlobalSDK()
20
+ const globalSync = useGlobalSync()
21
+ const language = useLanguage()
22
+
23
+ const folderName = createMemo(() => getFilename(props.project.worktree))
24
+ const defaultName = createMemo(() => props.project.name || folderName())
25
+
26
+ const [store, setStore] = createStore({
27
+ name: defaultName(),
28
+ color: props.project.icon?.color || "pink",
29
+ iconUrl: props.project.icon?.override || "",
30
+ startup: props.project.commands?.start ?? "",
31
+ saving: false,
32
+ })
33
+
34
+ const [dragOver, setDragOver] = createSignal(false)
35
+ const [iconHover, setIconHover] = createSignal(false)
36
+
37
+ function handleFileSelect(file: File) {
38
+ if (!file.type.startsWith("image/")) return
39
+ const reader = new FileReader()
40
+ reader.onload = (e) => {
41
+ setStore("iconUrl", e.target?.result as string)
42
+ setIconHover(false)
43
+ }
44
+ reader.readAsDataURL(file)
45
+ }
46
+
47
+ function handleDrop(e: DragEvent) {
48
+ e.preventDefault()
49
+ setDragOver(false)
50
+ const file = e.dataTransfer?.files[0]
51
+ if (file) handleFileSelect(file)
52
+ }
53
+
54
+ function handleDragOver(e: DragEvent) {
55
+ e.preventDefault()
56
+ setDragOver(true)
57
+ }
58
+
59
+ function handleDragLeave() {
60
+ setDragOver(false)
61
+ }
62
+
63
+ function handleInputChange(e: Event) {
64
+ const input = e.target as HTMLInputElement
65
+ const file = input.files?.[0]
66
+ if (file) handleFileSelect(file)
67
+ }
68
+
69
+ function clearIcon() {
70
+ setStore("iconUrl", "")
71
+ }
72
+
73
+ async function handleSubmit(e: SubmitEvent) {
74
+ e.preventDefault()
75
+
76
+ setStore("saving", true)
77
+ const name = store.name.trim() === folderName() ? "" : store.name.trim()
78
+ const start = store.startup.trim()
79
+
80
+ if (props.project.id && props.project.id !== "global") {
81
+ await globalSDK.client.project.update({
82
+ projectID: props.project.id,
83
+ directory: props.project.worktree,
84
+ name,
85
+ icon: { color: store.color, override: store.iconUrl },
86
+ commands: { start },
87
+ })
88
+ globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
89
+ setStore("saving", false)
90
+ dialog.close()
91
+ return
92
+ }
93
+
94
+ globalSync.project.meta(props.project.worktree, {
95
+ name,
96
+ icon: { color: store.color, override: store.iconUrl || undefined },
97
+ commands: { start: start || undefined },
98
+ })
99
+ setStore("saving", false)
100
+ dialog.close()
101
+ }
102
+
103
+ return (
104
+ <Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
105
+ <form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
106
+ <div class="flex flex-col gap-4">
107
+ <TextField
108
+ autofocus
109
+ type="text"
110
+ label={language.t("dialog.project.edit.name")}
111
+ placeholder={folderName()}
112
+ value={store.name}
113
+ onChange={(v) => setStore("name", v)}
114
+ />
115
+
116
+ <div class="flex flex-col gap-2">
117
+ <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
118
+ <div class="flex gap-3 items-start">
119
+ <div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
120
+ <div
121
+ class="relative size-16 rounded-md transition-colors cursor-pointer"
122
+ classList={{
123
+ "border-text-interactive-base bg-surface-info-base/20": dragOver(),
124
+ "border-border-base hover:border-border-strong": !dragOver(),
125
+ "overflow-hidden": !!store.iconUrl,
126
+ }}
127
+ onDrop={handleDrop}
128
+ onDragOver={handleDragOver}
129
+ onDragLeave={handleDragLeave}
130
+ onClick={() => {
131
+ if (store.iconUrl && iconHover()) {
132
+ clearIcon()
133
+ } else {
134
+ document.getElementById("icon-upload")?.click()
135
+ }
136
+ }}
137
+ >
138
+ <Show
139
+ when={store.iconUrl}
140
+ fallback={
141
+ <div class="size-full flex items-center justify-center">
142
+ <Avatar
143
+ fallback={store.name || defaultName()}
144
+ {...getAvatarColors(store.color)}
145
+ class="size-full"
146
+ style={{ "font-size": "32px" }}
147
+ />
148
+ </div>
149
+ }
150
+ >
151
+ <img
152
+ src={store.iconUrl}
153
+ alt={language.t("dialog.project.edit.icon.alt")}
154
+ class="size-full object-cover"
155
+ />
156
+ </Show>
157
+ </div>
158
+ <div
159
+ style={{
160
+ position: "absolute",
161
+ top: 0,
162
+ left: 0,
163
+ width: "64px",
164
+ height: "64px",
165
+ background: "rgba(0,0,0,0.6)",
166
+ "border-radius": "6px",
167
+ "z-index": 10,
168
+ "pointer-events": "none",
169
+ opacity: iconHover() && !store.iconUrl ? 1 : 0,
170
+ display: "flex",
171
+ "align-items": "center",
172
+ "justify-content": "center",
173
+ }}
174
+ >
175
+ <Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
176
+ </div>
177
+ <div
178
+ style={{
179
+ position: "absolute",
180
+ top: 0,
181
+ left: 0,
182
+ width: "64px",
183
+ height: "64px",
184
+ background: "rgba(0,0,0,0.6)",
185
+ "border-radius": "6px",
186
+ "z-index": 10,
187
+ "pointer-events": "none",
188
+ opacity: iconHover() && store.iconUrl ? 1 : 0,
189
+ display: "flex",
190
+ "align-items": "center",
191
+ "justify-content": "center",
192
+ }}
193
+ >
194
+ <Icon name="trash" size="large" class="text-icon-invert-base" />
195
+ </div>
196
+ </div>
197
+ <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
198
+ <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
199
+ <span>{language.t("dialog.project.edit.icon.hint")}</span>
200
+ <span>{language.t("dialog.project.edit.icon.recommended")}</span>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <Show when={!store.iconUrl}>
206
+ <div class="flex flex-col gap-2">
207
+ <label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
208
+ <div class="flex gap-1.5">
209
+ <For each={AVATAR_COLOR_KEYS}>
210
+ {(color) => (
211
+ <button
212
+ type="button"
213
+ aria-label={language.t("dialog.project.edit.color.select", { color })}
214
+ aria-pressed={store.color === color}
215
+ classList={{
216
+ "flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
217
+ "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
218
+ store.color === color,
219
+ "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
220
+ store.color !== color,
221
+ }}
222
+ onClick={() => setStore("color", color)}
223
+ >
224
+ <Avatar
225
+ fallback={store.name || defaultName()}
226
+ {...getAvatarColors(color)}
227
+ class="size-full rounded"
228
+ />
229
+ </button>
230
+ )}
231
+ </For>
232
+ </div>
233
+ </div>
234
+ </Show>
235
+
236
+ <TextField
237
+ multiline
238
+ label={language.t("dialog.project.edit.worktree.startup")}
239
+ description={language.t("dialog.project.edit.worktree.startup.description")}
240
+ placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
241
+ value={store.startup}
242
+ onChange={(v) => setStore("startup", v)}
243
+ spellcheck={false}
244
+ class="max-h-40 w-full font-mono text-xs no-scrollbar"
245
+ />
246
+ </div>
247
+
248
+ <div class="flex justify-end gap-2">
249
+ <Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
250
+ {language.t("common.cancel")}
251
+ </Button>
252
+ <Button type="submit" variant="primary" size="large" disabled={store.saving}>
253
+ {store.saving ? language.t("common.saving") : language.t("common.save")}
254
+ </Button>
255
+ </div>
256
+ </form>
257
+ </Dialog>
258
+ )
259
+ }
@@ -0,0 +1,104 @@
1
+ import { Component, createMemo } from "solid-js"
2
+ import { useNavigate, useParams } from "@solidjs/router"
3
+ import { useSync } from "@/context/sync"
4
+ import { useSDK } from "@/context/sdk"
5
+ import { usePrompt } from "@/context/prompt"
6
+ import { useDialog } from "@jonsoc/ui/context/dialog"
7
+ import { Dialog } from "@jonsoc/ui/dialog"
8
+ import { List } from "@jonsoc/ui/list"
9
+ import { extractPromptFromParts } from "@/utils/prompt"
10
+ import type { TextPart as SDKTextPart } from "@jonsoc/sdk/v2/client"
11
+ import { base64Encode } from "@jonsoc/util/encode"
12
+ import { useLanguage } from "@/context/language"
13
+
14
+ interface ForkableMessage {
15
+ id: string
16
+ text: string
17
+ time: string
18
+ }
19
+
20
+ function formatTime(date: Date): string {
21
+ return date.toLocaleTimeString(undefined, { timeStyle: "short" })
22
+ }
23
+
24
+ export const DialogFork: Component = () => {
25
+ const params = useParams()
26
+ const navigate = useNavigate()
27
+ const sync = useSync()
28
+ const sdk = useSDK()
29
+ const prompt = usePrompt()
30
+ const dialog = useDialog()
31
+ const language = useLanguage()
32
+
33
+ const messages = createMemo((): ForkableMessage[] => {
34
+ const sessionID = params.id
35
+ if (!sessionID) return []
36
+
37
+ const msgs = sync.data.message[sessionID] ?? []
38
+ const result: ForkableMessage[] = []
39
+
40
+ for (const message of msgs) {
41
+ if (message.role !== "user") continue
42
+
43
+ const parts = sync.data.part[message.id] ?? []
44
+ const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
45
+ if (!textPart) continue
46
+
47
+ result.push({
48
+ id: message.id,
49
+ text: textPart.text.replace(/\n/g, " ").slice(0, 200),
50
+ time: formatTime(new Date(message.time.created)),
51
+ })
52
+ }
53
+
54
+ return result.reverse()
55
+ })
56
+
57
+ const handleSelect = (item: ForkableMessage | undefined) => {
58
+ if (!item) return
59
+
60
+ const sessionID = params.id
61
+ if (!sessionID) return
62
+
63
+ const parts = sync.data.part[item.id] ?? []
64
+ const restored = extractPromptFromParts(parts, {
65
+ directory: sdk.directory,
66
+ attachmentName: language.t("common.attachment"),
67
+ })
68
+
69
+ dialog.close()
70
+
71
+ sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
72
+ if (!forked.data) return
73
+ navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
74
+ requestAnimationFrame(() => {
75
+ prompt.set(restored)
76
+ })
77
+ })
78
+ }
79
+
80
+ return (
81
+ <Dialog title={language.t("command.session.fork")}>
82
+ <List
83
+ class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
84
+ search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
85
+ emptyMessage={language.t("dialog.fork.empty")}
86
+ key={(x) => x.id}
87
+ items={messages}
88
+ filterKeys={["text"]}
89
+ onSelect={handleSelect}
90
+ >
91
+ {(item) => (
92
+ <div class="w-full flex items-center gap-2">
93
+ <span class="truncate flex-1 min-w-0 text-left" style={{ "font-weight": "400" }}>
94
+ {item.text}
95
+ </span>
96
+ <span class="text-text-weak shrink-0" style={{ "font-weight": "400" }}>
97
+ {item.time}
98
+ </span>
99
+ </div>
100
+ )}
101
+ </List>
102
+ </Dialog>
103
+ )
104
+ }
@@ -0,0 +1,59 @@
1
+ import { Dialog } from "@jonsoc/ui/dialog"
2
+ import { List } from "@jonsoc/ui/list"
3
+ import { Switch } from "@jonsoc/ui/switch"
4
+ import type { Component } from "solid-js"
5
+ import { useLocal } from "@/context/local"
6
+ import { popularProviders } from "@/hooks/use-providers"
7
+ import { useLanguage } from "@/context/language"
8
+
9
+ export const DialogManageModels: Component = () => {
10
+ const local = useLocal()
11
+ const language = useLanguage()
12
+ return (
13
+ <Dialog title={language.t("dialog.model.manage")} description={language.t("dialog.model.manage.description")}>
14
+ <List
15
+ search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
16
+ emptyMessage={language.t("dialog.model.empty")}
17
+ key={(x) => `${x?.provider?.id}:${x?.id}`}
18
+ items={local.model.list()}
19
+ filterKeys={["provider.name", "name", "id"]}
20
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
21
+ groupBy={(x) => x.provider.name}
22
+ sortGroupsBy={(a, b) => {
23
+ const aProvider = a.items[0].provider.id
24
+ const bProvider = b.items[0].provider.id
25
+ if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
26
+ if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
27
+ return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
28
+ }}
29
+ onSelect={(x) => {
30
+ if (!x) return
31
+ const visible = local.model.visible({
32
+ modelID: x.id,
33
+ providerID: x.provider.id,
34
+ })
35
+ local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
36
+ }}
37
+ >
38
+ {(i) => (
39
+ <div class="w-full flex items-center justify-between gap-x-3">
40
+ <span>{i.name}</span>
41
+ <div onClick={(e) => e.stopPropagation()}>
42
+ <Switch
43
+ checked={
44
+ !!local.model.visible({
45
+ modelID: i.id,
46
+ providerID: i.provider.id,
47
+ })
48
+ }
49
+ onChange={(checked) => {
50
+ local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
51
+ }}
52
+ />
53
+ </div>
54
+ </div>
55
+ )}
56
+ </List>
57
+ </Dialog>
58
+ )
59
+ }
@@ -0,0 +1,208 @@
1
+ import { useDialog } from "@jonsoc/ui/context/dialog"
2
+ import { Dialog } from "@jonsoc/ui/dialog"
3
+ import { FileIcon } from "@jonsoc/ui/file-icon"
4
+ import { List } from "@jonsoc/ui/list"
5
+ import { getDirectory, getFilename } from "@jonsoc/util/path"
6
+ import fuzzysort from "fuzzysort"
7
+ import { createMemo } from "solid-js"
8
+ import { useGlobalSDK } from "@/context/global-sdk"
9
+ import { useGlobalSync } from "@/context/global-sync"
10
+ import { useLanguage } from "@/context/language"
11
+
12
+ interface DialogSelectDirectoryProps {
13
+ title?: string
14
+ multiple?: boolean
15
+ onSelect: (result: string | string[] | null) => void
16
+ }
17
+
18
+ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
19
+ const sync = useGlobalSync()
20
+ const sdk = useGlobalSDK()
21
+ const dialog = useDialog()
22
+ const language = useLanguage()
23
+
24
+ const home = createMemo(() => sync.data.path.home)
25
+
26
+ const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
27
+
28
+ const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
29
+
30
+ function normalize(input: string) {
31
+ const v = input.replaceAll("\\", "/")
32
+ if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
33
+ return v.replace(/\/+/g, "/")
34
+ }
35
+
36
+ function normalizeDriveRoot(input: string) {
37
+ const v = normalize(input)
38
+ if (/^[A-Za-z]:$/.test(v)) return v + "/"
39
+ return v
40
+ }
41
+
42
+ function trimTrailing(input: string) {
43
+ const v = normalizeDriveRoot(input)
44
+ if (v === "/") return v
45
+ if (v === "//") return v
46
+ if (/^[A-Za-z]:\/$/.test(v)) return v
47
+ return v.replace(/\/+$/, "")
48
+ }
49
+
50
+ function join(base: string | undefined, rel: string) {
51
+ const b = trimTrailing(base ?? "")
52
+ const r = trimTrailing(rel).replace(/^\/+/, "")
53
+ if (!b) return r
54
+ if (!r) return b
55
+ if (b.endsWith("/")) return b + r
56
+ return b + "/" + r
57
+ }
58
+
59
+ function rootOf(input: string) {
60
+ const v = normalizeDriveRoot(input)
61
+ if (v.startsWith("//")) return "//"
62
+ if (v.startsWith("/")) return "/"
63
+ if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
64
+ return ""
65
+ }
66
+
67
+ function display(path: string) {
68
+ const full = trimTrailing(path)
69
+ const h = home()
70
+ if (!h) return full
71
+
72
+ const hn = trimTrailing(h)
73
+ const lc = full.toLowerCase()
74
+ const hc = hn.toLowerCase()
75
+ if (lc === hc) return "~"
76
+ if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
77
+ return full
78
+ }
79
+
80
+ function scoped(filter: string) {
81
+ const base = start()
82
+ if (!base) return
83
+
84
+ const raw = normalizeDriveRoot(filter.trim())
85
+ if (!raw) return { directory: trimTrailing(base), path: "" }
86
+
87
+ const h = home()
88
+ if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
89
+ if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
90
+
91
+ const root = rootOf(raw)
92
+ if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
93
+ return { directory: trimTrailing(base), path: raw }
94
+ }
95
+
96
+ async function dirs(dir: string) {
97
+ const key = trimTrailing(dir)
98
+ const existing = cache.get(key)
99
+ if (existing) return existing
100
+
101
+ const request = sdk.client.file
102
+ .list({ directory: key, path: "" })
103
+ .then((x) => x.data ?? [])
104
+ .catch(() => [])
105
+ .then((nodes) =>
106
+ nodes
107
+ .filter((n) => n.type === "directory")
108
+ .map((n) => ({
109
+ name: n.name,
110
+ absolute: trimTrailing(normalizeDriveRoot(n.absolute)),
111
+ })),
112
+ )
113
+
114
+ cache.set(key, request)
115
+ return request
116
+ }
117
+
118
+ async function match(dir: string, query: string, limit: number) {
119
+ const items = await dirs(dir)
120
+ if (!query) return items.slice(0, limit).map((x) => x.absolute)
121
+ return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
122
+ }
123
+
124
+ const directories = async (filter: string) => {
125
+ const input = scoped(filter)
126
+ if (!input) return [] as string[]
127
+
128
+ const raw = normalizeDriveRoot(filter.trim())
129
+ const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
130
+
131
+ const query = normalizeDriveRoot(input.path)
132
+
133
+ if (!isPath) {
134
+ const results = await sdk.client.find
135
+ .files({ directory: input.directory, query, type: "directory", limit: 50 })
136
+ .then((x) => x.data ?? [])
137
+ .catch(() => [])
138
+
139
+ return results.map((rel) => join(input.directory, rel)).slice(0, 50)
140
+ }
141
+
142
+ const segments = query.replace(/^\/+/, "").split("/")
143
+ const head = segments.slice(0, segments.length - 1).filter((x) => x && x !== ".")
144
+ const tail = segments[segments.length - 1] ?? ""
145
+
146
+ const cap = 12
147
+ const branch = 4
148
+ let paths = [input.directory]
149
+ for (const part of head) {
150
+ if (part === "..") {
151
+ paths = paths.map((p) => {
152
+ const v = trimTrailing(p)
153
+ if (v === "/") return v
154
+ if (/^[A-Za-z]:\/$/.test(v)) return v
155
+ const i = v.lastIndexOf("/")
156
+ if (i <= 0) return "/"
157
+ return v.slice(0, i)
158
+ })
159
+ continue
160
+ }
161
+
162
+ const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
163
+ paths = Array.from(new Set(next)).slice(0, cap)
164
+ if (paths.length === 0) return [] as string[]
165
+ }
166
+
167
+ const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
168
+ return Array.from(new Set(out)).slice(0, 50)
169
+ }
170
+
171
+ function resolve(absolute: string) {
172
+ props.onSelect(props.multiple ? [absolute] : absolute)
173
+ dialog.close()
174
+ }
175
+
176
+ return (
177
+ <Dialog title={props.title ?? language.t("command.project.open")}>
178
+ <List
179
+ search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
180
+ emptyMessage={language.t("dialog.directory.empty")}
181
+ loadingMessage={language.t("common.loading")}
182
+ items={directories}
183
+ key={(x) => x}
184
+ onSelect={(path) => {
185
+ if (!path) return
186
+ resolve(path)
187
+ }}
188
+ >
189
+ {(absolute) => {
190
+ const path = display(absolute)
191
+ return (
192
+ <div class="w-full flex items-center justify-between rounded-md">
193
+ <div class="flex items-center gap-x-3 grow min-w-0">
194
+ <FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
195
+ <div class="flex items-center text-14-regular min-w-0">
196
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
197
+ {getDirectory(path)}
198
+ </span>
199
+ <span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ )
204
+ }}
205
+ </List>
206
+ </Dialog>
207
+ )
208
+ }