@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,343 @@
1
+ import { createEffect, createMemo, onCleanup, Show } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { Portal } from "solid-js/web"
4
+ import { useParams } from "@solidjs/router"
5
+ import { useLayout } from "@/context/layout"
6
+ import { useCommand } from "@/context/command"
7
+ import { useLanguage } from "@/context/language"
8
+ // import { useServer } from "@/context/server"
9
+ // import { useDialog } from "@jonsoc/ui/context/dialog"
10
+ import { usePlatform } from "@/context/platform"
11
+ import { useSync } from "@/context/sync"
12
+ import { useGlobalSDK } from "@/context/global-sdk"
13
+ import { getFilename } from "@jonsoc/util/path"
14
+ import { base64Decode } from "@jonsoc/util/encode"
15
+
16
+ import { Icon } from "@jonsoc/ui/icon"
17
+ import { IconButton } from "@jonsoc/ui/icon-button"
18
+ import { Button } from "@jonsoc/ui/button"
19
+ import { Tooltip, TooltipKeybind } from "@jonsoc/ui/tooltip"
20
+ import { Popover } from "@jonsoc/ui/popover"
21
+ import { TextField } from "@jonsoc/ui/text-field"
22
+ import { Keybind } from "@jonsoc/ui/keybind"
23
+
24
+ export function SessionHeader() {
25
+ const globalSDK = useGlobalSDK()
26
+ const layout = useLayout()
27
+ const params = useParams()
28
+ const command = useCommand()
29
+ // const server = useServer()
30
+ // const dialog = useDialog()
31
+ const sync = useSync()
32
+ const platform = usePlatform()
33
+ const language = useLanguage()
34
+
35
+ const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
36
+ const project = createMemo(() => {
37
+ const directory = projectDirectory()
38
+ if (!directory) return
39
+ return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
40
+ })
41
+ const name = createMemo(() => {
42
+ const current = project()
43
+ if (current) return current.name || getFilename(current.worktree)
44
+ return getFilename(projectDirectory())
45
+ })
46
+ const hotkey = createMemo(() => command.keybind("file.open"))
47
+
48
+ const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
49
+ const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
50
+ const showShare = createMemo(() => shareEnabled() && !!currentSession())
51
+ const showReview = createMemo(() => !!currentSession())
52
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
53
+ const view = createMemo(() => layout.view(sessionKey))
54
+
55
+ const [state, setState] = createStore({
56
+ share: false,
57
+ unshare: false,
58
+ copied: false,
59
+ timer: undefined as number | undefined,
60
+ })
61
+ const shareUrl = createMemo(() => currentSession()?.share?.url)
62
+
63
+ createEffect(() => {
64
+ const url = shareUrl()
65
+ if (url) return
66
+ if (state.timer) window.clearTimeout(state.timer)
67
+ setState({ copied: false, timer: undefined })
68
+ })
69
+
70
+ onCleanup(() => {
71
+ if (state.timer) window.clearTimeout(state.timer)
72
+ })
73
+
74
+ function shareSession() {
75
+ const session = currentSession()
76
+ if (!session || state.share) return
77
+ setState("share", true)
78
+ globalSDK.client.session
79
+ .share({ sessionID: session.id, directory: projectDirectory() })
80
+ .catch((error) => {
81
+ console.error("Failed to share session", error)
82
+ })
83
+ .finally(() => {
84
+ setState("share", false)
85
+ })
86
+ }
87
+
88
+ function unshareSession() {
89
+ const session = currentSession()
90
+ if (!session || state.unshare) return
91
+ setState("unshare", true)
92
+ globalSDK.client.session
93
+ .unshare({ sessionID: session.id, directory: projectDirectory() })
94
+ .catch((error) => {
95
+ console.error("Failed to unshare session", error)
96
+ })
97
+ .finally(() => {
98
+ setState("unshare", false)
99
+ })
100
+ }
101
+
102
+ function copyLink() {
103
+ const url = shareUrl()
104
+ if (!url) return
105
+ navigator.clipboard
106
+ .writeText(url)
107
+ .then(() => {
108
+ if (state.timer) window.clearTimeout(state.timer)
109
+ setState("copied", true)
110
+ const timer = window.setTimeout(() => {
111
+ setState("copied", false)
112
+ setState("timer", undefined)
113
+ }, 3000)
114
+ setState("timer", timer)
115
+ })
116
+ .catch((error) => {
117
+ console.error("Failed to copy share link", error)
118
+ })
119
+ }
120
+
121
+ function viewShare() {
122
+ const url = shareUrl()
123
+ if (!url) return
124
+ platform.openLink(url)
125
+ }
126
+
127
+ const centerMount = createMemo(() => document.getElementById("jonsoc-titlebar-center"))
128
+ const rightMount = createMemo(() => document.getElementById("jonsoc-titlebar-right"))
129
+
130
+ return (
131
+ <>
132
+ <Show when={centerMount()}>
133
+ {(mount) => (
134
+ <Portal mount={mount()}>
135
+ <button
136
+ type="button"
137
+ class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
138
+ onClick={() => command.trigger("file.open")}
139
+ aria-label={language.t("session.header.searchFiles")}
140
+ >
141
+ <div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible">
142
+ <Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" />
143
+ <span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center">
144
+ {language.t("session.header.search.placeholder", { project: name() })}
145
+ </span>
146
+ </div>
147
+
148
+ <Show when={hotkey()}>{(keybind) => <Keybind class="shrink-0">{keybind()}</Keybind>}</Show>
149
+ </button>
150
+ </Portal>
151
+ )}
152
+ </Show>
153
+ <Show when={rightMount()}>
154
+ {(mount) => (
155
+ <Portal mount={mount()}>
156
+ <div class="flex items-center gap-3">
157
+ {/* <div class="hidden md:flex items-center gap-1"> */}
158
+ {/* <Button */}
159
+ {/* size="small" */}
160
+ {/* variant="ghost" */}
161
+ {/* onClick={() => { */}
162
+ {/* dialog.show(() => <DialogSelectServer />) */}
163
+ {/* }} */}
164
+ {/* > */}
165
+ {/* <div */}
166
+ {/* classList={{ */}
167
+ {/* "size-1.5 rounded-full": true, */}
168
+ {/* "bg-icon-success-base": server.healthy() === true, */}
169
+ {/* "bg-icon-critical-base": server.healthy() === false, */}
170
+ {/* "bg-border-weak-base": server.healthy() === undefined, */}
171
+ {/* }} */}
172
+ {/* /> */}
173
+ {/* <Icon name="server" size="small" class="text-icon-weak" /> */}
174
+ {/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
175
+ {/* </Button> */}
176
+ {/* <SessionLspIndicator /> */}
177
+ {/* <SessionMcpIndicator /> */}
178
+ {/* </div> */}
179
+ <div class="flex items-center gap-1">
180
+ <div class="hidden md:block shrink-0">
181
+ <TooltipKeybind
182
+ title={language.t("command.review.toggle")}
183
+ keybind={command.keybind("review.toggle")}
184
+ >
185
+ <Button
186
+ variant="ghost"
187
+ class="group/review-toggle size-6 p-0"
188
+ onClick={() => view().reviewPanel.toggle()}
189
+ aria-label={language.t("command.review.toggle")}
190
+ aria-expanded={view().reviewPanel.opened()}
191
+ aria-controls="review-panel"
192
+ tabIndex={showReview() ? 0 : -1}
193
+ >
194
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
195
+ <Icon
196
+ size="small"
197
+ name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
198
+ class="group-hover/review-toggle:hidden"
199
+ />
200
+ <Icon
201
+ size="small"
202
+ name="layout-right-partial"
203
+ class="hidden group-hover/review-toggle:inline-block"
204
+ />
205
+ <Icon
206
+ size="small"
207
+ name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
208
+ class="hidden group-active/review-toggle:inline-block"
209
+ />
210
+ </div>
211
+ </Button>
212
+ </TooltipKeybind>
213
+ </div>
214
+ <TooltipKeybind
215
+ class="hidden md:block shrink-0"
216
+ title={language.t("command.terminal.toggle")}
217
+ keybind={command.keybind("terminal.toggle")}
218
+ >
219
+ <Button
220
+ variant="ghost"
221
+ class="group/terminal-toggle size-6 p-0"
222
+ onClick={() => view().terminal.toggle()}
223
+ aria-label={language.t("command.terminal.toggle")}
224
+ aria-expanded={view().terminal.opened()}
225
+ aria-controls="terminal-panel"
226
+ >
227
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
228
+ <Icon
229
+ size="small"
230
+ name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
231
+ class="group-hover/terminal-toggle:hidden"
232
+ />
233
+ <Icon
234
+ size="small"
235
+ name="layout-bottom-partial"
236
+ class="hidden group-hover/terminal-toggle:inline-block"
237
+ />
238
+ <Icon
239
+ size="small"
240
+ name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
241
+ class="hidden group-active/terminal-toggle:inline-block"
242
+ />
243
+ </div>
244
+ </Button>
245
+ </TooltipKeybind>
246
+ </div>
247
+ <Show when={showShare()}>
248
+ <div class="flex items-center">
249
+ <Popover
250
+ title={language.t("session.share.popover.title")}
251
+ description={
252
+ shareUrl()
253
+ ? language.t("session.share.popover.description.shared")
254
+ : language.t("session.share.popover.description.unshared")
255
+ }
256
+ triggerAs={Button}
257
+ triggerProps={{
258
+ variant: "secondary",
259
+ classList: { "rounded-r-none": shareUrl() !== undefined },
260
+ style: { scale: 1 },
261
+ }}
262
+ trigger={language.t("session.share.action.share")}
263
+ >
264
+ <div class="flex flex-col gap-2">
265
+ <Show
266
+ when={shareUrl()}
267
+ fallback={
268
+ <div class="flex">
269
+ <Button
270
+ size="large"
271
+ variant="primary"
272
+ class="w-1/2"
273
+ onClick={shareSession}
274
+ disabled={state.share}
275
+ >
276
+ {state.share
277
+ ? language.t("session.share.action.publishing")
278
+ : language.t("session.share.action.publish")}
279
+ </Button>
280
+ </div>
281
+ }
282
+ >
283
+ <div class="flex flex-col gap-2 w-72">
284
+ <TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
285
+ <div class="grid grid-cols-2 gap-2">
286
+ <Button
287
+ size="large"
288
+ variant="secondary"
289
+ class="w-full shadow-none border border-border-weak-base"
290
+ onClick={unshareSession}
291
+ disabled={state.unshare}
292
+ >
293
+ {state.unshare
294
+ ? language.t("session.share.action.unpublishing")
295
+ : language.t("session.share.action.unpublish")}
296
+ </Button>
297
+ <Button
298
+ size="large"
299
+ variant="primary"
300
+ class="w-full"
301
+ onClick={viewShare}
302
+ disabled={state.unshare}
303
+ >
304
+ {language.t("session.share.action.view")}
305
+ </Button>
306
+ </div>
307
+ </div>
308
+ </Show>
309
+ </div>
310
+ </Popover>
311
+ <Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
312
+ <Tooltip
313
+ value={
314
+ state.copied
315
+ ? language.t("session.share.copy.copied")
316
+ : language.t("session.share.copy.copyLink")
317
+ }
318
+ placement="top"
319
+ gutter={8}
320
+ >
321
+ <IconButton
322
+ icon={state.copied ? "check" : "copy"}
323
+ variant="secondary"
324
+ class="rounded-l-none"
325
+ onClick={copyLink}
326
+ disabled={state.unshare}
327
+ aria-label={
328
+ state.copied
329
+ ? language.t("session.share.copy.copied")
330
+ : language.t("session.share.copy.copyLink")
331
+ }
332
+ />
333
+ </Tooltip>
334
+ </Show>
335
+ </div>
336
+ </Show>
337
+ </div>
338
+ </Portal>
339
+ )}
340
+ </Show>
341
+ </>
342
+ )
343
+ }
@@ -0,0 +1,93 @@
1
+ import { Show, createMemo } from "solid-js"
2
+ import { DateTime } from "luxon"
3
+ import { useSync } from "@/context/sync"
4
+ import { useLanguage } from "@/context/language"
5
+ import { Icon } from "@jonsoc/ui/icon"
6
+ import { getDirectory, getFilename } from "@jonsoc/util/path"
7
+ import { Select } from "@jonsoc/ui/select"
8
+
9
+ const MAIN_WORKTREE = "main"
10
+ const CREATE_WORKTREE = "create"
11
+
12
+ interface NewSessionViewProps {
13
+ worktree: string
14
+ onWorktreeChange: (value: string) => void
15
+ }
16
+
17
+ export function NewSessionView(props: NewSessionViewProps) {
18
+ const sync = useSync()
19
+ const language = useLanguage()
20
+
21
+ const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
22
+ const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
23
+ const current = createMemo(() => {
24
+ const selection = props.worktree
25
+ if (options().includes(selection)) return selection
26
+ return MAIN_WORKTREE
27
+ })
28
+ const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
29
+ const isWorktree = createMemo(() => {
30
+ const project = sync.project
31
+ if (!project) return false
32
+ return sync.data.path.directory !== project.worktree
33
+ })
34
+
35
+ const label = (value: string) => {
36
+ if (value === MAIN_WORKTREE) {
37
+ if (isWorktree()) return language.t("session.new.worktree.main")
38
+ const branch = sync.data.vcs?.branch
39
+ if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
40
+ return language.t("session.new.worktree.main")
41
+ }
42
+
43
+ if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
44
+
45
+ return getFilename(value)
46
+ }
47
+
48
+ return (
49
+ <div
50
+ class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
51
+ style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
52
+ >
53
+ <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
54
+ <div class="flex justify-center items-center gap-3">
55
+ <Icon name="folder" size="small" />
56
+ <div class="text-12-medium text-text-weak select-text">
57
+ {getDirectory(projectRoot())}
58
+ <span class="text-text-strong">{getFilename(projectRoot())}</span>
59
+ </div>
60
+ </div>
61
+ <div class="flex justify-center items-center gap-1">
62
+ <Icon name="branch" size="small" />
63
+ <Select
64
+ options={options()}
65
+ current={current()}
66
+ value={(x) => x}
67
+ label={label}
68
+ onSelect={(value) => {
69
+ props.onWorktreeChange(value ?? MAIN_WORKTREE)
70
+ }}
71
+ size="normal"
72
+ variant="ghost"
73
+ class="text-12-medium"
74
+ />
75
+ </div>
76
+ <Show when={sync.project}>
77
+ {(project) => (
78
+ <div class="flex justify-center items-center gap-3">
79
+ <Icon name="pencil-line" size="small" />
80
+ <div class="text-12-medium text-text-weak">
81
+ {language.t("session.new.lastModified")}&nbsp;
82
+ <span class="text-text-strong">
83
+ {DateTime.fromMillis(project().time.updated ?? project().time.created)
84
+ .setLocale(language.locale())
85
+ .toRelative()}
86
+ </span>
87
+ </div>
88
+ </div>
89
+ )}
90
+ </Show>
91
+ </div>
92
+ )
93
+ }
@@ -0,0 +1,56 @@
1
+ import { createMemo, Show } from "solid-js"
2
+ import type { JSX } from "solid-js"
3
+ import { createSortable } from "@thisbeyond/solid-dnd"
4
+ import { FileIcon } from "@jonsoc/ui/file-icon"
5
+ import { IconButton } from "@jonsoc/ui/icon-button"
6
+ import { Tooltip } from "@jonsoc/ui/tooltip"
7
+ import { Tabs } from "@jonsoc/ui/tabs"
8
+ import { getFilename } from "@jonsoc/util/path"
9
+ import { useFile } from "@/context/file"
10
+ import { useLanguage } from "@/context/language"
11
+
12
+ export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
13
+ return (
14
+ <div class="flex items-center gap-x-1.5">
15
+ <FileIcon
16
+ node={{ path: props.path, type: "file" }}
17
+ classList={{
18
+ "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
19
+ "grayscale-0": props.active,
20
+ }}
21
+ />
22
+ <span class="text-14-medium">{getFilename(props.path)}</span>
23
+ </div>
24
+ )
25
+ }
26
+
27
+ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
28
+ const file = useFile()
29
+ const language = useLanguage()
30
+ const sortable = createSortable(props.tab)
31
+ const path = createMemo(() => file.pathFromTab(props.tab))
32
+ return (
33
+ // @ts-ignore
34
+ <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
35
+ <div class="relative h-full">
36
+ <Tabs.Trigger
37
+ value={props.tab}
38
+ closeButton={
39
+ <Tooltip value={language.t("common.closeTab")} placement="bottom">
40
+ <IconButton
41
+ icon="close"
42
+ variant="ghost"
43
+ onClick={() => props.onTabClose(props.tab)}
44
+ aria-label={language.t("common.closeTab")}
45
+ />
46
+ </Tooltip>
47
+ }
48
+ hideCloseButton
49
+ onMiddleClick={() => props.onTabClose(props.tab)}
50
+ >
51
+ <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
52
+ </Tabs.Trigger>
53
+ </div>
54
+ </div>
55
+ )
56
+ }
@@ -0,0 +1,187 @@
1
+ import type { JSX } from "solid-js"
2
+ import { createSignal, Show } from "solid-js"
3
+ import { createSortable } from "@thisbeyond/solid-dnd"
4
+ import { IconButton } from "@jonsoc/ui/icon-button"
5
+ import { Tabs } from "@jonsoc/ui/tabs"
6
+ import { DropdownMenu } from "@jonsoc/ui/dropdown-menu"
7
+ import { Icon } from "@jonsoc/ui/icon"
8
+ import { useTerminal, type LocalPTY } from "@/context/terminal"
9
+ import { useLanguage } from "@/context/language"
10
+
11
+ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
12
+ const terminal = useTerminal()
13
+ const language = useLanguage()
14
+ const sortable = createSortable(props.terminal.id)
15
+ const [editing, setEditing] = createSignal(false)
16
+ const [title, setTitle] = createSignal(props.terminal.title)
17
+ const [menuOpen, setMenuOpen] = createSignal(false)
18
+ const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 })
19
+ const [blurEnabled, setBlurEnabled] = createSignal(false)
20
+
21
+ const isDefaultTitle = () => {
22
+ const number = props.terminal.titleNumber
23
+ if (!Number.isFinite(number) || number <= 0) return false
24
+ const match = props.terminal.title.match(/^Terminal (\d+)$/)
25
+ if (!match) return false
26
+ const parsed = Number(match[1])
27
+ if (!Number.isFinite(parsed) || parsed <= 0) return false
28
+ return parsed === number
29
+ }
30
+
31
+ const label = () => {
32
+ language.locale()
33
+ if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
34
+
35
+ const number = props.terminal.titleNumber
36
+ if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
37
+ if (props.terminal.title) return props.terminal.title
38
+ return language.t("terminal.title")
39
+ }
40
+
41
+ const close = () => {
42
+ const count = terminal.all().length
43
+ terminal.close(props.terminal.id)
44
+ if (count === 1) {
45
+ props.onClose?.()
46
+ }
47
+ }
48
+
49
+ const focus = () => {
50
+ if (editing()) return
51
+
52
+ if (document.activeElement instanceof HTMLElement) {
53
+ document.activeElement.blur()
54
+ }
55
+ const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
56
+ const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
57
+ if (!element) return
58
+
59
+ const textarea = element.querySelector("textarea") as HTMLTextAreaElement
60
+ if (textarea) {
61
+ textarea.focus()
62
+ return
63
+ }
64
+ element.focus()
65
+ element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
66
+ }
67
+
68
+ const edit = (e?: Event) => {
69
+ if (e) {
70
+ e.stopPropagation()
71
+ e.preventDefault()
72
+ }
73
+
74
+ setBlurEnabled(false)
75
+ setTitle(props.terminal.title)
76
+ setEditing(true)
77
+ setTimeout(() => {
78
+ const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
79
+ if (!input) return
80
+ input.focus()
81
+ input.select()
82
+ setTimeout(() => setBlurEnabled(true), 100)
83
+ }, 10)
84
+ }
85
+
86
+ const save = () => {
87
+ if (!blurEnabled()) return
88
+
89
+ const value = title().trim()
90
+ if (value && value !== props.terminal.title) {
91
+ terminal.update({ id: props.terminal.id, title: value })
92
+ }
93
+ setEditing(false)
94
+ }
95
+
96
+ const keydown = (e: KeyboardEvent) => {
97
+ if (e.key === "Enter") {
98
+ e.preventDefault()
99
+ save()
100
+ return
101
+ }
102
+ if (e.key === "Escape") {
103
+ e.preventDefault()
104
+ setEditing(false)
105
+ }
106
+ }
107
+
108
+ const menu = (e: MouseEvent) => {
109
+ e.preventDefault()
110
+ setMenuPosition({ x: e.clientX, y: e.clientY })
111
+ setMenuOpen(true)
112
+ }
113
+
114
+ return (
115
+ <div
116
+ // @ts-ignore
117
+ use:sortable
118
+ class="outline-none focus:outline-none focus-visible:outline-none"
119
+ classList={{
120
+ "h-full": true,
121
+ "opacity-0": sortable.isActiveDraggable,
122
+ }}
123
+ >
124
+ <div class="relative h-full">
125
+ <Tabs.Trigger
126
+ value={props.terminal.id}
127
+ onClick={focus}
128
+ onMouseDown={(e) => e.preventDefault()}
129
+ onContextMenu={menu}
130
+ class="!shadow-none"
131
+ classes={{
132
+ button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
133
+ }}
134
+ closeButton={
135
+ <IconButton
136
+ icon="close"
137
+ variant="ghost"
138
+ onClick={(e) => {
139
+ e.stopPropagation()
140
+ close()
141
+ }}
142
+ aria-label={language.t("terminal.close")}
143
+ />
144
+ }
145
+ >
146
+ <span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
147
+ {label()}
148
+ </span>
149
+ </Tabs.Trigger>
150
+ <Show when={editing()}>
151
+ <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
152
+ <input
153
+ id={`terminal-title-input-${props.terminal.id}`}
154
+ type="text"
155
+ value={title()}
156
+ onInput={(e) => setTitle(e.currentTarget.value)}
157
+ onBlur={save}
158
+ onKeyDown={keydown}
159
+ onMouseDown={(e) => e.stopPropagation()}
160
+ class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
161
+ />
162
+ </div>
163
+ </Show>
164
+ <DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
165
+ <DropdownMenu.Portal>
166
+ <DropdownMenu.Content
167
+ style={{
168
+ position: "fixed",
169
+ left: `${menuPosition().x}px`,
170
+ top: `${menuPosition().y}px`,
171
+ }}
172
+ >
173
+ <DropdownMenu.Item onSelect={edit}>
174
+ <Icon name="edit" class="w-4 h-4 mr-2" />
175
+ {language.t("common.rename")}
176
+ </DropdownMenu.Item>
177
+ <DropdownMenu.Item onSelect={close}>
178
+ <Icon name="close" class="w-4 h-4 mr-2" />
179
+ {language.t("common.close")}
180
+ </DropdownMenu.Item>
181
+ </DropdownMenu.Content>
182
+ </DropdownMenu.Portal>
183
+ </DropdownMenu>
184
+ </div>
185
+ </div>
186
+ )
187
+ }