@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.
- package/README.md +2 -2
- package/package.json +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +66 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +5 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +114 -34
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +107 -84
- package/src/services/pdf/index.ts +3 -0
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +7 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +2 -0
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- 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 {
|
|
3
|
-
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
4
|
-
import type {
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
43
|
+
const group = groups.get(item.appId) ?? [];
|
|
44
|
+
group.push(item);
|
|
45
|
+
groups.set(item.appId, group);
|
|
61
46
|
}
|
|
62
|
-
|
|
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
|
|
149
|
-
const [
|
|
150
|
-
const [
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (event.key === "ArrowDown") {
|
|
293
|
+
if (event.isComposing) return;
|
|
294
|
+
if (event.key === "Escape") {
|
|
295
|
+
if (mobileDetails()) {
|
|
339
296
|
event.preventDefault();
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
if (event.key === "ArrowUp") {
|
|
297
|
+
event.stopPropagation();
|
|
298
|
+
setMobileDetails(false);
|
|
299
|
+
focusInput();
|
|
300
|
+
} else if (choosingTag()) {
|
|
345
301
|
event.preventDefault();
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return;
|
|
302
|
+
event.stopPropagation();
|
|
303
|
+
leaveTags();
|
|
349
304
|
}
|
|
350
|
-
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (choosingTag()) {
|
|
308
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
351
309
|
event.preventDefault();
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
341
|
+
setTags((prev) => prev.slice(0, -1));
|
|
394
342
|
}
|
|
395
343
|
};
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
<
|
|
455
|
-
<div class="
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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={
|
|
610
|
-
<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
|
-
|
|
614
|
-
</
|
|
615
|
-
</
|
|
616
|
-
|
|
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
|
-
|
|
620
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
714
|
+
<dt>{entry.label}</dt>
|
|
715
|
+
<dd>{entry.value}</dd>
|
|
676
716
|
</>
|
|
677
|
-
)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
</div>
|
|
717
|
+
)}
|
|
718
|
+
</For>
|
|
719
|
+
</dl>
|
|
681
720
|
</Show>
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
</
|
|
735
|
-
|
|
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
|
);
|