@valentinkolb/cloud 0.4.0 → 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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -26,6 +26,7 @@ type SearchResponse = {
26
26
  query: string;
27
27
  count: number;
28
28
  items: SearchItem[];
29
+ unsupportedTags?: string[];
29
30
  };
30
31
 
31
32
  type ParsedInput = {
@@ -38,6 +39,12 @@ type GlobalSearchDialogProps = {
38
39
  helpApps: GlobalSearchHelpApp[];
39
40
  };
40
41
 
42
+ type TagSuggestion = {
43
+ tag: string;
44
+ appName: string;
45
+ appIcon: string;
46
+ };
47
+
41
48
  const PROVIDER_LIMIT = 10;
42
49
  const MIN_QUERY_LENGTH = 2;
43
50
  const SEARCH_DEBOUNCE_MS = 200;
@@ -51,6 +58,60 @@ const sortByPriorityAndTitle = (a: SearchItem, b: SearchItem) => {
51
58
  return a.title.localeCompare(b.title);
52
59
  };
53
60
 
61
+ /**
62
+ * Cluster items by their owning app while preserving the global priority
63
+ * order across groups: the first occurrence of each app fixes its slot, and
64
+ * items within a group keep their original (already sorted) order. Map
65
+ * iteration order in JS is insertion order, so this is stable.
66
+ */
67
+ const groupByApp = (items: SearchItem[]): SearchItem[] => {
68
+ const groups = new Map<string, SearchItem[]>();
69
+ for (const item of items) {
70
+ const arr = groups.get(item.appId);
71
+ if (arr) arr.push(item);
72
+ else groups.set(item.appId, [item]);
73
+ }
74
+ const out: SearchItem[] = [];
75
+ for (const group of groups.values()) out.push(...group);
76
+ return out;
77
+ };
78
+
79
+ type ListEntry =
80
+ | { kind: "header"; appId: string; appName: string; appIcon: string; count: number }
81
+ | { kind: "item"; item: SearchItem; flatIndex: number };
82
+
83
+ /**
84
+ * Build the flat render list with optional group headers interleaved.
85
+ * Headers only appear when 2+ apps have results (single-app queries stay
86
+ * uncluttered). `flatIndex` on each item entry mirrors the index into the
87
+ * underlying sorted list — used for selection/scroll/keyboard nav.
88
+ */
89
+ const buildListEntries = (items: SearchItem[]): ListEntry[] => {
90
+ if (items.length === 0) return [];
91
+
92
+ const counts = new Map<string, number>();
93
+ for (const item of items) counts.set(item.appId, (counts.get(item.appId) ?? 0) + 1);
94
+ const showHeaders = counts.size >= 2;
95
+
96
+ const entries: ListEntry[] = [];
97
+ let prevAppId: string | null = null;
98
+ for (let i = 0; i < items.length; i++) {
99
+ const item = items[i]!;
100
+ if (showHeaders && item.appId !== prevAppId) {
101
+ entries.push({
102
+ kind: "header",
103
+ appId: item.appId,
104
+ appName: item.appName,
105
+ appIcon: item.appIcon,
106
+ count: counts.get(item.appId) ?? 0,
107
+ });
108
+ prevAppId = item.appId;
109
+ }
110
+ entries.push({ kind: "item", item, flatIndex: i });
111
+ }
112
+ return entries;
113
+ };
114
+
54
115
  const parseInput = (raw: string): ParsedInput => {
55
116
  const tokens = raw.trim().split(/\s+/).filter(Boolean);
56
117
  const tags: string[] = [];
@@ -74,6 +135,13 @@ const parseInput = (raw: string): ParsedInput => {
74
135
  };
75
136
  };
76
137
 
138
+ const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
139
+
140
+ const removeTagFromInput = (raw: string, tag: string): string => {
141
+ const pattern = new RegExp(`(^|\\s)#${escapeRegex(tag)}(?=\\s|$)`, "gi");
142
+ return raw.replace(pattern, "").replace(/\s+/g, " ").trim();
143
+ };
144
+
77
145
  const metadataRows = (metadata?: SearchMetadata[]) =>
78
146
  (metadata ?? [])
79
147
  .filter((entry) => entry.label.trim().length > 0 && entry.value.trim().length > 0)
@@ -82,9 +150,13 @@ const metadataRows = (metadata?: SearchMetadata[]) =>
82
150
  export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
83
151
  const [rawInput, setRawInput] = createSignal("");
84
152
  const [resultItems, setResultItems] = createSignal<SearchItem[]>([]);
153
+ const [unsupportedTags, setUnsupportedTags] = createSignal<string[]>([]);
85
154
  const [activeIndex, setActiveIndex] = createSignal(0);
86
155
  const [previewFailed, setPreviewFailed] = createSignal(false);
87
156
  const [requestError, setRequestError] = createSignal<string | null>(null);
157
+ const [hasReceivedResponse, setHasReceivedResponse] = createSignal(false);
158
+ const [cursorPos, setCursorPos] = createSignal(0);
159
+ const [suggestionIndex, setSuggestionIndex] = createSignal(0);
88
160
 
89
161
  let inputRef: HTMLInputElement | undefined;
90
162
  const rowRefs = new Map<string, HTMLButtonElement>();
@@ -93,7 +165,113 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
93
165
  const canSearch = createMemo(
94
166
  () => parsedInput().tags.length > 0 || parsedInput().query.length >= MIN_QUERY_LENGTH,
95
167
  );
96
- const shouldShowList = createMemo(() => canSearch());
168
+
169
+ // Tag suggestions for the empty state — flat list of every tag declared by
170
+ // any app, deduped on tag name (first declaration wins) so we don't render
171
+ // the same chip twice when two apps share a tag.
172
+ const tagSuggestions = createMemo<TagSuggestion[]>(() => {
173
+ const seen = new Set<string>();
174
+ const out: TagSuggestion[] = [];
175
+ for (const app of props.helpApps) {
176
+ for (const tag of app.tags) {
177
+ const lower = tag.toLowerCase();
178
+ if (seen.has(lower)) continue;
179
+ seen.add(lower);
180
+ out.push({ tag: lower, appName: app.appName, appIcon: app.appIcon });
181
+ }
182
+ }
183
+ return out;
184
+ });
185
+
186
+ /**
187
+ * Detect whether the caret is currently inside a tag token (i.e. the user
188
+ * is mid-typing `#...`). Returns the token's start/end indices in rawInput
189
+ * plus the lowercased prefix typed after `#`. Returns null otherwise — no
190
+ * autocomplete popover should show.
191
+ *
192
+ * Walks back from the cursor to the last whitespace; if the token starts
193
+ * with `#`, we're in tag-input mode. If the prefix contains a non-tag
194
+ * character (`#` or whitespace), bails — the parser would reject it
195
+ * anyway, so suggestions would be misleading.
196
+ */
197
+ const tagContext = createMemo(() => {
198
+ const text = rawInput();
199
+ const pos = Math.min(cursorPos(), text.length);
200
+ let start = pos;
201
+ while (start > 0 && !/\s/.test(text[start - 1] ?? "")) start--;
202
+ // Extend to the next whitespace so we replace the WHOLE token, not just
203
+ // up to the caret. Otherwise editing in the middle of an existing tag
204
+ // (e.g. caret after `#no` in `#notebook`) leaves `tebook` dangling after
205
+ // the inserted suggestion. Prefix used for matching is still the part
206
+ // BEFORE the caret — that's what the user has committed to typing.
207
+ let end = pos;
208
+ while (end < text.length && !/\s/.test(text[end] ?? "")) end++;
209
+ const fullToken = text.slice(start, end);
210
+ if (!fullToken.startsWith("#")) return null;
211
+ const prefix = text.slice(start + 1, pos).toLowerCase();
212
+ if (prefix.length > 0 && !TAG_TOKEN_PATTERN.test(prefix)) return null;
213
+ return { start, end, prefix };
214
+ });
215
+
216
+ /**
217
+ * Tags filtered by the current prefix, with already-active tags excluded
218
+ * (no point suggesting one the user has). Starts-with match for predictability.
219
+ */
220
+ const filteredSuggestions = createMemo<TagSuggestion[]>(() => {
221
+ const ctx = tagContext();
222
+ if (!ctx) return [];
223
+ const active = new Set(parsedInput().tags);
224
+ const all = tagSuggestions().filter((s) => !active.has(s.tag));
225
+ if (ctx.prefix.length === 0) return all;
226
+ return all.filter((s) => s.tag.startsWith(ctx.prefix));
227
+ });
228
+
229
+ const popoverOpen = createMemo(() => filteredSuggestions().length > 0);
230
+
231
+ // Reset highlight when the filtered list changes shape (new prefix, etc.).
232
+ createEffect(() => {
233
+ filteredSuggestions();
234
+ setSuggestionIndex(0);
235
+ });
236
+
237
+ const updateCursor = () => {
238
+ if (!inputRef) return;
239
+ setCursorPos(inputRef.selectionStart ?? rawInput().length);
240
+ };
241
+
242
+ const insertTagAtCursor = (tag: string) => {
243
+ const ctx = tagContext();
244
+ const text = rawInput();
245
+ if (!ctx) {
246
+ // No tag context — append. Used by suggestion-mode chips.
247
+ if (parsedInput().tags.includes(tag)) {
248
+ inputRef?.focus();
249
+ return;
250
+ }
251
+ const next = text.length > 0 ? `${text.trimEnd()} #${tag} ` : `#${tag} `;
252
+ setRawInput(next);
253
+ const newPos = next.length;
254
+ queueMicrotask(() => {
255
+ if (inputRef) inputRef.setSelectionRange(newPos, newPos);
256
+ setCursorPos(newPos);
257
+ });
258
+ inputRef?.focus();
259
+ return;
260
+ }
261
+ const before = text.slice(0, ctx.start);
262
+ const after = text.slice(ctx.end).replace(/^\s+/, "");
263
+ // Always emit `#tag ` with one trailing space so the user can keep
264
+ // typing the next token without manual spacing.
265
+ const insertion = `#${tag} `;
266
+ const next = before + insertion + after;
267
+ setRawInput(next);
268
+ const newPos = before.length + insertion.length;
269
+ queueMicrotask(() => {
270
+ if (inputRef) inputRef.setSelectionRange(newPos, newPos);
271
+ setCursorPos(newPos);
272
+ });
273
+ inputRef?.focus();
274
+ };
97
275
 
98
276
  const searchMutation = mutation.create<SearchResponse, ParsedInput>({
99
277
  mutation: async (input, ctx) => {
@@ -113,23 +291,46 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
113
291
  return payload as SearchResponse;
114
292
  },
115
293
  onSuccess: (payload) => {
116
- setResultItems((payload.items ?? []).slice().sort(sortByPriorityAndTitle));
294
+ const sorted = (payload.items ?? []).slice().sort(sortByPriorityAndTitle);
295
+ setResultItems(groupByApp(sorted));
296
+ setUnsupportedTags(payload.unsupportedTags ?? []);
117
297
  setActiveIndex(0);
118
298
  setRequestError(null);
299
+ setHasReceivedResponse(true);
119
300
  },
120
301
  onError: (error) => {
121
302
  if (error.name === "AbortError") return;
122
303
  setResultItems([]);
304
+ setUnsupportedTags([]);
123
305
  setActiveIndex(0);
124
306
  setRequestError(error.message || "Search failed.");
307
+ setHasReceivedResponse(true);
125
308
  },
126
309
  });
127
310
 
128
311
  const activeItem = createMemo(() => resultItems()[activeIndex()] ?? null);
312
+ const listEntries = createMemo(() => buildListEntries(resultItems()));
313
+
314
+ // Empty-state branching. Single source of truth for what the body renders.
315
+ // - suggestions: user hasn't typed enough to search; show what's possible.
316
+ // - unsupported-tags: response told us no app accepts these tags.
317
+ // - no-results: search ran, returned nothing.
318
+ // - ready: render results.
319
+ // - idle: search debounced or in flight, no decision yet.
320
+ type BodyMode = "suggestions" | "unsupported-tags" | "no-results" | "ready" | "idle";
321
+ const bodyMode = createMemo<BodyMode>(() => {
322
+ if (!canSearch()) return "suggestions";
323
+ if (resultItems().length > 0) return "ready";
324
+ if (searchMutation.loading() || !hasReceivedResponse()) return "idle";
325
+ if (unsupportedTags().length > 0) return "unsupported-tags";
326
+ return "no-results";
327
+ });
129
328
 
130
329
  const { debouncedFn: debounceSearch, cancel: cancelDebounce } = timed.debounce((input: ParsedInput) => {
131
330
  setResultItems([]);
331
+ setUnsupportedTags([]);
132
332
  setActiveIndex(0);
333
+ setHasReceivedResponse(false);
133
334
  searchMutation.abort();
134
335
  void searchMutation.mutate(input);
135
336
  }, SEARCH_DEBOUNCE_MS);
@@ -162,7 +363,56 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
162
363
  setActiveIndex(next);
163
364
  };
