@k2b/cloud 0.38.0 → 0.39.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/package.json +6 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
export const composerInsertMessages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
insertFromCloud: "Insert from Cloud",
|
|
8
|
+
insertFromCloudHint: "Find something in your apps and insert it",
|
|
9
|
+
insertTag: ({ tag }: { tag: string }) => `Insert ${tag}`,
|
|
10
|
+
chooseFrom: ({ app }: { app: string }) => `Choose from ${app}`,
|
|
11
|
+
tagFromApp: ({ tag, app }: { tag: string; app: string }) => `${tag} from ${app}`,
|
|
12
|
+
searchTagFor: ({ tag, query }: { tag: string; query: string }) => `Search ${tag} for “${query}”`,
|
|
13
|
+
searchTag: ({ tag }: { tag: string }) => `Search ${tag}`,
|
|
14
|
+
fromAppWithPreview: ({ app }: { app: string }) => `From ${app}, with preview`,
|
|
15
|
+
searchCloudFor: ({ query }: { query: string }) => `Search Cloud for “${query}”`,
|
|
16
|
+
searchAllApps: "Search all apps for content",
|
|
17
|
+
},
|
|
18
|
+
de: {
|
|
19
|
+
insertFromCloud: "Aus Cloud einfügen",
|
|
20
|
+
insertFromCloudHint: "Etwas aus deinen Apps suchen und einfügen",
|
|
21
|
+
insertTag: ({ tag }) => `${tag} einfügen`,
|
|
22
|
+
chooseFrom: ({ app }) => `Aus ${app} wählen`,
|
|
23
|
+
tagFromApp: ({ tag, app }) => `${tag} aus ${app}`,
|
|
24
|
+
searchTagFor: ({ tag, query }) => `„${query}“ in ${tag} suchen`,
|
|
25
|
+
searchTag: ({ tag }) => `${tag} durchsuchen`,
|
|
26
|
+
fromAppWithPreview: ({ app }) => `Aus ${app}, mit Vorschau`,
|
|
27
|
+
searchCloudFor: ({ query }) => `„${query}“ in Cloud suchen`,
|
|
28
|
+
searchAllApps: "In allen Apps nach Inhalten suchen",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ChatAttachment, ChatCommand, ChatCommandResult, ChatCommandScope } from "@k2b/ui";
|
|
2
|
+
import type { CloudResourceRef } from "../contracts";
|
|
3
|
+
import type { CloudSearchTag } from "./composer-catalog";
|
|
4
|
+
import { composerInsertMessages } from "./composer-insert-messages";
|
|
5
|
+
import { type CloudResourcePickerItem, openCloudResourcePicker } from "./resource-picker";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
type CloudComposerCatalog,
|
|
9
|
+
type CloudSearchTag,
|
|
10
|
+
createCloudComposerCatalog,
|
|
11
|
+
loadCloudComposerCatalog,
|
|
12
|
+
} from "./composer-catalog";
|
|
13
|
+
|
|
14
|
+
export type CloudInsertMenuOptions = {
|
|
15
|
+
locale: string;
|
|
16
|
+
/** Every app's search tags, from `loadCloudComposerCatalog`. */
|
|
17
|
+
tags: readonly CloudSearchTag[];
|
|
18
|
+
/** Section of the rows in the composer's slash list. */
|
|
19
|
+
section?: string;
|
|
20
|
+
/** Turns the chosen resource into the reference the composer puts where `/…` was. */
|
|
21
|
+
mention: (item: CloudResourcePickerItem) => ChatAttachment;
|
|
22
|
+
/** References the draft already holds; the picker leaves them out. Read when the picker opens. */
|
|
23
|
+
excludeRefs?: () => readonly CloudResourceRef[];
|
|
24
|
+
/** Why nothing can be inserted now, such as a full message; it replaces each row's description. */
|
|
25
|
+
reason?: string;
|
|
26
|
+
/** First words of the composer's own scopes. A tag never claims them, so they keep narrowing as before. */
|
|
27
|
+
reservedWords?: readonly string[];
|
|
28
|
+
/** More first words for one tag, keyed `appId:tag`, such as a short word an app uses for its own tag. */
|
|
29
|
+
scopeWords?: Readonly<Record<string, readonly string[]>>;
|
|
30
|
+
/**
|
|
31
|
+
* Tags, keyed `appId:tag`, whose resources of one type this composer reads itself, such as
|
|
32
|
+
* `{ "assistant:studio-app": "assistant.app" }`. Their picker lists only that type, also without a Cloud reader, even
|
|
33
|
+
* after the person removes the scope chip. Every other picker lists only resources with a reader (`requireReader`).
|
|
34
|
+
*/
|
|
35
|
+
ownReaders?: Readonly<Record<string, string>>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CloudInsertMenu = {
|
|
39
|
+
/** "Insert from Cloud": the picker across all apps. */
|
|
40
|
+
insert: ChatCommand;
|
|
41
|
+
/** One row per search tag, only while searching. Enter opens the picker narrowed to the tag; Tab narrows the list. */
|
|
42
|
+
tags: ChatCommand[];
|
|
43
|
+
/** One scope per tag that keeps a first word of its own, titled like "Notes from Notebooks". */
|
|
44
|
+
scopes: ChatCommandScope[];
|
|
45
|
+
/** For the composer's `commandFallback`: "Search Notes for “q3”" in a tag scope, "Search Cloud for “q3”" otherwise. */
|
|
46
|
+
fallback: (search: { query: string; scope?: string }) => ChatCommand | undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const fold = (text: string) => text.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/ß/g, "ss");
|
|
50
|
+
const tagKey = (tag: Pick<CloudSearchTag, "appId" | "tag">) => `${tag.appId}:${tag.tag}`;
|
|
51
|
+
const scopeId = (tag: Pick<CloudSearchTag, "appId" | "tag">) => `cloud-tag:${tagKey(tag)}`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Slash-list rows that insert Cloud content the person can read. Every row opens the resource picker, narrowed on the
|
|
55
|
+
* server to an app and tag when it has one, and hands the chosen item back as a `mention` for the token's place.
|
|
56
|
+
*/
|
|
57
|
+
export const cloudInsertMenu = (options: CloudInsertMenuOptions): CloudInsertMenu => {
|
|
58
|
+
const t = composerInsertMessages.resolve([options.locale]).t;
|
|
59
|
+
const pick = async (tag: CloudSearchTag | undefined, query: string): Promise<ChatCommandResult> => {
|
|
60
|
+
const ownType = tag ? options.ownReaders?.[tagKey(tag)] : undefined;
|
|
61
|
+
const item = await openCloudResourcePicker({
|
|
62
|
+
title: tag ? t.insertTag({ tag: tag.title }) : t.insertFromCloud,
|
|
63
|
+
scope: tag ? { appId: tag.appId, tag: tag.tag, label: `${tag.title} · ${tag.appName}`, icon: tag.appIcon } : undefined,
|
|
64
|
+
query: query || undefined,
|
|
65
|
+
...(ownType ? { resourceType: ownType } : { requireReader: true }),
|
|
66
|
+
excludeRefs: options.excludeRefs?.(),
|
|
67
|
+
});
|
|
68
|
+
return item ? { mention: options.mention(item) } : "cancelled";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const reserved = new Set((options.reservedWords ?? []).map(fold));
|
|
72
|
+
// The title comes first, so Tab writes the word the person reads ("notizen ").
|
|
73
|
+
const candidates = options.tags.map((tag) => {
|
|
74
|
+
const title = fold(tag.title);
|
|
75
|
+
return [
|
|
76
|
+
...new Set(
|
|
77
|
+
[...(/\s/.test(title) ? [] : [title]), tag.tag, ...tag.aliases, ...(options.scopeWords?.[tagKey(tag)] ?? [])]
|
|
78
|
+
.map(fold)
|
|
79
|
+
.filter((word) => word && !/\s/.test(word) && !reserved.has(word)),
|
|
80
|
+
),
|
|
81
|
+
];
|
|
82
|
+
});
|
|
83
|
+
// A word two tags share narrows to neither, so Tab on either row would leave the list unscoped; neither keeps it.
|
|
84
|
+
const uses = new Map<string, number>();
|
|
85
|
+
for (const word of candidates.flat()) uses.set(word, (uses.get(word) ?? 0) + 1);
|
|
86
|
+
const scoped = new Map<string, CloudSearchTag>();
|
|
87
|
+
const scopes: ChatCommandScope[] = [];
|
|
88
|
+
const tags = options.tags.map((tag, index): ChatCommand => {
|
|
89
|
+
const words = candidates[index]!.filter((word) => uses.get(word) === 1);
|
|
90
|
+
if (words.length) {
|
|
91
|
+
scoped.set(scopeId(tag), tag);
|
|
92
|
+
scopes.push({ id: scopeId(tag), title: t.tagFromApp({ tag: tag.title, app: tag.appName }), words });
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id: scopeId(tag),
|
|
96
|
+
section: options.section,
|
|
97
|
+
name: tag.tag,
|
|
98
|
+
label: tag.title,
|
|
99
|
+
description: t.chooseFrom({ app: tag.appName }),
|
|
100
|
+
icon: tag.appIcon,
|
|
101
|
+
keywords: [tag.title, tag.appName, tag.tag, ...tag.aliases, ...(options.scopeWords?.[tagKey(tag)] ?? [])],
|
|
102
|
+
app: tag.appName,
|
|
103
|
+
scope: words.length ? scopeId(tag) : undefined,
|
|
104
|
+
hiddenUntilSearch: true,
|
|
105
|
+
reason: options.reason,
|
|
106
|
+
action: () => pick(tag, ""),
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
insert: {
|
|
112
|
+
id: "cloud-insert",
|
|
113
|
+
section: options.section,
|
|
114
|
+
name: "cloud",
|
|
115
|
+
label: t.insertFromCloud,
|
|
116
|
+
description: t.insertFromCloudHint,
|
|
117
|
+
icon: "ti ti-cloud-search",
|
|
118
|
+
keywords: ["cloud", "insert", "einfügen", "inhalt", "content", "ressource", "resource", "verweis", "reference", "link"],
|
|
119
|
+
reason: options.reason,
|
|
120
|
+
action: () => pick(undefined, ""),
|
|
121
|
+
},
|
|
122
|
+
tags,
|
|
123
|
+
scopes,
|
|
124
|
+
fallback: ({ query, scope }) => {
|
|
125
|
+
if (scope !== undefined) {
|
|
126
|
+
const tag = scoped.get(scope);
|
|
127
|
+
if (!tag) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
id: `cloud-search:${tagKey(tag)}`,
|
|
130
|
+
name: `cloud-search:${tagKey(tag)}`,
|
|
131
|
+
label: query ? t.searchTagFor({ tag: tag.title, query }) : t.searchTag({ tag: tag.title }),
|
|
132
|
+
description: t.fromAppWithPreview({ app: tag.appName }),
|
|
133
|
+
icon: "ti ti-search",
|
|
134
|
+
reason: options.reason,
|
|
135
|
+
action: () => pick(tag, query),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (!query) return undefined;
|
|
139
|
+
return {
|
|
140
|
+
id: "cloud-search",
|
|
141
|
+
name: "cloud-search",
|
|
142
|
+
label: t.searchCloudFor({ query }),
|
|
143
|
+
description: t.searchAllApps,
|
|
144
|
+
icon: "ti ti-search",
|
|
145
|
+
reason: options.reason,
|
|
146
|
+
action: () => pick(undefined, query),
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
|
|
3
|
+
export function isPanelReadyMessage(value: unknown, provider: string): value is { type: "cloud.panel.ready"; v: 1; provider: string } {
|
|
4
|
+
return (
|
|
5
|
+
isRecord(value) &&
|
|
6
|
+
Reflect.ownKeys(value).length === 3 &&
|
|
7
|
+
Object.hasOwn(value, "type") &&
|
|
8
|
+
Object.hasOwn(value, "v") &&
|
|
9
|
+
Object.hasOwn(value, "provider") &&
|
|
10
|
+
value.type === "cloud.panel.ready" &&
|
|
11
|
+
value.v === 1 &&
|
|
12
|
+
typeof value.provider === "string" &&
|
|
13
|
+
value.provider === provider
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isPanelCloseMessage(value: unknown): value is { type: "cloud.panel.close"; v?: 1 } {
|
|
18
|
+
return (
|
|
19
|
+
isRecord(value) &&
|
|
20
|
+
Object.hasOwn(value, "type") &&
|
|
21
|
+
value.type === "cloud.panel.close" &&
|
|
22
|
+
(Reflect.ownKeys(value).length === 1 || (Reflect.ownKeys(value).length === 2 && Object.hasOwn(value, "v") && value.v === 1))
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { attachResizeSeparator, parkWorkspaceDetailDrawers } from "@k2b/ui";
|
|
2
|
+
import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
|
|
3
|
+
import { platformMessages } from "../ssr/platform-messages";
|
|
4
|
+
import { readShellData } from "../ssr/shell-data";
|
|
5
|
+
import type { PanelRequest } from "./panel";
|
|
6
|
+
import { isPanelCloseMessage, isPanelReadyMessage } from "./panel-messages";
|
|
7
|
+
|
|
8
|
+
export const PANEL_READY_TIMEOUT_MS = 10_000;
|
|
9
|
+
let installed: { dispose: () => void; request: (request: PanelRequest) => void } | undefined;
|
|
10
|
+
|
|
11
|
+
export function handlePanelRequest(request: PanelRequest): void {
|
|
12
|
+
installed?.request(request);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Owns the slot's single iframe, independently persisted desktop and transient phone state. */
|
|
16
|
+
export function installPanelSlot(): () => void {
|
|
17
|
+
if (installed) return installed.dispose;
|
|
18
|
+
const canvas = document.querySelector<HTMLElement>(".cloud-app-canvas");
|
|
19
|
+
const slot = document.getElementById("cloud-panel");
|
|
20
|
+
const body = slot?.querySelector<HTMLElement>(".cloud-panel__body");
|
|
21
|
+
const panel = readShellData()?.panel;
|
|
22
|
+
if (!canvas || !slot || !body || !panel?.providers.length) return () => {};
|
|
23
|
+
const t = platformMessages.resolve([document.documentElement.lang || "en"]).t;
|
|
24
|
+
// The mode uses the same px breakpoint as panel.css and the shell, read fresh, so CSS and the controller always
|
|
25
|
+
// agree; `phoneChange` only tells when to look again.
|
|
26
|
+
const isPhone = () => !window.matchMedia("(min-width: 768px)").matches;
|
|
27
|
+
const phoneChange = window.matchMedia("(max-width: 767.98px)");
|
|
28
|
+
const column = window.matchMedia("(min-width: 1024px)");
|
|
29
|
+
const attribute = () => (isPhone() ? "data-panel-sheet" : "data-panel");
|
|
30
|
+
const isOpen = () => canvas.getAttribute(attribute()) === "open";
|
|
31
|
+
const status = slot.querySelector<HTMLElement>("[data-cloud-panel-status]");
|
|
32
|
+
const separator = slot.querySelector<HTMLElement>(".cloud-panel__resize");
|
|
33
|
+
const tabs = [...slot.querySelectorAll<HTMLButtonElement>("[data-cloud-panel-provider]")];
|
|
34
|
+
const grow = slot.querySelector<HTMLButtonElement>("[data-cloud-panel-grow]");
|
|
35
|
+
let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
|
|
36
|
+
let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
|
|
37
|
+
let search: string | undefined;
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
let opener: HTMLElement | undefined;
|
|
40
|
+
const rem = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
41
|
+
const storedWidth = parseFloat(canvas.style.getPropertyValue("--cloud-panel-width-set"));
|
|
42
|
+
let committedWidth = Number.isFinite(storedWidth) ? storedWidth : undefined;
|
|
43
|
+
let width = committedWidth ?? PANEL_MIN_WIDTH_REM;
|
|
44
|
+
const setWidth = (value: number) => {
|
|
45
|
+
width = value;
|
|
46
|
+
canvas.style.setProperty("--cloud-panel-width-set", `${value}rem`);
|
|
47
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(value * 100) / 100));
|
|
48
|
+
};
|
|
49
|
+
const seedWidth = () => {
|
|
50
|
+
if (committedWidth !== undefined || !column.matches || !isOpen()) return;
|
|
51
|
+
const measured = slot.getBoundingClientRect().width / rem();
|
|
52
|
+
if (measured > 0) width = Math.min(PANEL_MAX_WIDTH_REM, Math.max(PANEL_MIN_WIDTH_REM, measured));
|
|
53
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(width * 100) / 100));
|
|
54
|
+
};
|
|
55
|
+
// Another tab may have saved a newer width; opening or closing keeps it instead of writing back an old one.
|
|
56
|
+
const adoptStoredWidth = (apply: boolean) => {
|
|
57
|
+
const stored = readPanelCookie(
|
|
58
|
+
document.cookie,
|
|
59
|
+
panel.providers.map((item) => item.id),
|
|
60
|
+
).widthRem;
|
|
61
|
+
if (stored === undefined) return;
|
|
62
|
+
committedWidth = stored;
|
|
63
|
+
if (apply && stored !== width) setWidth(stored);
|
|
64
|
+
};
|
|
65
|
+
const writeCookie = () => {
|
|
66
|
+
if (isPhone() || canvas.hasAttribute("data-panel-transient")) return;
|
|
67
|
+
document.cookie = serializePanelCookie(
|
|
68
|
+
{ open: canvas.dataset.panel === "open", provider: provider.id, widthRem: committedWidth },
|
|
69
|
+
location.protocol === "https:",
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
const update = () => {
|
|
73
|
+
for (const button of document.querySelectorAll("[data-cloud-panel-toggle]")) button.setAttribute("aria-expanded", String(isOpen()));
|
|
74
|
+
for (const tab of tabs) {
|
|
75
|
+
const selected = tab.dataset.cloudPanelProvider === provider.id;
|
|
76
|
+
tab.setAttribute("aria-selected", String(selected));
|
|
77
|
+
tab.tabIndex = selected ? 0 : -1;
|
|
78
|
+
}
|
|
79
|
+
const description = slot.querySelector<HTMLElement>(".k2b-placeholder__description");
|
|
80
|
+
if (description) description.textContent = t.panelNotResponding({ label: provider.label });
|
|
81
|
+
};
|
|
82
|
+
const clearTimer = () => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
timer = undefined;
|
|
85
|
+
};
|
|
86
|
+
const loading = () => {
|
|
87
|
+
clearTimer();
|
|
88
|
+
slot.dataset.state = "loading";
|
|
89
|
+
if (status) status.textContent = t.panelLoading({ label: provider.label });
|
|
90
|
+
timer = setTimeout(() => {
|
|
91
|
+
slot.dataset.state = "failed";
|
|
92
|
+
if (status) status.textContent = `${t.panelLoadFailed}. ${t.panelNotResponding({ label: provider.label })}`;
|
|
93
|
+
}, PANEL_READY_TIMEOUT_MS);
|
|
94
|
+
};
|
|
95
|
+
const markReady = () => {
|
|
96
|
+
clearTimer();
|
|
97
|
+
slot.dataset.state = "ready";
|
|
98
|
+
if (status) status.textContent = "";
|
|
99
|
+
};
|
|
100
|
+
const unload = () => {
|
|
101
|
+
clearTimer();
|
|
102
|
+
frame?.remove();
|
|
103
|
+
frame = null;
|
|
104
|
+
slot.dataset.state = "closed";
|
|
105
|
+
if (status) status.textContent = "";
|
|
106
|
+
};
|
|
107
|
+
const target = () => {
|
|
108
|
+
const url = new URL(provider.href, location.origin);
|
|
109
|
+
if (search !== undefined) url.search = search;
|
|
110
|
+
return url;
|
|
111
|
+
};
|
|
112
|
+
// The page the iframe shows now; a reader may have moved on inside the panel since it loaded.
|
|
113
|
+
const shownHref = () => {
|
|
114
|
+
try {
|
|
115
|
+
return frame?.contentWindow?.location.href;
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const load = (replace = false) => {
|
|
121
|
+
const url = target();
|
|
122
|
+
if (!replace && frame?.src === url.href) return;
|
|
123
|
+
frame?.remove();
|
|
124
|
+
// The server validates every provider path; the iframe still never leaves this origin.
|
|
125
|
+
if (url.origin !== location.origin) {
|
|
126
|
+
frame = null;
|
|
127
|
+
clearTimer();
|
|
128
|
+
slot.dataset.state = "failed";
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
frame = document.createElement("iframe");
|
|
132
|
+
frame.className = "cloud-panel__frame";
|
|
133
|
+
frame.title = provider.label;
|
|
134
|
+
frame.dataset.cloudPanelFrame = provider.id;
|
|
135
|
+
frame.src = url.href;
|
|
136
|
+
loading();
|
|
137
|
+
body.append(frame);
|
|
138
|
+
};
|
|
139
|
+
let removeFallbackFocus = () => {};
|
|
140
|
+
const returnFocus = () => {
|
|
141
|
+
if (opener?.isConnected && opener.getClientRects().length && !opener.closest("[inert]")) {
|
|
142
|
+
opener.focus();
|
|
143
|
+
if (document.activeElement === opener) return;
|
|
144
|
+
}
|
|
145
|
+
const main = canvas.querySelector<HTMLElement>("[data-cloud-app-area] main.layout-content-main, main[data-cloud-app-area]");
|
|
146
|
+
if (!main) return;
|
|
147
|
+
removeFallbackFocus();
|
|
148
|
+
if (!main.hasAttribute("tabindex")) {
|
|
149
|
+
main.tabIndex = -1;
|
|
150
|
+
removeFallbackFocus = () => {
|
|
151
|
+
main.removeAttribute("tabindex");
|
|
152
|
+
main.removeEventListener("blur", removeFallbackFocus);
|
|
153
|
+
};
|
|
154
|
+
main.addEventListener("blur", removeFallbackFocus, { once: true });
|
|
155
|
+
}
|
|
156
|
+
main.focus();
|
|
157
|
+
};
|
|
158
|
+
const close = () => {
|
|
159
|
+
if (!isOpen()) return;
|
|
160
|
+
canvas.removeAttribute(attribute());
|
|
161
|
+
disposeResize();
|
|
162
|
+
disposeResize = attachResize();
|
|
163
|
+
unload();
|
|
164
|
+
adoptStoredWidth(false);
|
|
165
|
+
writeCookie();
|
|
166
|
+
update();
|
|
167
|
+
returnFocus();
|
|
168
|
+
};
|
|
169
|
+
const open = (id: string, nextSearch?: string) => {
|
|
170
|
+
const next = panel.providers.find((item) => item.id === id);
|
|
171
|
+
if (!next) return;
|
|
172
|
+
parkWorkspaceDetailDrawers(document);
|
|
173
|
+
const active = document.activeElement;
|
|
174
|
+
if (active instanceof HTMLElement && active !== document.body && !slot.contains(active)) opener = active;
|
|
175
|
+
provider = next;
|
|
176
|
+
search = typeof nextSearch === "string" && nextSearch.length <= 2048 ? nextSearch : undefined;
|
|
177
|
+
canvas.setAttribute(attribute(), "open");
|
|
178
|
+
slot.dataset.provider = provider.id;
|
|
179
|
+
adoptStoredWidth(true);
|
|
180
|
+
seedWidth();
|
|
181
|
+
// A requested search is a destination: it replaces a page reached inside the panel.
|
|
182
|
+
const shown = shownHref();
|
|
183
|
+
load(search !== undefined && shown !== undefined && shown !== "about:blank" && shown !== target().href);
|
|
184
|
+
writeCookie();
|
|
185
|
+
update();
|
|
186
|
+
frame?.focus();
|
|
187
|
+
};
|
|
188
|
+
const request = (request: PanelRequest) => {
|
|
189
|
+
if (request.action === "open") open(request.provider, request.search);
|
|
190
|
+
else if (request.action === "close" || isOpen()) close();
|
|
191
|
+
else open(provider.id, search);
|
|
192
|
+
};
|
|
193
|
+
const message = (event: MessageEvent<unknown>) => {
|
|
194
|
+
if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
|
|
195
|
+
if (isPanelReadyMessage(event.data, provider.id)) markReady();
|
|
196
|
+
else if (isPanelCloseMessage(event.data)) close();
|
|
197
|
+
};
|
|
198
|
+
const click = (event: MouseEvent) => {
|
|
199
|
+
if (!(event.target instanceof Element)) return;
|
|
200
|
+
if (event.target.closest("[data-cloud-panel-close]")) close();
|
|
201
|
+
else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
|
|
202
|
+
load(true);
|
|
203
|
+
frame?.focus();
|
|
204
|
+
} else if (event.target.closest("[data-cloud-panel-grow]") && isPhone()) {
|
|
205
|
+
const full = slot.dataset.panelSize !== "full";
|
|
206
|
+
if (full) slot.dataset.panelSize = "full";
|
|
207
|
+
else delete slot.dataset.panelSize;
|
|
208
|
+
grow?.setAttribute("aria-label", full ? t.shrinkPanel : t.enlargePanel);
|
|
209
|
+
} else {
|
|
210
|
+
const tab = event.target.closest<HTMLElement>("[data-cloud-panel-provider]");
|
|
211
|
+
if (tab?.dataset.cloudPanelProvider) open(tab.dataset.cloudPanelProvider);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const key = (event: KeyboardEvent) => {
|
|
215
|
+
if (event.defaultPrevented || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
216
|
+
if (!(event.target instanceof Element) || !slot.contains(event.target)) return;
|
|
217
|
+
if (event.key === "Escape") {
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
close();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const index = tabs.findIndex((tab) => tab === event.target);
|
|
223
|
+
if (index < 0) return;
|
|
224
|
+
let next: number;
|
|
225
|
+
if (event.key === "ArrowRight") next = (index + 1) % tabs.length;
|
|
226
|
+
else if (event.key === "ArrowLeft") next = (index + tabs.length - 1) % tabs.length;
|
|
227
|
+
else if (event.key === "Home") next = 0;
|
|
228
|
+
else if (event.key === "End") next = tabs.length - 1;
|
|
229
|
+
else return; // Buttons activate with native Enter/Space clicks.
|
|
230
|
+
event.preventDefault();
|
|
231
|
+
for (const [index, tab] of tabs.entries()) tab.tabIndex = index === next ? 0 : -1;
|
|
232
|
+
tabs[next]?.focus();
|
|
233
|
+
};
|
|
234
|
+
const modeChange = () => {
|
|
235
|
+
disposeResize();
|
|
236
|
+
if (isOpen()) {
|
|
237
|
+
seedWidth();
|
|
238
|
+
load();
|
|
239
|
+
} else unload();
|
|
240
|
+
disposeResize = attachResize();
|
|
241
|
+
update();
|
|
242
|
+
};
|
|
243
|
+
const closeSheet = () => {
|
|
244
|
+
const wasOpen = canvas.dataset.panelSheet === "open";
|
|
245
|
+
canvas.removeAttribute("data-panel-sheet");
|
|
246
|
+
if (wasOpen && isPhone()) {
|
|
247
|
+
unload();
|
|
248
|
+
update();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const pageShow = (event: PageTransitionEvent) => {
|
|
252
|
+
if (event.persisted) closeSheet();
|
|
253
|
+
};
|
|
254
|
+
const entryChange = (event: NavigationCurrentEntryChangeEvent) => {
|
|
255
|
+
if (event.navigationType === "push") closeSheet();
|
|
256
|
+
};
|
|
257
|
+
let dragWidth = width;
|
|
258
|
+
let dragStoredWidth = committedWidth;
|
|
259
|
+
let dragInlineWidth = "";
|
|
260
|
+
const restoreDragWidth = () => {
|
|
261
|
+
width = dragWidth;
|
|
262
|
+
committedWidth = dragStoredWidth;
|
|
263
|
+
if (dragInlineWidth) canvas.style.setProperty("--cloud-panel-width-set", dragInlineWidth);
|
|
264
|
+
else canvas.style.removeProperty("--cloud-panel-width-set");
|
|
265
|
+
separator?.setAttribute("aria-valuenow", String(Math.round(width * 100) / 100));
|
|
266
|
+
};
|
|
267
|
+
let endingDrag = false;
|
|
268
|
+
seedWidth();
|
|
269
|
+
const attachResize = () =>
|
|
270
|
+
separator && column.matches
|
|
271
|
+
? attachResizeSeparator(separator, {
|
|
272
|
+
min: PANEL_MIN_WIDTH_REM,
|
|
273
|
+
max: PANEL_MAX_WIDTH_REM,
|
|
274
|
+
value: () => width,
|
|
275
|
+
get unit() {
|
|
276
|
+
return rem();
|
|
277
|
+
},
|
|
278
|
+
step: 1,
|
|
279
|
+
largeStep: 4,
|
|
280
|
+
grow: "start",
|
|
281
|
+
collapseBelow: 16,
|
|
282
|
+
onResize: (value) => {
|
|
283
|
+
if (endingDrag) {
|
|
284
|
+
endingDrag = false;
|
|
285
|
+
restoreDragWidth();
|
|
286
|
+
} else setWidth(value);
|
|
287
|
+
},
|
|
288
|
+
onResizeEnd: (value) => {
|
|
289
|
+
endingDrag = false;
|
|
290
|
+
committedWidth = value;
|
|
291
|
+
writeCookie();
|
|
292
|
+
},
|
|
293
|
+
onCollapseIntent: (armed) => {
|
|
294
|
+
if (armed) slot.dataset.closing = "true";
|
|
295
|
+
else delete slot.dataset.closing;
|
|
296
|
+
},
|
|
297
|
+
onCollapse: () => {
|
|
298
|
+
endingDrag = false;
|
|
299
|
+
restoreDragWidth();
|
|
300
|
+
close();
|
|
301
|
+
},
|
|
302
|
+
onDragChange: (dragging) => {
|
|
303
|
+
if (dragging) {
|
|
304
|
+
dragWidth = width;
|
|
305
|
+
dragStoredWidth = committedWidth;
|
|
306
|
+
dragInlineWidth = canvas.style.getPropertyValue("--cloud-panel-width-set");
|
|
307
|
+
slot.dataset.resizing = "true";
|
|
308
|
+
} else {
|
|
309
|
+
delete slot.dataset.resizing;
|
|
310
|
+
endingDrag = true;
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
: () => {};
|
|
315
|
+
let disposeResize = attachResize();
|
|
316
|
+
window.addEventListener("message", message);
|
|
317
|
+
window.addEventListener("keydown", key);
|
|
318
|
+
slot.addEventListener("click", click);
|
|
319
|
+
phoneChange.addEventListener("change", modeChange);
|
|
320
|
+
column.addEventListener("change", modeChange);
|
|
321
|
+
window.addEventListener("pageshow", pageShow);
|
|
322
|
+
window.addEventListener("popstate", closeSheet);
|
|
323
|
+
window.navigation?.addEventListener("currententrychange", entryChange);
|
|
324
|
+
window.addEventListener("resize", seedWidth);
|
|
325
|
+
// An SSR iframe can have posted its one-shot ready before this lazy module
|
|
326
|
+
// arrived. Its runtime also marks its own same-origin document, so read that
|
|
327
|
+
// once the listener is in place instead of loading the provider again.
|
|
328
|
+
if (isOpen() && frame) {
|
|
329
|
+
if (frame.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id) markReady();
|
|
330
|
+
else loading();
|
|
331
|
+
} else if (isOpen()) load();
|
|
332
|
+
else unload();
|
|
333
|
+
update();
|
|
334
|
+
let disposed = false;
|
|
335
|
+
const dispose = () => {
|
|
336
|
+
if (disposed) return;
|
|
337
|
+
disposed = true;
|
|
338
|
+
clearTimer();
|
|
339
|
+
disposeResize();
|
|
340
|
+
removeFallbackFocus();
|
|
341
|
+
window.removeEventListener("message", message);
|
|
342
|
+
window.removeEventListener("keydown", key);
|
|
343
|
+
slot.removeEventListener("click", click);
|
|
344
|
+
phoneChange.removeEventListener("change", modeChange);
|
|
345
|
+
column.removeEventListener("change", modeChange);
|
|
346
|
+
window.removeEventListener("pageshow", pageShow);
|
|
347
|
+
window.removeEventListener("popstate", closeSheet);
|
|
348
|
+
window.navigation?.removeEventListener("currententrychange", entryChange);
|
|
349
|
+
window.removeEventListener("resize", seedWidth);
|
|
350
|
+
installed = undefined;
|
|
351
|
+
};
|
|
352
|
+
installed = { dispose, request };
|
|
353
|
+
return dispose;
|
|
354
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type PanelRequest = { action: "open"; provider: string; search?: string; accepted: boolean } | { action: "close" | "toggle" };
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface WindowEventMap {
|
|
5
|
+
"cloud.panel.request": CustomEvent<PanelRequest>;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Opens a visible provider. False leaves navigation with the caller when this page has no matching slot. */
|
|
10
|
+
export function openPanel(provider: string, options?: { search?: string }): boolean {
|
|
11
|
+
const detail: PanelRequest = {
|
|
12
|
+
action: "open",
|
|
13
|
+
provider,
|
|
14
|
+
search: typeof options?.search === "string" && options.search.length <= 2048 ? options.search : undefined,
|
|
15
|
+
accepted: false,
|
|
16
|
+
};
|
|
17
|
+
window.dispatchEvent(new CustomEvent("cloud.panel.request", { detail }));
|
|
18
|
+
return detail.accepted;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Closes the panel when it is open. */
|
|
22
|
+
export function closePanel(): void {
|
|
23
|
+
if (window.parent !== window && document.querySelector("[data-cloud-panel-page]")) {
|
|
24
|
+
window.parent.postMessage({ type: "cloud.panel.close" }, location.origin);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
window.dispatchEvent(new CustomEvent<PanelRequest>("cloud.panel.request", { detail: { action: "close" } }));
|
|
28
|
+
}
|
|
@@ -6,10 +6,14 @@ import { resourceSearchDialogOptions } from "./resource-search-dialog";
|
|
|
6
6
|
import { resourceSearchMessages } from "./resource-search-messages";
|
|
7
7
|
|
|
8
8
|
export type CloudResourcePickerItem = SearchItem;
|
|
9
|
+
/** Narrows the search on the server to one app and, optionally, one of its search tags; shown as the removable chip. */
|
|
10
|
+
export type CloudResourcePickerScope = { appId: string; tag?: string; label: string; icon?: string };
|
|
9
11
|
export type CloudResourcePickerOptions = {
|
|
10
12
|
title?: string;
|
|
11
13
|
placeholder?: string;
|
|
12
14
|
initialAppId?: string;
|
|
15
|
+
scope?: CloudResourcePickerScope;
|
|
16
|
+
query?: string;
|
|
13
17
|
excludeRefs?: readonly CloudResourceRef[];
|
|
14
18
|
requireReader?: boolean;
|
|
15
19
|
resourceType?: string;
|
|
@@ -20,7 +24,14 @@ export const openCloudResourcePicker = (options: CloudResourcePickerOptions = {}
|
|
|
20
24
|
const t = resourceSearchMessages.resolve([locale]).t;
|
|
21
25
|
return dialogCore.open<CloudResourcePickerItem>(
|
|
22
26
|
(close) => (
|
|
23
|
-
<CloudResourceSearch
|
|
27
|
+
<CloudResourceSearch
|
|
28
|
+
{...options}
|
|
29
|
+
request={options.scope !== undefined || options.query !== undefined ? { scope: options.scope, query: options.query } : undefined}
|
|
30
|
+
title={options.title ?? t.chooseResource}
|
|
31
|
+
selectionMode
|
|
32
|
+
onSelect={close}
|
|
33
|
+
onClose={() => close()}
|
|
34
|
+
/>
|
|
24
35
|
),
|
|
25
36
|
// A phone shows the picker as a full-screen sheet; global search keeps its floating card.
|
|
26
37
|
{
|