@k2b/cloud 0.41.0 → 0.42.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 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { type Job, type JobConfig, RetentionGapError, type Topic, type Worker } from "@k2b/sync";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { logger } from "../services/logging";
|
|
4
|
+
import { compareStreamCursor } from "./cursor";
|
|
5
|
+
import { createDoc } from "./documents";
|
|
6
|
+
import { applySharedTextEvent, MalformedSharedTextEventError, SHARED_TEXT_NAME, sharedTextNodeId } from "./events";
|
|
7
|
+
import type { SharedTextContributor, SharedTextSaveInput, SharedTextStoredState, SharedTextSyncEvent } from "./types";
|
|
8
|
+
|
|
9
|
+
const ACK_WAIT_MS = 120_000;
|
|
10
|
+
const RECONCILE_LIMIT = 5_000;
|
|
11
|
+
// Each lookup is a short-lived tenant replay stopped after its first event; 16 is far below 5,000 latestCursor lookups per scan page.
|
|
12
|
+
// In steady state, only documents edited within the last snapshot delay or stalled documents remain behind.
|
|
13
|
+
const BACKLOG_LOOKUPS = 16;
|
|
14
|
+
|
|
15
|
+
export const sharedTextSnapshotJobConfig = (
|
|
16
|
+
config: Pick<JobConfig, "id" | "deadLetterRetention" | "terminalRetentionMs" | "dedupeWindowMs"> & {
|
|
17
|
+
retention: NonNullable<JobConfig["retention"]>;
|
|
18
|
+
},
|
|
19
|
+
) =>
|
|
20
|
+
({
|
|
21
|
+
id: config.id,
|
|
22
|
+
delivery: { ackWaitMs: ACK_WAIT_MS, maxAttempts: 20, backoffMs: [5_000] },
|
|
23
|
+
// Ordered by document across 8 partitions. Each process reconstructs one
|
|
24
|
+
// document at a time, keeping its memory budget fixed while a fleet can
|
|
25
|
+
// save up to 8 documents concurrently. Changing the count is a resource change.
|
|
26
|
+
ordering: { mode: "partitioned" as const, partitions: 8 },
|
|
27
|
+
retention: config.retention,
|
|
28
|
+
terminalRetentionMs: config.terminalRetentionMs ?? 30 * 24 * 60 * 60 * 1000,
|
|
29
|
+
// 256 dead letters at Sync's 128 KiB payload limit plus its 4 KiB of
|
|
30
|
+
// dead-letter headroom, the depth Sync gives a job by default. A snapshot is
|
|
31
|
+
// dead-lettered only for incomplete or malformed history or after 20 failed
|
|
32
|
+
// attempts; each one carries a document ID and a cursor of a few hundred bytes.
|
|
33
|
+
deadLetterRetention: config.deadLetterRetention ?? { maxBytes: 256 * (128 * 1024 + 4096) },
|
|
34
|
+
...(config.dedupeWindowMs === undefined ? {} : { dedupeWindowMs: config.dedupeWindowMs }),
|
|
35
|
+
}) satisfies JobConfig;
|
|
36
|
+
|
|
37
|
+
export type SharedTextSnapshotRequest = { docId: string; targetCursor: string; reason: string };
|
|
38
|
+
export type SharedTextSnapshotStore = {
|
|
39
|
+
/** Stable keyset order by docId; include only documents that take snapshots. */
|
|
40
|
+
list(options: { limit: number; after?: string; signal?: AbortSignal }): Promise<Array<{ docId: string; cursor: string | null }>>;
|
|
41
|
+
/** Anchor legacy cursors to this log before returning. */
|
|
42
|
+
load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Atomically fence restoreRevision and non-newer cursors. Apply projections
|
|
45
|
+
* only when the write was accepted, never when overtaken. Return normally
|
|
46
|
+
* when covered, gone or locked; throw after a restore if not already covered
|
|
47
|
+
* so the job retries from the current snapshot.
|
|
48
|
+
*/
|
|
49
|
+
save(docId: string, input: SharedTextSaveInput): Promise<void>;
|
|
50
|
+
/** Preserve every valid retained update, including pending structs/deletes;
|
|
51
|
+
* save at a captured head with history incomplete. Lost bytes remain lost. */
|
|
52
|
+
recover(docId: string, cause: RetentionGapError | MalformedSharedTextEventError, options: { signal?: AbortSignal }): Promise<unknown>;
|
|
53
|
+
/** Move an idle cursor only while from is still stored and no newer event exists. */
|
|
54
|
+
advance(docId: string, input: { from: string; to: string; latest: string | null }): Promise<boolean>;
|
|
55
|
+
};
|
|
56
|
+
export type SharedTextSnapshotBacklog = {
|
|
57
|
+
log: string;
|
|
58
|
+
measuredAt: Date;
|
|
59
|
+
documents: number;
|
|
60
|
+
oldest: null | {
|
|
61
|
+
docId: string;
|
|
62
|
+
sequence: number;
|
|
63
|
+
ageMs: number | null;
|
|
64
|
+
sequenceDistance: number;
|
|
65
|
+
historyGap?: boolean;
|
|
66
|
+
/** The lookup budget left documents that may contain an older update. */
|
|
67
|
+
partial?: true;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
type SnapshotLog = {
|
|
71
|
+
id: string;
|
|
72
|
+
topic(): Pick<Topic<SharedTextSyncEvent>, "head" | "latestCursor" | "cursorSequence" | "cursorAt" | "replay">;
|
|
73
|
+
firstSequence(): Promise<number>;
|
|
74
|
+
};
|
|
75
|
+
type SnapshotOptions = { log: SnapshotLog; store: SharedTextSnapshotStore; logger?: ReturnType<typeof logger> };
|
|
76
|
+
type InputCodec<Input> = { encode(request: SharedTextSnapshotRequest): Input; decode(input: Input): SharedTextSnapshotRequest };
|
|
77
|
+
type SnapshotJob<Input> = () => Pick<Job<Input>, "submit" | "process">;
|
|
78
|
+
|
|
79
|
+
export function snapshots(
|
|
80
|
+
options: SnapshotOptions & { job: SnapshotJob<SharedTextSnapshotRequest>; input?: undefined },
|
|
81
|
+
): ReturnType<typeof createSnapshots>;
|
|
82
|
+
export function snapshots<Input>(
|
|
83
|
+
options: SnapshotOptions & { job: SnapshotJob<Input>; input: InputCodec<Input> },
|
|
84
|
+
): ReturnType<typeof createSnapshots>;
|
|
85
|
+
export function snapshots<Input>(
|
|
86
|
+
options: SnapshotOptions &
|
|
87
|
+
({ job: SnapshotJob<SharedTextSnapshotRequest>; input?: undefined } | { job: SnapshotJob<Input>; input: InputCodec<Input> }),
|
|
88
|
+
) {
|
|
89
|
+
if (options.input) return createSnapshots(options, options.job, options.input);
|
|
90
|
+
return createSnapshots(options, options.job, { encode: (request) => request, decode: (input) => input });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const createSnapshots = <Input>(
|
|
94
|
+
{ log, store, logger: suppliedLogger }: SnapshotOptions,
|
|
95
|
+
job: SnapshotJob<Input>,
|
|
96
|
+
input: InputCodec<Input>,
|
|
97
|
+
) => {
|
|
98
|
+
const output = suppliedLogger ?? logger("shared-text-snapshots");
|
|
99
|
+
let worker: Promise<Worker> | null = null;
|
|
100
|
+
let lastBacklog: SharedTextSnapshotBacklog | null = null;
|
|
101
|
+
const queue = async (request: SharedTextSnapshotRequest): Promise<void> => {
|
|
102
|
+
const sequence = log.topic().cursorSequence(request.targetCursor);
|
|
103
|
+
// A final unload at a newer cursor must not coalesce with an already settling snapshot.
|
|
104
|
+
await job().submit({ key: `${request.docId}:${sequence}`, coalesce: true, orderingKey: request.docId, input: input.encode(request) });
|
|
105
|
+
};
|
|
106
|
+
const start = async (): Promise<void> => {
|
|
107
|
+
if (worker) {
|
|
108
|
+
await worker;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
worker = job().process(
|
|
112
|
+
{
|
|
113
|
+
concurrency: 1,
|
|
114
|
+
onError: async ({ error, context }) => {
|
|
115
|
+
output.error("Snapshot job failed; missing or malformed history requires attention", {
|
|
116
|
+
docId: input.decode(context.input).docId,
|
|
117
|
+
error: error instanceof Error ? error.message : String(error),
|
|
118
|
+
attempt: context.attempt,
|
|
119
|
+
});
|
|
120
|
+
if (error instanceof RetentionGapError)
|
|
121
|
+
return {
|
|
122
|
+
action: "dead_letter",
|
|
123
|
+
reason: "Document history is incomplete; retained updates and pending dependencies were recovered",
|
|
124
|
+
};
|
|
125
|
+
if (error instanceof MalformedSharedTextEventError)
|
|
126
|
+
return {
|
|
127
|
+
action: "dead_letter",
|
|
128
|
+
reason: `Retained event cannot be applied; valid updates and pending dependencies were recovered: ${error.message}`,
|
|
129
|
+
};
|
|
130
|
+
return { action: "retry" };
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
async (context) => {
|
|
134
|
+
const request = input.decode(context.input);
|
|
135
|
+
const { docId } = request;
|
|
136
|
+
let heartbeatError: unknown;
|
|
137
|
+
const timer = setInterval(() => {
|
|
138
|
+
void context.heartbeat().catch((error) => {
|
|
139
|
+
heartbeatError = error;
|
|
140
|
+
});
|
|
141
|
+
}, 15_000);
|
|
142
|
+
try {
|
|
143
|
+
const topic = log.topic();
|
|
144
|
+
const initial = await store.load(docId, { signal: context.signal });
|
|
145
|
+
// Head BEFORE the document's latest event: once it is applied up to
|
|
146
|
+
// that event, the head is a valid (fresher) cursor for the snapshot.
|
|
147
|
+
const head = await topic.head();
|
|
148
|
+
// Cover queued older cursors in one replay; later jobs skip already saved state.
|
|
149
|
+
const target = await topic.latestCursor({ tenantId: docId });
|
|
150
|
+
if (!target) {
|
|
151
|
+
output.warn("Snapshot skipped: no retained events for the note", { docId, requestedCursor: request.targetCursor });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const requestedAt = Date.now();
|
|
155
|
+
if (!initial || (initial.cursor && compareStreamCursor(initial.cursor, target) >= 0)) return;
|
|
156
|
+
const doc = createDoc(initial);
|
|
157
|
+
const contributors = new Map<string, SharedTextContributor>();
|
|
158
|
+
try {
|
|
159
|
+
let reachedTarget = false;
|
|
160
|
+
try {
|
|
161
|
+
for await (const event of topic.replay({
|
|
162
|
+
tenantId: docId,
|
|
163
|
+
after: initial.cursor ?? topic.cursorAt(0),
|
|
164
|
+
until: target,
|
|
165
|
+
signal: context.signal,
|
|
166
|
+
})) {
|
|
167
|
+
applySharedTextEvent(doc, event, docId);
|
|
168
|
+
if (event.data.actor)
|
|
169
|
+
contributors.set(`${event.data.actor.kind}:${event.data.actor.id}`, {
|
|
170
|
+
...event.data.actor,
|
|
171
|
+
lastContributedAt: event.publishedAt,
|
|
172
|
+
});
|
|
173
|
+
reachedTarget = event.cursor === target;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// Preserve all valid retained updates and unresolved dependencies;
|
|
177
|
+
// record the missing/invalid history once in the DLQ after recovery.
|
|
178
|
+
if (error instanceof RetentionGapError || error instanceof MalformedSharedTextEventError)
|
|
179
|
+
await store.recover(docId, error, { signal: context.signal });
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
context.signal.throwIfAborted();
|
|
183
|
+
if (heartbeatError) throw heartbeatError;
|
|
184
|
+
if (!reachedTarget) throw new Error("Snapshot replay did not reach its target");
|
|
185
|
+
await context.heartbeat();
|
|
186
|
+
context.signal.throwIfAborted();
|
|
187
|
+
await store.save(docId, {
|
|
188
|
+
state: Y.encodeStateAsUpdate(doc),
|
|
189
|
+
text: doc.getText(SHARED_TEXT_NAME).toString(),
|
|
190
|
+
cursor: compareStreamCursor(head, target) > 0 ? head : target,
|
|
191
|
+
restoreRevision: initial.restoreRevision,
|
|
192
|
+
requestedAt,
|
|
193
|
+
actor: null,
|
|
194
|
+
contributors: [...contributors.values()],
|
|
195
|
+
});
|
|
196
|
+
} finally {
|
|
197
|
+
doc.destroy();
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
clearInterval(timer);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
try {
|
|
205
|
+
await worker;
|
|
206
|
+
output.debug("Snapshot worker started", { nodeId: sharedTextNodeId });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
worker = null;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const stop = async (): Promise<void> => {
|
|
213
|
+
const active = await worker;
|
|
214
|
+
worker = null;
|
|
215
|
+
active?.stop();
|
|
216
|
+
await active?.drain({ timeoutMs: ACK_WAIT_MS });
|
|
217
|
+
};
|
|
218
|
+
const reconcile = async (config: { signal?: AbortSignal; heartbeat?: () => Promise<void> } = {}) => {
|
|
219
|
+
const topic = log.topic();
|
|
220
|
+
let after: string | undefined;
|
|
221
|
+
let checked = 0;
|
|
222
|
+
let queued = 0;
|
|
223
|
+
let advanced = 0;
|
|
224
|
+
let lastHeadSeq = 0;
|
|
225
|
+
const backlogCandidates: Array<{ docId: string; stored: string; storedSeq: number; latest: string }> = [];
|
|
226
|
+
let unretainedMinSeq: number | null = null;
|
|
227
|
+
while (true) {
|
|
228
|
+
config.signal?.throwIfAborted();
|
|
229
|
+
const candidates = await store.list({ limit: RECONCILE_LIMIT, after, signal: config.signal });
|
|
230
|
+
// Head BEFORE each document's latest event: an idle document is covered up to it.
|
|
231
|
+
const head = await topic.head();
|
|
232
|
+
const headSeq = topic.cursorSequence(head);
|
|
233
|
+
lastHeadSeq = headSeq;
|
|
234
|
+
const firstSeq = await log.firstSequence();
|
|
235
|
+
const refreshBelow = firstSeq + Math.floor((headSeq - firstSeq) / 2);
|
|
236
|
+
for (const candidate of candidates) {
|
|
237
|
+
config.signal?.throwIfAborted();
|
|
238
|
+
if (checked % 200 === 0) await config.heartbeat?.();
|
|
239
|
+
checked++;
|
|
240
|
+
let stored = candidate.cursor;
|
|
241
|
+
let shared = false;
|
|
242
|
+
if (stored) {
|
|
243
|
+
try {
|
|
244
|
+
topic.cursorSequence(stored);
|
|
245
|
+
shared = true;
|
|
246
|
+
} catch {
|
|
247
|
+
/* Legacy cursor: load anchors it. */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!shared) {
|
|
251
|
+
const loaded = await store.load(candidate.docId, { signal: config.signal });
|
|
252
|
+
if (!loaded) continue;
|
|
253
|
+
stored = loaded.cursor;
|
|
254
|
+
}
|
|
255
|
+
const latest = await topic.latestCursor({ tenantId: candidate.docId });
|
|
256
|
+
// A document without a stored cursor replays from the log's start, like the job does.
|
|
257
|
+
if (latest && (!stored || compareStreamCursor(latest, stored) > 0)) {
|
|
258
|
+
await queue({ docId: candidate.docId, targetCursor: latest, reason: "reconcile" });
|
|
259
|
+
queued++;
|
|
260
|
+
const from = stored ?? topic.cursorAt(0);
|
|
261
|
+
const storedSeq = topic.cursorSequence(from);
|
|
262
|
+
const index = backlogCandidates.findIndex((entry) => entry.storedSeq > storedSeq);
|
|
263
|
+
if (backlogCandidates.length === BACKLOG_LOOKUPS && index === -1) {
|
|
264
|
+
unretainedMinSeq = Math.min(unretainedMinSeq ?? storedSeq, storedSeq);
|
|
265
|
+
} else {
|
|
266
|
+
if (backlogCandidates.length === BACKLOG_LOOKUPS) {
|
|
267
|
+
const evicted = backlogCandidates.pop()!;
|
|
268
|
+
unretainedMinSeq = Math.min(unretainedMinSeq ?? evicted.storedSeq, evicted.storedSeq);
|
|
269
|
+
}
|
|
270
|
+
backlogCandidates.splice(index === -1 ? backlogCandidates.length : index, 0, {
|
|
271
|
+
docId: candidate.docId,
|
|
272
|
+
stored: from,
|
|
273
|
+
storedSeq,
|
|
274
|
+
latest,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!stored) continue;
|
|
280
|
+
if (topic.cursorSequence(stored) < refreshBelow && (await store.advance(candidate.docId, { from: stored, to: head, latest })))
|
|
281
|
+
advanced++;
|
|
282
|
+
}
|
|
283
|
+
if (candidates.length < RECONCILE_LIMIT) break;
|
|
284
|
+
after = candidates.at(-1)!.docId;
|
|
285
|
+
}
|
|
286
|
+
const report: SharedTextSnapshotBacklog = { log: log.id, measuredAt: new Date(), documents: queued, oldest: null };
|
|
287
|
+
for (const [index, candidate] of backlogCandidates.entries()) {
|
|
288
|
+
if (report.oldest && candidate.storedSeq + 1 >= report.oldest.sequence) break;
|
|
289
|
+
// The first lookup runs in the scan's last heartbeat interval, each later one
|
|
290
|
+
// renews it. A lost lease ends the run; only the lookup itself fails softly.
|
|
291
|
+
if (index > 0) await config.heartbeat?.();
|
|
292
|
+
const lookup = new AbortController();
|
|
293
|
+
const signal = config.signal ? AbortSignal.any([lookup.signal, config.signal]) : lookup.signal;
|
|
294
|
+
try {
|
|
295
|
+
let found = false;
|
|
296
|
+
for await (const event of topic.replay({ tenantId: candidate.docId, after: candidate.stored, until: candidate.latest, signal })) {
|
|
297
|
+
found = true;
|
|
298
|
+
if (!report.oldest || event.sequence < report.oldest.sequence)
|
|
299
|
+
report.oldest = {
|
|
300
|
+
docId: candidate.docId,
|
|
301
|
+
sequence: event.sequence,
|
|
302
|
+
ageMs: report.measuredAt.getTime() - event.publishedAt.getTime(),
|
|
303
|
+
sequenceDistance: lastHeadSeq - event.sequence,
|
|
304
|
+
};
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
if (!found) throw new Error("Snapshot backlog replay returned no retained event");
|
|
308
|
+
} catch (error) {
|
|
309
|
+
config.signal?.throwIfAborted();
|
|
310
|
+
output.warn("Could not read shared text snapshot backlog history", {
|
|
311
|
+
docId: candidate.docId,
|
|
312
|
+
error: error instanceof Error ? error.message : String(error),
|
|
313
|
+
});
|
|
314
|
+
report.oldest = {
|
|
315
|
+
docId: candidate.docId,
|
|
316
|
+
sequence: candidate.storedSeq + 1,
|
|
317
|
+
ageMs: null,
|
|
318
|
+
sequenceDistance: lastHeadSeq - candidate.storedSeq,
|
|
319
|
+
historyGap: error instanceof RetentionGapError,
|
|
320
|
+
};
|
|
321
|
+
break;
|
|
322
|
+
} finally {
|
|
323
|
+
lookup.abort();
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
candidate === backlogCandidates.at(-1) &&
|
|
327
|
+
report.oldest &&
|
|
328
|
+
unretainedMinSeq !== null &&
|
|
329
|
+
unretainedMinSeq + 1 < report.oldest.sequence
|
|
330
|
+
)
|
|
331
|
+
report.oldest.partial = true;
|
|
332
|
+
}
|
|
333
|
+
config.signal?.throwIfAborted();
|
|
334
|
+
output.info("Snapshot reconcile finished", { checked, queued, advanced });
|
|
335
|
+
output.info("Shared text snapshot backlog", report);
|
|
336
|
+
lastBacklog = report;
|
|
337
|
+
return { checked, queued, advanced };
|
|
338
|
+
};
|
|
339
|
+
return { queue, start, stop, reconcile, backlog: () => lastBacklog };
|
|
340
|
+
};
|
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -3,14 +3,16 @@ import type { JSX } from "solid-js/jsx-runtime";
|
|
|
3
3
|
import type { PanelConsentSnapshot } from "../contracts/panel-context";
|
|
4
4
|
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
5
5
|
import { getLocale } from "../server/locale";
|
|
6
|
+
import type { AuthContext } from "../server/middleware/auth";
|
|
6
7
|
import type { LayoutAnnouncementsState } from "../server/middleware/settings";
|
|
7
8
|
import AdminSidebar from "./AdminSidebar";
|
|
8
9
|
import Layout from "./Layout";
|
|
9
10
|
import { platformMessages } from "./platform-messages";
|
|
10
|
-
import {
|
|
11
|
+
import { getDisplayRuntimeContext, type RuntimeContext } from "./runtime";
|
|
11
12
|
|
|
12
13
|
type Breadcrumb = { title: string; href?: string };
|
|
13
14
|
type AdminLayoutContext = {
|
|
15
|
+
get(key: "actor"): AuthContext["Variables"]["actor"] | undefined;
|
|
14
16
|
get(key: "panelConsent"): PanelConsentSnapshot | undefined;
|
|
15
17
|
get(key: "railPreferences"): RailSnapshot | undefined;
|
|
16
18
|
get(key: "user"): any;
|
|
@@ -34,7 +36,7 @@ export default function AdminLayout(props: Props) {
|
|
|
34
36
|
const t = platformMessages.resolve([locale]).t;
|
|
35
37
|
const url = new URL(c.req.raw.url);
|
|
36
38
|
const currentPath = `${url.pathname}${url.search}`;
|
|
37
|
-
const runtime =
|
|
39
|
+
const runtime = getDisplayRuntimeContext(c);
|
|
38
40
|
// Admin is the area's root, as an app's root is: the rail and the admin sidebar lead there. So it is no level
|
|
39
41
|
// above an admin page, and every admin page shows its title alone, whichever app owns it.
|
|
40
42
|
const overview = title === "Overview" || title === t.overview;
|
|
@@ -57,6 +57,9 @@ function BarNavigation(props: { currentPath: string }) {
|
|
|
57
57
|
syncLocation();
|
|
58
58
|
window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
59
59
|
window.addEventListener("popstate", syncLocation);
|
|
60
|
+
window.addEventListener("ssr:nav-popstate", syncLocation);
|
|
61
|
+
window.addEventListener("ssr:nav-swapped", sync);
|
|
62
|
+
window.addEventListener("ssr:nav-swapped", syncLocation);
|
|
60
63
|
window.addEventListener("hashchange", syncLocation);
|
|
61
64
|
// In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
|
|
62
65
|
// reports; where a browser lacks it, the bar follows at the next page load.
|
|
@@ -64,6 +67,9 @@ function BarNavigation(props: { currentPath: string }) {
|
|
|
64
67
|
onCleanup(() => {
|
|
65
68
|
window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
66
69
|
window.removeEventListener("popstate", syncLocation);
|
|
70
|
+
window.removeEventListener("ssr:nav-popstate", syncLocation);
|
|
71
|
+
window.removeEventListener("ssr:nav-swapped", sync);
|
|
72
|
+
window.removeEventListener("ssr:nav-swapped", syncLocation);
|
|
67
73
|
window.removeEventListener("hashchange", syncLocation);
|
|
68
74
|
window.navigation?.removeEventListener("currententrychange", syncLocation);
|
|
69
75
|
});
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { PANEL_HELP_ID_MAX_LENGTH, PanelPageContextSchema } from "../contracts/p
|
|
|
10
10
|
import { isPwaShellAvailable } from "../contracts/pwa";
|
|
11
11
|
import { defaultRailPreferences } from "../contracts/rail-preferences";
|
|
12
12
|
import { hasRole, type User } from "../contracts/shared";
|
|
13
|
+
import { isAppSessionActor } from "../server/actor";
|
|
13
14
|
import { getLocale } from "../server/locale";
|
|
14
15
|
import { getDateConfig } from "../server/time";
|
|
15
16
|
import { dates, resolveHomePath } from "../shared";
|
|
@@ -27,14 +28,15 @@ import { HELP_VIEW_LIMIT } from "./layout-help-start";
|
|
|
27
28
|
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
28
29
|
import PageTrailRow from "./PageTrailRow";
|
|
29
30
|
import PanelSlot from "./PanelSlot";
|
|
31
|
+
import { getPageSwapBuild, pageSwapSignature } from "./page-swap";
|
|
30
32
|
import { derivePageTrail } from "./page-trail";
|
|
31
33
|
import { platformMessages } from "./platform-messages";
|
|
32
34
|
import { sortRailApps } from "./rail-navigation";
|
|
33
35
|
import { requestPath } from "./request-path";
|
|
34
|
-
import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
|
|
36
|
+
import { getDisplayRuntimeContext, getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
|
|
35
37
|
import ShellFocusToggle from "./ShellFocusToggle";
|
|
36
38
|
import ShellRuntime from "./ShellRuntime.island";
|
|
37
|
-
import { type ShellData, ShellDataProvider } from "./shell-data";
|
|
39
|
+
import { type ShellData, ShellDataProvider, ShellDataScript } from "./shell-data";
|
|
38
40
|
|
|
39
41
|
// Types
|
|
40
42
|
type Breadcrumb = LayoutBreadcrumb;
|
|
@@ -164,7 +166,9 @@ export default function Layout(props: LayoutProps) {
|
|
|
164
166
|
const lang = getLocale(c);
|
|
165
167
|
const t = platformMessages.resolve([lang]).t;
|
|
166
168
|
const user = c.get("user");
|
|
167
|
-
const
|
|
169
|
+
const displayIds = new Set(getDisplayRuntimeContext(c).apps.map((app) => app.id));
|
|
170
|
+
const displayApps = runtime.apps.filter((app) => displayIds.has(app.id));
|
|
171
|
+
const providers = user && props.panel !== false ? visiblePanelProviders(displayApps, user, lang) : [];
|
|
168
172
|
const panelState = readPanelCookie(
|
|
169
173
|
providers.length ? cookie : undefined,
|
|
170
174
|
providers.map((provider) => provider.id),
|
|
@@ -178,12 +182,14 @@ export default function Layout(props: LayoutProps) {
|
|
|
178
182
|
readAppWorkspaceLayoutCookie(cookie, currentApp?.id),
|
|
179
183
|
workspaceSidebarCollapsible,
|
|
180
184
|
);
|
|
181
|
-
const { primary: primaryApps, more: moreApps } = buildNavLinks(
|
|
185
|
+
const { primary: primaryApps, more: moreApps } = buildNavLinks(displayApps, user, lang);
|
|
182
186
|
const allApps = sortRailApps([...primaryApps, ...moreApps], lang);
|
|
183
187
|
const visibleAppIds = new Set(allApps.map((app) => app.id));
|
|
184
188
|
const searchLinks = Object.fromEntries(
|
|
185
|
-
|
|
189
|
+
displayApps.flatMap((app) => (visibleAppIds.has(app.id) && app.searchLinks?.length ? [[app.id, app.searchLinks] as const] : [])),
|
|
186
190
|
);
|
|
191
|
+
const swapBuild = getPageSwapBuild(c);
|
|
192
|
+
const swap = swapBuild !== undefined && !focusMode && !!currentRoute?.prefix;
|
|
187
193
|
const settings = c.get("settings");
|
|
188
194
|
const announcements = c.get("announcements");
|
|
189
195
|
const appName = settings?.app?.name || "Cloud";
|
|
@@ -212,7 +218,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
212
218
|
// The app bar and the app's navigation hidden with Cmd/Ctrl+\ or the sidebar buttons; phones ignore it in CSS.
|
|
213
219
|
const shellFocus = showRail && readShellFocusCookie(cookie);
|
|
214
220
|
// The profile menu offers the mobile app only while the installation runs it.
|
|
215
|
-
const appHref = user && isPwaShellAvailable(
|
|
221
|
+
const appHref = user && isPwaShellAvailable(displayApps) ? "/me/app" : undefined;
|
|
216
222
|
const profileName = user?.displayName || user?.uid || "?";
|
|
217
223
|
const profileAvatarSrc =
|
|
218
224
|
user?.avatarHash && user.id
|
|
@@ -271,6 +277,30 @@ export default function Layout(props: LayoutProps) {
|
|
|
271
277
|
searchLinks,
|
|
272
278
|
appId: currentApp?.id,
|
|
273
279
|
};
|
|
280
|
+
// What the kept shell shows outside the column: when any of it differs, the next page loads as a document.
|
|
281
|
+
const signature = swap
|
|
282
|
+
? pageSwapSignature({
|
|
283
|
+
app: currentApp?.id,
|
|
284
|
+
kind: user ? "app" : "anonymous",
|
|
285
|
+
user: user?.id ?? null,
|
|
286
|
+
appSession: isAppSessionActor(c.get("actor")),
|
|
287
|
+
locale: lang,
|
|
288
|
+
theme: c.get("page").theme,
|
|
289
|
+
build: swapBuild,
|
|
290
|
+
shell: {
|
|
291
|
+
// The document's <html> theme and the profile menu keep this preference.
|
|
292
|
+
themePreference: theme,
|
|
293
|
+
providers,
|
|
294
|
+
apps: allApps,
|
|
295
|
+
legalLinks,
|
|
296
|
+
searchLinks,
|
|
297
|
+
home: shellData.home,
|
|
298
|
+
profile: shellData.profile,
|
|
299
|
+
announcements: user ? (announcements?.latestAnnouncementVersion ?? null) : null,
|
|
300
|
+
help: registeredHelp?.manifestHash ?? (layoutHelpFailed(c) ? "failed" : null),
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
: undefined;
|
|
274
304
|
if (focusMode) {
|
|
275
305
|
return (
|
|
276
306
|
<LocaleProvider locale={lang}>
|
|
@@ -310,8 +340,11 @@ export default function Layout(props: LayoutProps) {
|
|
|
310
340
|
return (
|
|
311
341
|
<LocaleProvider locale={lang}>
|
|
312
342
|
<div
|
|
313
|
-
class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
|
|
343
|
+
class={`cloud-app-canvas relative flex w-full ${fullPage && !swap ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
|
|
314
344
|
style={canvasStyle}
|
|
345
|
+
data-nav-shell={signature}
|
|
346
|
+
data-nav-scope={swap ? currentRoute?.prefix : undefined}
|
|
347
|
+
data-nav-attributes={swap ? "data-layout-full-page data-workspace-sidebar-collapsed" : undefined}
|
|
315
348
|
data-panel={panelState.open ? "open" : undefined}
|
|
316
349
|
data-app-id={currentApp?.id}
|
|
317
350
|
data-app-base={currentRoute?.prefix}
|
|
@@ -334,13 +367,18 @@ export default function Layout(props: LayoutProps) {
|
|
|
334
367
|
? { entries: announcements.announcements, latestVersion: announcements.latestAnnouncementVersion }
|
|
335
368
|
: undefined
|
|
336
369
|
}
|
|
337
|
-
banners={user && announcements?.banners.length ? true : undefined}
|
|
370
|
+
banners={user && (swap || announcements?.banners.length) ? true : undefined}
|
|
338
371
|
/>
|
|
339
|
-
<ShellDataProvider data={shellData}>
|
|
372
|
+
<ShellDataProvider data={shellData} skipScript={swap}>
|
|
340
373
|
{showRail && <LayoutRail currentPath={requestPath(c)} panelOpen={panel ? panelState.open : undefined} />}
|
|
341
374
|
{showRail && <ShellFocusToggle variant="float" />}
|
|
342
375
|
{showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
|
|
343
|
-
<div
|
|
376
|
+
<div
|
|
377
|
+
class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col"
|
|
378
|
+
data-cloud-app-area=""
|
|
379
|
+
data-nav-root={swap ? "" : undefined}
|
|
380
|
+
>
|
|
381
|
+
{swap && <ShellDataScript data={shellData} />}
|
|
344
382
|
<LayoutHeader
|
|
345
383
|
panelOpen={panel ? panelState.open : undefined}
|
|
346
384
|
authenticated={showRail}
|
|
@@ -353,19 +391,31 @@ export default function Layout(props: LayoutProps) {
|
|
|
353
391
|
{user && <ExpiryWarnings user={user} dateConfig={dateConfig} locale={lang} />}
|
|
354
392
|
<PageTrailProvider trail={pageTrail}>
|
|
355
393
|
<PageTrailRow trail={pageTrail} />
|
|
356
|
-
<main
|
|
394
|
+
<main
|
|
395
|
+
class={`layout-content-main min-h-0 min-w-0 flex-1 ${mainLayoutClass}`}
|
|
396
|
+
data-scroll-preserve={swap ? "cloud-main" : undefined}
|
|
397
|
+
data-scroll-page={swap ? "" : undefined}
|
|
398
|
+
>
|
|
357
399
|
<AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
|
|
358
400
|
</main>
|
|
359
401
|
</PageTrailProvider>
|
|
360
402
|
{!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
|
|
403
|
+
{swap && (
|
|
404
|
+
<div data-print-fallback="">
|
|
405
|
+
<p>{t.printFallbackTitle}</p>
|
|
406
|
+
<p>{props.printHint ?? t.printFallbackHint}</p>
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
361
409
|
</div>
|
|
362
410
|
{panel && <PanelSlot panel={panel} state={panelState} platform={shellData.platform} />}
|
|
363
411
|
{showRail && <LayoutTabBar currentPath={requestPath(c)} />}
|
|
364
412
|
</ShellDataProvider>
|
|
365
|
-
|
|
366
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
413
|
+
{!swap && (
|
|
414
|
+
<div data-print-fallback="">
|
|
415
|
+
<p>{t.printFallbackTitle}</p>
|
|
416
|
+
<p>{props.printHint ?? t.printFallbackHint}</p>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
369
419
|
</div>
|
|
370
420
|
</LocaleProvider>
|
|
371
421
|
);
|
|
@@ -100,7 +100,7 @@ if (!main) throw new Error("Missing Layout main");
|
|
|
100
100
|
const embedded = params.has("embedded");
|
|
101
101
|
const referenceHost = params.get("host");
|
|
102
102
|
if (referenceHost === "grids" || referenceHost === "pulse") {
|
|
103
|
-
// The Grids
|
|
103
|
+
// The Grids query reference windows: one article beside the reference sidebar, without the Cloud shell.
|
|
104
104
|
const host = document.createElement("div");
|
|
105
105
|
host.className = "h-dvh";
|
|
106
106
|
document.body.replaceChildren(host);
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
|
|
2
|
+
import { listenPopState } from "@k2b/ssr/nav";
|
|
2
3
|
import { clipboard } from "@k2b/stdlib/solid";
|
|
3
4
|
import {
|
|
4
5
|
BottomSheet,
|
|
@@ -383,12 +384,12 @@ const HelpShell = (props: {
|
|
|
383
384
|
// The hub keeps its search, as the reader's own Back button does. An unknown topic shows the hub, as the server does.
|
|
384
385
|
else setView("hub");
|
|
385
386
|
};
|
|
386
|
-
|
|
387
|
+
const stopPopState = props.syncPageUrl ? listenPopState(onPopState) : undefined;
|
|
387
388
|
onCleanup(() => {
|
|
388
389
|
scrollArea?.removeEventListener("scroll", onScroll);
|
|
389
390
|
window.removeEventListener("resize", onResize);
|
|
390
391
|
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame);
|
|
391
|
-
|
|
392
|
+
stopPopState?.();
|
|
392
393
|
});
|
|
393
394
|
});
|
|
394
395
|
onCleanup(() => copyAllController?.abort());
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { installAppWorkspaceController, installInputModeController, keyboardPlatform } from "@k2b/ui";
|
|
2
2
|
import { onCleanup, onMount } from "solid-js";
|
|
3
3
|
import { installPanelContextReceiver } from "../browser/panel-context-receiver";
|
|
4
|
+
import { hasPanelLeaveGuard, panelLinkDestination } from "../browser/panel-leave";
|
|
4
5
|
import { syncTimezoneCookie } from "./timezone-cookie";
|
|
5
6
|
|
|
6
7
|
export default function PanelRuntime(props: { provider?: string; scope?: string }) {
|
|
@@ -24,15 +25,18 @@ export default function PanelRuntime(props: { provider?: string; scope?: string
|
|
|
24
25
|
};
|
|
25
26
|
const close = () => window.parent.postMessage({ type: "cloud.panel.close" }, location.origin);
|
|
26
27
|
const click = (event: MouseEvent) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) return;
|
|
30
|
-
const url = new URL(anchor.getAttribute("href")!, document.baseURI);
|
|
31
|
-
if (url.origin !== location.origin) return;
|
|
28
|
+
const url = panelLinkDestination(event);
|
|
29
|
+
if (!url) return;
|
|
32
30
|
const scope = props.scope?.replace(/\/$/, "");
|
|
33
|
-
|
|
31
|
+
const internal = scope !== undefined && (url.pathname === scope || url.pathname.startsWith(`${scope}/`));
|
|
32
|
+
if (!hasPanelLeaveGuard()) {
|
|
33
|
+
if (internal) return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
window.parent.location.assign(url.href);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
34
38
|
event.preventDefault();
|
|
35
|
-
window.parent.
|
|
39
|
+
window.parent.postMessage({ type: "cloud.panel.navigate", v: 1, href: url.href }, location.origin);
|
|
36
40
|
};
|
|
37
41
|
const key = (event: KeyboardEvent) => {
|
|
38
42
|
if (event.isComposing || event.defaultPrevented || event.altKey || event.shiftKey) return;
|
package/src/ssr/PwaLayout.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { visiblePwaParts } from "./app-navigation";
|
|
|
6
6
|
import type { LayoutContext } from "./layout-context";
|
|
7
7
|
import PwaRuntime from "./PwaRuntime.island";
|
|
8
8
|
import { pwaMessages } from "./pwa-messages";
|
|
9
|
-
import {
|
|
9
|
+
import { getDisplayRuntimeContext } from "./runtime";
|
|
10
10
|
import TimezoneCookie from "./TimezoneCookie.island";
|
|
11
11
|
|
|
12
12
|
/** Parts with their own tab; Start lists every part and doubles as "More". */
|
|
@@ -42,7 +42,7 @@ export default function PwaLayout(props: PwaLayoutProps) {
|
|
|
42
42
|
c.header("Referrer-Policy", "no-referrer");
|
|
43
43
|
c.header("Content-Security-Policy", "frame-ancestors 'none'");
|
|
44
44
|
|
|
45
|
-
const parts = visiblePwaParts(
|
|
45
|
+
const parts = visiblePwaParts(getDisplayRuntimeContext(c).apps, user, locale);
|
|
46
46
|
const path = new URL(c.req.raw.url).pathname;
|
|
47
47
|
const tabs = parts.slice(0, TAB_PARTS);
|
|
48
48
|
const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
|
package/src/ssr/RailApps.tsx
CHANGED
|
@@ -44,11 +44,17 @@ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapsho
|
|
|
44
44
|
const stopBadges = watchAppBadges(props.apps.flatMap((app) => (app.badge ? [app.badge] : [])));
|
|
45
45
|
window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
46
46
|
window.addEventListener("popstate", syncLocation);
|
|
47
|
+
window.addEventListener("ssr:nav-popstate", syncLocation);
|
|
48
|
+
window.addEventListener("ssr:nav-swapped", sync);
|
|
49
|
+
window.addEventListener("ssr:nav-swapped", syncLocation);
|
|
47
50
|
window.addEventListener("hashchange", syncLocation);
|
|
48
51
|
onCleanup(() => {
|
|
49
52
|
stopBadges();
|
|
50
53
|
window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
51
54
|
window.removeEventListener("popstate", syncLocation);
|
|
55
|
+
window.removeEventListener("ssr:nav-popstate", syncLocation);
|
|
56
|
+
window.removeEventListener("ssr:nav-swapped", sync);
|
|
57
|
+
window.removeEventListener("ssr:nav-swapped", syncLocation);
|
|
52
58
|
window.removeEventListener("hashchange", syncLocation);
|
|
53
59
|
});
|
|
54
60
|
});
|