@k2b/cloud 0.30.0 → 0.32.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 +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +64 -39
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +120 -2
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/server/widget-request.ts +27 -0
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
|
@@ -8,16 +8,20 @@ type ProfilePreferencesProps = {
|
|
|
8
8
|
avatarSrc?: string;
|
|
9
9
|
initialTheme: CloudTheme;
|
|
10
10
|
name: string;
|
|
11
|
-
placement: "header" | "rail";
|
|
12
11
|
};
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The avatar at the bottom of the rail. Its menu opens beside the rail and,
|
|
15
|
+
* because the avatar is the rail's last control, grows upward from the window's
|
|
16
|
+
* bottom edge.
|
|
17
|
+
*/
|
|
14
18
|
export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
15
19
|
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
|
|
16
20
|
|
|
17
21
|
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
|
|
18
22
|
|
|
19
23
|
return (
|
|
20
|
-
<div class="layout-profile-preferences"
|
|
24
|
+
<div class="layout-profile-preferences">
|
|
21
25
|
<button type="button" class="layout-profile-preferences__link" aria-label={preferences.messages().menuLabel}>
|
|
22
26
|
{avatar()}
|
|
23
27
|
</button>
|
|
@@ -49,7 +53,7 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
49
53
|
class="layout-profile-preferences__dropdown"
|
|
50
54
|
items={items()}
|
|
51
55
|
label={preferences.messages().menuLabel}
|
|
52
|
-
position=
|
|
56
|
+
position="right-start"
|
|
53
57
|
width="14rem"
|
|
54
58
|
>
|
|
55
59
|
<Dropdown.Trigger
|
|
@@ -10,7 +10,7 @@ import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } fr
|
|
|
10
10
|
function RailIcon(props: { link: RailLink; currentPath: string }) {
|
|
11
11
|
let target: HTMLAnchorElement | undefined;
|
|
12
12
|
const active = () => railLinkActive(props.link, props.currentPath);
|
|
13
|
-
const badge = useAppBadgeDescription(() => props.link.badge);
|
|
13
|
+
const badge = useAppBadgeDescription(() => ({ endpoint: props.link.badge, appId: props.link.appId }));
|
|
14
14
|
return (
|
|
15
15
|
<>
|
|
16
16
|
<a
|
|
@@ -23,7 +23,7 @@ function RailIcon(props: { link: RailLink; currentPath: string }) {
|
|
|
23
23
|
>
|
|
24
24
|
<i class={`${props.link.iconClass} text-base`} aria-hidden="true" />
|
|
25
25
|
<span class="sr-only">{props.link.label}</span>
|
|
26
|
-
<AppBadge endpoint={props.link.badge} />
|
|
26
|
+
<AppBadge endpoint={props.link.badge} appId={props.link.appId} />
|
|
27
27
|
</a>
|
|
28
28
|
<Tooltip target={() => target} content={props.link.label} placement="right" delay={0} />
|
|
29
29
|
</>
|
package/src/ssr/app-badges.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createSignal } from "solid-js";
|
|
2
2
|
import { APP_BADGES_REFRESH_EVENT } from "../browser/app-badges";
|
|
3
|
+
import { APPROVAL_COUNTS_PATH } from "../contracts/approval-paths";
|
|
4
|
+
import type { ApprovalCounts } from "../contracts/approvals";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Shortest time between two scheduled reads in one tab, and the longest one
|
|
@@ -13,8 +15,20 @@ export const APP_BADGE_INTERVAL_MS = 60_000;
|
|
|
13
15
|
// count. Only the browser ever writes it; server renders always see no badge.
|
|
14
16
|
const [counts, setCounts] = createSignal<ReadonlyMap<string, number>>(new Map());
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
const [approvalCounts, setApprovalCounts] = createSignal<ApprovalCounts | null>(null);
|
|
19
|
+
|
|
20
|
+
/** Retain an SSR count until the first browser read; a failed read still resolves to zero. */
|
|
21
|
+
export const liveApprovalCount = (appId?: string): number | null => {
|
|
22
|
+
const current = approvalCounts();
|
|
23
|
+
if (!current) return null;
|
|
24
|
+
if (appId === undefined) return current.total;
|
|
25
|
+
return Object.hasOwn(current.apps, appId) ? (current.apps[appId] ?? 0) : 0;
|
|
26
|
+
};
|
|
27
|
+
export const approvalCount = (appId?: string): number => liveApprovalCount(appId) ?? 0;
|
|
28
|
+
|
|
29
|
+
/** Endpoint and approval counts for one app; 0 until the browser has read them. */
|
|
30
|
+
export const appBadgeCount = (input: { endpoint?: string; appId?: string }): number =>
|
|
31
|
+
(input.endpoint ? (counts().get(input.endpoint) ?? 0) : 0) + (input.appId ? approvalCount(input.appId) : 0);
|
|
18
32
|
|
|
19
33
|
/** A positive safe integer from an `AppNavBadge` body, else 0. */
|
|
20
34
|
export const parseAppBadge = (body: unknown): number => {
|
|
@@ -33,6 +47,33 @@ const readBadge = async (endpoint: string, signal: AbortSignal): Promise<number>
|
|
|
33
47
|
}
|
|
34
48
|
};
|
|
35
49
|
|
|
50
|
+
const isCount = (value: unknown, minimum: number): value is number =>
|
|
51
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
52
|
+
|
|
53
|
+
/** What `ApprovalCountsSchema` accepts from a JSON body, without loading validators into the shell; a test compares both. */
|
|
54
|
+
export const parseApprovalCounts = (body: unknown): ApprovalCounts | null => {
|
|
55
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
|
|
56
|
+
if (Object.keys(body).length !== 2 || !("total" in body) || !("apps" in body)) return null;
|
|
57
|
+
const { total, apps } = body;
|
|
58
|
+
if (!isCount(total, 0) || typeof apps !== "object" || apps === null || Array.isArray(apps)) return null;
|
|
59
|
+
// Like Zod's record, ignore a `__proto__` key that JSON.parse keeps as an own property.
|
|
60
|
+
const entries = Object.entries(apps).filter(([app]) => app !== "__proto__");
|
|
61
|
+
return entries.every(([, count]) => isCount(count, 1)) ? { total, apps: Object.fromEntries(entries) } : null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const readApprovalCounts = async (signal: AbortSignal): Promise<ApprovalCounts> => {
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(APPROVAL_COUNTS_PATH, { credentials: "same-origin", headers: { accept: "application/json" }, signal });
|
|
67
|
+
if (response.status === 200) {
|
|
68
|
+
const parsed = parseApprovalCounts(await response.json());
|
|
69
|
+
if (parsed) return parsed;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// A failing read clears approval counts just like an app badge.
|
|
73
|
+
}
|
|
74
|
+
return { total: 0, apps: {} };
|
|
75
|
+
};
|
|
76
|
+
|
|
36
77
|
let watching = false;
|
|
37
78
|
|
|
38
79
|
/**
|
|
@@ -45,8 +86,8 @@ let watching = false;
|
|
|
45
86
|
* read still running after the interval gives up. Returns a disposer.
|
|
46
87
|
*/
|
|
47
88
|
export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
|
|
48
|
-
const unique = [...new Set(endpoints)];
|
|
49
|
-
if (typeof window === "undefined" ||
|
|
89
|
+
const unique = [...new Set([...endpoints, APPROVAL_COUNTS_PATH])];
|
|
90
|
+
if (typeof window === "undefined" || watching) return () => {};
|
|
50
91
|
watching = true;
|
|
51
92
|
const stopped = new AbortController();
|
|
52
93
|
// Endpoints with a read in flight, mapped to whether one more read waits for it.
|
|
@@ -60,9 +101,12 @@ export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
|
|
|
60
101
|
return;
|
|
61
102
|
}
|
|
62
103
|
running.set(endpoint, false);
|
|
63
|
-
|
|
104
|
+
const signal = AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)]);
|
|
105
|
+
const result = endpoint === APPROVAL_COUNTS_PATH ? readApprovalCounts(signal) : readBadge(endpoint, signal);
|
|
106
|
+
void result.then((count) => {
|
|
64
107
|
if (stopped.signal.aborted) return;
|
|
65
|
-
setCounts((previous) => new Map(previous).set(endpoint, count));
|
|
108
|
+
if (typeof count === "number") setCounts((previous) => new Map(previous).set(endpoint, count));
|
|
109
|
+
else setApprovalCounts(count);
|
|
66
110
|
const queued = running.get(endpoint);
|
|
67
111
|
running.delete(endpoint);
|
|
68
112
|
if (queued && document.visibilityState !== "hidden") read(endpoint);
|
package/src/ssr/islands/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { default as PageTrail, type PageTrailProps } from "../PageTrail";
|
|
1
2
|
export { default as WorkspaceNavigationProvider } from "../WorkspaceNavigationProvider";
|
|
2
3
|
export { provideWorkspaceNavigation } from "../workspace-navigation";
|
|
3
4
|
export { default as SearchBar } from "./SearchBar";
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readPageTrail, setPageTrail } from "@k2b/ui";
|
|
2
|
+
import { derivePageTrail, dropSelfLinks } from "./page-trail";
|
|
3
|
+
|
|
1
4
|
export type LayoutBreadcrumb = {
|
|
2
5
|
title: string;
|
|
3
6
|
href?: string;
|
|
@@ -10,13 +13,70 @@ export type LayoutUpdate = {
|
|
|
10
13
|
|
|
11
14
|
export const LAYOUT_UPDATE_EVENT = "cloud:layout:update";
|
|
12
15
|
|
|
16
|
+
/** The canvas attribute that carries the current app's base path, for the trail rule in the browser. */
|
|
17
|
+
export const APP_BASE_ATTRIBUTE = "data-app-base";
|
|
18
|
+
/** Set on the canvas of a page that shows its title without a way up: anonymous and focus-mode pages. */
|
|
19
|
+
const TITLE_ONLY_ATTRIBUTE = "data-page-trail-title-only";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Window event before the way up (a trail link or Alt+↑) loads a page. Its
|
|
23
|
+
* `detail.href` is where it leads: the page above, as this tab left it.
|
|
24
|
+
* Cancelling it keeps the document; the listener then navigates itself.
|
|
25
|
+
*/
|
|
26
|
+
export const PAGE_UP_EVENT = "cloud:page-up";
|
|
27
|
+
export type PageUpDetail = { href: string };
|
|
28
|
+
|
|
29
|
+
const here = () => `${location.pathname}${location.search}`;
|
|
30
|
+
|
|
13
31
|
export const layout = {
|
|
32
|
+
/**
|
|
33
|
+
* Updates the page after an in-page navigation: the document title, and the
|
|
34
|
+
* page title with its way up wherever the shell or `PageTrail` shows it.
|
|
35
|
+
* `breadcrumbs` is the same title array `Layout` takes, under the same rule;
|
|
36
|
+
* `title` alone renames the current page and keeps the levels above it.
|
|
37
|
+
*/
|
|
14
38
|
update(update: LayoutUpdate) {
|
|
15
39
|
if (typeof window === "undefined") return;
|
|
16
40
|
|
|
17
41
|
const title = update.title ?? update.breadcrumbs?.at(-1)?.title;
|
|
18
42
|
if (title) document.title = title;
|
|
43
|
+
const canvas = document.querySelector(".cloud-app-canvas");
|
|
44
|
+
const base = canvas?.getAttribute(APP_BASE_ATTRIBUTE) ?? undefined;
|
|
45
|
+
const current = readPageTrail();
|
|
46
|
+
const derived = update.breadcrumbs?.length
|
|
47
|
+
? derivePageTrail(update.breadcrumbs, base, title ?? current?.title ?? document.title)
|
|
48
|
+
: title && current
|
|
49
|
+
? { ...current, title }
|
|
50
|
+
: undefined;
|
|
51
|
+
if (!derived) return;
|
|
52
|
+
const trail = update.title ? { ...derived, title: update.title } : derived;
|
|
53
|
+
setPageTrail(canvas?.hasAttribute(TITLE_ONLY_ATTRIBUTE) ? { ...trail, ancestors: [] } : trail);
|
|
54
|
+
if (update.breadcrumbs?.length) {
|
|
55
|
+
// An island may change the address right after this call, in the same task, so the link to the page itself
|
|
56
|
+
// is known only then. Comparing earlier would drop the page the island has just left.
|
|
57
|
+
queueMicrotask(() => {
|
|
58
|
+
const published = readPageTrail();
|
|
59
|
+
if (!published) return;
|
|
60
|
+
const settled = dropSelfLinks(published, here());
|
|
61
|
+
if (settled !== published) setPageTrail(settled);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
19
64
|
|
|
20
65
|
window.dispatchEvent(new CustomEvent<LayoutUpdate>(LAYOUT_UPDATE_EVENT, { detail: update }));
|
|
21
66
|
},
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lets an island that navigates in place, such as a file browser, take the
|
|
70
|
+
* way up itself instead of a page load that would end its running work.
|
|
71
|
+
* `go` gets the destination and returns `true` when it goes there in place.
|
|
72
|
+
* Returns the disposer.
|
|
73
|
+
*/
|
|
74
|
+
handleUp(go: (href: string) => boolean): () => void {
|
|
75
|
+
if (typeof window === "undefined") return () => {};
|
|
76
|
+
const listener = (event: Event) => {
|
|
77
|
+
if (!event.defaultPrevented && go((event as CustomEvent<PageUpDetail>).detail.href)) event.preventDefault();
|
|
78
|
+
};
|
|
79
|
+
window.addEventListener(PAGE_UP_EVENT, listener);
|
|
80
|
+
return () => window.removeEventListener(PAGE_UP_EVENT, listener);
|
|
81
|
+
},
|
|
22
82
|
};
|
|
@@ -18,7 +18,7 @@ export function openMobileMenu(view: (beforeSelect: () => Promise<boolean>) => D
|
|
|
18
18
|
history.pushState({ ...previous, [marker]: id }, "");
|
|
19
19
|
const abort = new AbortController();
|
|
20
20
|
let leavingPage = false;
|
|
21
|
-
const mobile = window.matchMedia("(max-width:
|
|
21
|
+
const mobile = window.matchMedia("(max-width: 767px)");
|
|
22
22
|
const pop = () => {
|
|
23
23
|
if (history.state?.[marker] !== id) abort.abort();
|
|
24
24
|
};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The way up, in the browser: links in a page trail lead to the page above,
|
|
3
|
+
* as this tab left it. A plain up link would reset the list a person
|
|
4
|
+
* filtered, searched, and scrolled; this runtime remembers each page's last
|
|
5
|
+
* URL and scroll position per tab and returns there. The trail's links keep
|
|
6
|
+
* their canonical `href`, so a new tab, a copied link, or a reload still lead
|
|
7
|
+
* to the page itself. After returning, the row that led down is marked.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PAGE_TRAIL_EVENT, readPageTrail } from "@k2b/ui";
|
|
11
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
12
|
+
import { PAGE_UP_EVENT, type PageUpDetail } from "./layout-runtime";
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = "cloud:page-trail";
|
|
15
|
+
/**
|
|
16
|
+
* Recent pages per tab. A trail goes back through a few levels, often across
|
|
17
|
+
* a detour into another app; thirty distinct pages cover that with room to
|
|
18
|
+
* spare while the memory stays a few kilobytes of `sessionStorage`.
|
|
19
|
+
*/
|
|
20
|
+
const VISIT_LIMIT = 30;
|
|
21
|
+
/** How long a returning page may take to render the regions and rows it restores, at one try per frame. */
|
|
22
|
+
const RESTORE_FRAMES = 60;
|
|
23
|
+
const TRAIL_LINK = "a[data-k2b-page-trail-link]";
|
|
24
|
+
const MAIN = ".layout-content-main";
|
|
25
|
+
|
|
26
|
+
type Region = { key: string; top: number; left: number };
|
|
27
|
+
/** A page as this tab left it; `up` holds the links of the levels above it then. */
|
|
28
|
+
type Visit = { url: string; regions: Region[]; up?: string[] };
|
|
29
|
+
type Memory = { visits: Visit[]; returning?: { url: string; from: string } };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `path` as a page of this origin, or nothing: read against the origin,
|
|
33
|
+
* `//host`, `@host`, or `javascript:` would lead elsewhere, and a path that
|
|
34
|
+
* parses to `//host`, such as `/..//host`, would once an island reads its
|
|
35
|
+
* pathname.
|
|
36
|
+
*/
|
|
37
|
+
const localPage = (path: string): URL | undefined => {
|
|
38
|
+
if (!path.startsWith("/")) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(path, location.origin);
|
|
41
|
+
return url.origin === location.origin && !url.pathname.startsWith("//") ? url : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isRegion = (value: unknown): value is Region =>
|
|
48
|
+
typeof value === "object" &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
"key" in value &&
|
|
51
|
+
typeof value.key === "string" &&
|
|
52
|
+
"top" in value &&
|
|
53
|
+
typeof value.top === "number" &&
|
|
54
|
+
"left" in value &&
|
|
55
|
+
typeof value.left === "number";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The tab's storage is not trusted: a visit or a return counts only when its
|
|
59
|
+
* addresses are pages of this origin and the rest has the shape this runtime writes.
|
|
60
|
+
*/
|
|
61
|
+
const isVisit = (value: unknown): value is Visit =>
|
|
62
|
+
typeof value === "object" &&
|
|
63
|
+
value !== null &&
|
|
64
|
+
"url" in value &&
|
|
65
|
+
typeof value.url === "string" &&
|
|
66
|
+
localPage(value.url) !== undefined &&
|
|
67
|
+
"regions" in value &&
|
|
68
|
+
Array.isArray(value.regions) &&
|
|
69
|
+
value.regions.every(isRegion) &&
|
|
70
|
+
(!("up" in value) ||
|
|
71
|
+
(Array.isArray(value.up) && value.up.every((item: unknown) => typeof item === "string" && URL.canParse(item, location.href))));
|
|
72
|
+
|
|
73
|
+
const isReturning = (value: unknown): value is NonNullable<Memory["returning"]> =>
|
|
74
|
+
typeof value === "object" &&
|
|
75
|
+
value !== null &&
|
|
76
|
+
"url" in value &&
|
|
77
|
+
typeof value.url === "string" &&
|
|
78
|
+
localPage(value.url) !== undefined &&
|
|
79
|
+
"from" in value &&
|
|
80
|
+
typeof value.from === "string" &&
|
|
81
|
+
localPage(value.from) !== undefined;
|
|
82
|
+
|
|
83
|
+
const read = (): Memory => {
|
|
84
|
+
try {
|
|
85
|
+
const value: unknown = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "null");
|
|
86
|
+
if (value !== null && typeof value === "object" && "visits" in value && Array.isArray(value.visits)) {
|
|
87
|
+
const visits = value.visits.filter(isVisit);
|
|
88
|
+
return "returning" in value && isReturning(value.returning) ? { visits, returning: value.returning } : { visits };
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// A full or blocked storage only costs the memory, never the navigation.
|
|
92
|
+
}
|
|
93
|
+
return { visits: [] };
|
|
94
|
+
};
|
|
95
|
+
const write = (memory: Memory) => {
|
|
96
|
+
try {
|
|
97
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(memory));
|
|
98
|
+
} catch {
|
|
99
|
+
// See `read`.
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const here = () => `${location.pathname}${location.search}${location.hash}`;
|
|
104
|
+
const pathOf = (url: URL) => url.pathname.replace(/\/+$/, "") || "/";
|
|
105
|
+
const resolve = (href: string) => new URL(href, location.href);
|
|
106
|
+
|
|
107
|
+
/** A remembered page is the page `target` names when it has the same path and every query value `target` sets. */
|
|
108
|
+
const isPage = (visited: URL, target: URL) =>
|
|
109
|
+
visited.origin === target.origin &&
|
|
110
|
+
pathOf(visited) === pathOf(target) &&
|
|
111
|
+
[...target.searchParams].every(([key, value]) => visited.searchParams.getAll(key).includes(value));
|
|
112
|
+
/** How many of `from`'s query values `url` carries, less the ones the two do not share. */
|
|
113
|
+
const queryMatch = (url: URL, from: URL) => {
|
|
114
|
+
const shared = [...url.searchParams].filter(([key, value]) => from.searchParams.getAll(key).includes(value)).length;
|
|
115
|
+
return 2 * shared - ([...url.searchParams].length - shared) - ([...from.searchParams].length - shared);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The page's scroll positions: the window, the shell's page scroller, and every keyed `data-scroll-preserve` region. */
|
|
119
|
+
const captureRegions = (): Region[] => {
|
|
120
|
+
const regions: Region[] = [{ key: "@window", top: window.scrollY, left: window.scrollX }];
|
|
121
|
+
const main = document.querySelector<HTMLElement>(MAIN);
|
|
122
|
+
if (main) regions.push({ key: "@main", top: main.scrollTop, left: main.scrollLeft });
|
|
123
|
+
for (const element of document.querySelectorAll<HTMLElement>("[data-scroll-preserve]")) {
|
|
124
|
+
const key = element.dataset.scrollPreserve;
|
|
125
|
+
if (key) regions.push({ key, top: element.scrollTop, left: element.scrollLeft });
|
|
126
|
+
}
|
|
127
|
+
return regions.filter((region) => region.top || region.left);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Remembers the page as it is now, newest first, with the levels above it. */
|
|
131
|
+
export const rememberPage = () => {
|
|
132
|
+
const memory = read();
|
|
133
|
+
const url = here();
|
|
134
|
+
const up = readPageTrail()?.ancestors.map((level) => level.href) ?? [];
|
|
135
|
+
memory.visits = [{ url, regions: captureRegions(), up }, ...memory.visits.filter((visit) => visit.url !== url)].slice(0, VISIT_LIMIT);
|
|
136
|
+
write(memory);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where an up link leads in this tab: the page's last URL here, or its own
|
|
141
|
+
* `href`. A page below the target is never that page, even when its address
|
|
142
|
+
* carries the target's query, as a Files document carries its folder's: it
|
|
143
|
+
* names the target as a level above it.
|
|
144
|
+
*/
|
|
145
|
+
export const upDestination = (href: string): string => {
|
|
146
|
+
const target = resolve(href);
|
|
147
|
+
const below = (visit: Visit) => (visit.up ?? []).some((level) => isPage(resolve(level), target) && isPage(target, resolve(level)));
|
|
148
|
+
const visit = read().visits.find((candidate) => isPage(resolve(candidate.url), target) && !below(candidate));
|
|
149
|
+
return visit?.url ?? `${target.pathname}${target.search}${target.hash}`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Goes one or more levels up to `href`, back to how this tab left that page.
|
|
154
|
+
* An island that navigates in place takes the way up itself (see
|
|
155
|
+
* `layout.handleUp`); otherwise the page loads. Returns whether it took the
|
|
156
|
+
* way up; a link it does not take stays a plain link.
|
|
157
|
+
*/
|
|
158
|
+
export const goUp = (href: string): boolean => {
|
|
159
|
+
// The way up stays on this origin; a link elsewhere, such as `javascript:`, names no page above.
|
|
160
|
+
if (resolve(href).origin !== location.origin) return false;
|
|
161
|
+
rememberPage();
|
|
162
|
+
const destination = upDestination(href);
|
|
163
|
+
// The destination is read as a path of this origin and must stay there; one that starts with `//` names another host.
|
|
164
|
+
const url = localPage(destination);
|
|
165
|
+
if (!url) return false;
|
|
166
|
+
const detail: PageUpDetail = { href: destination };
|
|
167
|
+
if (!window.dispatchEvent(new CustomEvent(PAGE_UP_EVENT, { cancelable: true, detail }))) return true;
|
|
168
|
+
const memory = read();
|
|
169
|
+
memory.returning = { url: destination, from: here() };
|
|
170
|
+
write(memory);
|
|
171
|
+
location.assign(url.href);
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const regionElement = (key: string): HTMLElement | null =>
|
|
176
|
+
key === "@main"
|
|
177
|
+
? document.querySelector<HTMLElement>(MAIN)
|
|
178
|
+
: document.querySelector<HTMLElement>(`[data-scroll-preserve="${CSS.escape(key)}"]`);
|
|
179
|
+
|
|
180
|
+
/** Scores of links to the page itself rank above every link to a page above it, which score by path length. */
|
|
181
|
+
const SAME_PAGE = 1_000_000;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The link on this page that led towards `from`: the same page, or, for
|
|
185
|
+
* nested paths, the nearest page above it. Navigation, the rail, and trails
|
|
186
|
+
* themselves lead elsewhere and do not count.
|
|
187
|
+
*/
|
|
188
|
+
const rowTowards = (from: URL): HTMLElement | null => {
|
|
189
|
+
const scope = document.querySelector(MAIN) ?? document.body;
|
|
190
|
+
let best: { element: HTMLAnchorElement; score: number } | null = null;
|
|
191
|
+
for (const link of scope.querySelectorAll<HTMLAnchorElement>("a[href]")) {
|
|
192
|
+
if (link.closest(".k2b-page-title, .k2b-app-workspace__sidebar, .layout-rail, nav")) continue;
|
|
193
|
+
const url = resolve(link.getAttribute("href") ?? "");
|
|
194
|
+
if (url.origin !== from.origin) continue;
|
|
195
|
+
// The row may carry the list's query or leave it out; either way it names the same page. The closer its query
|
|
196
|
+
// is to `from`'s, the surer it is the row: a link without one, such as "All things", only names the list.
|
|
197
|
+
const same = isPage(from, url) || isPage(url, from);
|
|
198
|
+
const above = !same && url.search === "" && pathOf(from).startsWith(`${pathOf(url)}/`);
|
|
199
|
+
if (!same && !above) continue;
|
|
200
|
+
const score = same ? SAME_PAGE + queryMatch(url, from) : pathOf(url).length;
|
|
201
|
+
if (!best || score > best.score) best = { element: link, score };
|
|
202
|
+
}
|
|
203
|
+
return best ? (best.element.closest<HTMLElement>("tr, [role='row'], li") ?? best.element) : null;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Restores a returning page's scroll positions and marks the row that led down. Islands render late, so it retries per frame. */
|
|
207
|
+
const restoreReturn = (visit: Visit | undefined, from: URL) => {
|
|
208
|
+
const pending = new Map((visit?.regions ?? []).map((region) => [region.key, region]));
|
|
209
|
+
let marked: HTMLElement | null = null;
|
|
210
|
+
let frames = 0;
|
|
211
|
+
const step = () => {
|
|
212
|
+
for (const [key, region] of pending) {
|
|
213
|
+
// A region is restored once both axes hold; content that renders late can still widen or lengthen it.
|
|
214
|
+
if (key === "@window") {
|
|
215
|
+
window.scrollTo(region.left, region.top);
|
|
216
|
+
if (Math.abs(window.scrollY - region.top) < 1 && Math.abs(window.scrollX - region.left) < 1) pending.delete(key);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const element = regionElement(key);
|
|
220
|
+
if (!element) continue;
|
|
221
|
+
element.scrollTop = region.top;
|
|
222
|
+
element.scrollLeft = region.left;
|
|
223
|
+
if (Math.abs(element.scrollTop - region.top) < 1 && Math.abs(element.scrollLeft - region.left) < 1) pending.delete(key);
|
|
224
|
+
}
|
|
225
|
+
if (!marked?.isConnected) {
|
|
226
|
+
marked = rowTowards(from);
|
|
227
|
+
if (marked) {
|
|
228
|
+
marked.setAttribute("data-page-trail-recent", "");
|
|
229
|
+
if (!visit?.regions.length) marked.scrollIntoView({ block: "nearest" });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
frames += 1;
|
|
233
|
+
if (frames < RESTORE_FRAMES && (pending.size || !marked)) requestAnimationFrame(step);
|
|
234
|
+
};
|
|
235
|
+
requestAnimationFrame(step);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** The localized name and description of the "One level up" command for a parent page. */
|
|
239
|
+
export type PageUpLabels = (parent: string) => { title: string; description: string };
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Offers "One level up" (Alt+↑, ⌥↑ on a Mac) while the page has a page above
|
|
243
|
+
* it, through the shared command system: the shortcut, the palette, and the
|
|
244
|
+
* shortcut help list it only then, and the shortcut stays quiet in text
|
|
245
|
+
* fields. It follows trail changes from `layout.update()`.
|
|
246
|
+
*/
|
|
247
|
+
const offerPageUp = (labels: PageUpLabels): (() => void) => {
|
|
248
|
+
let unregister = () => {};
|
|
249
|
+
const update = () => {
|
|
250
|
+
unregister();
|
|
251
|
+
unregister = () => {};
|
|
252
|
+
const parent = readPageTrail()?.ancestors.at(-1);
|
|
253
|
+
if (!parent) return;
|
|
254
|
+
unregister = registerContextAwareCommand({
|
|
255
|
+
id: "cloud.page-up",
|
|
256
|
+
...labels(parent.title),
|
|
257
|
+
icon: "ti ti-arrow-up",
|
|
258
|
+
shortcut: "alt+arrowup",
|
|
259
|
+
action: () => {
|
|
260
|
+
goUp(parent.href);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
update();
|
|
265
|
+
window.addEventListener(PAGE_TRAIL_EVENT, update);
|
|
266
|
+
return () => {
|
|
267
|
+
window.removeEventListener(PAGE_TRAIL_EVENT, update);
|
|
268
|
+
unregister();
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Installs the runtime for the document. Every click and Enter remembers the
|
|
274
|
+
* page as it is before anything navigates, also in place, as a list row that
|
|
275
|
+
* opens on Enter does; a plain click on a trail link goes up through the
|
|
276
|
+
* memory. Returns the disposer.
|
|
277
|
+
*/
|
|
278
|
+
export const installPageTrailRuntime = (labels: PageUpLabels): (() => void) => {
|
|
279
|
+
const stopPageUp = offerPageUp(labels);
|
|
280
|
+
const memory = read();
|
|
281
|
+
const returning = memory.returning;
|
|
282
|
+
if (returning) {
|
|
283
|
+
delete memory.returning;
|
|
284
|
+
write(memory);
|
|
285
|
+
const current = resolve(here());
|
|
286
|
+
if (pathOf(resolve(returning.url)) === pathOf(current) && resolve(returning.url).search === current.search)
|
|
287
|
+
restoreReturn(
|
|
288
|
+
memory.visits.find((visit) => visit.url === returning.url),
|
|
289
|
+
resolve(returning.from),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const onCapture = () => rememberPage();
|
|
293
|
+
const onKey = (event: KeyboardEvent) => {
|
|
294
|
+
if (event.key === "Enter") rememberPage();
|
|
295
|
+
};
|
|
296
|
+
const onClick = (event: MouseEvent) => {
|
|
297
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
298
|
+
const link = (event.target as Element | null)?.closest?.<HTMLAnchorElement>(TRAIL_LINK);
|
|
299
|
+
if (!link || (link.target && link.target !== "_self")) return;
|
|
300
|
+
if (goUp(link.href)) event.preventDefault();
|
|
301
|
+
};
|
|
302
|
+
document.addEventListener("click", onCapture, true);
|
|
303
|
+
document.addEventListener("keydown", onKey, true);
|
|
304
|
+
document.addEventListener("click", onClick);
|
|
305
|
+
window.addEventListener("pagehide", onCapture);
|
|
306
|
+
return () => {
|
|
307
|
+
stopPageUp();
|
|
308
|
+
document.removeEventListener("click", onCapture, true);
|
|
309
|
+
document.removeEventListener("keydown", onKey, true);
|
|
310
|
+
document.removeEventListener("click", onClick);
|
|
311
|
+
window.removeEventListener("pagehide", onCapture);
|
|
312
|
+
};
|
|
313
|
+
};
|