@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.1792022492092.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +548 -98
- package/config/portal-route-roots.json +47 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +32901 -5934
- package/dist/base-skills/notis-apps/SKILL.md +76 -51
- package/dist/base-skills/notis-apps/references/architecture.md +116 -164
- package/dist/base-skills/notis-apps/references/design.md +26 -107
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +52 -74
- package/dist/base-skills/notis-apps/references/release.md +105 -96
- package/dist/base-skills/notis-apps/references/sdk.md +115 -60
- package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
- package/dist/base-skills/notis-apps/references/views.md +145 -0
- package/dist/base-skills/notis-cli/SKILL.md +29 -12
- package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +6 -5
- package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
- package/dist/base-skills/notis-query/SKILL.md +34 -60
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -59
- package/dist/base-skills/notis-query/references/documents.md +53 -39
- package/dist/base-skills/notis-query/references/query.md +57 -524
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
- package/dist/sdk/src/components/DocumentPage.tsx +144 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/config.ts +4 -26
- package/dist/sdk/src/documents.ts +2 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useBackend.ts +3 -2
- package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +4 -2
- package/dist/sdk/src/hooks/useHandover.ts +7 -7
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +3 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
- package/dist/sdk/src/hooks/useQuery.ts +7 -4
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +36 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +42 -5
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +5 -3
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +163 -13
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +96 -72
- package/dist/sdk/src/vite.ts +11 -1
- package/dist/skill-sync/index.js +571 -80
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +858 -233
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1028 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +175 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +16 -5
- package/skills/notis-apps/cli.md +323 -163
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
- package/skills/notis-onboarding/BRIEF.md +7 -3
- package/src/command-specs/apps.js +2 -1
- package/src/command-specs/auth.js +2 -2
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +331 -79
- package/src/command-specs/helpers.js +2 -0
- package/src/command-specs/index.js +11 -5
- package/src/command-specs/profile.js +42 -1
- package/src/command-specs/skills.js +242 -1
- package/src/command-specs/spaces.js +645 -0
- package/src/command-specs/tools.js +0 -6
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +92 -29
- package/src/runtime/app-boundary-validator.js +7 -4
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +321 -253
- package/src/runtime/computer-identity.d.ts +30 -0
- package/src/runtime/computer-identity.js +151 -0
- package/src/runtime/config-lock.js +135 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +5 -21
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +18 -1
- package/src/runtime/profiles.js +5 -107
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +26 -4
- package/src/runtime/skill-sync/index.ts +230 -35
- package/src/runtime/skill-sync/local-scanner.ts +188 -29
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +70 -0
- package/src/runtime/skill-sync/sync-plan.ts +16 -9
- package/src/runtime/skill-sync/types.ts +32 -6
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +10 -7
- package/src/runtime/sync-skills.d.ts +3 -1
- package/src/runtime/sync-skills.js +10 -2
- package/src/runtime/transport.js +8 -6
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker.js +14 -2
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +7 -10
- package/template/app/globals.css +1 -0
- package/src/command-specs/reports.js +0 -97
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { normalizeDocumentRecord } from '../documents';
|
|
6
|
+
import type { DocumentRecord, NotisRuntime } from '../runtime';
|
|
7
|
+
import type { NativeDelete, NativeInsert, NativeMutationReceipt, NativeQuery, NativeUpdate } from '../nativeData';
|
|
8
|
+
import { useQuery, type UseQueryResult } from './useQuery';
|
|
9
|
+
import { SdkQueryError } from '../queryErrors';
|
|
10
|
+
|
|
11
|
+
function actionFor(runtime: NotisRuntime | null, binding: string, operation: 'query' | 'insert' | 'update' | 'delete'): string {
|
|
12
|
+
const descriptor = runtime?.space?.bindings[binding];
|
|
13
|
+
const action = descriptor?.kind === 'database' ? descriptor.operations?.[operation] : undefined;
|
|
14
|
+
if (runtime?.resource?.kind !== 'space' || !runtime.callAction || !action) {
|
|
15
|
+
const diagnostic = `This Space does not expose ${operation} for database binding "${binding}".`;
|
|
16
|
+
if (operation === 'query') throw new SdkQueryError('bindingUnavailable', diagnostic);
|
|
17
|
+
throw new Error(`This Space does not expose ${operation} for that database binding.`);
|
|
18
|
+
}
|
|
19
|
+
return action;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A declared query adapter, never an account-wide slug or owner lookup.
|
|
24
|
+
*
|
|
25
|
+
* `subscribe: true` follows the binding's live change feed where the host has one (a
|
|
26
|
+
* signed-in Editor of the Space, owner or teammate): `live` is then true and changes made
|
|
27
|
+
* elsewhere (another device, a teammate, an agent, an automation) re-read the rows on their
|
|
28
|
+
* own. Where `live` is false, keep offering a manual reload.
|
|
29
|
+
*/
|
|
30
|
+
export function useNativeDocuments(binding: string, options: {
|
|
31
|
+
query?: NativeQuery; fetchAll?: boolean; enabled?: boolean; staleTimeMs?: number; subscribe?: boolean;
|
|
32
|
+
} = {}): UseQueryResult<{ documents: DocumentRecord[]; schemaRevision: number }> & { documents: DocumentRecord[]; schemaRevision?: number; live: boolean } {
|
|
33
|
+
const runtime = useNotisRuntime();
|
|
34
|
+
const [live, setLive] = useState(false);
|
|
35
|
+
const follow = options.subscribe === true && options.enabled !== false;
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!follow || !runtime?.subscribeDatabase) { setLive(false); return; }
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
// The Space host re-reads every query of the view when the database changes, so the
|
|
40
|
+
// change callback has nothing left to do; the feed is a signal and never carries rows.
|
|
41
|
+
const stop = runtime.subscribeDatabase(binding, () => {}, { onStatusChange: value => { if (!cancelled) setLive(value); } });
|
|
42
|
+
return () => { cancelled = true; setLive(false); stop?.(); };
|
|
43
|
+
}, [runtime, binding, follow]);
|
|
44
|
+
const query = useQuery(['native-documents', binding, options.query ?? {}, Boolean(options.fetchAll)], async () => {
|
|
45
|
+
const action = actionFor(runtime, binding, 'query');
|
|
46
|
+
if (options.query?.mode && !['rows', 'search'].includes(options.query.mode)) throw new Error('Use useActionQuery for counts and aggregates.');
|
|
47
|
+
const rows: unknown[] = [];
|
|
48
|
+
let offset = options.query?.offset ?? 0;
|
|
49
|
+
let revision: number | undefined;
|
|
50
|
+
while (true) {
|
|
51
|
+
const result = await runtime!.callAction!<{ rows: unknown[]; schema_revision: number; has_more: boolean; next_offset: number | null }>(
|
|
52
|
+
action, { request: { ...options.query, offset } }, { readOnly: true, dedupe: true, schemaRevision: revision });
|
|
53
|
+
if (!result || !Array.isArray(result.rows) || !Number.isSafeInteger(result.schema_revision)) throw new SdkQueryError('responseIncomplete', 'Native query must return rows and a safe integer schema_revision.');
|
|
54
|
+
if (revision !== undefined && revision !== result.schema_revision) throw new SdkQueryError('schemaChanged');
|
|
55
|
+
revision = result.schema_revision;
|
|
56
|
+
rows.push(...result.rows);
|
|
57
|
+
if (!options.fetchAll || !result.has_more) break;
|
|
58
|
+
if (typeof result.next_offset !== 'number' || result.next_offset <= offset) throw new SdkQueryError('responseIncomplete', 'Native query pagination did not advance next_offset.');
|
|
59
|
+
offset = result.next_offset;
|
|
60
|
+
}
|
|
61
|
+
return { documents: rows.map(normalizeDocumentRecord), schemaRevision: revision! };
|
|
62
|
+
}, { readOnly: true, enabled: options.enabled, staleTimeMs: options.staleTimeMs });
|
|
63
|
+
return { ...query, documents: query.data?.documents ?? EMPTY_DOCUMENTS, schemaRevision: query.data?.schemaRevision, live };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Explicit insert/update/trash; update never silently creates a missing row. */
|
|
67
|
+
export function useNativeMutation<TOperation extends 'insert' | 'update' | 'delete'>(binding: string, operation: TOperation): {
|
|
68
|
+
mutate: (input: TOperation extends 'insert' ? NativeInsert : TOperation extends 'update' ? NativeUpdate : NativeDelete,
|
|
69
|
+
options: { schemaRevision: number; requestId: string }) => Promise<NativeMutationReceipt>;
|
|
70
|
+
loading: boolean; error: Error | null;
|
|
71
|
+
} {
|
|
72
|
+
const runtime = useNotisRuntime();
|
|
73
|
+
const [pending, setPending] = useState(0), [error, setError] = useState<Error | null>(null);
|
|
74
|
+
const mutate = useCallback(async (input: NativeInsert | NativeUpdate | NativeDelete, options: { schemaRevision: number; requestId: string }) => {
|
|
75
|
+
const action = actionFor(runtime, binding, operation);
|
|
76
|
+
setPending((value) => value + 1); setError(null);
|
|
77
|
+
try { return await runtime!.callAction!<NativeMutationReceipt>(action, { request: input }, options); }
|
|
78
|
+
catch (cause) { const failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure); throw failure; }
|
|
79
|
+
finally { setPending((value) => value - 1); }
|
|
80
|
+
}, [runtime, binding, operation]);
|
|
81
|
+
return { mutate, loading: pending > 0, error };
|
|
82
|
+
}
|
|
83
|
+
const EMPTY_DOCUMENTS: DocumentRecord[] = [];
|
|
@@ -4,6 +4,8 @@ import { useNotisRuntime } from '../provider';
|
|
|
4
4
|
import type { RuntimeResource, AppDescriptor, CollectionItemDetail, DatabaseDescriptor, RouteDescriptor } from '../runtime';
|
|
5
5
|
|
|
6
6
|
interface NotisContext {
|
|
7
|
+
/** UI selection only; it grants no access and is never a record scope. */
|
|
8
|
+
selection: string | null;
|
|
7
9
|
resource: RuntimeResource | null;
|
|
8
10
|
/** App metadata (id, name, icon, description). Null before runtime loads. */
|
|
9
11
|
app: AppDescriptor | null;
|
|
@@ -28,6 +30,7 @@ export function useNotis(): NotisContext {
|
|
|
28
30
|
const runtime = useNotisRuntime();
|
|
29
31
|
|
|
30
32
|
return {
|
|
33
|
+
selection: runtime?.context?.selection ?? null,
|
|
31
34
|
resource: runtime?.resource ?? null,
|
|
32
35
|
app: runtime?.app ?? null,
|
|
33
36
|
route: runtime?.route ?? null,
|
|
@@ -3,28 +3,49 @@
|
|
|
3
3
|
import { useCallback } from 'react';
|
|
4
4
|
import { useNotisRuntime } from '../provider';
|
|
5
5
|
|
|
6
|
+
export interface SpaceNavigationOptions {
|
|
7
|
+
recordKey?: string | null;
|
|
8
|
+
/** Declared view parameters. The destination authorizes records and drops unknown names. */
|
|
9
|
+
params?: Record<string, string | number | boolean | null | undefined>;
|
|
10
|
+
selection?: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
interface NavigationActions {
|
|
14
|
+
/** Resolve a declared portable destination under the current viewer's access. */
|
|
15
|
+
toNamedSpace: (alias: string, options?: SpaceNavigationOptions) => Promise<void>;
|
|
16
|
+
/** Open an exact Space, optionally at a native record's stable record_key. */
|
|
17
|
+
toSpace: (spaceId: string, options?: SpaceNavigationOptions) => void;
|
|
7
18
|
/** Navigate to a route within the app by its path. */
|
|
8
19
|
toRoute: (path: string, options?: { resourceId?: string | null }) => void;
|
|
9
|
-
/** Navigate to a document detail view. */
|
|
10
|
-
toDocument: (documentId: string, title?: string | null) => void;
|
|
11
20
|
/** Navigate to the app's default route. */
|
|
12
21
|
toApp: () => void;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
|
-
* Navigation helpers for
|
|
25
|
+
* Navigation helpers for Spaces and app routes.
|
|
17
26
|
* When rendered inside the portal, uses the runtime.navigate() bridge.
|
|
18
27
|
* Without a portal runtime, falls back to window.location for route navigation.
|
|
19
28
|
*
|
|
20
29
|
* ```tsx
|
|
21
30
|
* const nav = useNotisNavigation();
|
|
22
|
-
* nav.
|
|
31
|
+
* nav.toSpace(spaceId, { recordKey });
|
|
23
32
|
* ```
|
|
24
33
|
*/
|
|
25
34
|
export function useNotisNavigation(): NavigationActions {
|
|
26
35
|
const runtime = useNotisRuntime();
|
|
27
36
|
|
|
37
|
+
const toNamedSpace = useCallback(async (alias: string, options?: SpaceNavigationOptions) => {
|
|
38
|
+
if (runtime?.resource?.kind !== 'space' || !runtime.navigate) throw new Error('Open this Space in a current Notis host to navigate.');
|
|
39
|
+
await runtime.navigate({ kind: 'space-reference', alias, documentId: options?.recordKey ?? null, selection: options?.selection ?? null,
|
|
40
|
+
...(options?.params ? { params: options.params } : {}) });
|
|
41
|
+
}, [runtime]);
|
|
42
|
+
|
|
43
|
+
const toSpace = useCallback((spaceId: string, options?: SpaceNavigationOptions) => {
|
|
44
|
+
if (!runtime?.navigate) throw new Error('Open this Space in a current Notis host to navigate.');
|
|
45
|
+
runtime.navigate({ kind: 'space', spaceId, documentId: options?.recordKey ?? null,
|
|
46
|
+
...(options?.params ? { params: options.params } : {}), ...(options?.selection ? { selection: options.selection } : {}) });
|
|
47
|
+
}, [runtime]);
|
|
48
|
+
|
|
28
49
|
const toRoute = useCallback((path: string, options?: { resourceId?: string | null }) => {
|
|
29
50
|
if (runtime?.navigate) {
|
|
30
51
|
runtime.navigate({ kind: 'route', path, resourceId: options?.resourceId ?? null });
|
|
@@ -36,17 +57,11 @@ export function useNotisNavigation(): NavigationActions {
|
|
|
36
57
|
}
|
|
37
58
|
}, [runtime]);
|
|
38
59
|
|
|
39
|
-
const toDocument = useCallback((documentId: string, title?: string | null) => {
|
|
40
|
-
if (runtime?.navigate) {
|
|
41
|
-
runtime.navigate({ kind: 'document', documentId, title: title ?? undefined });
|
|
42
|
-
}
|
|
43
|
-
}, [runtime]);
|
|
44
|
-
|
|
45
60
|
const toApp = useCallback(() => {
|
|
46
61
|
if (runtime?.navigate) {
|
|
47
62
|
runtime.navigate({ kind: 'app' });
|
|
48
63
|
}
|
|
49
64
|
}, [runtime]);
|
|
50
65
|
|
|
51
|
-
return {
|
|
66
|
+
return { toNamedSpace, toSpace, toRoute, toApp };
|
|
52
67
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
4
4
|
import { useNotisRuntime } from '../provider';
|
|
5
5
|
import { createQueryClient, queryKey } from '../queryCache';
|
|
6
|
+
import { spaceQueryKey } from '../space';
|
|
7
|
+
import { localizeQueryError } from '../queryErrors';
|
|
6
8
|
|
|
7
9
|
export interface UseQueryOptions {
|
|
8
10
|
/** Required acknowledgement: only idempotent reads may run on mount or prefetch. */
|
|
@@ -26,7 +28,7 @@ export function useQuery<T>(key: readonly unknown[], read: () => Promise<T>, opt
|
|
|
26
28
|
// Older hosts deliberately get a hook-local cache: no cross-runtime/account reuse.
|
|
27
29
|
const fallback = useMemo(() => createQueryClient(), [runtime]);
|
|
28
30
|
const client = runtime?.queryClient ?? fallback;
|
|
29
|
-
const cacheKey = queryKey(key);
|
|
31
|
+
const cacheKey = queryKey(spaceQueryKey(runtime, key));
|
|
30
32
|
const enabled = Boolean(runtime) && options.enabled !== false && options.readOnly === true;
|
|
31
33
|
const readRef = useRef(read);
|
|
32
34
|
useLayoutEffect(() => { readRef.current = read; });
|
|
@@ -45,12 +47,13 @@ export function useQuery<T>(key: readonly unknown[], read: () => Promise<T>, opt
|
|
|
45
47
|
client.invalidate(cacheKey);
|
|
46
48
|
await fetch().catch(() => {});
|
|
47
49
|
}, [cacheKey, client, enabled, fetch]);
|
|
50
|
+
const error = useMemo(() => localizeQueryError(snapshot.error, runtime?.context?.locale), [snapshot.error, runtime?.context?.locale]);
|
|
48
51
|
return {
|
|
49
52
|
data: enabled ? snapshot.data : undefined,
|
|
50
53
|
hasData: enabled && snapshot.hasData,
|
|
51
54
|
loading: enabled && !snapshot.hasData && !snapshot.error,
|
|
52
55
|
isFetching: enabled && snapshot.isFetching,
|
|
53
|
-
error: enabled ?
|
|
56
|
+
error: enabled ? error : null,
|
|
54
57
|
refetch,
|
|
55
58
|
};
|
|
56
59
|
}
|
|
@@ -62,10 +65,10 @@ export function useQueryClient(): {
|
|
|
62
65
|
} {
|
|
63
66
|
const runtime = useNotisRuntime();
|
|
64
67
|
return useMemo(() => ({
|
|
65
|
-
invalidate: (key?: readonly unknown[]) => runtime?.queryClient?.invalidate(key ? queryKey(key) : undefined),
|
|
68
|
+
invalidate: (key?: readonly unknown[]) => runtime?.queryClient?.invalidate(key ? queryKey(spaceQueryKey(runtime, key)) : undefined),
|
|
66
69
|
prefetch: <T,>(key: readonly unknown[], read: () => Promise<T>, options: UseQueryOptions) => {
|
|
67
70
|
if (!runtime?.queryClient || options.enabled === false || options.readOnly !== true) return Promise.resolve(undefined);
|
|
68
|
-
return runtime.queryClient.prefetch(queryKey(key), read, { staleTimeMs: options.staleTimeMs });
|
|
71
|
+
return runtime.queryClient.prefetch(queryKey(spaceQueryKey(runtime, key)), read, { staleTimeMs: options.staleTimeMs });
|
|
69
72
|
},
|
|
70
73
|
}), [runtime]);
|
|
71
74
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useNotisRuntime } from '../provider';
|
|
5
|
+
import { useQuery, type UseQueryResult } from './useQuery';
|
|
6
|
+
import { SdkQueryError } from '../queryErrors';
|
|
7
|
+
import type { SpaceDefinition, SpaceShownOptions, SpaceShownResult, SpaceViewParam } from '../space';
|
|
8
|
+
|
|
9
|
+
type ParamValue<P> = P extends { type: 'enum'; values: readonly (infer V)[] } ? V
|
|
10
|
+
: P extends { type: 'number' } ? number : P extends { type: 'boolean' } ? boolean : string;
|
|
11
|
+
|
|
12
|
+
/** Typed params of a definition: `useViewParams<typeof definition>()`. */
|
|
13
|
+
export type ViewParamsOf<D extends Pick<SpaceDefinition, 'params'>> = D['params'] extends Record<string, SpaceViewParam>
|
|
14
|
+
? { [K in keyof D['params']]?: ParamValue<D['params'][K]> } : Record<string, string | number | boolean | undefined>;
|
|
15
|
+
|
|
16
|
+
export interface ViewParams<T> {
|
|
17
|
+
/** Declared params only, typed; invalid URL values are left out (their default applies). */
|
|
18
|
+
params: T;
|
|
19
|
+
/** Declared params whose URL value was invalid and ignored. */
|
|
20
|
+
invalid: string[];
|
|
21
|
+
/** Required params without a value. */
|
|
22
|
+
missing: string[];
|
|
23
|
+
declared: Record<string, SpaceViewParam>;
|
|
24
|
+
ready: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RECORD_KEY = /^(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
28
|
+
const NUMBER = /^-?\d+(?:\.\d+)?$/;
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
31
|
+
|
|
32
|
+
function parseValue(declaration: SpaceViewParam, raw: string): string | number | boolean | undefined {
|
|
33
|
+
switch (declaration.type) {
|
|
34
|
+
case 'text': return raw.length <= 500 && !CONTROL.test(raw) ? raw : undefined;
|
|
35
|
+
case 'enum': return declaration.values?.includes(raw) ? raw : undefined;
|
|
36
|
+
case 'number': { const value = Number(raw); return NUMBER.test(raw) && Number.isFinite(value) ? value : undefined; }
|
|
37
|
+
case 'boolean': return raw === 'true' ? true : raw === 'false' ? false : undefined;
|
|
38
|
+
case 'date': {
|
|
39
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return undefined;
|
|
40
|
+
const parsed = new Date(`${raw}T00:00:00Z`);
|
|
41
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === raw ? raw : undefined;
|
|
42
|
+
}
|
|
43
|
+
case 'record': {
|
|
44
|
+
if (!RECORD_KEY.test(raw)) return undefined;
|
|
45
|
+
const hex = raw.replaceAll('-', '').toLowerCase();
|
|
46
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
47
|
+
}
|
|
48
|
+
default: return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Keep declared, valid values only; unknown params are dropped and defaults applied. */
|
|
53
|
+
export function parseViewParams(declared: Record<string, SpaceViewParam> | undefined,
|
|
54
|
+
raw: Record<string, string> | null | undefined): Omit<ViewParams<Record<string, string | number | boolean>>, 'declared' | 'ready'> {
|
|
55
|
+
const params: Record<string, string | number | boolean> = {};
|
|
56
|
+
const invalid: string[] = [], missing: string[] = [];
|
|
57
|
+
for (const [name, declaration] of Object.entries(declared || {})) {
|
|
58
|
+
const value = raw && Object.hasOwn(raw, name) && typeof raw[name] === 'string' && raw[name] !== '' ? raw[name] : undefined;
|
|
59
|
+
const parsed = value === undefined ? undefined : parseValue(declaration, value);
|
|
60
|
+
if (value !== undefined && parsed === undefined) invalid.push(name);
|
|
61
|
+
if (parsed !== undefined) params[name] = parsed;
|
|
62
|
+
else if (declaration.default !== undefined) params[name] = declaration.default;
|
|
63
|
+
else if (declaration.required) missing.push(name);
|
|
64
|
+
}
|
|
65
|
+
return { params, invalid, missing };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The view's declared URL params, typed and validated. Unknown params (such as `?foo=`) never
|
|
70
|
+
* reach the view. Record params are record keys; the server authorizes them where it uses them.
|
|
71
|
+
*/
|
|
72
|
+
export function useViewParams<D extends Pick<SpaceDefinition, 'params'> = SpaceDefinition>(): ViewParams<ViewParamsOf<D>> {
|
|
73
|
+
const runtime = useNotisRuntime();
|
|
74
|
+
const declared = runtime?.space?.params;
|
|
75
|
+
const raw = runtime?.context?.params;
|
|
76
|
+
return useMemo(() => ({ ...parseViewParams(declared, raw), declared: declared || {}, ready: runtime !== null }) as ViewParams<ViewParamsOf<D>>,
|
|
77
|
+
[declared, raw, runtime]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UseShownOptions extends SpaceShownOptions {
|
|
81
|
+
enabled?: boolean;
|
|
82
|
+
staleTimeMs?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True when a declared list has an authorized host reader (Editor or exact record Site). */
|
|
86
|
+
export function useShownAvailable(name: string): boolean {
|
|
87
|
+
const runtime = useNotisRuntime();
|
|
88
|
+
const list = runtime?.space?.shows && Object.hasOwn(runtime.space.shows, name) ? runtime.space.shows[name] : null;
|
|
89
|
+
return runtime?.resource?.kind === 'space' && typeof runtime.shown === 'function' && Boolean(list && 'database' in list);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs exactly the list this view declares in `shows` with the current params, so the declaration
|
|
94
|
+
* is the list the view displays. Only sorting and paging are chosen here.
|
|
95
|
+
*
|
|
96
|
+
* A list this view context cannot read (undeclared, described only, or no host reader) answers
|
|
97
|
+
* with `error` instead of a query that never starts, so the view can show an error state. Missing
|
|
98
|
+
* required params are not an error: they are listed by `useViewParams().missing`.
|
|
99
|
+
*/
|
|
100
|
+
export function useShown<TRow = Record<string, unknown>>(name: string, options: UseShownOptions = {}):
|
|
101
|
+
UseQueryResult<SpaceShownResult & { rows: TRow[] }> {
|
|
102
|
+
const runtime = useNotisRuntime();
|
|
103
|
+
const available = useShownAvailable(name);
|
|
104
|
+
const { params, missing } = useViewParams();
|
|
105
|
+
const { sorts, pageSize, offset, includeContent } = options;
|
|
106
|
+
const list = runtime?.space?.shows && Object.hasOwn(runtime.space.shows, name) ? runtime.space.shows[name] : null;
|
|
107
|
+
const reads = list && 'database' in list && Array.isArray(list.params) ? list.params : null;
|
|
108
|
+
// Rows are keyed by the params the list reads: picking another record in the view keeps the rows
|
|
109
|
+
// already shown instead of reading the same list again. The read itself always sends every param.
|
|
110
|
+
const keyed = useMemo(() => reads ? Object.fromEntries(Object.entries(params).filter(([param]) => reads.includes(param))) : params,
|
|
111
|
+
[params, reads]);
|
|
112
|
+
const result = useQuery(['shown', name, keyed, sorts ?? null, pageSize ?? null, offset ?? null, includeContent ?? false], async () => {
|
|
113
|
+
if (!runtime?.shown) throw new SdkQueryError('listUnavailable');
|
|
114
|
+
return runtime.shown<SpaceShownResult & { rows: TRow[] }>(name, params as Record<string, unknown>,
|
|
115
|
+
{ sorts, pageSize, offset, includeContent });
|
|
116
|
+
}, { readOnly: true, enabled: available && !missing.length && options.enabled !== false, staleTimeMs: options.staleTimeMs });
|
|
117
|
+
const unavailable = runtime !== null && !available && options.enabled !== false;
|
|
118
|
+
const error = useMemo(() => unavailable ? new SdkQueryError('listUnavailable', `The list "${name}" is unavailable in this view: declare it in \`shows\` `
|
|
119
|
+
+ `with a database, or gate it on useShownAvailable("${name}").`, runtime?.context?.locale) : null, [unavailable, name, runtime?.context?.locale]);
|
|
120
|
+
return unavailable ? { ...result, loading: false, isFetching: false, error } : result;
|
|
121
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useNotisRuntime } from '../provider';
|
|
4
|
+
import { useQuery, type UseQueryResult } from './useQuery';
|
|
5
|
+
import {
|
|
6
|
+
SPACE_VIEWER_READ_OPERATIONS,
|
|
7
|
+
type SpaceViewerDatabase,
|
|
8
|
+
type SpaceViewerReadFamily,
|
|
9
|
+
type SpaceViewerReadInput,
|
|
10
|
+
type SpaceViewerReadOperation,
|
|
11
|
+
type SpaceViewerReadResult,
|
|
12
|
+
type SpaceViewerSkill,
|
|
13
|
+
} from '../space';
|
|
14
|
+
|
|
15
|
+
export interface UseViewerReadOptions {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
staleTimeMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Bounded crawl of the viewer's Skill inventory: 50 pages of 100.
|
|
21
|
+
const MAX_SKILL_PAGES = 50;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when this page may read what the signed-in viewer can open: the Space declares
|
|
25
|
+
* `viewerReads` for this family and the host offers it (never on a Site or anonymous link).
|
|
26
|
+
* Render a fallback when false.
|
|
27
|
+
*/
|
|
28
|
+
export function useViewerReadAvailable(family: SpaceViewerReadFamily): boolean {
|
|
29
|
+
const runtime = useNotisRuntime();
|
|
30
|
+
return runtime?.resource?.kind === 'space' && typeof runtime.viewerRead === 'function'
|
|
31
|
+
&& Boolean(runtime.space?.viewerReads?.includes(family));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One cached, read-only viewer read. Results are scoped to the current viewer by the host cache scope. */
|
|
35
|
+
export function useViewerRead<TOperation extends SpaceViewerReadOperation>(operation: TOperation,
|
|
36
|
+
input: SpaceViewerReadInput[TOperation], options: UseViewerReadOptions = {}): UseQueryResult<SpaceViewerReadResult[TOperation]> {
|
|
37
|
+
const runtime = useNotisRuntime();
|
|
38
|
+
const available = useViewerReadAvailable(SPACE_VIEWER_READ_OPERATIONS[operation]);
|
|
39
|
+
return useQuery(['viewer-read', operation, input], async () => {
|
|
40
|
+
if (!runtime?.viewerRead) throw new Error('Viewer reads are unavailable here.');
|
|
41
|
+
return runtime.viewerRead<SpaceViewerReadResult[TOperation]>(operation, input as Record<string, unknown>);
|
|
42
|
+
}, { readOnly: true, enabled: available && options.enabled !== false, staleTimeMs: options.staleTimeMs });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every database the signed-in viewer can open, with its available links and read target. */
|
|
46
|
+
export function useViewerDatabases(options: UseViewerReadOptions = {}): UseQueryResult<SpaceViewerDatabase[]> {
|
|
47
|
+
const runtime = useNotisRuntime();
|
|
48
|
+
const available = useViewerReadAvailable('databases');
|
|
49
|
+
return useQuery(['viewer-databases'], async () => {
|
|
50
|
+
if (!runtime?.viewerRead) throw new Error('Viewer reads are unavailable here.');
|
|
51
|
+
const result = await runtime.viewerRead<SpaceViewerReadResult['list_databases']>('list_databases', {});
|
|
52
|
+
if (!Array.isArray(result?.databases)) throw new Error('The database list is incomplete.');
|
|
53
|
+
return result.databases;
|
|
54
|
+
}, { readOnly: true, enabled: available && options.enabled !== false, staleTimeMs: options.staleTimeMs });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ListSkillsRead = (input: SpaceViewerReadInput['list_skills']) => Promise<SpaceViewerReadResult['list_skills']>;
|
|
58
|
+
|
|
59
|
+
// Content pages read at once. Each one holds an API worker while its snapshots are read, and the API
|
|
60
|
+
// has only a few, so a large inventory never takes them all: two pages, plus one quick cursor read.
|
|
61
|
+
export const SKILL_CONTENT_PAGES_AT_ONCE = 2;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every Skill page, in order. Pages with instructions are slow (each reads its snapshots), so the next
|
|
65
|
+
* page's cursor comes from a quick read without them and that page starts while the previous one is
|
|
66
|
+
* still read, at most SKILL_CONTENT_PAGES_AT_ONCE content pages at a time. A cursor that no longer
|
|
67
|
+
* matches (the list changed meanwhile) drops the pages started from it, waits for them to end and
|
|
68
|
+
* continues from the real cursor.
|
|
69
|
+
*/
|
|
70
|
+
export async function readAllViewerSkills(read: ListSkillsRead,
|
|
71
|
+
{ includeContent, includeDisabled }: { includeContent: boolean; includeDisabled: boolean }): Promise<SpaceViewerSkill[]> {
|
|
72
|
+
const page = (after: string | null, content: boolean) => read({ limit: 100, include_content: content,
|
|
73
|
+
include_disabled: includeDisabled, ...(after ? { after } : {}) }).then(result => {
|
|
74
|
+
if (!Array.isArray(result?.skills)) throw new Error('The Skill list is incomplete.');
|
|
75
|
+
return result;
|
|
76
|
+
});
|
|
77
|
+
const skills: SpaceViewerSkill[] = [];
|
|
78
|
+
// The pages started and not read yet, in order, each with the cursor it started from.
|
|
79
|
+
const started: { after: string | null; result: Promise<SpaceViewerReadResult['list_skills']> }[] = [];
|
|
80
|
+
// Where the next page to start begins, and whether a quick read (or the last page read) says it exists.
|
|
81
|
+
let cursor: string | null = null;
|
|
82
|
+
let known = true;
|
|
83
|
+
let pages = 0;
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (known && started.length < (includeContent ? SKILL_CONTENT_PAGES_AT_ONCE : 1) && pages + started.length < MAX_SKILL_PAGES) {
|
|
86
|
+
const current = { after: cursor, result: page(cursor, includeContent) };
|
|
87
|
+
void current.result.catch(() => {});
|
|
88
|
+
started.push(current);
|
|
89
|
+
known = false;
|
|
90
|
+
if (includeContent) {
|
|
91
|
+
const probe = await page(cursor, false);
|
|
92
|
+
known = Boolean(probe.has_more && probe.next);
|
|
93
|
+
cursor = probe.next ?? null;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const head = started.shift();
|
|
98
|
+
if (!head) break;
|
|
99
|
+
const result = await head.result;
|
|
100
|
+
skills.push(...result.skills);
|
|
101
|
+
pages += 1;
|
|
102
|
+
if (!result.has_more || !result.next) return skills;
|
|
103
|
+
// A page started ahead is kept only when it continues exactly where this one ended; otherwise (and
|
|
104
|
+
// when none was started) the next page starts from this page's own cursor.
|
|
105
|
+
if (started[0]?.after !== result.next) {
|
|
106
|
+
const dropped = started.splice(0);
|
|
107
|
+
await Promise.allSettled(dropped.map(entry => entry.result));
|
|
108
|
+
cursor = result.next;
|
|
109
|
+
known = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw new Error('This account has more Skills than a page can read at once.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every Skill the signed-in viewer can see, all pages. With `includeContent`, `skill_md`
|
|
117
|
+
* is present where the viewer holds content access and null elsewhere (curated, legacy).
|
|
118
|
+
*/
|
|
119
|
+
export function useViewerSkills(options: UseViewerReadOptions & { includeContent?: boolean; includeDisabled?: boolean } = {}):
|
|
120
|
+
UseQueryResult<SpaceViewerSkill[]> {
|
|
121
|
+
const runtime = useNotisRuntime();
|
|
122
|
+
const available = useViewerReadAvailable('skills');
|
|
123
|
+
const includeContent = options.includeContent === true, includeDisabled = options.includeDisabled === true;
|
|
124
|
+
return useQuery(['viewer-skills', includeContent, includeDisabled], async () => {
|
|
125
|
+
if (!runtime?.viewerRead) throw new Error('Viewer reads are unavailable here.');
|
|
126
|
+
return readAllViewerSkills((input: SpaceViewerReadInput['list_skills']) => runtime.viewerRead!('list_skills', input),
|
|
127
|
+
{ includeContent, includeDisabled });
|
|
128
|
+
}, { readOnly: true, enabled: available && options.enabled !== false, staleTimeMs: options.staleTimeMs });
|
|
129
|
+
}
|
package/dist/sdk/src/index.ts
CHANGED
|
@@ -10,6 +10,28 @@
|
|
|
10
10
|
export { NotisProvider, useNotisRuntime } from './provider';
|
|
11
11
|
|
|
12
12
|
// Hooks
|
|
13
|
+
export { useAction, useActionQuery } from './hooks/useAction';
|
|
14
|
+
export { useNativeDocuments, useNativeMutation } from './hooks/useNativeDocuments';
|
|
15
|
+
export type { NativeField, NativeFilter, NativeQuery, NativeInsert, NativeUpdate, NativeDelete, NativeMutationReceipt } from './nativeData';
|
|
16
|
+
export type { SpaceDatabaseCreate, SpaceResourceReference } from './space';
|
|
17
|
+
export type { SpaceActionTemplate, SpaceActionDescriptor, SpaceRuntimeDescriptor, SpaceActionOptions } from './space';
|
|
18
|
+
export type { SpaceDocumentBodyRequest, SpaceDocumentBodyRead, SpaceDocumentBodySave, SpaceDocumentBodyResult } from './space';
|
|
19
|
+
export { useNativeDocumentBody } from './hooks/useNativeDocumentBody';
|
|
20
|
+
export { useViewerRead, useViewerReadAvailable, useViewerDatabases, useViewerSkills } from './hooks/useViewerReads';
|
|
21
|
+
export type { UseViewerReadOptions } from './hooks/useViewerReads';
|
|
22
|
+
export { useViewParams, useShown, useShownAvailable, parseViewParams } from './hooks/useViewParams';
|
|
23
|
+
export type { ViewParams, ViewParamsOf, UseShownOptions } from './hooks/useViewParams';
|
|
24
|
+
export { SPACE_VIEW_SPEC_VERSION } from './space';
|
|
25
|
+
export type { SpaceViewParam, SpaceViewParamType, SpaceShows, SpaceShownDescriptor, SpaceShownOptions, SpaceShownResult,
|
|
26
|
+
SpaceMemoryPolicy, SpaceMemorySnapshot, SpaceChrome, SpaceMarkdownInput } from './space';
|
|
27
|
+
export { SPACE_VIEWER_READ_OPERATIONS } from './space';
|
|
28
|
+
export { SPACE_CLOUD_COMPUTER_OPERATIONS, spaceCloudComputerAllows } from './space';
|
|
29
|
+
export type { SpaceCloudComputerLevel, SpaceCloudComputerOperation, SpaceCloudComputerStatus, SpaceCloudComputerRelease, SpaceCloudComputerRunInput,
|
|
30
|
+
SpaceCloudComputerRunResult, SpaceCloudComputerUploadInput, SpaceCloudComputerUploadResult, SpaceCloudComputerTransport } from './space';
|
|
31
|
+
export type { SpaceViewerReadFamily, SpaceViewerReadOperation, SpaceViewerReadInput, SpaceViewerReadResult, SpaceViewerLink,
|
|
32
|
+
SpaceViewerDatabase, SpaceViewerDatabaseSchema, SpaceViewerSkill, SpaceViewerSkillPage } from './space';
|
|
33
|
+
export { SpaceActionError, SpaceNavigationCancelled, isSpaceNavigationCancelled } from './space';
|
|
34
|
+
export type { SpaceActionFailureDetails } from './space';
|
|
13
35
|
export { useQuery, useQueryClient } from './hooks/useQuery';
|
|
14
36
|
export type { UseQueryOptions, UseQueryResult } from './hooks/useQuery';
|
|
15
37
|
export { createQueryClient, createPrefetchQueue, queryKey } from './queryCache';
|
|
@@ -35,6 +57,8 @@ export { useHandover } from './hooks/useHandover';
|
|
|
35
57
|
export type { UseHandoverResult } from './hooks/useHandover';
|
|
36
58
|
export { useCloudComputer } from './hooks/useCloudComputer';
|
|
37
59
|
export type { UseCloudComputerResult } from './hooks/useCloudComputer';
|
|
60
|
+
export { useCloudComputerShell } from './hooks/useCloudComputerShell';
|
|
61
|
+
export type { UseCloudComputerShellResult } from './hooks/useCloudComputerShell';
|
|
38
62
|
export { useNotisNavigation } from './hooks/useNotisNavigation';
|
|
39
63
|
export { useTopBarSearch } from './hooks/useTopBarSearch';
|
|
40
64
|
export { useBackend } from './hooks/useBackend';
|
|
@@ -53,6 +77,9 @@ export {
|
|
|
53
77
|
export { Markdown } from './components/Markdown';
|
|
54
78
|
export type { MarkdownProps } from './components/Markdown';
|
|
55
79
|
export { DocumentEditor } from './components/DocumentEditor';
|
|
80
|
+
export { DocumentPage, DocumentPageSkeleton, usePrefetchRecord } from './components/DocumentPage';
|
|
81
|
+
export { RecordProperties, HtmlFrame, ReportFrame, ShareControl } from './components/RecordComponents';
|
|
82
|
+
export { RenderChartData } from './components/RenderChartData';
|
|
56
83
|
export { MarkdownEditor } from './components/MarkdownEditor';
|
|
57
84
|
export { NotisSelectionBoundary, NOTIS_CONTEXT_CLIPBOARD_TYPE } from './components/NotisSelectionBoundary';
|
|
58
85
|
export type { NotisSelectionBoundaryProps } from './components/NotisSelectionBoundary';
|
|
@@ -135,6 +162,15 @@ export type {
|
|
|
135
162
|
HandoverPayload,
|
|
136
163
|
HandoverResult,
|
|
137
164
|
NotisDocumentEditorProps,
|
|
165
|
+
NotisDocumentPageProps,
|
|
166
|
+
NotisDocumentPageCrumb,
|
|
167
|
+
NotisDocumentPageMenuItem,
|
|
168
|
+
NotisDocumentPageParts,
|
|
169
|
+
NotisRecordProps,
|
|
170
|
+
NotisRecordPropertiesProps,
|
|
171
|
+
NotisHtmlFrameProps,
|
|
172
|
+
NotisReportFrameProps,
|
|
173
|
+
NotisShareControlProps,
|
|
138
174
|
NotisMarkdownEditorProps,
|
|
139
175
|
NotisMarkdownEditorSavePayload,
|
|
140
176
|
NotisMarkdownEditorSaveResult,
|
|
@@ -5,11 +5,13 @@ import React, {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
8
9
|
useMemo,
|
|
9
10
|
useRef,
|
|
10
11
|
useState,
|
|
11
12
|
type ReactNode,
|
|
12
13
|
} from 'react';
|
|
14
|
+
import { isInteractionElementVisible } from './visibility';
|
|
13
15
|
|
|
14
16
|
export type ShortcutScope = 'app' | 'route' | 'collection' | 'detail' | 'modal';
|
|
15
17
|
|
|
@@ -195,7 +197,8 @@ export function isEditableShortcutEvent(event: Event): boolean {
|
|
|
195
197
|
return true;
|
|
196
198
|
}
|
|
197
199
|
if (tag === 'A' && target.hasAttribute('href')) return true;
|
|
198
|
-
|
|
200
|
+
// A focused separator (a resizable edge) owns its arrow keys.
|
|
201
|
+
return ['button', 'link', 'menuitem', 'separator', 'switch', 'tab'].includes(
|
|
199
202
|
target.getAttribute('role') || '',
|
|
200
203
|
);
|
|
201
204
|
});
|
|
@@ -215,6 +218,25 @@ function consumeShortcut(event: KeyboardEvent, definition: ShortcutDefinition):
|
|
|
215
218
|
void definition.onTrigger(event);
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
// Apps built before collections stopped binding Escape to an empty selection
|
|
222
|
+
// still register it, which swallows Escape for nothing. With collection items
|
|
223
|
+
// rendered and none selected, leave Escape to the enclosing surface (the Portal
|
|
224
|
+
// closes its frontmost layer, then returns to Inbox).
|
|
225
|
+
function isEmptyCollectionClear(shortcut: ShortcutDefinition, ownerDocument: Document, event: KeyboardEvent): boolean {
|
|
226
|
+
if (event.key !== 'Escape' || shortcut.id !== 'collection.clear') return false;
|
|
227
|
+
const items = collectionItems(ownerDocument).filter((item) => isInteractionElementVisible(item as HTMLElement));
|
|
228
|
+
return items.length > 0 && !items.some((item) => item.getAttribute('aria-selected') === 'true');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** App views render in open shadow roots, which document queries do not enter. */
|
|
232
|
+
function collectionItems(root: Document | ShadowRoot): Element[] {
|
|
233
|
+
const items = Array.from(root.querySelectorAll('[data-notis-collection-item-id], [data-notis-row-id]'));
|
|
234
|
+
for (const element of Array.from(root.querySelectorAll('*'))) {
|
|
235
|
+
if (element.shadowRoot) items.push(...collectionItems(element.shadowRoot));
|
|
236
|
+
}
|
|
237
|
+
return items;
|
|
238
|
+
}
|
|
239
|
+
|
|
218
240
|
// Native dialogs make the background inert, but document capture listeners still
|
|
219
241
|
// receive their keyboard events. Leave those events to the dialog and its controls.
|
|
220
242
|
function isDialogEvent(event: KeyboardEvent): boolean {
|
|
@@ -223,10 +245,10 @@ function isDialogEvent(event: KeyboardEvent): boolean {
|
|
|
223
245
|
);
|
|
224
246
|
}
|
|
225
247
|
|
|
226
|
-
export function ShortcutProvider({ children }: { children: ReactNode }) {
|
|
248
|
+
export function ShortcutProvider({ children, isAvailable }: { children: ReactNode; isAvailable?: () => boolean }) {
|
|
227
249
|
const parentRegistry = useContext(ShortcutContext);
|
|
228
250
|
if (parentRegistry) return <>{children}</>;
|
|
229
|
-
return <ShortcutProviderRoot>{children}</ShortcutProviderRoot>;
|
|
251
|
+
return <ShortcutProviderRoot isAvailable={isAvailable}>{children}</ShortcutProviderRoot>;
|
|
230
252
|
}
|
|
231
253
|
|
|
232
254
|
export interface ShortcutHelpEntry {
|
|
@@ -293,7 +315,8 @@ function registerFallbackShortcut(input: Omit<ShortcutRegistration, 'id' | 'orde
|
|
|
293
315
|
const token = chordToken(eventChord(event));
|
|
294
316
|
for (const registration of applicableRegistrations(registrations.values())) {
|
|
295
317
|
for (const shortcut of registration.getShortcuts()) {
|
|
296
|
-
if (shortcut.enabled === false || (editable && !shortcut.allowInEditable) || (event.repeat && !shortcut.allowRepeat)
|
|
318
|
+
if (shortcut.enabled === false || (editable && !shortcut.allowInEditable) || (event.repeat && !shortcut.allowRepeat)
|
|
319
|
+
|| isEmptyCollectionClear(shortcut, ownerDocument, event)) continue;
|
|
297
320
|
const match = parseShortcut(shortcut).some(
|
|
298
321
|
(candidate) => candidate.sequence.length === 1 && chordToken(candidate.sequence[0]!) === token,
|
|
299
322
|
);
|
|
@@ -348,7 +371,7 @@ function collectShortcutHelpEntries(
|
|
|
348
371
|
return entries;
|
|
349
372
|
}
|
|
350
373
|
|
|
351
|
-
function ShortcutProviderRoot({ children }: { children: ReactNode }) {
|
|
374
|
+
function ShortcutProviderRoot({ children, isAvailable }: { children: ReactNode; isAvailable?: () => boolean }) {
|
|
352
375
|
const registrationsRef = useRef(new Map<number, ShortcutRegistration>());
|
|
353
376
|
const nextIdRef = useRef(1);
|
|
354
377
|
const nextOrderRef = useRef(1);
|
|
@@ -356,12 +379,21 @@ function ShortcutProviderRoot({ children }: { children: ReactNode }) {
|
|
|
356
379
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
357
380
|
const helpOpenRef = useRef(false);
|
|
358
381
|
const [, setRegistryVersion] = useState(0);
|
|
382
|
+
const availabilityRef = useRef(isAvailable);
|
|
359
383
|
|
|
360
384
|
const updateHelpOpen = useCallback((open: boolean) => {
|
|
361
385
|
helpOpenRef.current = open;
|
|
362
386
|
setHelpOpen(open);
|
|
363
387
|
}, []);
|
|
364
388
|
|
|
389
|
+
useLayoutEffect(() => {
|
|
390
|
+
availabilityRef.current = isAvailable;
|
|
391
|
+
if (isAvailable?.() === false) {
|
|
392
|
+
sequenceRef.current = { chords: [], at: 0 };
|
|
393
|
+
updateHelpOpen(false);
|
|
394
|
+
}
|
|
395
|
+
}, [isAvailable, updateHelpOpen]);
|
|
396
|
+
|
|
365
397
|
const register = useCallback((input: Omit<ShortcutRegistration, 'id' | 'order'>) => {
|
|
366
398
|
const id = nextIdRef.current++;
|
|
367
399
|
registrationsRef.current.set(id, {
|
|
@@ -378,6 +410,10 @@ function ShortcutProviderRoot({ children }: { children: ReactNode }) {
|
|
|
378
410
|
|
|
379
411
|
useEffect(() => {
|
|
380
412
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
413
|
+
if (availabilityRef.current?.() === false) {
|
|
414
|
+
sequenceRef.current = { chords: [], at: 0 };
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
381
417
|
if (event.defaultPrevented || isDialogEvent(event)) return;
|
|
382
418
|
const editable = isEditableShortcutEvent(event);
|
|
383
419
|
if (!editable && !event.repeat && event.key === '?' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
@@ -407,6 +443,7 @@ function ShortcutProviderRoot({ children }: { children: ReactNode }) {
|
|
|
407
443
|
.filter((shortcut) => shortcut.enabled !== false)
|
|
408
444
|
.filter((shortcut) => !editable || shortcut.allowInEditable)
|
|
409
445
|
.filter((shortcut) => !event.repeat || shortcut.allowRepeat)
|
|
446
|
+
.filter((shortcut) => !isEmptyCollectionClear(shortcut, document, event))
|
|
410
447
|
.flatMap(parseShortcut);
|
|
411
448
|
if (candidates.length === 0) continue;
|
|
412
449
|
|