164
365
 
366
+ const removeTag = (tag: string) => {
367
+ setRawInput((prev) => removeTagFromInput(prev, tag));
368
+ inputRef?.focus();
369
+ };
370
+
165
371
  const handleKeyDown = (event: KeyboardEvent) => {
372
+ // Tag autocomplete intercepts navigation when the popover is open. Only
373
+ // reaches results-list nav when no suggestion is being chosen.
374
+ if (popoverOpen()) {
375
+ if (event.key === "ArrowDown") {
376
+ event.preventDefault();
377
+ const list = filteredSuggestions();
378
+ setSuggestionIndex((i) => (i + 1) % list.length);
379
+ return;
380
+ }
381
+ if (event.key === "ArrowUp") {
382
+ event.preventDefault();
383
+ const list = filteredSuggestions();
384
+ setSuggestionIndex((i) => (i - 1 + list.length) % list.length);
385
+ return;
386
+ }
387
+ if (event.key === "Enter" || event.key === "Tab") {
388
+ event.preventDefault();
389
+ const list = filteredSuggestions();
390
+ const choice = list[suggestionIndex()];
391
+ if (choice) insertTagAtCursor(choice.tag);
392
+ return;
393
+ }
394
+ if (event.key === "Escape") {
395
+ event.preventDefault();
396
+ // Close popover by erasing the in-flight tag token (caret-prefixed
397
+ // `#xyz`) — leaves the rest of the query intact. If the user wants
398
+ // to keep `#xyz`, they can press Space instead.
399
+ const ctx = tagContext();
400
+ if (ctx) {
401
+ const text = rawInput();
402
+ const next = text.slice(0, ctx.start) + text.slice(ctx.end).replace(/^\s+/, "");
403
+ setRawInput(next);
404
+ queueMicrotask(() => {
405
+ if (inputRef) inputRef.setSelectionRange(ctx.start, ctx.start);
406
+ setCursorPos(ctx.start);
407
+ });
408
+ }
409
+ return;
410
+ }
411
+ // Space commits the current token as-is and closes the popover by
412
+ // virtue of advancing the cursor past `#`. No special handling needed —
413
+ // tagContext recomputes naturally.
414
+ }
415
+
166
416
  if (event.key === "ArrowDown") {
167
417
  event.preventDefault();
168
418
  moveSelection(1);
@@ -211,7 +461,9 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
211
461
  cancelDebounce();
212
462
  searchMutation.abort();
213
463
  setResultItems([]);
464
+ setUnsupportedTags([]);
214
465
  setActiveIndex(0);
466
+ setHasReceivedResponse(false);
215
467
  return;
216
468
  }
217
469
 
@@ -232,144 +484,320 @@ export default function GlobalSearchDialog(props: GlobalSearchDialogProps) {
232
484
  class="flex h-full min-h-0 flex-col text-zinc-900 dark:text-zinc-100 [--spotlight-body-max:calc(50vh-5.5rem)] [@media(min-height:1100px)]:[--spotlight-body-max:calc(33vh-5.5rem)]"
233
485
  onWheel={(event) => event.stopPropagation()}
234
486
  >
235
- <label class="flex items-center gap-3 px-4 py-3.5">
236
- <i class="ti ti-search text-xl text-dimmed" />
237
- <input
238
- id="spotlight-input"
239
- ref={inputRef}
240
- type="search"
241
- value={rawInput()}
242
- onInput={(event) => setRawInput(event.currentTarget.value)}
243
- onKeyDown={handleKeyDown}
244
- placeholder="Search across apps..."
245
- aria-label="Global search"
246
- class="w-full border-0 bg-transparent text-base outline-none placeholder:text-dimmed md:text-lg"
247
- spellcheck={false}
248
- autocapitalize="off"
249
- autocomplete="off"
250
- autocorrect="off"
251
- />
252
- <Show when={searchMutation.loading()}>
253
- <i class="ti ti-loader-2 animate-spin text-dimmed" />
487
+ <div class="relative">
488
+ <label class="flex items-center gap-3 px-4 py-3.5">
489
+ <i class="ti ti-search text-xl text-dimmed" />
490
+ <input
491
+ id="spotlight-input"
492
+ ref={inputRef}
493
+ type="search"
494
+ value={rawInput()}
495
+ onInput={(event) => {
496
+ setRawInput(event.currentTarget.value);
497
+ setCursorPos(event.currentTarget.selectionStart ?? event.currentTarget.value.length);
498
+ }}
499
+ onKeyUp={updateCursor}
500
+ onClick={updateCursor}
501
+ onSelect={updateCursor}
502
+ onKeyDown={handleKeyDown}
503
+ placeholder="Search across apps..."
504
+ aria-label="Global search"
505
+ class="w-full border-0 bg-transparent text-base outline-none placeholder:text-dimmed md:text-lg"
506
+ spellcheck={false}
507
+ autocapitalize="off"
508
+ autocomplete="off"
509
+ autocorrect="off"
510
+ />
511
+ <Show when={searchMutation.loading()}>
512
+ <i class="ti ti-loader-2 animate-spin text-dimmed" />
513
+ </Show>
514
+ </label>
515
+
516
+ {/* Tag autocomplete popover. Anchored to the input row, overlays the
517
+ body when the user is mid-typing a `#tag` token. Keyboard nav
518
+ (Up/Down/Tab/Enter/Escape) is intercepted by handleKeyDown. */}
519
+ <Show when={popoverOpen()}>
520
+ <div
521
+ class="absolute left-3 right-3 top-full z-30 -mt-1 max-h-64 overflow-y-auto overscroll-contain rounded-xl bg-white/95 p-1.5 shadow-lg ring-1 ring-inset ring-zinc-300/60 backdrop-blur-sm dark:bg-zinc-900/95 dark:ring-zinc-700/60"
522
+ role="listbox"
523
+ aria-label="Tag suggestions"
524
+ >
525
+ <For each={filteredSuggestions()}>
526
+ {(suggestion, index) => {
527
+ const selected = () => index() === suggestionIndex();
528
+ return (
529
+ <button
530
+ type="button"
531
+ role="option"
532
+ aria-selected={selected()}
533
+ onMouseEnter={() => setSuggestionIndex(index())}
534
+ onMouseDown={(e) => {
535
+ // mousedown so the input doesn't lose focus to the click.
536
+ e.preventDefault();
537
+ insertTagAtCursor(suggestion.tag);
538
+ }}
539
+ class="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors"
540
+ classList={{
541
+ "bg-blue-50/85 dark:bg-blue-950/45": selected(),
542
+ "hover:bg-zinc-100/85 dark:hover:bg-zinc-800/60": !selected(),
543
+ }}
544
+ >
545
+ <i class={`${suggestion.appIcon} text-[12px] text-dimmed`} />
546
+ <span class="font-medium">#{suggestion.tag}</span>
547
+ <span class="text-[10px] text-dimmed">{suggestion.appName}</span>
548
+ </button>
549
+ );
550
+ }}
551
+ </For>
552
+ </div>
254
553
  </Show>
255
- </label>
554
+ </div>
256
555
 
257
556
  <div
258
557
  class="overflow-hidden transition-[height,opacity] duration-200 ease-out"
259
558
  style={{
260
- height: shouldShowList() ? "var(--spotlight-body-max)" : "0px",
261
- opacity: shouldShowList() ? "1" : "0",
559
+ height: "var(--spotlight-body-max)",
560
+ opacity: "1",
262
561
  }}
263
562
  >
264
563
  <div class="flex h-full min-h-0 flex-col gap-2 px-3 pb-3">
265
- <div class="flex h-8 items-center justify-between gap-3">
266
- <div class="flex min-w-0 items-center gap-2 text-[11px] text-dimmed">
267
- <Show when={parsedInput().tags.length > 0}>
268
- <div class="flex items-center gap-1">
269
- <For each={parsedInput().tags}>
270
- {(tag) => <span class="rounded bg-zinc-200/70 px-1.5 py-0.5 text-[10px] dark:bg-zinc-800/70">#{tag}</span>}
271
- </For>
272
- </div>
273
- </Show>
274
- <span>
275
- {resultItems().length} results found{" "}
276
- <span aria-hidden="true">•</span>{" "}
277
- <button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={openHelp}>
278
- improve with tags
279
- </button>
280
- </span>
564
+ {/* Active-tag chip row + meta. Hidden in suggestions mode. */}
565
+ <Show when={bodyMode() !== "suggestions"}>
566
+ <div class="flex h-8 items-center justify-between gap-3">
567
+ <div class="flex min-w-0 flex-wrap items-center gap-2 text-[11px] text-dimmed">
568
+ <Show when={parsedInput().tags.length > 0}>
569
+ <div class="flex flex-wrap items-center gap-1">
570
+ <For each={parsedInput().tags}>
571
+ {(tag) => {
572
+ const isUnsupported = () => unsupportedTags().includes(tag);
573
+ return (
574
+ <span
575
+ class="group inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px]"
576
+ classList={{
577
+ "bg-zinc-200/70 dark:bg-zinc-800/70": !isUnsupported(),
578
+ "bg-amber-200/60 text-amber-900 dark:bg-amber-900/35 dark:text-amber-200": isUnsupported(),
579
+ }}
580
+ >
581
+ #{tag}
582
+ <button
583
+ type="button"
584
+ class="opacity-60 hover:opacity-100"
585
+ onClick={() => removeTag(tag)}
586
+ aria-label={`Remove tag ${tag}`}
587
+ title={`Remove #${tag}`}
588
+ >
589
+ <i class="ti ti-x text-[10px]" />
590
+ </button>
591
+ </span>
592
+ );
593
+ }}
594
+ </For>
595
+ </div>
596
+ </Show>
597
+ <Show when={bodyMode() === "ready"}>
598
+ <span>
599
+ {resultItems().length} result{resultItems().length === 1 ? "" : "s"}{" "}
600
+ <span aria-hidden="true">•</span>{" "}
601
+ <button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={openHelp}>
602
+ tag help
603
+ </button>
604
+ </span>
605
+ </Show>
606
+ </div>
281
607
  </div>
282
- </div>
608
+ </Show>
283
609
 
284
610
  <div class="min-h-0 flex-1 overflow-hidden">
285
611
  <Show when={requestError()}>{(message) => <div class="info-block-danger mb-2 text-xs">{message()}</div>}</Show>
286
612
 
287
- <div class="grid h-full min-h-0 grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_18rem]">
288
- <section class="min-h-0 overflow-y-auto overscroll-y-contain pr-1" onWheel={(event) => event.stopPropagation()}>
289
- <div class="flex flex-col gap-1.5">
290
- <For each={resultItems()}>
291
- {(row, index) => {
292
- const selected = () => index() === activeIndex();
293
- const item = row;
294
- return (
613
+ {/* Suggestions: empty input, show available tags as clickable chips. */}
614
+ <Show when={bodyMode() === "suggestions"}>
615
+ <div class="flex h-full min-h-0 flex-col gap-3 overflow-y-auto pr-1">
616
+ <p class="text-xs text-dimmed">Type to search, or use a <code class="rounded bg-zinc-100 px-1 py-0.5 text-[10px] dark:bg-zinc-900">#tag</code> to focus on one app.</p>
617
+ <Show
618
+ when={tagSuggestions().length > 0}
619
+ fallback={<p class="text-xs text-dimmed">No tags available.</p>}
620
+ >
621
+ <div class="flex flex-wrap gap-1.5">
622
+ <For each={tagSuggestions()}>
623
+ {(suggestion) => (
295
624
  <button
296
- ref={(element) => bindRowRef(rowKey(item), element)}
297
625
  type="button"
298
- onMouseEnter={() => setActiveIndex(index())}
299
- onClick={() => openItem(item)}
300
- class="w-full rounded-xl p-2.5 text-left transition-colors"
301
- classList={{
302
- "bg-blue-50/85 dark:bg-blue-950/45": selected(),
303
- "bg-zinc-50/75 hover:bg-zinc-100/85 dark:bg-zinc-900/45 dark:hover:bg-zinc-900/65": !selected(),
304
- }}
626
+ onClick={() => insertTagAtCursor(suggestion.tag)}
627
+ class="inline-flex items-center gap-1.5 rounded-full bg-zinc-100/80 px-2.5 py-1 text-[11px] text-zinc-700 transition-colors hover:bg-zinc-200/80 dark:bg-zinc-900/55 dark:text-zinc-200 dark:hover:bg-zinc-800/80"
628
+ title={`Add #${suggestion.tag} (${suggestion.appName})`}
305
629
  >
306
- <div class="flex items-start gap-2.5">
307
- <i class={`${item.icon ?? item.appIcon} mt-0.5 text-[13px] text-dimmed`} />
308
- <div class="min-w-0">
309
- <p class="truncate text-xs">{item.title}</p>
310
- <Show when={item.preview}>
311
- <p class="mt-0.5 truncate text-[11px] text-dimmed">{item.preview}</p>
312
- </Show>
313
- <p class="mt-1 text-[10px] text-dimmed">{item.appName}</p>
314
- </div>
315
- </div>
630
+ <i class={`${suggestion.appIcon} text-[11px] text-dimmed`} />
631
+ <span>#{suggestion.tag}</span>
316
632
  </button>
317
- );
318
- }}
633
+ )}
634
+ </For>
635
+ </div>
636
+ </Show>
637
+ <button
638
+ type="button"
639
+ class="self-start text-[11px] text-blue-500 hover:underline dark:text-blue-400"
640
+ onClick={openHelp}
641
+ >
642
+ See all tag descriptions
643
+ </button>
644
+ </div>
645
+ </Show>
646
+
647
+ {/* Unsupported tags: response told us no app handles them. */}
648
+ <Show when={bodyMode() === "unsupported-tags"}>
649
+ <div class="flex h-full min-h-0 flex-col items-center justify-center gap-3 text-center">
650
+ <i class="ti ti-tag-off text-2xl text-dimmed" />
651
+ <div class="flex flex-col gap-1">
652
+ <p class="text-xs">
653
+ No app supports{" "}
654
+ <For each={unsupportedTags()}>
655
+ {(tag, index) => (
656
+ <>
657
+ <code class="rounded bg-amber-200/60 px-1 py-0.5 text-[10px] text-amber-900 dark:bg-amber-900/35 dark:text-amber-200">#{tag}</code>
658
+ <Show when={index() < unsupportedTags().length - 1}>{" "}</Show>
659
+ </>
660
+ )}
661
+ </For>
662
+ .
663
+ </p>
664
+ <p class="text-[11px] text-dimmed">Remove the tag or pick one below.</p>
665
+ </div>
666
+ <div class="flex flex-wrap items-center justify-center gap-1.5">
667
+ <For each={unsupportedTags()}>
668
+ {(tag) => (
669
+ <button
670
+ type="button"
671
+ onClick={() => removeTag(tag)}
672
+ class="inline-flex items-center gap-1 rounded-full bg-amber-200/60 px-2.5 py-1 text-[11px] text-amber-900 hover:bg-amber-300/60 dark:bg-amber-900/35 dark:text-amber-200 dark:hover:bg-amber-900/55"
673
+ >
674
+ <span>Remove #{tag}</span>
675
+ <i class="ti ti-x text-[10px]" />
676
+ </button>
677
+ )}
319
678
  </For>
320
679
  </div>
321
- </section>
322
-
323
- <aside
324
- class="hidden min-h-0 overflow-y-auto overscroll-y-contain rounded-xl bg-zinc-50/80 p-3 dark:bg-zinc-900/55 md:block"
325
- onWheel={(event) => event.stopPropagation()}
326
- >
327
- <Show when={activeItem()} fallback={<div class="text-xs text-dimmed">Select a result to preview details.</div>}>
328
- {(item) => (
329
- <div class="flex flex-col gap-4">
330
- <div class="flex items-center gap-3">
331
- <div class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-900">
332
- <Show
333
- when={isValidImagePreviewUrl(item().previewUrl) && !previewFailed()}
334
- fallback={<i class={`${item().icon ?? item().appIcon} text-lg text-dimmed`} />}
680
+ </div>
681
+ </Show>
682
+
683
+ {/* No matches at all (query/tags valid, just empty result set). */}
684
+ <Show when={bodyMode() === "no-results"}>
685
+ <div class="flex h-full min-h-0 flex-col items-center justify-center gap-2 text-center">
686
+ <i class="ti ti-mood-empty text-2xl text-dimmed" />
687
+ <p class="text-xs text-dimmed">
688
+ No matches{parsedInput().query.length > 0 ? <> for <span class="text-zinc-700 dark:text-zinc-200">"{parsedInput().query}"</span></> : null}.
689
+ </p>
690
+ </div>
691
+ </Show>
692
+
693
+ {/* Idle (debounce / loading first response) keep the area quiet. */}
694
+ <Show when={bodyMode() === "idle"}>
695
+ <div class="flex h-full min-h-0 flex-col items-center justify-center text-dimmed">
696
+ <i class="ti ti-loader-2 animate-spin text-base" />
697
+ </div>
698
+ </Show>
699
+
700
+ {/* Ready: results list + detail aside. */}
701
+ <Show when={bodyMode() === "ready"}>
702
+ <div class="grid h-full min-h-0 grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_18rem]">
703
+ <section class="min-h-0 overflow-y-auto overscroll-y-contain pr-1" onWheel={(event) => event.stopPropagation()}>
704
+ <div class="flex flex-col">
705
+ <For each={listEntries()}>
706
+ {(entry) => {
707
+ if (entry.kind === "header") {
708
+ // Faint section header — no <hr>, no border. First
709
+ // header gets less top spacing so it doesn't shove
710
+ // the list down on group transitions.
711
+ return (
712
+ <div class="flex items-center gap-1.5 px-1 pt-3 pb-1 text-[10px] uppercase tracking-wide text-dimmed first:pt-1">
713
+ <i class={`${entry.appIcon} text-[11px]`} />
714
+ <span>{entry.appName}</span>
715
+ <span class="opacity-60">· {entry.count}</span>
716
+ </div>
717
+ );
718
+ }
719
+ const item = entry.item;
720
+ const selected = () => entry.flatIndex === activeIndex();
721
+ return (
722
+ <button
723
+ ref={(element) => bindRowRef(rowKey(item), element)}
724
+ type="button"
725
+ onMouseEnter={() => setActiveIndex(entry.flatIndex)}
726
+ onClick={() => openItem(item)}
727
+ class="mt-1.5 w-full rounded-xl p-2.5 text-left transition-colors first:mt-0"
728
+ classList={{
729
+ "bg-blue-50/85 dark:bg-blue-950/45": selected(),
730
+ "bg-zinc-50/75 hover:bg-zinc-100/85 dark:bg-zinc-900/45 dark:hover:bg-zinc-900/65": !selected(),
731
+ }}
335
732
  >
336
- <img
337
- src={item().previewUrl}
338
- alt={item().title}
339
- class="h-full w-full object-cover"
340
- onError={() => setPreviewFailed(true)}
341
- />
342
- </Show>
343
- </div>
344
- <div class="min-w-0">
345
- <p class="truncate text-sm">{item().title}</p>
346
- <p class="mt-0.5 truncate text-xs text-dimmed">{item().appName}</p>
347
- </div>
348
- </div>
349
-
350
- <Show when={item().preview}>
351
- <p class="text-xs leading-relaxed text-dimmed">{item().preview}</p>
352
- </Show>
353
-
354
- <Show when={metadataRows(item().metadata).length > 0}>
355
- <div class="rounded-lg bg-zinc-100/65 p-2 dark:bg-zinc-900/65">
356
- <div class="divide-y divide-zinc-200/80 dark:divide-zinc-800/80">
357
- <For each={metadataRows(item().metadata)}>
358
- {(entry) => (
359
- <div class="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs first:pt-0 last:pb-0">
360
- <span class="truncate text-dimmed">{entry.label}</span>
361
- <span class="truncate">{entry.value}</span>
733
+ <div class="flex items-start gap-2.5">
734
+ <i class={`${item.icon ?? item.appIcon} mt-0.5 text-[13px] text-dimmed`} />
735
+ <div class="min-w-0">
736
+ <p class="truncate text-xs">{item.title}</p>
737
+ <Show when={item.preview}>
738
+ <p class="mt-0.5 truncate text-[11px] text-dimmed">{item.preview}</p>
739
+ </Show>
740
+ <p class="mt-1 text-[10px] text-dimmed">{item.appName}</p>
362
741
  </div>
363
- )}
364
- </For>
742
+ </div>
743
+ </button>
744
+ );
745
+ }}
746
+ </For>
747
+ </div>
748
+ </section>
749
+
750
+ <aside
751
+ class="hidden min-h-0 overflow-y-auto overscroll-y-contain rounded-xl bg-zinc-50/80 p-3 dark:bg-zinc-900/55 md:block"
752
+ onWheel={(event) => event.stopPropagation()}
753
+ >
754
+ <Show when={activeItem()} fallback={<div class="text-xs text-dimmed">Select a result to preview details.</div>}>
755
+ {(item) => (
756
+ <div class="flex flex-col gap-4">
757
+ <div class="flex items-center gap-3">
758
+ <div class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-900">
759
+ <Show
760
+ when={isValidImagePreviewUrl(item().previewUrl) && !previewFailed()}
761
+ fallback={<i class={`${item().icon ?? item().appIcon} text-lg text-dimmed`} />}
762
+ >
763
+ <img
764
+ src={item().previewUrl}
765
+ alt={item().title}
766
+ class="h-full w-full object-cover"
767
+ onError={() => setPreviewFailed(true)}
768
+ />
769
+ </Show>
770
+ </div>
771
+ <div class="min-w-0">
772
+ <p class="truncate text-sm">{item().title}</p>
773
+ <p class="mt-0.5 truncate text-xs text-dimmed">{item().appName}</p>
365
774
  </div>
366
775
  </div>
367
- </Show>
368
- </div>
369
- )}
370
- </Show>
371
- </aside>
372
- </div>
776
+
777
+ <Show when={item().preview}>
778
+ <p class="text-xs leading-relaxed text-dimmed">{item().preview}</p>
779
+ </Show>
780
+
781
+ <Show when={metadataRows(item().metadata).length > 0}>
782
+ <div class="rounded-lg bg-zinc-100/65 p-2 dark:bg-zinc-900/65">
783
+ <div class="divide-y divide-zinc-200/80 dark:divide-zinc-800/80">
784
+ <For each={metadataRows(item().metadata)}>
785
+ {(entry) => (
786
+ <div class="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs first:pt-0 last:pb-0">
787
+ <span class="truncate text-dimmed">{entry.label}</span>
788
+ <span class="truncate">{entry.value}</span>
789
+ </div>
790
+ )}
791
+ </For>
792
+ </div>
793
+ </div>
794
+ </Show>
795
+ </div>
796
+ )}
797
+ </Show>
798
+ </aside>
799
+ </div>
800
+ </Show>
373
801
  </div>
374
802
  </div>
375
803
  </div>