@valentinkolb/cloud 0.3.1 → 0.5.0

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 (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Generic single- or multi-line text editor with completion support.
3
+ *
4
+ * Same completion engine as `<MarkdownEditor>` but without any
5
+ * markdown opinions — works for plain text, formulas, code, search
6
+ * builders, mentions, etc.
7
+ *
8
+ * Two visual modes:
9
+ *
10
+ * 1. **Plain textarea (default)** — visible textarea, no overlay.
11
+ * The dropdown anchors to the textarea's bottom edge (like the
12
+ * `<Select>` dropdown). Simpler, no glyph-alignment work
13
+ * needed.
14
+ *
15
+ * 2. **Overlay mode** (when `highlight` is provided) — invisible
16
+ * textarea on top of a preview div that renders the user's
17
+ * `highlight(text)` HTML. Overtype pattern: the textarea cursor
18
+ * sits exactly over the highlighted glyphs. Ghost preview is
19
+ * injected at the caret in the preview, and the dropdown
20
+ * anchors there.
21
+ *
22
+ * Async suggestions
23
+ * -----------------
24
+ * Sync `suggest` returns hit the editor synchronously (immediate
25
+ * dropdown/ghost). Async `suggest` (returning a Promise) goes
26
+ * through a debounced AbortController-aware fetch:
27
+ *
28
+ * - `loading` state shows a spinner row in the dropdown.
29
+ * - Previous suggestions stay visible (dimmed) while fetching.
30
+ * - Each keystroke aborts the previous in-flight request.
31
+ * - Errors surface a Retry button.
32
+ *
33
+ * Focus / keyboard model
34
+ * ----------------------
35
+ * Same contract as MarkdownEditor's completion dropdown — Popover API
36
+ * (top-layer, modal-safe, no focus capture). ArrowUp/Down navigate,
37
+ * Tab/Enter accept, Esc closes. When completions are configured and
38
+ * a dropdown is active, Tab is "trapped" (swallowed if no ghost) so
39
+ * a single Tab press always has a useful effect — see the MarkdownEditor
40
+ * comment on focus-trap rationale.
41
+ */
42
+
43
+ import { createSignal, createEffect, createMemo, onMount, onCleanup, untrack, For, Show } from "solid-js";
44
+ import { timed } from "@valentinkolb/stdlib/solid";
45
+ import {
46
+ type Completion,
47
+ type QueryContext,
48
+ type Suggestion,
49
+ applySuggestion,
50
+ buildSuggestContext,
51
+ collectKnownLabels,
52
+ detectQuery,
53
+ displayLabel,
54
+ plainTextHighlight,
55
+ renderWithOverlay,
56
+ resetCompletionState,
57
+ resolveSuggestions,
58
+ suggestSync,
59
+ tryExpand,
60
+ tryRestore,
61
+ } from "../completion";
62
+
63
+ export type AutocompleteEditorProps = {
64
+ /** Reactive value accessor — current text. */
65
+ value?: () => string | undefined | null;
66
+ /** Fired on every input event (use this for controlled state). */
67
+ onInput?: (value: string) => void;
68
+ /** Fired on textarea change (commit on blur). */
69
+ onChange?: (value: string) => void;
70
+ /**
71
+ * Fired when the user presses the submit gesture:
72
+ * - single-line mode: bare Enter
73
+ * - multi-line mode: Cmd/Ctrl+Enter
74
+ */
75
+ onSubmit?: () => void;
76
+
77
+ /** Completion definitions. Each defines a trigger + suggest fn. */
78
+ completions?: Completion[];
79
+ /** Keep abbreviation-style Backspace restore after accepting an expansion. Default true. */
80
+ restoreExpansionOnBackspace?: boolean;
81
+
82
+ /**
83
+ * Optional syntax-highlighter: plain text → safe HTML. When
84
+ * provided, the editor switches to overlay mode (invisible
85
+ * textarea + preview div). When omitted, plain textarea.
86
+ *
87
+ * Width-safe requirements: monospace font, no font-weight /
88
+ * font-style changes between tokens (use colour/background only).
89
+ * See `MarkdownEditor`'s highlighter for a reference implementation.
90
+ */
91
+ highlight?: (text: string) => string;
92
+
93
+ /**
94
+ * Single-line mode: Enter calls `onSubmit`, no newlines allowed.
95
+ * Default `false` (multi-line textarea).
96
+ */
97
+ singleLine?: boolean;
98
+
99
+ /** Approximate visible rows (ignored when `singleLine`). Default 3. */
100
+ lines?: number;
101
+ /** Fill the height provided by the parent container in multi-line mode. */
102
+ fill?: boolean;
103
+
104
+ placeholder?: string;
105
+ disabled?: boolean;
106
+ spellcheck?: boolean;
107
+ id?: string;
108
+ name?: string;
109
+ ariaLabel?: string;
110
+ ariaDescribedBy?: string;
111
+ ariaInvalid?: boolean;
112
+ ariaRequired?: boolean;
113
+ maxLength?: number;
114
+ /** Visual error state — adds red border. */
115
+ error?: boolean;
116
+ /** Visual variant. Defaults to the compact zinc input surface. */
117
+ variant?: "default" | "paper";
118
+ };
119
+
120
+ const AutocompleteEditor = (props: AutocompleteEditorProps) => {
121
+ let textareaEl: HTMLTextAreaElement | undefined;
122
+ let previewEl: HTMLDivElement | undefined;
123
+ let dropdownEl: HTMLDivElement | undefined;
124
+
125
+ /* ── State ─────────────────────────────────────────────── */
126
+
127
+ const [composing, setComposing] = createSignal(false);
128
+ const [localValue, setLocalValue] = createSignal(props.value?.() ?? "");
129
+
130
+ type CompletionState = {
131
+ ctx: QueryContext;
132
+ suggestions: Suggestion[];
133
+ selectedIndex: number;
134
+ };
135
+ const [completionState, setCompletionState] = createSignal<CompletionState | null>(null);
136
+ const [loading, setLoading] = createSignal(false);
137
+ const [error, setError] = createSignal<string | null>(null);
138
+ const [isDarkTheme, setIsDarkTheme] = createSignal(false);
139
+
140
+ const activeSuggestion = (): Suggestion | null => {
141
+ const s = completionState();
142
+ return s ? (s.suggestions[s.selectedIndex] ?? null) : null;
143
+ };
144
+
145
+ const suggestionGhost = (state: CompletionState, suggestion: Suggestion): { at: number; text: string } | undefined => {
146
+ const edit = suggestion.textEdit;
147
+ if (!edit) return { at: state.ctx.end, text: suggestion.text.slice(state.ctx.text.length) };
148
+ if (edit.end !== state.ctx.end) return undefined;
149
+ const replacing = localValue().slice(edit.start, edit.end);
150
+ if (!edit.text.toLowerCase().startsWith(replacing.toLowerCase())) return undefined;
151
+ return { at: edit.end, text: edit.text.slice(replacing.length) };
152
+ };
153
+
154
+ // Async fetch lifecycle. One AbortController per in-flight request;
155
+ // each new keystroke aborts the previous so stale results never
156
+ // overwrite fresher state.
157
+ let currentAbort: AbortController | null = null;
158
+ let lastAsyncCompletion: Completion | null = null;
159
+ let lastAsyncQuery: string = "";
160
+ let lastAsyncCtx: ReturnType<typeof buildSuggestContext> | null = null;
161
+
162
+ /* ── Memoised inputs ──────────────────────────────────── */
163
+
164
+ const completions = createMemo(() => props.completions);
165
+ const knownLabels = createMemo(() => collectKnownLabels(completions()));
166
+ const useOverlay = createMemo(() => Boolean(props.highlight));
167
+
168
+ /* ── External value sync ──────────────────────────────── */
169
+
170
+ createEffect(() => {
171
+ const incoming = props.value?.();
172
+ if (incoming === undefined || incoming === null) return;
173
+ if (incoming !== untrack(localValue)) setLocalValue(incoming);
174
+ });
175
+
176
+ /* ── Imperative textarea sync ──────────────────────────── */
177
+
178
+ createEffect(() => {
179
+ const target = localValue();
180
+ if (composing()) return;
181
+ if (textareaEl && textareaEl.value !== target) {
182
+ textareaEl.value = target;
183
+ }
184
+ });
185
+
186
+ /* ── Overlay preview rendering ─────────────────────────── */
187
+
188
+ createEffect(() => {
189
+ if (!useOverlay() || !previewEl) return;
190
+ const state = completionState();
191
+ const active = activeSuggestion();
192
+
193
+ const ghostArg = state && active ? suggestionGhost(state, active) : undefined;
194
+
195
+ const highlight = props.highlight ?? plainTextHighlight;
196
+ previewEl.innerHTML = renderWithOverlay(localValue(), highlight, {
197
+ ghost: ghostArg,
198
+ });
199
+
200
+ if (textareaEl) {
201
+ previewEl.scrollTop = textareaEl.scrollTop;
202
+ previewEl.scrollLeft = textareaEl.scrollLeft;
203
+ }
204
+ });
205
+
206
+ /* ── Completion pipeline ───────────────────────────────── */
207
+
208
+ const clearCompletion = (): void => {
209
+ setCompletionState(null);
210
+ setLoading(false);
211
+ setError(null);
212
+ closeDropdown();
213
+ currentAbort?.abort();
214
+ currentAbort = null;
215
+ };
216
+
217
+ const recomputeCompletion = (): void => {
218
+ if (!textareaEl) return;
219
+ const ctx = detectQuery(textareaEl, completions());
220
+ if (!ctx) {
221
+ clearCompletion();
222
+ return;
223
+ }
224
+
225
+ const suggestCtx = buildSuggestContext(textareaEl, ctx);
226
+
227
+ // Spin up an abort signal for THIS attempt — we'll either
228
+ // consume it sync below, or hand it to the async path.
229
+ currentAbort?.abort();
230
+ currentAbort = new AbortController();
231
+ const signal = currentAbort.signal;
232
+
233
+ const result = resolveSuggestions(ctx.completion, ctx.query, suggestCtx, signal);
234
+
235
+ if (result.kind === "sync") {
236
+ setError(null);
237
+ setLoading(false);
238
+ applySuggestionList(ctx, result.data);
239
+ return;
240
+ }
241
+
242
+ // Async path: keep previous suggestions visible (dimmed via
243
+ // `loading` state) and schedule the debounced fetch.
244
+ setError(null);
245
+ setLoading(true);
246
+ lastAsyncCompletion = ctx.completion;
247
+ lastAsyncQuery = ctx.query;
248
+ lastAsyncCtx = suggestCtx;
249
+ debouncedFetch.debouncedFn(ctx, suggestCtx, result.promise, signal);
250
+ };
251
+
252
+ /** Take a fresh suggestion list, filter to usable ones, and merge
253
+ * with the current selection state. */
254
+ const applySuggestionList = (ctx: QueryContext, list: Suggestion[]): void => {
255
+ const lower = ctx.text.toLowerCase();
256
+ const currentText = localValue();
257
+ const usable = list.filter((s) => {
258
+ const edit = s.textEdit;
259
+ if (edit) {
260
+ if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) || edit.start < 0 || edit.end < edit.start) return false;
261
+ if (edit.end > currentText.length) return false;
262
+ return currentText.slice(edit.start, edit.end) !== edit.text;
263
+ }
264
+ return s.text.toLowerCase().startsWith(lower) && s.text.length > ctx.text.length;
265
+ });
266
+ if (usable.length === 0) {
267
+ clearCompletion();
268
+ return;
269
+ }
270
+
271
+ // Preserve highlight across keystrokes when the same suggestion
272
+ // is still present — less jarring than always resetting.
273
+ const prev = completionState();
274
+ const prevSelected = prev?.suggestions[prev.selectedIndex]?.text;
275
+ const keptIndex = prevSelected ? usable.findIndex((s) => s.text === prevSelected) : -1;
276
+ const selectedIndex = keptIndex >= 0 ? keptIndex : 0;
277
+
278
+ setCompletionState({ ctx, suggestions: usable, selectedIndex });
279
+
280
+ if (ctx.completion.dropdown) {
281
+ if (!dropdownOpenSignal()) setDropdownOpenSignal(true);
282
+ queueMicrotask(positionDropdown);
283
+ } else if (dropdownOpenSignal()) {
284
+ closeDropdown();
285
+ }
286
+ };
287
+
288
+ // Debounced async fetch — kicks off ~180ms after the LAST
289
+ // keystroke. The debounce delays the actual await so we don't
290
+ // hammer remote endpoints on every char.
291
+ const debouncedFetch = timed.debounce(
292
+ (ctx: QueryContext, suggestCtx: ReturnType<typeof buildSuggestContext>, promise: Promise<Suggestion[]>, signal: AbortSignal) => {
293
+ void runFetch(ctx, suggestCtx, promise, signal);
294
+ },
295
+ 180,
296
+ );
297
+
298
+ const runFetch = async (
299
+ ctx: QueryContext,
300
+ suggestCtx: ReturnType<typeof buildSuggestContext>,
301
+ promise: Promise<Suggestion[]>,
302
+ signal: AbortSignal,
303
+ ): Promise<void> => {
304
+ try {
305
+ const list = await promise;
306
+ if (signal.aborted) return; // newer keystroke superseded us
307
+ setLoading(false);
308
+ applySuggestionList(ctx, list);
309
+ } catch (e: unknown) {
310
+ if (signal.aborted) return;
311
+ if (e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError") return;
312
+ setLoading(false);
313
+ setError(e instanceof Error ? e.message : String(e));
314
+ }
315
+ };
316
+
317
+ /** Retry the most recent failed async fetch. */
318
+ const retryAsync = (): void => {
319
+ if (!lastAsyncCompletion || !lastAsyncCtx) return;
320
+ setError(null);
321
+ setLoading(true);
322
+ currentAbort?.abort();
323
+ currentAbort = new AbortController();
324
+ const signal = currentAbort.signal;
325
+ const promise = lastAsyncCompletion.suggest(lastAsyncQuery, lastAsyncCtx, signal);
326
+ if (!(promise instanceof Promise)) {
327
+ // Suggest turned sync between calls — treat as direct result.
328
+ setLoading(false);
329
+ // Re-detect ctx so we have a fresh queryContext.
330
+ recomputeCompletion();
331
+ return;
332
+ }
333
+ void runFetch(
334
+ // Reuse prev queryCtx by re-detecting; cheaper than caching it.
335
+ detectQuery(textareaEl!, completions())!,
336
+ lastAsyncCtx,
337
+ promise,
338
+ signal,
339
+ );
340
+ };
341
+
342
+ /* ── Dropdown positioning + open/close ─────────────────── */
343
+
344
+ const [dropdownOpenSignal, setDropdownOpenSignal] = createSignal(false);
345
+
346
+ const positionDropdown = (): void => {
347
+ if (!dropdownEl || !textareaEl) return;
348
+ // The popover may have been unmounted by `<Show>` between the
349
+ // microtask scheduling and this call (e.g. state cleared in a
350
+ // fast keystroke sequence). `showPopover` throws on disconnected
351
+ // elements, so bail when the ref points to a detached node.
352
+ if (!dropdownEl.isConnected) return;
353
+ syncTheme();
354
+
355
+ // In overlay mode, anchor to the caret marker in the preview.
356
+ // Otherwise anchor to the textarea's bottom edge.
357
+ let rect: DOMRect;
358
+ if (useOverlay() && previewEl) {
359
+ const anchorEl = previewEl.querySelector<HTMLElement>("[data-completion-anchor]");
360
+ rect = anchorEl ? anchorEl.getBoundingClientRect() : textareaEl.getBoundingClientRect();
361
+ } else {
362
+ rect = textareaEl.getBoundingClientRect();
363
+ }
364
+
365
+ const dropdownMaxHeight = 260;
366
+ const spaceBelow = window.innerHeight - rect.bottom;
367
+ const spaceAbove = rect.top;
368
+ const openAbove = spaceBelow < dropdownMaxHeight && spaceAbove > spaceBelow;
369
+
370
+ const dropdownWidth = 280;
371
+ const margin = 8;
372
+ const left = Math.min(rect.left, window.innerWidth - dropdownWidth - margin);
373
+
374
+ dropdownEl.style.left = `${Math.max(margin, left)}px`;
375
+ dropdownEl.style.width = `${dropdownWidth}px`;
376
+
377
+ if (openAbove) {
378
+ dropdownEl.style.top = "auto";
379
+ dropdownEl.style.bottom = `${window.innerHeight - rect.top + 4}px`;
380
+ } else {
381
+ dropdownEl.style.top = `${rect.bottom + 4}px`;
382
+ dropdownEl.style.bottom = "auto";
383
+ }
384
+
385
+ if (!dropdownEl.matches(":popover-open")) {
386
+ dropdownEl.showPopover();
387
+ }
388
+ };
389
+
390
+ const closeDropdown = (): void => {
391
+ if (dropdownEl?.matches(":popover-open")) dropdownEl.hidePopover();
392
+ if (dropdownOpenSignal()) setDropdownOpenSignal(false);
393
+ };
394
+
395
+ const syncTheme = (): void => {
396
+ if (typeof document === "undefined") return;
397
+ setIsDarkTheme(document.documentElement.classList.contains("dark") || document.body.classList.contains("dark"));
398
+ };
399
+
400
+ const acceptActiveSuggestion = (): boolean => {
401
+ if (!textareaEl) return false;
402
+ const state = completionState();
403
+ const active = activeSuggestion();
404
+ if (!state || !active) return false;
405
+ if (!active.textEdit && active.text === state.ctx.text) {
406
+ clearCompletion();
407
+ return false;
408
+ }
409
+ const applied = applySuggestion(textareaEl, state.ctx, active, { trackExpansion: props.restoreExpansionOnBackspace ?? true });
410
+ clearCompletion();
411
+ if (!applied) return false;
412
+ queueMicrotask(recomputeCompletion);
413
+ return true;
414
+ };
415
+
416
+ const moveSelection = (direction: 1 | -1): void => {
417
+ const state = completionState();
418
+ if (!state) return;
419
+ const len = state.suggestions.length;
420
+ if (len === 0) return;
421
+ const next = (state.selectedIndex + direction + len) % len;
422
+ setCompletionState({ ...state, selectedIndex: next });
423
+ };
424
+
425
+ /* ── Event handlers ───────────────────────────────────── */
426
+
427
+ onMount(() => {
428
+ const onSelectionChange = (): void => {
429
+ if (document.activeElement === textareaEl) recomputeCompletion();
430
+ };
431
+ document.addEventListener("selectionchange", onSelectionChange);
432
+ onCleanup(() => document.removeEventListener("selectionchange", onSelectionChange));
433
+ onCleanup(() => {
434
+ if (dropdownEl?.matches(":popover-open")) dropdownEl.hidePopover();
435
+ currentAbort?.abort();
436
+ });
437
+ });
438
+
439
+ const onInput = (e: InputEvent & { currentTarget: HTMLTextAreaElement }): void => {
440
+ if (e.inputType.startsWith("insert") && tryExpand(e.currentTarget, completions())) return;
441
+ const v = e.currentTarget.value;
442
+ setLocalValue(v);
443
+ props.onInput?.(v);
444
+ recomputeCompletion();
445
+ };
446
+
447
+ const onChange = (e: Event & { currentTarget: HTMLTextAreaElement }): void => {
448
+ props.onChange?.(e.currentTarget.value);
449
+ };
450
+
451
+ const onKeyDown = (e: KeyboardEvent): void => {
452
+ if (!textareaEl) return;
453
+ if (e.isComposing) return;
454
+
455
+ if ((props.restoreExpansionOnBackspace ?? true) && e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
456
+ if (tryRestore(textareaEl)) {
457
+ e.preventDefault();
458
+ return;
459
+ }
460
+ }
461
+
462
+ const hasCompletions = (completions()?.length ?? 0) > 0;
463
+ const state = completionState();
464
+ const hasActive = state !== null;
465
+ const isDropdown = state?.ctx.completion.dropdown === true;
466
+
467
+ if (hasActive && isDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
468
+ e.preventDefault();
469
+ moveSelection(e.key === "ArrowDown" ? 1 : -1);
470
+ return;
471
+ }
472
+
473
+ // Enter: in single-line mode → submit. In multi-line → insert
474
+ // newline (unless dropdown is open, then accept the row).
475
+ if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
476
+ if (hasActive && isDropdown && dropdownOpenSignal()) {
477
+ e.preventDefault();
478
+ acceptActiveSuggestion();
479
+ return;
480
+ }
481
+ if (props.singleLine) {
482
+ e.preventDefault();
483
+ props.onSubmit?.();
484
+ return;
485
+ }
486
+ }
487
+
488
+ // Cmd/Ctrl+Enter submits in multi-line mode.
489
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !props.singleLine) {
490
+ if (props.onSubmit) {
491
+ e.preventDefault();
492
+ props.onSubmit();
493
+ return;
494
+ }
495
+ }
496
+
497
+ if (e.key === "Tab" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
498
+ if (hasActive) {
499
+ e.preventDefault();
500
+ acceptActiveSuggestion();
501
+ return;
502
+ }
503
+ if (hasCompletions) {
504
+ e.preventDefault();
505
+ return;
506
+ }
507
+ }
508
+
509
+ if (e.key === "Escape") {
510
+ if (hasActive) {
511
+ e.preventDefault();
512
+ clearCompletion();
513
+ return;
514
+ }
515
+ if (hasCompletions) {
516
+ e.preventDefault();
517
+ textareaEl.blur();
518
+ }
519
+ }
520
+ };
521
+
522
+ const onScroll = (e: Event & { currentTarget: HTMLTextAreaElement }): void => {
523
+ if (!previewEl) return;
524
+ previewEl.scrollTop = e.currentTarget.scrollTop;
525
+ previewEl.scrollLeft = e.currentTarget.scrollLeft;
526
+ };
527
+
528
+ /* ── Render ───────────────────────────────────────────── */
529
+
530
+ const surfaceStyle = (): string => {
531
+ if (props.fill && !props.singleLine) return `--ac-h: 100%`;
532
+ if (props.singleLine) return `--ac-h: 2.5rem`;
533
+ const lines = props.lines ?? 3;
534
+ return `--ac-h: ${lines * 1.5}rem`;
535
+ };
536
+
537
+ return (
538
+ <div
539
+ class="ac-editor"
540
+ data-fill={props.fill && !props.singleLine ? "true" : undefined}
541
+ data-disabled={props.disabled ? "true" : undefined}
542
+ data-error={props.error ? "true" : undefined}
543
+ data-variant={props.variant === "paper" ? "paper" : undefined}
544
+ >
545
+ <div class="ac-editor-surface" style={surfaceStyle()}>
546
+ <Show when={!localValue() && props.placeholder}>
547
+ <div class="ac-editor-placeholder" aria-hidden="true">
548
+ {props.placeholder}
549
+ </div>
550
+ </Show>
551
+ <Show when={useOverlay()}>
552
+ <div ref={(el) => (previewEl = el)} class="ac-editor-layer ac-editor-preview" aria-hidden="true" />
553
+ </Show>
554
+ <textarea
555
+ ref={(el) => (textareaEl = el)}
556
+ id={props.id}
557
+ name={props.name}
558
+ class={useOverlay() ? "ac-editor-layer ac-editor-input ac-editor-input--overlay" : "ac-editor-layer ac-editor-input"}
559
+ onInput={onInput}
560
+ onChange={onChange}
561
+ onKeyDown={onKeyDown}
562
+ onScroll={onScroll}
563
+ onCompositionStart={() => setComposing(true)}
564
+ onCompositionEnd={() => setComposing(false)}
565
+ onBlur={(e) => {
566
+ const next = e.relatedTarget as HTMLElement | null;
567
+ if (next && dropdownEl && dropdownEl.contains(next)) return;
568
+ resetCompletionState();
569
+ clearCompletion();
570
+ }}
571
+ disabled={props.disabled}
572
+ spellcheck={props.spellcheck ?? false}
573
+ maxLength={props.maxLength}
574
+ rows={props.singleLine ? 1 : (props.lines ?? 3)}
575
+ aria-label={props.ariaLabel}
576
+ aria-describedby={props.ariaDescribedBy}
577
+ aria-invalid={props.ariaInvalid}
578
+ aria-required={props.ariaRequired}
579
+ />
580
+ </div>
581
+
582
+ {/* Dropdown popover — only mounted when there's something to
583
+ show (sync state OR async loading with a previous state).
584
+ Avoids the empty-popover fade-out flicker that the global
585
+ [popover] close transition would cause. */}
586
+ <Show when={completionState() || loading() || error()}>
587
+ <div
588
+ ref={(el) => (dropdownEl = el)}
589
+ popover="manual"
590
+ class="popup fixed inset-auto m-0 border border-zinc-200 p-1 dark:border-zinc-700"
591
+ classList={{ dark: isDarkTheme() }}
592
+ >
593
+ <div class="flex max-h-60 flex-col gap-0.5 overflow-y-auto" role="listbox" aria-label="Suggestions">
594
+ <Show when={loading()}>
595
+ <div class="flex items-center gap-2 px-2 py-1.5 text-xs text-zinc-500 dark:text-zinc-400">
596
+ <i class="ti ti-loader-2 animate-spin" /> Loading...
597
+ </div>
598
+ </Show>
599
+ <Show when={error()}>
600
+ <div class="flex items-center gap-2 px-2 py-1.5 text-xs text-red-500">
601
+ <i class="ti ti-alert-triangle shrink-0" />
602
+ <span class="flex-1 truncate">{error()}</span>
603
+ <button
604
+ type="button"
605
+ class="text-zinc-500 underline hover:text-zinc-700 dark:hover:text-zinc-300"
606
+ onMouseDown={(e) => {
607
+ e.preventDefault();
608
+ retryAsync();
609
+ }}
610
+ >
611
+ Retry
612
+ </button>
613
+ </div>
614
+ </Show>
615
+ <Show when={completionState()}>
616
+ {(state) => (
617
+ <For each={state().suggestions}>
618
+ {(suggestion, index) => {
619
+ const isSelected = () => index() === state().selectedIndex;
620
+ return (
621
+ <div
622
+ onMouseDown={(e) => {
623
+ e.preventDefault();
624
+ setCompletionState({ ...state(), selectedIndex: index() });
625
+ acceptActiveSuggestion();
626
+ textareaEl?.focus();
627
+ }}
628
+ onMouseEnter={() => setCompletionState({ ...state(), selectedIndex: index() })}
629
+ role="option"
630
+ aria-selected={isSelected()}
631
+ class={`group flex cursor-pointer select-none items-center gap-2 rounded px-2 py-1.5 text-sm transition-colors ${
632
+ isSelected()
633
+ ? "bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300"
634
+ : "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800"
635
+ } ${loading() ? "opacity-60" : ""}`}
636
+ >
637
+ <span class="font-mono truncate">{displayLabel(suggestion, state().ctx.completion)}</span>
638
+ <Show when={suggestion.hint}>
639
+ <span class="ml-auto text-xs text-zinc-500 dark:text-zinc-400 truncate">{suggestion.hint}</span>
640
+ </Show>
641
+ </div>
642
+ );
643
+ }}
644
+ </For>
645
+ )}
646
+ </Show>
647
+ </div>
648
+ </div>
649
+ </Show>
650
+ </div>
651
+ );
652
+ };
653
+
654
+ export default AutocompleteEditor;
655
+ export type { Completion, Suggestion, SuggestContext } from "../completion";
656
+ export { abbreviations } from "../completion";