@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -1,790 +1,728 @@
1
+ import { shortcutLabel } from "./command-shortcuts";
1
2
  import { query, timed } from "@k2b/stdlib/solid";
2
- import { NoticeCard, ScrollArea, Select, useLocale } from "@k2b/ui";
3
- import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
4
- import type { SearchApp, SearchItem, SearchResponse } from "../api/search/schemas";
3
+ import { Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
4
+ import { createEffect, createMemo, createSignal, createUniqueId, For, on, onCleanup, onMount, Show } from "solid-js";
5
+ import type { SearchItem, SearchResponse } from "../api/search/schemas";
5
6
  import type { CloudResourceRef } from "../contracts";
7
+ import { LOCALE_HEADER } from "../shared/locale";
8
+ import { matchNavigationSearchItems, type NavigationSearchItem } from "./navigation-search";
6
9
  import { cloudResourceSearchUrl, filterCloudResourceSearchItems } from "./resource-search";
10
+ import { commitTypedTags, matchingSearchTags, searchTags, tagAtCursor } from "./resource-search-input";
7
11
  import { resourceSearchMessages } from "./resource-search-messages";
12
+ import type { GlobalSearchOptions, SearchScope } from "./search-bridge";
8
13
 
9
- type ParsedInput = {
10
- query: string;
11
- tags: string[];
12
- };
13
-
14
- export type CloudResourceSearchHelpApp = {
15
- appId: string;
16
- appName: string;
17
- appIcon: string;
18
- tags: string[];
19
- };
14
+ import { commandSearchItem, matchingCommands, type PaletteCommand } from "./search-commands";
20
15
 
21
16
  export type CloudResourceSearchProps = {
17
+ commands?: readonly PaletteCommand[];
18
+ commandsLoading?: boolean;
19
+ commandsError?: boolean;
20
+ onCommand?: (command: PaletteCommand, newTab: boolean) => void;
22
21
  onSelect: (item: SearchItem) => void;
23
- onHelp?: () => void;
24
- helpApps?: readonly CloudResourceSearchHelpApp[];
22
+ onOpenInNewTab?: (item: SearchItem) => void;
23
+ onClose: () => void;
24
+ selectionMode?: boolean;
25
+ title?: string;
25
26
  initialAppId?: string;
27
+ request?: GlobalSearchOptions;
28
+ disabled?: boolean;
29
+ navigationError?: string;
26
30
  placeholder?: string;
27
31
  requireReader?: boolean;
32
+ navigationItems?: readonly NavigationSearchItem[];
33
+ searchResources?: boolean;
28
34
  excludeRefs?: readonly CloudResourceRef[];
29
35
  };
30
36
 
31
- type TagSuggestion = {
32
- tag: string;
33
- appName: string;
34
- appIcon: string;
35
- };
36
-
37
- const MIN_QUERY_LENGTH = 2;
38
- const SEARCH_DEBOUNCE_MS = 200;
39
- const TAG_TOKEN_PATTERN = /^[^\s#]+$/;
40
-
41
- const rowKey = (item: SearchItem) => `row:${item.appId}:${item.ref.type}:${item.ref.id}`;
42
- const isValidImagePreviewUrl = (url?: string) => typeof url === "string" && url.startsWith("/");
43
- const sortByPriorityAndTitle = (a: SearchItem, b: SearchItem) => {
44
- const byPriority = (b.priority ?? 0) - (a.priority ?? 0);
45
- if (byPriority !== 0) return byPriority;
46
- return a.title.localeCompare(b.title);
47
- };
37
+ const isLinkableCommand = (command: PaletteCommand) => typeof command.action !== "function" && "command" in command.action;
48
38
 
49
- /**
50
- * Cluster items by their owning app while preserving the global priority
51
- * order across groups: the first occurrence of each app fixes its slot, and
52
- * items within a group keep their original (already sorted) order. Map
53
- * iteration order in JS is insertion order, so this is stable.
54
- */
55
- const groupByApp = (items: SearchItem[]): SearchItem[] => {
39
+ const itemKey = (item: SearchItem) => `${item.ref.type}:${item.ref.id}`;
40
+ const groupByApp = (items: SearchItem[]) => {
56
41
  const groups = new Map<string, SearchItem[]>();
57
42
  for (const item of items) {
58
- const arr = groups.get(item.appId);
59
- if (arr) arr.push(item);
60
- else groups.set(item.appId, [item]);
43
+ const group = groups.get(item.appId) ?? [];
44
+ group.push(item);
45
+ groups.set(item.appId, group);
61
46
  }
62
- const out: SearchItem[] = [];
63
- for (const group of groups.values()) out.push(...group);
64
- return out;
65
- };
66
-
67
- type ListEntry =
68
- | { kind: "header"; appId: string; appName: string; appIcon: string; count: number }
69
- | { kind: "item"; item: SearchItem; flatIndex: number };
70
-
71
- /**
72
- * Build the flat render list with optional group headers interleaved.
73
- * Headers only appear when 2+ apps have results (single-app queries stay
74
- * uncluttered). `flatIndex` on each item entry mirrors the index into the
75
- * underlying sorted list — used for selection/scroll/keyboard nav.
76
- */
77
- const buildListEntries = (items: SearchItem[]): ListEntry[] => {
78
- if (items.length === 0) return [];
79
-
80
- const counts = new Map<string, number>();
81
- for (const item of items) counts.set(item.appId, (counts.get(item.appId) ?? 0) + 1);
82
- const showHeaders = counts.size >= 2;
83
-
84
- const entries: ListEntry[] = [];
85
- let prevAppId: string | null = null;
86
- for (let i = 0; i < items.length; i++) {
87
- const item = items[i]!;
88
- if (showHeaders && item.appId !== prevAppId) {
89
- entries.push({
90
- kind: "header",
91
- appId: item.appId,
92
- appName: item.appName,
93
- appIcon: item.appIcon,
94
- count: counts.get(item.appId) ?? 0,
95
- });
96
- prevAppId = item.appId;
97
- }
98
- entries.push({ kind: "item", item, flatIndex: i });
99
- }
100
- return entries;
101
- };
102
-
103
- const parseInput = (raw: string): ParsedInput => {
104
- const tokens = raw.trim().split(/\s+/).filter(Boolean);
105
- const tags: string[] = [];
106
- const queryTokens: string[] = [];
107
-
108
- for (const token of tokens) {
109
- if (token.startsWith("#") && token.length > 1) {
110
- const tag = token.slice(1).toLowerCase();
111
- if (TAG_TOKEN_PATTERN.test(tag)) {
112
- tags.push(tag);
113
- continue;
114
- }
115
- }
116
-
117
- queryTokens.push(token);
118
- }
119
-
120
- return {
121
- query: queryTokens.join(" "),
122
- tags: [...new Set(tags)],
123
- };
124
- };
125
-
126
- const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
127
-
128
- const removeTagFromInput = (raw: string, tag: string): string => {
129
- const pattern = new RegExp(`(^|\\s)#${escapeRegex(tag)}(?=\\s|$)`, "gi");
130
- return raw.replace(pattern, "").replace(/\s+/g, " ").trim();
131
- };
132
-
133
- const metadataRows = (metadata?: SearchItem["metadata"]) =>
134
- (metadata ?? []).filter((entry) => entry.label.trim().length > 0 && entry.value.trim().length > 0).slice(0, 5);
135
-
136
- const loadSearch = async (url: string, abortSignal: AbortSignal, fallbackMessage: string): Promise<SearchResponse> => {
137
- const response = await fetch(url, { signal: abortSignal });
138
- const payload = (await response.json()) as SearchResponse | { message?: string };
139
- if (!response.ok) {
140
- throw new Error("message" in payload ? (payload.message ?? fallbackMessage) : fallbackMessage);
141
- }
142
- return payload as SearchResponse;
47
+ return [...groups.values()].flat();
143
48
  };
144
49
 
145
50
  export default function CloudResourceSearch(props: CloudResourceSearchProps) {
146
51
  const locale = useLocale();
147
52
  const t = () => resourceSearchMessages.resolve([locale()]).t;
148
- const [rawInput, setRawInput] = createSignal("");
149
- const [appId, setAppId] = createSignal<string | null>(props.initialAppId ?? null);
150
- const [searchUrl, setSearchUrl] = createSignal(
151
- cloudResourceSearchUrl({ query: "", tags: [], appId: props.initialAppId, requireReader: props.requireReader }),
53
+ const id = createUniqueId();
54
+ const [input, setInput] = createSignal("");
55
+ const [tags, setTags] = createSignal<string[]>([]);
56
+ const [scope, setScope] = createSignal<SearchScope | undefined>(
57
+ props.request?.scope ?? (props.initialAppId ? { appId: props.initialAppId, label: props.initialAppId } : undefined),
152
58
  );
59
+ const appId = () => scope()?.appId ?? scope()?.ref?.type.split(".")[0];
60
+ const scopeLabel = () =>
61
+ !props.request && props.initialAppId
62
+ ? (response()?.apps.find((app) => app.id === props.initialAppId)?.name ?? scope()?.label)
63
+ : scope()?.label;
64
+ createEffect(() => {
65
+ const request = props.request;
66
+ if (!request) return;
67
+ setScope(request.scope);
68
+ if (request.query !== undefined) {
69
+ setInput(request.query);
70
+ setCaret(request.query.length);
71
+ }
72
+ setTags([]);
73
+ setBrowsingTags(false);
74
+ queueMicrotask(() => inputRef?.focus());
75
+ });
76
+ const [caret, setCaret] = createSignal(0);
77
+ const [browsingTags, setBrowsingTags] = createSignal(false);
153
78
  const [activeIndex, setActiveIndex] = createSignal(0);
79
+ let userSelected = false;
80
+ const selectIndex = (index: number) => {
81
+ userSelected = true;
82
+ setActiveIndex(index);
83
+ };
84
+ const [tagIndex, setTagIndex] = createSignal(0);
85
+ const [selectedKey, setSelectedKey] = createSignal<string>();
86
+ const [mobileDetails, setMobileDetails] = createSignal(false);
154
87
  const [previewFailed, setPreviewFailed] = createSignal(false);
155
- const [cursorPos, setCursorPos] = createSignal(0);
156
- const [suggestionIndex, setSuggestionIndex] = createSignal(0);
157
-
158
- let inputRef: HTMLInputElement | undefined;
159
- const rowRefs = new Map<string, HTMLButtonElement>();
160
-
161
- const parsedInput = createMemo(() => parseInput(rawInput()));
162
- const canSearch = createMemo(() => Boolean(appId()) || parsedInput().tags.length > 0 || parsedInput().query.length >= MIN_QUERY_LENGTH);
88
+ let inputRef!: HTMLInputElement;
89
+ let bodyRef!: HTMLDivElement;
90
+ let backRef: HTMLButtonElement | undefined;
91
+
92
+ const commandMode = () => !props.selectionMode && Boolean(props.onCommand) && input().trimStart().startsWith(">");
93
+ const commandQuery = () => (commandMode() ? input().trimStart().slice(1).trim() : textQuery());
94
+ const commandFor = (item: SearchItem) =>
95
+ item.ref.type === "cloud.command" ? visibleCommands().find((command) => command.id === item.ref.id) : undefined;
96
+ const tagContext = createMemo(() => (commandMode() ? null : tagAtCursor(input(), caret())));
97
+ const choosingTag = () => !commandMode() && (browsingTags() || tagContext() !== null);
98
+ const textQuery = createMemo(() => {
99
+ const ctx = tagContext();
100
+ return (ctx ? input().slice(0, ctx.start) + input().slice(ctx.end) : input()).trim();
101
+ });
102
+ const visibleCommands = createMemo(() =>
103
+ props.selectionMode || !props.onCommand ? [] : matchingCommands(props.commands ?? [], commandQuery(), commandMode()),
104
+ );
105
+ const canSearch = () => !commandMode() && (Boolean(appId()) || tags().length > 0 || textQuery().length >= 2);
106
+ const desiredUrl = createMemo(() =>
107
+ cloudResourceSearchUrl({
108
+ query: canSearch() ? textQuery() : "",
109
+ tags: commandMode() ? [] : tags(),
110
+ appId: commandMode() ? undefined : appId(),
111
+ scope: commandMode() ? undefined : scope()?.ref,
112
+ scopeTag: commandMode() ? undefined : scope()?.tag,
113
+ requireReader: props.requireReader,
114
+ }),
115
+ );
116
+ const [searchUrl, setSearchUrl] = createSignal(desiredUrl());
163
117
  const searchQuery = query.create({
164
118
  source: searchUrl,
165
- enabled: () => searchUrl().length > 0,
166
- load: (url, { abortSignal }) => loadSearch(url, abortSignal, t().searchFailed),
119
+ load: async (url, { abortSignal }) => {
120
+ if (props.searchResources === false) {
121
+ const data: SearchResponse = { query: "", count: 0, apps: [], items: [] };
122
+ return { url, data };
123
+ }
124
+ const response = await fetch(url, { signal: abortSignal, headers: { [LOCALE_HEADER]: locale() } });
125
+ if (!response.ok) throw new Error(t().searchFailed);
126
+ const data: SearchResponse = await response.json();
127
+ return { url, data };
128
+ },
167
129
  });
168
- const resultItems = createMemo(() => {
169
- const sorted = filterCloudResourceSearchItems(searchQuery.data()?.items ?? [], props)
170
- .slice()
171
- .sort(sortByPriorityAndTitle);
172
- return groupByApp(sorted);
130
+ const response = () => searchQuery.data()?.data;
131
+ const fresh = () => searchQuery.data()?.url === desiredUrl() && !searchQuery.error();
132
+ const pending = () => searchUrl() !== desiredUrl() || searchQuery.loading() || searchQuery.refreshing();
133
+ const catalog = createMemo(() => searchTags(response()?.apps ?? [], appId()));
134
+ const quickTags = createMemo(() =>
135
+ catalog()
136
+ .filter((tag, index, all) => all.findIndex((other) => other.appName === tag.appName) === index)
137
+ .slice(0, 3),
138
+ );
139
+ const suggestions = createMemo(() => matchingSearchTags(catalog(), tagContext()?.prefix ?? "", tags()));
140
+ const navigation = createMemo(() =>
141
+ canSearch() && !scope()?.ref && scope()?.tag === undefined
142
+ ? matchNavigationSearchItems(props.navigationItems ?? [], {
143
+ query: textQuery(),
144
+ tags: tags(),
145
+ appId: appId(),
146
+ requireReader: props.requireReader,
147
+ })
148
+ : [],
149
+ );
150
+ const items = createMemo(() => {
151
+ const commands = visibleCommands().map((command) => commandSearchItem(command, command.context ? t().contextActions : t().actions));
152
+ if (!canSearch()) return commands;
153
+ // Keep last-good rows only within the same context, never across scope changes.
154
+ const loaded = new URL(searchQuery.data()?.url ?? "/api/search", "https://cloud.invalid");
155
+ const sameScope =
156
+ (loaded.searchParams.get("scope_type") ?? undefined) === scope()?.ref?.type &&
157
+ (loaded.searchParams.get("scope_id") ?? undefined) === scope()?.ref?.id &&
158
+ (loaded.searchParams.get("app") ?? undefined) === appId() &&
159
+ (loaded.searchParams.get("scope_tag") ?? undefined) === scope()?.tag;
160
+ const resources = filterCloudResourceSearchItems(sameScope ? (response()?.items ?? []) : [], props).sort(
161
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title),
162
+ );
163
+ const results = [...groupByApp(resources), ...groupByApp(navigation())];
164
+ // Context actions stay visible when opening an app-scoped search without text.
165
+ return textQuery() ? [...results, ...commands] : [...commands, ...results];
173
166
  });
174
- const apps = createMemo<SearchApp[]>(() => searchQuery.data()?.apps ?? []);
175
- const unsupportedTags = createMemo(() => searchQuery.data()?.unsupportedTags ?? []);
176
-
177
- // Tag suggestions for the empty state — flat list of every tag declared by
178
- // any app, deduped on tag name (first declaration wins) so we don't render
179
- // the same chip twice when two apps share a tag.
180
- const tagSuggestions = createMemo<TagSuggestion[]>(() => {
181
- const seen = new Set<string>();
182
- const out: TagSuggestion[] = [];
183
- for (const app of props.helpApps ?? []) {
184
- for (const tag of app.tags) {
185
- const lower = tag.toLowerCase();
186
- if (seen.has(lower)) continue;
187
- seen.add(lower);
188
- out.push({ tag: lower, appName: app.appName, appIcon: app.appIcon });
189
- }
167
+ const selectable = (item: SearchItem) =>
168
+ !props.disabled && !choosingTag() && (commandFor(item) || navigation().includes(item) || (fresh() && !pending()));
169
+ const activeItem = () => items()[activeIndex()];
170
+ const selectedItem = () => items().find((item) => itemKey(item) === selectedKey());
171
+ const previewItem = () => (props.selectionMode ? (selectedItem() ?? activeItem()) : activeItem());
172
+ const showList = () => !choosingTag() && items().length > 0;
173
+ const showResults = () => showList() && (canSearch() || commandMode());
174
+ const unknownTags = () => (fresh() && !pending() ? (response()?.unsupportedTags ?? []) : []);
175
+
176
+ const { debouncedFn: scheduleSearch, cancel } = timed.debounce((url: string) => setSearchUrl(url), 200);
177
+ createEffect(() => {
178
+ const url = desiredUrl();
179
+ if (choosingTag()) {
180
+ cancel();
181
+ return;
182
+ }
183
+ if (url === searchUrl()) {
184
+ cancel();
185
+ return;
186
+ }
187
+ if (canSearch()) scheduleSearch(url);
188
+ else {
189
+ cancel();
190
+ setSearchUrl(url);
190
191
  }
191
- return out;
192
192
  });
193
-
194
- /**
195
- * Detect whether the caret is currently inside a tag token (i.e. the user
196
- * is mid-typing `#...`). Returns the token's start/end indices in rawInput
197
- * plus the lowercased prefix typed after `#`. Returns null otherwise — no
198
- * autocomplete popover should show.
199
- *
200
- * Walks back from the cursor to the last whitespace; if the token starts
201
- * with `#`, we're in tag-input mode. If the prefix contains a non-tag
202
- * character (`#` or whitespace), bails — the parser would reject it
203
- * anyway, so suggestions would be misleading.
204
- */
205
- const tagContext = createMemo(() => {
206
- const text = rawInput();
207
- const pos = Math.min(cursorPos(), text.length);
208
- let start = pos;
209
- while (start > 0 && !/\s/.test(text[start - 1] ?? "")) start--;
210
- // Extend to the next whitespace so we replace the WHOLE token, not just
211
- // up to the caret. Otherwise editing in the middle of an existing tag
212
- // (e.g. caret after `#no` in `#notebook`) leaves `tebook` dangling after
213
- // the inserted suggestion. Prefix used for matching is still the part
214
- // BEFORE the caret — that's what the user has committed to typing.
215
- let end = pos;
216
- while (end < text.length && !/\s/.test(text[end] ?? "")) end++;
217
- const fullToken = text.slice(start, end);
218
- if (!fullToken.startsWith("#")) return null;
219
- const prefix = text.slice(start + 1, pos).toLowerCase();
220
- if (prefix.length > 0 && !TAG_TOKEN_PATTERN.test(prefix)) return null;
221
- return { start, end, prefix };
193
+ createEffect(() => {
194
+ desiredUrl();
195
+ setSelectedKey(undefined);
196
+ setMobileDetails(false);
222
197
  });
223
-
224
- /**
225
- * Tags filtered by the current prefix, with already-active tags excluded
226
- * (no point suggesting one the user has). Starts-with match for predictability.
227
- */
228
- const filteredSuggestions = createMemo<TagSuggestion[]>(() => {
229
- const ctx = tagContext();
230
- if (!ctx) return [];
231
- const active = new Set(parsedInput().tags);
232
- const all = tagSuggestions().filter((s) => !active.has(s.tag));
233
- if (ctx.prefix.length === 0) return all;
234
- return all.filter((s) => s.tag.startsWith(ctx.prefix));
198
+ createEffect(
199
+ on(
200
+ () => ({ rows: items(), context: JSON.stringify([desiredUrl(), commandMode(), commandQuery()]) }),
201
+ (current, previous) => {
202
+ if (userSelected && previous?.context === current.context) {
203
+ const selected = previous.rows[activeIndex()];
204
+ // Preserve the actual target across asynchronous catalog/context updates.
205
+ // A removed target leaves no selection rather than selecting a different action.
206
+ setActiveIndex(selected ? current.rows.findIndex((row) => itemKey(row) === itemKey(selected)) : -1);
207
+ } else {
208
+ userSelected = false;
209
+ const explicitCommand = commandMode() && Boolean(commandQuery());
210
+ setActiveIndex(current.rows[0]?.ref.type === "cloud.command" && !explicitCommand ? -1 : 0);
211
+ }
212
+ },
213
+ ),
214
+ );
215
+ createEffect(() => {
216
+ suggestions();
217
+ setTagIndex(0);
235
218
  });
236
-
237
- const popoverOpen = createMemo(() => filteredSuggestions().length > 0);
238
-
239
- // Reset highlight when the filtered list changes shape (new prefix, etc.).
240
219
  createEffect(() => {
241
- filteredSuggestions();
242
- setSuggestionIndex(0);
220
+ previewItem();
221
+ setPreviewFailed(false);
222
+ });
223
+ onCleanup(cancel);
224
+ onMount(() => {
225
+ const frame = requestAnimationFrame(() => inputRef?.focus());
226
+ onCleanup(() => cancelAnimationFrame(frame));
243
227
  });
244
228
 
245
- const updateCursor = () => {
246
- if (!inputRef) return;
247
- setCursorPos(inputRef.selectionStart ?? rawInput().length);
229
+ const focusInput = (pos = input().length) => {
230
+ queueMicrotask(() => {
231
+ inputRef.focus();
232
+ inputRef.setSelectionRange(pos, pos);
233
+ setCaret(pos);
234
+ });
248
235
  };
249
-
250
- const insertTagAtCursor = (tag: string) => {
236
+ const addTag = (tag: string) => {
251
237
  const ctx = tagContext();
252
- const text = rawInput();
253
- if (!ctx) {
254
- // No tag context — append. Used by suggestion-mode chips.
255
- if (parsedInput().tags.includes(tag)) {
256
- inputRef?.focus();
257
- return;
258
- }
259
- const next = text.length > 0 ? `${text.trimEnd()} #${tag} ` : `#${tag} `;
260
- setRawInput(next);
261
- const newPos = next.length;
262
- queueMicrotask(() => {
263
- if (inputRef) inputRef.setSelectionRange(newPos, newPos);
264
- setCursorPos(newPos);
265
- });
266
- inputRef?.focus();
267
- return;
238
+ const next = ctx ? input().slice(0, ctx.start) + input().slice(ctx.end) : input();
239
+ setInput(next.trim());
240
+ setCaret(next.trim().length);
241
+ setTags((prev) => [...new Set([...prev, tag])]);
242
+ setBrowsingTags(false);
243
+ focusInput();
244
+ };
245
+ const leaveTags = () => {
246
+ const ctx = tagContext();
247
+ if (ctx) {
248
+ const next = (input().slice(0, ctx.start) + input().slice(ctx.end)).trim();
249
+ setInput(next);
250
+ setCaret(next.length);
268
251
  }
269
- const before = text.slice(0, ctx.start);
270
- const after = text.slice(ctx.end).replace(/^\s+/, "");
271
- // Always emit `#tag ` with one trailing space so the user can keep
272
- // typing the next token without manual spacing.
273
- const insertion = `#${tag} `;
274
- const next = before + insertion + after;
275
- setRawInput(next);
276
- const newPos = before.length + insertion.length;
277
- queueMicrotask(() => {
278
- if (inputRef) inputRef.setSelectionRange(newPos, newPos);
279
- setCursorPos(newPos);
280
- });
281
- inputRef?.focus();
252
+ setBrowsingTags(false);
253
+ focusInput();
282
254
  };
283
-
284
- const activeItem = createMemo(() => resultItems()[activeIndex()] ?? null);
285
- const listEntries = createMemo(() => buildListEntries(resultItems()));
286
-
287
- // Empty-state branching. Single source of truth for what the body renders.
288
- // - suggestions: user hasn't typed enough to search; show what's possible.
289
- // - unsupported-tags: response told us no app accepts these tags.
290
- // - no-results: search ran, returned nothing.
291
- // - ready: render results.
292
- // - idle: search debounced or in flight, no decision yet.
293
- type BodyMode = "suggestions" | "unsupported-tags" | "no-results" | "ready" | "idle";
294
- const bodyMode = createMemo<BodyMode>(() => {
295
- if (!canSearch()) return "suggestions";
296
- if (resultItems().length > 0) return "ready";
297
- if (searchQuery.loading() || searchQuery.refreshing() || (!searchQuery.data() && !searchQuery.error())) return "idle";
298
- if (unsupportedTags().length > 0) return "unsupported-tags";
299
- return "no-results";
300
- });
301
-
302
- const { debouncedFn: debounceSearch, cancel: cancelDebounce } = timed.debounce(
303
- (request: { input: ParsedInput; appId: string | null }) => {
304
- setSearchUrl(cloudResourceSearchUrl({ ...request.input, appId: request.appId, requireReader: props.requireReader }));
305
- },
306
- SEARCH_DEBOUNCE_MS,
307
- );
308
-
309
- const bindRowRef = (key: string, element?: HTMLButtonElement) => {
310
- if (!element) {
311
- rowRefs.delete(key);
255
+ const onInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
256
+ const el = event.currentTarget;
257
+ if (event.isComposing) {
258
+ setInput(el.value);
259
+ setCaret(el.selectionStart ?? el.value.length);
312
260
  return;
313
261
  }
314
- rowRefs.set(key, element);
262
+ const parsed = commitTypedTags(el.value, el.selectionStart ?? el.value.length);
263
+ setInput(parsed.input);
264
+ setCaret(parsed.caret);
265
+ if (parsed.tags.length) {
266
+ setTags((prev) => [...new Set([...prev, ...parsed.tags])]);
267
+ setBrowsingTags(false);
268
+ focusInput(parsed.caret);
269
+ }
315
270
  };
316
-
317
- const selectItem = (row?: SearchItem) => {
318
- if (!row) return;
319
- props.onSelect(row);
271
+ const chooseItem = (item: SearchItem) => {
272
+ if (!selectable(item)) return;
273
+ const command = commandFor(item);
274
+ if (command) {
275
+ props.onCommand?.(command, false);
276
+ return;
277
+ }
278
+ if (props.selectionMode) {
279
+ setSelectedKey(itemKey(item));
280
+ selectIndex(items().indexOf(item));
281
+ } else props.onSelect(item);
320
282
  };
321
-
322
- const moveSelection = (delta: -1 | 1) => {
323
- const list = resultItems();
324
- if (list.length === 0) return;
325
- const next = (activeIndex() + delta + list.length) % list.length;
326
- setActiveIndex(next);
283
+ const scrollOption = (index: number, kind: "tag" | "result") => {
284
+ queueMicrotask(() => bodyRef?.querySelector<HTMLElement>(`[data-${kind}-index="${index}"]`)?.scrollIntoView({ block: "nearest" }));
327
285
  };
328
-
329
- const removeTag = (tag: string) => {
330
- setRawInput((prev) => removeTagFromInput(prev, tag));
331
- inputRef?.focus();
286
+ const openNewTab = (item: SearchItem) => {
287
+ const command = commandFor(item);
288
+ if (command) {
289
+ if (typeof command.action !== "function" && "command" in command.action) props.onCommand?.(command, true);
290
+ } else props.onOpenInNewTab?.(item);
332
291
  };
333
-
334
292
  const handleKeyDown = (event: KeyboardEvent) => {
335
- // Tag autocomplete intercepts navigation when the popover is open. Only
336
- // reaches results-list nav when no suggestion is being chosen.
337
- if (popoverOpen()) {
338
- if (event.key === "ArrowDown") {
293
+ if (event.isComposing) return;
294
+ if (event.key === "Escape") {
295
+ if (mobileDetails()) {
339
296
  event.preventDefault();
340
- const list = filteredSuggestions();
341
- setSuggestionIndex((i) => (i + 1) % list.length);
342
- return;
343
- }
344
- if (event.key === "ArrowUp") {
297
+ event.stopPropagation();
298
+ setMobileDetails(false);
299
+ focusInput();
300
+ } else if (choosingTag()) {
345
301
  event.preventDefault();
346
- const list = filteredSuggestions();
347
- setSuggestionIndex((i) => (i - 1 + list.length) % list.length);
348
- return;
302
+ event.stopPropagation();
303
+ leaveTags();
349
304
  }
350
- if (event.key === "Enter" || event.key === "Tab") {
305
+ return;
306
+ }
307
+ if (choosingTag()) {
308
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
351
309
  event.preventDefault();
352
- const list = filteredSuggestions();
353
- const choice = list[suggestionIndex()];
354
- if (choice) insertTagAtCursor(choice.tag);
355
- return;
356
- }
357
- if (event.key === "Escape") {
310
+ if (!suggestions().length) return;
311
+ const next = (tagIndex() + (event.key === "ArrowDown" ? 1 : -1) + suggestions().length) % suggestions().length;
312
+ setTagIndex(next);
313
+ scrollOption(next, "tag");
314
+ } else if (event.key === "Enter") {
358
315
  event.preventDefault();
359
- // Close popover by erasing the in-flight tag token (caret-prefixed
360
- // `#xyz`) — leaves the rest of the query intact. If the user wants
361
- // to keep `#xyz`, they can press Space instead.
362
- const ctx = tagContext();
363
- if (ctx) {
364
- const text = rawInput();
365
- const next = text.slice(0, ctx.start) + text.slice(ctx.end).replace(/^\s+/, "");
366
- setRawInput(next);
367
- queueMicrotask(() => {
368
- if (inputRef) inputRef.setSelectionRange(ctx.start, ctx.start);
369
- setCursorPos(ctx.start);
370
- });
371
- }
372
- return;
316
+ const choice = suggestions()[tagIndex()];
317
+ if (choice) addTag(choice.tag);
318
+ else if (tagContext()?.prefix) addTag(tagContext()!.prefix);
373
319
  }
374
- // Space commits the current token as-is and closes the popover by
375
- // virtue of advancing the cursor past `#`. No special handling needed —
376
- // tagContext recomputes naturally.
377
- }
378
-
379
- if (event.key === "ArrowDown") {
380
- event.preventDefault();
381
- moveSelection(1);
382
320
  return;
383
321
  }
384
-
385
- if (event.key === "ArrowUp") {
322
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
386
323
  event.preventDefault();
387
- moveSelection(-1);
388
- return;
389
- }
390
-
391
- if (event.key === "Enter") {
324
+ if (!items().length) return;
325
+ const next =
326
+ activeIndex() < 0
327
+ ? event.key === "ArrowDown"
328
+ ? 0
329
+ : items().length - 1
330
+ : (activeIndex() + (event.key === "ArrowDown" ? 1 : -1) + items().length) % items().length;
331
+ selectIndex(next);
332
+ scrollOption(next, "result");
333
+ } else if (event.key === "Enter" && activeItem()) {
334
+ event.preventDefault();
335
+ const item = activeItem()!;
336
+ if ((event.metaKey || event.ctrlKey) && props.onOpenInNewTab && !props.selectionMode) {
337
+ if (selectable(item)) openNewTab(item);
338
+ } else chooseItem(item);
339
+ } else if (event.key === "Backspace" && !input() && tags().length) {
392
340
  event.preventDefault();
393
- selectItem(activeItem() ?? undefined);
341
+ setTags((prev) => prev.slice(0, -1));
394
342
  }
395
343
  };
396
-
397
- createEffect(() => {
398
- const response = searchQuery.data();
399
- if (response) setActiveIndex(0);
400
- });
401
-
402
- createEffect(() => {
403
- const maxIndex = resultItems().length - 1;
404
- if (maxIndex < 0) {
405
- setActiveIndex(0);
406
- return;
407
- }
408
-
409
- if (activeIndex() > maxIndex) setActiveIndex(maxIndex);
410
- });
411
-
412
- createEffect(() => {
413
- const item = activeItem();
414
- const key = item ? rowKey(item) : null;
415
-
416
- setPreviewFailed(false);
417
- if (!key) return;
418
-
419
- queueMicrotask(() => {
420
- rowRefs.get(key)?.scrollIntoView({ block: "nearest" });
421
- });
422
- });
423
-
424
- createEffect(() => {
425
- const input = parsedInput();
426
- const selectedAppId = appId();
427
-
428
- if (!canSearch()) {
429
- cancelDebounce();
430
- setSearchUrl(cloudResourceSearchUrl({ ...input, requireReader: props.requireReader }));
431
- setActiveIndex(0);
432
- return;
433
- }
434
-
435
- debounceSearch({ input, appId: selectedAppId });
436
- });
437
-
438
- onCleanup(() => {
439
- cancelDebounce();
440
- });
441
-
442
- onMount(() => {
443
- requestAnimationFrame(() => inputRef?.focus());
444
- });
344
+ const updateCaret = () => setCaret(inputRef.selectionStart ?? input().length);
345
+ const previewMetadata = () => (previewItem()?.metadata ?? []).filter((entry) => entry.label.trim() && entry.value.trim()).slice(0, 5);
445
346
 
446
347
  return (
447
348
  <div
448
- class="grid h-full min-h-0 grid-cols-1 grid-rows-[auto_minmax(0,1fr)] text-zinc-900 dark:text-zinc-100"
449
- classList={{
450
- "md:grid-cols-[minmax(0,1fr)_18rem] md:gap-x-3": bodyMode() === "ready",
349
+ role="group"
350
+ aria-label={props.title ?? t().searchCloudResources}
351
+ class="cloud-resource-search"
352
+ classList={{ "has-results": showResults(), "is-picker": props.selectionMode, "shows-details": mobileDetails() && showResults() }}
353
+ onKeyDown={(event) => {
354
+ if (event.target === inputRef) return;
355
+ if (event.key === "Escape") handleKeyDown(event);
356
+ else if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && props.onOpenInNewTab && !props.selectionMode) {
357
+ handleKeyDown(event);
358
+ }
451
359
  }}
452
- onWheel={(event) => event.stopPropagation()}
453
360
  >
454
- <div class="flex min-w-0 items-center gap-2 pr-3">
455
- <div class="relative min-w-0 flex-1">
456
- <label class="flex items-center gap-3 px-4 py-3.5">
457
- <i class="ti ti-search text-xl text-dimmed" />
458
- <input
459
- id="spotlight-input"
460
- ref={inputRef}
461
- type="search"
462
- value={rawInput()}
463
- onInput={(event) => {
464
- setRawInput(event.currentTarget.value);
465
- setCursorPos(event.currentTarget.selectionStart ?? event.currentTarget.value.length);
466
- }}
467
- onKeyUp={updateCursor}
468
- onClick={updateCursor}
469
- onSelect={updateCursor}
470
- onKeyDown={handleKeyDown}
471
- placeholder={props.placeholder ?? t().searchPlaceholder}
472
- aria-label={t().searchCloudResources}
473
- class="w-full border-0 bg-transparent text-base outline-none placeholder:text-dimmed md:text-lg"
474
- spellcheck={false}
475
- autocapitalize="off"
476
- autocomplete="off"
477
- autocorrect="off"
478
- />
479
- <Show when={searchQuery.loading() || searchQuery.refreshing()}>
480
- <i class="ti ti-loader-2 animate-spin text-dimmed" />
481
- </Show>
482
- </label>
483
-
484
- {/* Tag autocomplete popover. Anchored to the input row, overlays the
485
- body when the user is mid-typing a `#tag` token. Keyboard nav
486
- (Up/Down/Tab/Enter/Escape) is intercepted by handleKeyDown. */}
487
- <Show when={popoverOpen()}>
488
- <div
489
- 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"
490
- role="listbox"
491
- aria-label={t().tagSuggestions}
492
- >
493
- <For each={filteredSuggestions()}>
494
- {(suggestion, index) => {
495
- const selected = () => index() === suggestionIndex();
496
- return (
497
- <button
498
- type="button"
499
- role="option"
500
- aria-selected={selected()}
501
- onMouseEnter={() => setSuggestionIndex(index())}
502
- onMouseDown={(e) => {
503
- // mousedown so the input doesn't lose focus to the click.
504
- e.preventDefault();
505
- insertTagAtCursor(suggestion.tag);
506
- }}
507
- class="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors"
508
- classList={{
509
- "bg-blue-50/85 dark:bg-blue-950/45": selected(),
510
- "hover:bg-zinc-100/85 dark:hover:bg-zinc-800/60": !selected(),
511
- }}
512
- >
513
- <i class={`${suggestion.appIcon} text-[12px] text-dimmed`} />
514
- <span class="font-medium">#{suggestion.tag}</span>
515
- <span class="text-[10px] text-dimmed">{suggestion.appName}</span>
516
- </button>
517
- );
361
+ <Show when={props.selectionMode}>
362
+ <div class="cloud-resource-search__title">{props.title ?? t().chooseResource}</div>
363
+ </Show>
364
+ <IconButton class="cloud-resource-search__close" label={t().close} onClick={props.onClose}>
365
+ <i class="ti ti-x" aria-hidden="true" />
366
+ </IconButton>
367
+ <label class="cloud-resource-search__input">
368
+ <Show
369
+ when={commandMode() ? props.commandsLoading : pending() && !choosingTag()}
370
+ fallback={<i class="ti ti-search" aria-hidden="true" />}
371
+ >
372
+ <i class="ti ti-loader-2 animate-spin" role="status" aria-label={t().loading} />
373
+ </Show>
374
+ <div class="cloud-resource-search__field">
375
+ <Show when={scope()}>
376
+ {(context) => (
377
+ <button
378
+ type="button"
379
+ class="cloud-resource-search__tag max-w-full"
380
+ title={scopeLabel()}
381
+ onClick={() => {
382
+ setScope(undefined);
383
+ setTags([]);
384
+ focusInput();
518
385
  }}
519
- </For>
520
- </div>
386
+ aria-label={t().removeScope({ app: scopeLabel() ?? context().label })}
387
+ >
388
+ <Show when={context().icon}>{(icon) => <i class={`${icon()} shrink-0`} aria-hidden="true" />}</Show>
389
+ <span class="min-w-0 truncate">{scopeLabel()}</span> <i class="ti ti-x shrink-0" aria-hidden="true" />
390
+ </button>
391
+ )}
521
392
  </Show>
393
+ <For each={tags()}>
394
+ {(tag) => (
395
+ <button
396
+ type="button"
397
+ class="cloud-resource-search__tag"
398
+ classList={{ "is-unknown": unknownTags().includes(tag) }}
399
+ onClick={() => {
400
+ setTags((prev) => prev.filter((value) => value !== tag));
401
+ focusInput();
402
+ }}
403
+ aria-label={t().removeTag({ tag })}
404
+ >
405
+ #{tag}
406
+ <i class="ti ti-x" aria-hidden="true" />
407
+ </button>
408
+ )}
409
+ </For>
410
+ <input
411
+ ref={inputRef}
412
+ id={`${id}-input`}
413
+ type="text"
414
+ role="combobox"
415
+ aria-autocomplete="list"
416
+ aria-label={t().searchCloudResources}
417
+ aria-controls={`${id}-options`}
418
+ aria-expanded={choosingTag() ? suggestions().length > 0 : showList()}
419
+ aria-activedescendant={
420
+ choosingTag()
421
+ ? suggestions().length
422
+ ? `${id}-tag-${tagIndex()}`
423
+ : undefined
424
+ : showList() && activeIndex() >= 0
425
+ ? `${id}-result-${activeIndex()}`
426
+ : undefined
427
+ }
428
+ value={input()}
429
+ onInput={onInput}
430
+ onClick={updateCaret}
431
+ onSelect={updateCaret}
432
+ onKeyUp={updateCaret}
433
+ onKeyDown={handleKeyDown}
434
+ placeholder={
435
+ commandMode()
436
+ ? t().commandPlaceholder
437
+ : tags().length
438
+ ? ""
439
+ : (props.placeholder ??
440
+ (props.selectionMode ? t().pickerPlaceholder : scope() ? t().scopedSearchPlaceholder : t().searchPlaceholder))
441
+ }
442
+ autocomplete="off"
443
+ autocapitalize="off"
444
+ spellcheck={false}
445
+ maxLength={500}
446
+ />
522
447
  </div>
523
- <Select
524
- class="w-44 shrink-0"
525
- aria-label={t().filterByApp}
526
- value={appId}
527
- onValueChange={setAppId}
528
- placeholder={t().allApps}
529
- clearable
530
- options={apps().map((app) => ({ id: app.id, label: app.name, icon: app.icon }))}
531
- />
532
- </div>
533
-
534
- <div class="min-h-0 overflow-hidden">
535
- <div class="flex h-full min-h-0 flex-col gap-2 px-3 pb-3">
536
- {/* Active-tag chip row + meta. Hidden in suggestions mode. */}
537
- <Show when={bodyMode() !== "suggestions"}>
538
- <div class="flex h-8 items-center justify-between gap-3">
539
- <div class="flex min-w-0 flex-wrap items-center gap-2 text-[11px] text-dimmed">
540
- <Show when={parsedInput().tags.length > 0}>
541
- <div class="flex flex-wrap items-center gap-1">
542
- <For each={parsedInput().tags}>
543
- {(tag) => {
544
- const isUnsupported = () => unsupportedTags().includes(tag);
545
- return (
546
- <span
547
- class="group inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px]"
548
- classList={{
549
- "bg-zinc-200/70 dark:bg-zinc-800/70": !isUnsupported(),
550
- "bg-amber-200/60 text-amber-900 dark:bg-amber-900/35 dark:text-amber-200": isUnsupported(),
551
- }}
552
- >
553
- #{tag}
554
- <button
555
- type="button"
556
- class="opacity-60 hover:opacity-100"
557
- onClick={() => removeTag(tag)}
558
- aria-label={t().removeTag({ tag })}
559
- title={t().removeTagTitle({ tag })}
560
- >
561
- <i class="ti ti-x text-[10px]" />
562
- </button>
563
- </span>
564
- );
565
- }}
566
- </For>
567
- </div>
568
- </Show>
569
- <Show when={bodyMode() === "ready"}>
570
- <span>
571
- {t().resultCount({ count: resultItems().length })} <span aria-hidden="true">•</span>{" "}
572
- <Show when={props.onHelp}>
573
- <button type="button" class="text-blue-500 hover:underline dark:text-blue-400" onClick={() => props.onHelp?.()}>
574
- {t().tagHelp}
575
- </button>
448
+ </label>
449
+ <ScrollArea ref={bodyRef} class="cloud-resource-search__body" aria-busy={pending() && !choosingTag()}>
450
+ <Show
451
+ when={choosingTag()}
452
+ fallback={
453
+ <>
454
+ <Show when={props.navigationError}>
455
+ {(message) => (
456
+ <p role="alert" class="cloud-resource-search__hint">
457
+ {message()}
458
+ </p>
459
+ )}
460
+ </Show>
461
+ <Show when={searchQuery.error()}>
462
+ <div class="cloud-resource-search__hint" role="status">
463
+ {t().searchFailed}{" "}
464
+ <Button variant="text" size="xs" onClick={() => void searchQuery.refresh()}>
465
+ {t().retry}
466
+ </Button>
467
+ </div>
468
+ </Show>
469
+ <Show when={fresh() && response()?.failedApps?.length}>
470
+ <p class="cloud-resource-search__hint" role="status">
471
+ {t().partialFailure}
472
+ </p>
473
+ </Show>
474
+ <Show when={!canSearch() && !commandMode()}>
475
+ <div class="cloud-resource-search__idle">
476
+ <p>{props.selectionMode ? t().pickerHint : props.searchResources === false ? t().navigationHint : t().startHint}</p>
477
+ <div class="cloud-resource-search__quick" aria-busy={!response() && !searchQuery.error()}>
478
+ <Show
479
+ when={response() || searchQuery.error()}
480
+ fallback={<For each={[0, 1, 2]}>{() => <span class="cloud-resource-search__tag-skeleton" aria-hidden="true" />}</For>}
481
+ >
482
+ <For each={quickTags()}>
483
+ {(tag) => (
484
+ <Button variant="subtle" size="sm" onClick={() => addTag(tag.tag)}>
485
+ #{tag.tag}
486
+ </Button>
487
+ )}
488
+ </For>
576
489
  </Show>
577
- </span>
578
- </Show>
579
- </div>
580
- </div>
581
- </Show>
582
-
583
- <div class="min-h-0 flex-1 overflow-hidden">
584
- <Show when={searchQuery.error()}>
585
- {(error) => (
586
- <NoticeCard tone="danger" icon={false} class="mb-2">
587
- {error().message}
588
- </NoticeCard>
589
- )}
590
- </Show>
591
-
592
- {/* Suggestions: empty input, show available tags as clickable chips. */}
593
- <Show when={bodyMode() === "suggestions"}>
594
- <ScrollArea class="flex h-full min-h-0 flex-col gap-3 pr-1">
595
- <p class="text-xs text-dimmed">
596
- {t().typeToSearchBeforeTag} <code class="rounded bg-zinc-100 px-1 py-0.5 text-[10px] dark:bg-zinc-900">#tag</code>{" "}
597
- {t().typeToSearchAfterTag}
490
+ <Show when={!response() || catalog().length > 0}>
491
+ <Button
492
+ variant="ghost"
493
+ size="sm"
494
+ onClick={() => {
495
+ setBrowsingTags(true);
496
+ focusInput();
497
+ }}
498
+ >
499
+ {t().allTags}
500
+ </Button>
501
+ </Show>
502
+ <Show when={props.onCommand && !props.selectionMode}>
503
+ <Button
504
+ variant="ghost"
505
+ size="sm"
506
+ onClick={() => {
507
+ setInput(">");
508
+ setCaret(1);
509
+ focusInput();
510
+ }}
511
+ >
512
+ {t().actions}
513
+ </Button>
514
+ </Show>
515
+ </div>
516
+ </div>
517
+ </Show>
518
+ <Show when={commandMode()}>
519
+ <p class="cloud-resource-search__hint">{t().commandHint}</p>
520
+ </Show>
521
+ <Show when={props.commandsError && commandMode()}>
522
+ <p role="status" class="cloud-resource-search__hint">
523
+ {t().commandFailed}
598
524
  </p>
599
- <Show when={tagSuggestions().length > 0} fallback={<p class="text-xs text-dimmed">{t().noTagsAvailable}</p>}>
600
- <div class="flex flex-wrap gap-1">
601
- <For each={tagSuggestions()}>
602
- {(suggestion) => (
525
+ </Show>
526
+ <Show when={showList()}>
527
+ <div class="cloud-resource-search__count" aria-live="polite">
528
+ {t().resultCount({ count: items().length })}
529
+ </div>
530
+ <section id={`${id}-options`} role="listbox" aria-label={t().searchCloudResources} class="cloud-resource-search__results">
531
+ <For each={items()}>
532
+ {(item, index) => (
533
+ <>
534
+ <Show when={index() === 0 || items()[index() - 1]?.appId !== item.appId}>
535
+ <div class="cloud-resource-search__group" role="presentation">
536
+ {item.appName}
537
+ </div>
538
+ </Show>
603
539
  <button
604
540
  type="button"
605
- onClick={() => insertTagAtCursor(suggestion.tag)}
606
- class="inline-flex items-center gap-1 rounded-full bg-zinc-100/80 px-2 py-0.5 text-[10px] leading-4 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"
607
- title={t().addTag({ tag: suggestion.tag, app: suggestion.appName })}
541
+ id={`${id}-result-${index()}`}
542
+ data-result-index={index()}
543
+ role="option"
544
+ aria-selected={props.selectionMode ? selectedKey() === itemKey(item) : activeIndex() === index()}
545
+ aria-disabled={!selectable(item)}
546
+ class="cloud-resource-search__result"
547
+ classList={{
548
+ "is-active": activeIndex() === index(),
549
+ "is-selected": props.selectionMode && selectedKey() === itemKey(item),
550
+ }}
551
+ onFocus={() => selectIndex(index())}
552
+ onMouseEnter={() => {
553
+ if (!props.selectionMode) selectIndex(index());
554
+ }}
555
+ onClick={(event) => {
556
+ if ((event.metaKey || event.ctrlKey) && props.onOpenInNewTab && !props.selectionMode) {
557
+ if (selectable(item)) openNewTab(item);
558
+ } else chooseItem(item);
559
+ }}
608
560
  >
609
- <i class={`${suggestion.appIcon} text-[10px] text-dimmed`} />
610
- <span>#{suggestion.tag}</span>
561
+ <i class={item.icon ?? item.appIcon} aria-hidden="true" />
562
+ <span class="cloud-resource-search__result-copy">
563
+ <span>{item.title}</span>
564
+ <small>{item.preview ?? item.appName}</small>
565
+ </span>
566
+ <Show when={commandFor(item)?.shortcut}>
567
+ {(shortcut) => <kbd class="shrink-0 text-xs text-dimmed">{shortcutLabel(shortcut())}</kbd>}
568
+ </Show>
569
+ <Show when={props.selectionMode && selectedKey() === itemKey(item)}>
570
+ <i class="ti ti-check" aria-hidden="true" />
571
+ </Show>
611
572
  </button>
612
- )}
613
- </For>
614
- </div>
615
- </Show>
616
- <Show when={props.onHelp}>
573
+ </>
574
+ )}
575
+ </For>
576
+ </section>
577
+ </Show>
578
+ <Show
579
+ when={
580
+ (canSearch() || commandMode()) &&
581
+ !items().length &&
582
+ !pending() &&
583
+ fresh() &&
584
+ !searchQuery.error() &&
585
+ !response()?.failedApps?.length
586
+ }
587
+ >
588
+ <p class="cloud-resource-search__hint" role="status">
589
+ {unknownTags().length ? t().unsupportedTags : t().noMatches}
590
+ </p>
591
+ </Show>
592
+ </>
593
+ }
594
+ >
595
+ <div class="cloud-resource-search__tag-heading">
596
+ <span>{t().tagSuggestions}</span>
597
+ <Button variant="text" size="xs" onClick={leaveTags}>
598
+ {t().backToSearch}
599
+ </Button>
600
+ </div>
601
+ <Show
602
+ when={suggestions().length > 0}
603
+ fallback={
604
+ <p class="cloud-resource-search__hint">
605
+ {searchQuery.loading() ? t().loadingTags : searchQuery.error() ? t().searchFailed : t().noTags}
606
+ </p>
607
+ }
608
+ >
609
+ <div role="listbox" id={`${id}-options`} aria-label={t().tagSuggestions} class="cloud-resource-search__tags">
610
+ <For each={suggestions()}>
611
+ {(tag, index) => (
617
612
  <button
618
613
  type="button"
619
- class="self-start text-[11px] text-blue-500 hover:underline dark:text-blue-400"
620
- onClick={() => props.onHelp?.()}
614
+ role="option"
615
+ id={`${id}-tag-${index()}`}
616
+ data-tag-index={index()}
617
+ aria-selected={tagIndex() === index()}
618
+ class="cloud-resource-search__tag-option"
619
+ classList={{ "is-active": tagIndex() === index() }}
620
+ onFocus={() => setTagIndex(index())}
621
+ onMouseEnter={() => setTagIndex(index())}
622
+ onClick={() => addTag(tag.tag)}
621
623
  >
622
- {t().seeAllTagDescriptions}
624
+ <i class={tag.appIcon} aria-hidden="true" />
625
+ <span>
626
+ <strong>{tag.title}</strong>
627
+ <small>{tag.description}</small>
628
+ </span>
629
+ <code>#{tag.tag}</code>
623
630
  </button>
631
+ )}
632
+ </For>
633
+ </div>
634
+ </Show>
635
+ </Show>
636
+ </ScrollArea>
637
+ <Show when={showResults() || (choosingTag() && suggestions().length > 0)}>
638
+ <footer class="cloud-resource-search__footer">
639
+ <Show when={showResults()}>
640
+ <Button
641
+ class="cloud-resource-search__details"
642
+ disabled={!previewItem()}
643
+ variant="text"
644
+ size="xs"
645
+ onClick={() => {
646
+ setMobileDetails(true);
647
+ queueMicrotask(() => backRef?.focus());
648
+ }}
649
+ >
650
+ {t().details}
651
+ </Button>
652
+ </Show>
653
+ <Show
654
+ when={props.selectionMode && !choosingTag()}
655
+ fallback={
656
+ <div class="cloud-resource-search__keys">
657
+ <span>↑ ↓ {t().navigate}</span>
658
+ <span>↵ {choosingTag() ? t().select : t().open}</span>
659
+ <Show
660
+ when={
661
+ props.onOpenInNewTab &&
662
+ !props.selectionMode &&
663
+ !choosingTag() &&
664
+ (!activeItem() || !commandFor(activeItem()!) || isLinkableCommand(commandFor(activeItem()!)!))
665
+ }
666
+ >
667
+ <span>⌘/Ctrl ↵ {t().newTab}</span>
624
668
  </Show>
625
- </ScrollArea>
626
- </Show>
627
-
628
- {/* Unsupported tags: response told us no app handles them. */}
629
- <Show when={bodyMode() === "unsupported-tags"}>
630
- <div class="flex h-full min-h-0 flex-col items-center justify-center gap-3 text-center">
631
- <i class="ti ti-tag-off text-2xl text-dimmed" />
632
- <div class="flex flex-col gap-1">
633
- <p class="text-xs">
634
- {t().noAppSupports}{" "}
635
- <For each={unsupportedTags()}>
636
- {(tag, index) => (
637
- <>
638
- <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">
639
- #{tag}
640
- </code>
641
- <Show when={index() < unsupportedTags().length - 1}> </Show>
642
- </>
643
- )}
644
- </For>
645
- .
646
- </p>
647
- <p class="text-[11px] text-dimmed">{t().removeTagOrPick}</p>
648
- </div>
649
- <div class="flex flex-wrap items-center justify-center gap-1.5">
650
- <For each={unsupportedTags()}>
651
- {(tag) => (
652
- <button
653
- type="button"
654
- onClick={() => removeTag(tag)}
655
- 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"
656
- >
657
- <span>{t().removeNamedTag({ tag })}</span>
658
- <i class="ti ti-x text-[10px]" />
659
- </button>
660
- )}
661
- </For>
662
- </div>
663
669
  </div>
670
+ }
671
+ >
672
+ <span class="cloud-resource-search__selection">{selectedItem()?.title ?? t().chooseFirst}</span>
673
+ <Button
674
+ size="sm"
675
+ disabled={!selectedItem() || !selectable(selectedItem()!)}
676
+ onClick={() => {
677
+ const item = selectedItem();
678
+ if (item && selectable(item)) props.onSelect(item);
679
+ }}
680
+ >
681
+ {t().add}
682
+ </Button>
683
+ </Show>
684
+ </footer>
685
+ </Show>
686
+ <Show when={showResults() && previewItem()}>
687
+ {(item) => (
688
+ <ScrollArea class="cloud-resource-search__preview">
689
+ <Button
690
+ ref={backRef}
691
+ variant="text"
692
+ size="xs"
693
+ class="cloud-resource-search__back"
694
+ onClick={() => {
695
+ setMobileDetails(false);
696
+ focusInput();
697
+ }}
698
+ >
699
+ ← {t().back}
700
+ </Button>
701
+ <div class="cloud-resource-search__preview-app">
702
+ <i class={item().icon ?? item().appIcon} aria-hidden="true" />
703
+ {item().appName}
704
+ </div>
705
+ <h2>{item().title}</h2>
706
+ <Show when={item().previewUrl?.startsWith("/") && !previewFailed()}>
707
+ <img class="cloud-resource-search__image" src={item().previewUrl} alt="" onError={() => setPreviewFailed(true)} />
664
708
  </Show>
665
-
666
- {/* No matches at all (query/tags valid, just empty result set). */}
667
- <Show when={bodyMode() === "no-results"}>
668
- <div class="flex h-full min-h-0 flex-col items-center justify-center gap-2 text-center">
669
- <i class="ti ti-mood-empty text-2xl text-dimmed" />
670
- <p class="text-xs text-dimmed">
671
- {t().noMatches}
672
- {parsedInput().query.length > 0 ? (
709
+ <Show when={previewMetadata().length > 0}>
710
+ <dl>
711
+ <For each={previewMetadata()}>
712
+ {(entry) => (
673
713
  <>
674
- {" "}
675
- {t().matchesFor} <span class="text-zinc-700 dark:text-zinc-200">"{parsedInput().query}"</span>
714
+ <dt>{entry.label}</dt>
715
+ <dd>{entry.value}</dd>
676
716
  </>
677
- ) : null}
678
- .
679
- </p>
680
- </div>
717
+ )}
718
+ </For>
719
+ </dl>
681
720
  </Show>
682
-
683
- {/* Ready: the left result list; the detail spans both grid rows below. */}
684
- <Show when={bodyMode() === "ready"}>
685
- <div class="h-full min-h-0">
686
- <section class="h-full min-h-0 overflow-y-auto overscroll-y-contain pr-1" onWheel={(event) => event.stopPropagation()}>
687
- <div class="flex flex-col">
688
- <For each={listEntries()}>
689
- {(entry) => {
690
- if (entry.kind === "header") {
691
- // Faint section header — no <hr>, no border. First
692
- // header gets less top spacing so it doesn't shove
693
- // the list down on group transitions.
694
- return (
695
- <div class="flex items-center gap-1.5 px-1 pt-3 pb-1 text-[10px] uppercase tracking-wide text-dimmed first:pt-1">
696
- <i class={`${entry.appIcon} text-[11px]`} />
697
- <span>{entry.appName}</span>
698
- <span class="opacity-60">· {entry.count}</span>
699
- </div>
700
- );
701
- }
702
- const item = entry.item;
703
- const selected = () => entry.flatIndex === activeIndex();
704
- return (
705
- <button
706
- ref={(element) => bindRowRef(rowKey(item), element)}
707
- type="button"
708
- onMouseEnter={() => setActiveIndex(entry.flatIndex)}
709
- onClick={() => selectItem(item)}
710
- class="mt-1.5 w-full rounded-xl p-2.5 text-left transition-colors first:mt-0"
711
- classList={{
712
- "bg-blue-50/85 dark:bg-blue-950/45": selected(),
713
- "bg-zinc-50/75 hover:bg-zinc-100/85 dark:bg-zinc-900/45 dark:hover:bg-zinc-900/65": !selected(),
714
- }}
715
- >
716
- <div class="flex items-start gap-2.5">
717
- <i class={`${item.icon ?? item.appIcon} mt-0.5 text-[13px] text-dimmed`} />
718
- <div class="min-w-0">
719
- <p class="truncate text-xs">{item.title}</p>
720
- <Show when={item.preview}>
721
- <p class="mt-0.5 truncate text-[11px] text-dimmed">{item.preview}</p>
722
- </Show>
723
- <p class="mt-1 text-[10px] text-dimmed">{item.appName}</p>
724
- </div>
725
- </div>
726
- </button>
727
- );
728
- }}
729
- </For>
730
- </div>
731
- </section>
732
- </div>
721
+ <Show when={item().preview}>
722
+ <p>{item().preview}</p>
733
723
  </Show>
734
- </div>
735
- </div>
736
- </div>
737
-
738
- <Show when={bodyMode() === "ready"}>
739
- <aside
740
- 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:col-start-2 md:row-start-1 md:row-span-2 md:my-3 md:mr-3 md:block"
741
- onWheel={(event) => event.stopPropagation()}
742
- >
743
- <Show when={activeItem()} fallback={<div class="text-xs text-dimmed">{t().selectPreview}</div>}>
744
- {(item) => (
745
- <div class="flex flex-col gap-4">
746
- <div class="flex items-center gap-3">
747
- <div class="grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-900">
748
- <Show
749
- when={isValidImagePreviewUrl(item().previewUrl) && !previewFailed()}
750
- fallback={<i class={`${item().icon ?? item().appIcon} text-lg text-dimmed`} />}
751
- >
752
- <img
753
- src={item().previewUrl}
754
- alt={item().title}
755
- class="h-full w-full object-cover"
756
- onError={() => setPreviewFailed(true)}
757
- />
758
- </Show>
759
- </div>
760
- <div class="min-w-0">
761
- <p class="truncate text-sm">{item().title}</p>
762
- <p class="mt-0.5 truncate text-xs text-dimmed">{item().appName}</p>
763
- </div>
764
- </div>
765
-
766
- <Show when={item().preview}>
767
- <p class="text-xs leading-relaxed text-dimmed">{item().preview}</p>
768
- </Show>
769
-
770
- <Show when={metadataRows(item().metadata).length > 0}>
771
- <div class="rounded-lg bg-zinc-100/65 p-2 dark:bg-zinc-900/65">
772
- <div class="flex flex-col gap-1">
773
- <For each={metadataRows(item().metadata)}>
774
- {(entry) => (
775
- <div class="grid grid-cols-[7rem_minmax(0,1fr)] items-center gap-2 rounded-md px-1 py-1 text-xs">
776
- <span class="truncate text-dimmed">{entry.label}</span>
777
- <span class="truncate">{entry.value}</span>
778
- </div>
779
- )}
780
- </For>
781
- </div>
782
- </div>
783
- </Show>
784
- </div>
785
- )}
786
- </Show>
787
- </aside>
724
+ </ScrollArea>
725
+ )}
788
726
  </Show>
789
727
  </div>
790
728
  );