@shenora/react 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/dist/bridge.d.ts +1 -1
- package/dist/bridge.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/operations.d.ts +256 -0
- package/dist/operations.js +191 -0
- package/dist/store.d.ts +15 -4
- package/dist/store.js +3 -1
- package/dist/useDropZone.d.ts +11 -0
- package/dist/useDropZone.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,80 @@ single component**. A failed `post` has no promise to reject, so it is reported
|
|
|
66
66
|
configureBridge({ onPostError: (failure) => log.error(failure.module, failure.type, failure.error) });
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
### Tracked operations: a ready-made progress store
|
|
70
|
+
|
|
71
|
+
For work the host tracks with `Shenora.Ipc`'s operation registry (an `IModuleContext.Run`/`Start`
|
|
72
|
+
route on the C# side), `useShenoraOperations()` is a `createShenoraStore` instance built the same
|
|
73
|
+
way as the example above — no per-feature event wiring needed:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { useShenoraOperations } from '@shenora/react';
|
|
77
|
+
|
|
78
|
+
const running = useShenoraOperations((s) => s.running); // every in-flight operation
|
|
79
|
+
const waiting = useShenoraOperations((s) => s.waiting); // stopped, awaiting a decision — "needs you", one bucket
|
|
80
|
+
const importJob = useShenoraOperations((s) => s.byId[jobId]); // one, by id
|
|
81
|
+
|
|
82
|
+
useShenoraOperations.actions.cancel(jobId); // only does anything if the op opted into Cancellable
|
|
83
|
+
useShenoraOperations.actions.dismiss(jobId); // decline a waiting offer — refuses a running one
|
|
84
|
+
useShenoraOperations.actions.wait(jobId); // ASK the host to wait running work — refuses anything not running
|
|
85
|
+
useShenoraOperations.actions.clearFinished();
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`operation.progress` is an exported `OperationProgress` (`{ value: number; total?: number; unit?:
|
|
89
|
+
string }`) — import the type rather than re-declaring the shape — the APP's own unit
|
|
90
|
+
(bytes-of-a-known-total, items-of-a-known-total, an absolute count with no known total, or a genuine
|
|
91
|
+
percent), never a kit-assumed percentage: `total` is the denominator when one is known and `undefined`
|
|
92
|
+
when there isn't one, and `unit` is app-defined and uninterpreted, exactly like `kind`. The kit ships
|
|
93
|
+
no percent helper — render a ratio only when you have a `total`:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import type { OperationProgress } from '@shenora/react';
|
|
97
|
+
|
|
98
|
+
function format(progress?: OperationProgress): string {
|
|
99
|
+
if (!progress) return 'starting…';
|
|
100
|
+
return progress.total
|
|
101
|
+
? `${Math.round((progress.value / progress.total) * 100)}%`
|
|
102
|
+
: `${progress.value}${progress.unit ? ` ${progress.unit}` : ''}`; // no known total
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
That division is your own policy, not the kit's, which is why it lives here instead of in `src/`.
|
|
107
|
+
|
|
108
|
+
For the two events the store deliberately does NOT subscribe to — `OPERATION_RESUME_REQUESTED` and
|
|
109
|
+
`OPERATION_WAIT_REQUESTED`, which target the OWNING module's service rather than this generic store —
|
|
110
|
+
the module name and the event vocabulary are exported too, so you match by symbol and stay inside the
|
|
111
|
+
host↔client mirror the wire tests pin:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { OperationEventTypes, OperationModuleName, useShenoraEvent } from '@shenora/react';
|
|
115
|
+
|
|
116
|
+
useShenoraEvent(OperationModuleName, OperationEventTypes.ResumeRequested, (payload) => …);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
It snapshots via `LIST` on first subscribe (so a progress strip that mounts mid-run isn't empty), then
|
|
120
|
+
folds `OPERATION_UPDATED` by id — one subscription however many components read it. The client
|
|
121
|
+
mirrors the host's three bands (design §5A.2): `running` (Active), `waiting` (Waiting), and `finished`
|
|
122
|
+
(Terminal). All three are derived from `byId` on every read, never a second copy to keep in sync —
|
|
123
|
+
`waiting` is a single-status filter, exactly like `running`. `OperationStatus` carries ONE waiting
|
|
124
|
+
value reached ONE way — the host's `IOperation.Wait` on a live operation — so there is no sub-case to
|
|
125
|
+
tell apart. Filtering by your own `module`/`kind` is a plain `Array.filter` over any of them. A
|
|
126
|
+
`waiting` operation carries `waitReason` — an app-defined string, like `kind`, OPTIONAL on the host
|
|
127
|
+
side — for your UI to branch on. The host never prunes a waiting entry on its own: it stays until
|
|
128
|
+
someone resumes, dismisses, or finishes it. `resume`/`dismiss`/`wait` are all fire-and-forget client
|
|
129
|
+
requests — the host's own `IOperation.Wait`/`Resume` (called by whoever owns the operation, hearing
|
|
130
|
+
`OPERATION_WAIT_REQUESTED`/`OPERATION_RESUME_REQUESTED`) is what actually changes the state; asking
|
|
131
|
+
is not acting. `clearFinished`/`resume`/`wait` do not touch local state themselves at all: the host's
|
|
132
|
+
`OPERATION_REMOVED { operationIds }` is the ONE authoritative removal signal the store folds, deleting
|
|
133
|
+
exactly the named ids — `MaxHistory` eviction and `clearFinished`
|
|
134
|
+
publish it, so a long-lived store's mirror of bounded host history cannot drift from what the host
|
|
135
|
+
actually did (this replaced two hand-written optimistic local prunes that a past release carried —
|
|
136
|
+
one of which was this project's only Critical, a `resume` prune that dropped a still-waiting row).
|
|
137
|
+
`dismiss` never needed one, since the host's `Dismiss` publishes an ordinary terminal snapshot over
|
|
138
|
+
the wire, the same as a real cancel. Use `createOperationsStore({ module, scope })` instead of the
|
|
139
|
+
default export if your host renamed `OperationRegistryOptions.ModuleName` or
|
|
140
|
+
you need a scope-filtered instance (a secondary window, an auxiliary session) — `clearFinished`
|
|
141
|
+
forwards that scope so clearing history in one window cannot wipe another's.
|
|
142
|
+
|
|
69
143
|
### Observing the whole stream
|
|
70
144
|
|
|
71
145
|
`useShenoraEvent` and `createShenoraStore` listen for an exact `(module, type)`. When the vocabulary
|
package/dist/bridge.d.ts
CHANGED
|
@@ -96,7 +96,7 @@ export declare class ShenoraBridge {
|
|
|
96
96
|
* Send WITHOUT awaiting a reply, and return the request id.
|
|
97
97
|
*
|
|
98
98
|
* This is the default shape for a desktop shell, and {@link invoke} is the special case — see
|
|
99
|
-
* `docs/
|
|
99
|
+
* `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
|
|
100
100
|
* (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
|
|
101
101
|
* design, because the dispatch pipeline preserves the caller's synchronization context so facades
|
|
102
102
|
* can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
|
package/dist/bridge.js
CHANGED
|
@@ -124,7 +124,7 @@ export class ShenoraBridge {
|
|
|
124
124
|
* Send WITHOUT awaiting a reply, and return the request id.
|
|
125
125
|
*
|
|
126
126
|
* This is the default shape for a desktop shell, and {@link invoke} is the special case — see
|
|
127
|
-
* `docs/
|
|
127
|
+
* `docs/DECISIONS.md` D23. Two reasons: a correlated call carries a deadline
|
|
128
128
|
* (30 s by default) and real work does not; and request/response is UI-THREAD-COUPLED here by
|
|
129
129
|
* design, because the dispatch pipeline preserves the caller's synchronization context so facades
|
|
130
130
|
* can touch the window. Reserve `invoke` for calls that are quick AND safe on the UI thread — the
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
|
5
5
|
export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
|
|
6
6
|
export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
|
|
7
7
|
export { BaseModuleService } from './moduleService.js';
|
|
8
|
+
export { OperationStatuses, OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, type OperationStatus, type OperationLabel, type OperationProgress, type OperationInfo, type OperationsState, type OperationsActions, type OperationsStoreOptions, } from './operations.js';
|
|
8
9
|
export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
|
|
9
10
|
export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
|
|
10
11
|
export { useShenora, useShenoraEvent, useShenoraQuery, type ShenoraQueryResult } from './hooks.js';
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,12 @@ export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
|
9
9
|
export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
|
|
10
10
|
export { createShenoraStore, } from './store.js';
|
|
11
11
|
export { BaseModuleService } from './moduleService.js';
|
|
12
|
+
export { OperationStatuses,
|
|
13
|
+
// The event vocabulary + the default module name. `createOperationsStore` deliberately does NOT
|
|
14
|
+
// subscribe to RESUME_REQUESTED / WAIT_REQUESTED — those target the OWNING module's own service,
|
|
15
|
+
// not the generic store — so the app writing that handler needs both symbols, and until now had
|
|
16
|
+
// neither: it had to hard-code the literals the wire-mirror tests exist to keep it from doing.
|
|
17
|
+
OperationEventTypes, OperationModuleName, createOperationsStore, useShenoraOperations, } from './operations.js';
|
|
12
18
|
export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
|
|
13
19
|
export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
|
|
14
20
|
export { useShenora, useShenoraEvent, useShenoraQuery } from './hooks.js';
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { ShenoraBridge } from './bridge.js';
|
|
2
|
+
import type { ShenoraEventBus } from './eventBus.js';
|
|
3
|
+
import { type ShenoraStore } from './store.js';
|
|
4
|
+
import type { IpcError } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Mirrors `Shenora.Ipc.OperationStatus` (design §4.2) — crosses the wire as its camelCase name for
|
|
7
|
+
* free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
|
|
8
|
+
* needed on either side. Pinned against the host by
|
|
9
|
+
* `WireMirrorTests.Every_operation_status_exists_on_both_sides` — a status added on one side and
|
|
10
|
+
* not the other fails that test by name, not by a green suite that never looked.
|
|
11
|
+
*/
|
|
12
|
+
export declare const OperationStatuses: {
|
|
13
|
+
readonly Running: "running";
|
|
14
|
+
readonly Completed: "completed";
|
|
15
|
+
readonly Failed: "failed";
|
|
16
|
+
readonly Cancelled: "cancelled";
|
|
17
|
+
/**
|
|
18
|
+
* Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
|
|
19
|
+
* (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
|
|
20
|
+
* one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
|
|
21
|
+
* a waiting entry is a pending offer, not something finished.
|
|
22
|
+
*
|
|
23
|
+
* Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
|
|
24
|
+
* body is parked, not gone, so there is always something to resume. Exits via the host's
|
|
25
|
+
* `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
|
|
26
|
+
* complete/fail.
|
|
27
|
+
*
|
|
28
|
+
* Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
|
|
29
|
+
* rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
|
|
30
|
+
* host transition already treated as one band, and the host once also accepted crash-checkpoint
|
|
31
|
+
* entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
|
|
32
|
+
* to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
|
|
33
|
+
* the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
|
|
34
|
+
*/
|
|
35
|
+
readonly Waiting: "waiting";
|
|
36
|
+
};
|
|
37
|
+
/** One of {@link OperationStatuses}. */
|
|
38
|
+
export type OperationStatus = (typeof OperationStatuses)[keyof typeof OperationStatuses];
|
|
39
|
+
/**
|
|
40
|
+
* Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
|
|
41
|
+
* `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
|
|
42
|
+
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
43
|
+
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
44
|
+
*/
|
|
45
|
+
export declare const OperationEventTypes: {
|
|
46
|
+
readonly Updated: "OPERATION_UPDATED";
|
|
47
|
+
readonly ResumeRequested: "OPERATION_RESUME_REQUESTED";
|
|
48
|
+
/**
|
|
49
|
+
* A client asked to wait a running operation (generic-library audit finding 3, renamed from
|
|
50
|
+
* `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
|
|
51
|
+
* actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
|
|
52
|
+
* {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
|
|
53
|
+
* store.
|
|
54
|
+
*/
|
|
55
|
+
readonly WaitRequested: "OPERATION_WAIT_REQUESTED";
|
|
56
|
+
/**
|
|
57
|
+
* One or more operation ids left the host registry with no corresponding `Updated` snapshot —
|
|
58
|
+
* `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
|
|
59
|
+
* generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
|
|
60
|
+
* folds it by deleting those ids, which is what let the two hand-written optimistic prunes
|
|
61
|
+
* (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
|
|
62
|
+
* cannot diverge from what the host actually did, replacing two guesses that could.
|
|
63
|
+
*/
|
|
64
|
+
readonly Removed: "OPERATION_REMOVED";
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
|
|
68
|
+
* `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
|
|
69
|
+
* `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
|
|
70
|
+
* {@link OperationEventTypes}.
|
|
71
|
+
*/
|
|
72
|
+
export declare const OperationRoutes: {
|
|
73
|
+
readonly List: "LIST";
|
|
74
|
+
readonly Cancel: "CANCEL";
|
|
75
|
+
readonly ClearFinished: "CLEAR_FINISHED";
|
|
76
|
+
readonly Resume: "RESUME";
|
|
77
|
+
/** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
|
|
78
|
+
readonly Dismiss: "DISMISS";
|
|
79
|
+
/**
|
|
80
|
+
* Ask the owning module to wait a running operation by id (generic-library audit finding 3,
|
|
81
|
+
* renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
|
|
82
|
+
* Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
|
|
83
|
+
* and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
|
|
84
|
+
*/
|
|
85
|
+
readonly Wait: "WAIT";
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
|
|
89
|
+
* `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
|
|
90
|
+
*/
|
|
91
|
+
export declare const OperationModuleName = "OPERATIONS";
|
|
92
|
+
/**
|
|
93
|
+
* Mirrors `Shenora.Ipc.OperationLabel` — human-facing text the HOST never renders itself: an
|
|
94
|
+
* untranslated fallback plus an app i18n key and interpolation parameters (headless, D13).
|
|
95
|
+
*/
|
|
96
|
+
export interface OperationLabel {
|
|
97
|
+
text?: string;
|
|
98
|
+
key?: string;
|
|
99
|
+
parameters?: Record<string, string>;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Mirrors `Shenora.Ipc.OperationProgress` — how far a tracked operation has gotten, in the APP's own
|
|
103
|
+
* unit, never a kit-assumed percent (generic-library audit, before publish: percent is not the
|
|
104
|
+
* mechanism, it is one way an app happens to measure). `total` is the denominator when one is known;
|
|
105
|
+
* `undefined` means there is NO known total — an absolute count with nothing to divide by (bytes
|
|
106
|
+
* streamed so far off a chunked response, say), never zero. `unit` is app-defined, like `kind`
|
|
107
|
+
* (`'bytes'`, `'files'`, `'percent'`) — the kit never interprets it and ships no percent helper: render
|
|
108
|
+
* a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
|
|
109
|
+
* is the consumer's own policy (see the README example).
|
|
110
|
+
*/
|
|
111
|
+
export interface OperationProgress {
|
|
112
|
+
value: number;
|
|
113
|
+
total?: number;
|
|
114
|
+
unit?: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Mirrors `Shenora.Ipc.OperationInfo` — a full snapshot of one tracked operation. Every lifecycle
|
|
118
|
+
* transition (start, progress, terminal) publishes one of these under `OPERATION_UPDATED`, so the
|
|
119
|
+
* client folds by `id`: last write wins, with no cross-type ordering hazard.
|
|
120
|
+
*/
|
|
121
|
+
export interface OperationInfo {
|
|
122
|
+
id: string;
|
|
123
|
+
module: string;
|
|
124
|
+
kind: string;
|
|
125
|
+
scope?: string;
|
|
126
|
+
status: OperationStatus;
|
|
127
|
+
progress?: OperationProgress;
|
|
128
|
+
title?: OperationLabel;
|
|
129
|
+
detail?: OperationLabel;
|
|
130
|
+
/** Why the operation is `'waiting'` — an app-defined string, like `kind`; the kit never interprets it. */
|
|
131
|
+
waitReason?: string;
|
|
132
|
+
error?: IpcError;
|
|
133
|
+
cancellable: boolean;
|
|
134
|
+
startedAt: string;
|
|
135
|
+
finishedAt?: string;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* State behind {@link useShenoraOperations}. Mirrors the host's THREE bands (design §5A.2):
|
|
139
|
+
*
|
|
140
|
+
* | Band | Getter | Exits |
|
|
141
|
+
* |---|---|---|
|
|
142
|
+
* | Active | {@link running} | complete / fail / cancel / wait |
|
|
143
|
+
* | Waiting — stopped, resumable, awaiting a decision | {@link waiting} | resume / dismiss / complete / fail |
|
|
144
|
+
* | Terminal | {@link finished} | — (prunable via `clearFinished`) |
|
|
145
|
+
*
|
|
146
|
+
* `waiting` is now the WHOLE band, not a union of two getters — the host's `OperationStatus` carries
|
|
147
|
+
* only one waiting value (the former `paused`/`interrupted` pair collapsed into it, since every host
|
|
148
|
+
* transition already treated them as one band). A waiting entry is a pending offer, not finished
|
|
149
|
+
* history: the host never prunes it on its own, and only `Resume` or `Dismiss` removes it. Every
|
|
150
|
+
* getter here is DERIVED from `byId` on every read — never a second copy a fold has to remember to
|
|
151
|
+
* keep in sync. `byId` itself is the only thing any reducer here ever writes.
|
|
152
|
+
*/
|
|
153
|
+
export interface OperationsState {
|
|
154
|
+
byId: Record<string, OperationInfo>;
|
|
155
|
+
/** Every currently-running operation, in `byId` order. */
|
|
156
|
+
readonly running: OperationInfo[];
|
|
157
|
+
/**
|
|
158
|
+
* The WAITING band (design §5A.2): stopped, resumable, awaiting a decision — exactly what
|
|
159
|
+
* `Dismiss`/`RequestResume` both accept, so a status bar can render "needs you" as one bucket.
|
|
160
|
+
* Every entry here reached the band the same way (a live `Wait()` on the host), so there is no
|
|
161
|
+
* sub-case to distinguish — see {@link OperationStatuses.Waiting}.
|
|
162
|
+
*/
|
|
163
|
+
readonly waiting: OperationInfo[];
|
|
164
|
+
/** Every operation that reached a terminal status (completed/failed/cancelled). */
|
|
165
|
+
readonly finished: OperationInfo[];
|
|
166
|
+
}
|
|
167
|
+
/** Fire-and-forget actions exposed on {@link useShenoraOperations}, routed to `OperationsFacade`. */
|
|
168
|
+
export interface OperationsActions {
|
|
169
|
+
/** `CANCEL { operationId }` — the app-level cancel route `ipc-contracts` prescribes. */
|
|
170
|
+
cancel: (operationId: string) => string;
|
|
171
|
+
/**
|
|
172
|
+
* `DISMISS { operationId }` — decline a pending `waiting` offer (design §5A.3), mirroring
|
|
173
|
+
* {@link cancel}'s shape. No optimistic local prune: the host's `Dismiss` transitions the entry to
|
|
174
|
+
* `cancelled` and publishes an ordinary `OPERATION_UPDATED` snapshot for it (same as a real
|
|
175
|
+
* cancel), so the store already folds the result from the wire.
|
|
176
|
+
*/
|
|
177
|
+
dismiss: (operationId: string) => string;
|
|
178
|
+
/**
|
|
179
|
+
* `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
|
|
180
|
+
* configured scope (generic-library audit finding 1) so a scoped store's "clear completed" cannot
|
|
181
|
+
* wipe another scope's history host-side. No local mutation here: the host's
|
|
182
|
+
* `OPERATION_REMOVED` (finding 4) is the only thing that removes a row from this store now — see
|
|
183
|
+
* {@link OperationEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
|
|
184
|
+
* entry, added because removals had no wire event at all; that guess is retired now that one exists.
|
|
185
|
+
*/
|
|
186
|
+
clearFinished: () => string;
|
|
187
|
+
/**
|
|
188
|
+
* `RESUME { operationId }` — ask the host to resume a waiting operation, mirroring {@link wait}.
|
|
189
|
+
* No local mutation: asking never changes state by itself — the owning module's own
|
|
190
|
+
* `IOperation.Resume` publishes the `running` transition, and the store folds it from the wire like
|
|
191
|
+
* any other `OPERATION_UPDATED`.
|
|
192
|
+
*
|
|
193
|
+
* This action carried an optimistic local prune through two designs and was the source of the
|
|
194
|
+
* release's only Critical — it deleted a row the host deliberately kept, making a still-waiting
|
|
195
|
+
* operation unreachable. The prune existed because the host's `RequestResume` was ASYMMETRIC
|
|
196
|
+
* (removing crash-checkpoint entries, keeping live ones). The 0.2.0 design pass cut the checkpoint
|
|
197
|
+
* half, so the asymmetry is gone at the source and there is nothing left for a client to mirror.
|
|
198
|
+
*/
|
|
199
|
+
resume: (operationId: string) => string;
|
|
200
|
+
/**
|
|
201
|
+
* `WAIT { operationId }` — ask the owning module to wait a running operation (generic-library
|
|
202
|
+
* audit finding 3, renamed from `pause`), mirroring {@link dismiss}'s shape. No optimistic local
|
|
203
|
+
* prune: asking never changes the state by itself — the owning module's own `IOperation.Wait` is
|
|
204
|
+
* what publishes the `waiting` transition, and the store folds it from the wire like any other
|
|
205
|
+
* `OPERATION_UPDATED`.
|
|
206
|
+
*/
|
|
207
|
+
wait: (operationId: string) => string;
|
|
208
|
+
}
|
|
209
|
+
/** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createOperationsStore}. */
|
|
210
|
+
export interface OperationsStoreOptions {
|
|
211
|
+
/**
|
|
212
|
+
* The request/event module this store talks to. Must match the host's
|
|
213
|
+
* `OperationRegistryOptions.ModuleName` — default `'OPERATIONS'` on both sides — when an app
|
|
214
|
+
* renamed it to avoid a collision with one of its own module names (the duplicate-module guard
|
|
215
|
+
* `OperationsFacade`'s own docs describe). A store bound to the default name cannot reach a
|
|
216
|
+
* renamed host at all, which is exactly the gap this field closes.
|
|
217
|
+
*/
|
|
218
|
+
module?: string;
|
|
219
|
+
/**
|
|
220
|
+
* Optional app-defined scope, applied to THREE places so the store stays internally consistent:
|
|
221
|
+
* the bus subscription (only deltas whose event scope matches are folded), the actions' request
|
|
222
|
+
* envelope, and the initial `LIST` snapshot's payload (`OperationsFacade` reads its scope filter
|
|
223
|
+
* from the payload, not the envelope — see `OperationsFacade.RouteMessageAsync`). Threading it
|
|
224
|
+
* into only the first two would load every scope on first subscribe and never remove the
|
|
225
|
+
* out-of-scope rows, since no delta for them ever arrives: a silent, permanent leak.
|
|
226
|
+
*/
|
|
227
|
+
scope?: string;
|
|
228
|
+
/** Test/multi-transport seams. Default: the shared bridge and event bus. */
|
|
229
|
+
bridge?: ShenoraBridge;
|
|
230
|
+
bus?: ShenoraEventBus;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Build a store instance over the operations module — the factory {@link useShenoraOperations}
|
|
234
|
+
* itself is built from. Exposed (rather than only the ready-made hook) for the same reason
|
|
235
|
+
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
236
|
+
* (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
|
|
237
|
+
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
238
|
+
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
239
|
+
* stuck with the shared, unscoped default.
|
|
240
|
+
*/
|
|
241
|
+
export declare function createOperationsStore(options?: OperationsStoreOptions): ShenoraStore<OperationsState, OperationsActions>;
|
|
242
|
+
/**
|
|
243
|
+
* The client side of the operations primitive (design §4.6, `OperationsFacade` +
|
|
244
|
+
* `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
|
|
245
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
246
|
+
* because the host is authoritative (the store primitive's own late-mounter case is now
|
|
247
|
+
* host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
|
|
248
|
+
* bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
|
|
249
|
+
* (every entry in it reached the band the same way, so there is no sub-case to filter for; read
|
|
250
|
+
* `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
|
|
251
|
+
* {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
|
|
252
|
+
*
|
|
253
|
+
* Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
|
|
254
|
+
* IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
|
|
255
|
+
*/
|
|
256
|
+
export declare const useShenoraOperations: ShenoraStore<OperationsState, OperationsActions>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createShenoraStore } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors `Shenora.Ipc.OperationStatus` (design §4.2) — crosses the wire as its camelCase name for
|
|
4
|
+
* free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
|
|
5
|
+
* needed on either side. Pinned against the host by
|
|
6
|
+
* `WireMirrorTests.Every_operation_status_exists_on_both_sides` — a status added on one side and
|
|
7
|
+
* not the other fails that test by name, not by a green suite that never looked.
|
|
8
|
+
*/
|
|
9
|
+
export const OperationStatuses = {
|
|
10
|
+
Running: 'running',
|
|
11
|
+
Completed: 'completed',
|
|
12
|
+
Failed: 'failed',
|
|
13
|
+
Cancelled: 'cancelled',
|
|
14
|
+
/**
|
|
15
|
+
* Not progressing, awaiting a decision — the APP names why via {@link OperationInfo.waitReason}
|
|
16
|
+
* (e.g. `'credentials'`/`'dns'`/`'queued'`/`'rate-limited'`), never a kit-owned reason enum. Not
|
|
17
|
+
* one of the terminal statuses in {@link OperationsState.finished}, and never pruned as history —
|
|
18
|
+
* a waiting entry is a pending offer, not something finished.
|
|
19
|
+
*
|
|
20
|
+
* Reached ONE way, and that is the point: the host's `IOperation.Wait` on a live operation. The
|
|
21
|
+
* body is parked, not gone, so there is always something to resume. Exits via the host's
|
|
22
|
+
* `IOperation.Resume` (back to `running`), the `DISMISS` route (to `cancelled`), or a direct
|
|
23
|
+
* complete/fail.
|
|
24
|
+
*
|
|
25
|
+
* Two simplifications got it here, both undoing the same mistake — encoding HOW an entry arrived
|
|
26
|
+
* rather than WHAT state it is in. It was once two values (`paused` and `interrupted`) that every
|
|
27
|
+
* host transition already treated as one band, and the host once also accepted crash-checkpoint
|
|
28
|
+
* entries with no live body at all (`RegisterWaiting` + `resumePayload`), which forced every caller
|
|
29
|
+
* to answer "does this one have a handle?". The checkpoint half was cut in 0.2.0: crash recovery is
|
|
30
|
+
* the APP's business — it owns the checkpoint, and a resumed run is a fresh `Start()` like any other.
|
|
31
|
+
*/
|
|
32
|
+
Waiting: 'waiting',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Mirrors `Shenora.Ipc.OperationEvents` — pinned against the host by
|
|
36
|
+
* `WireMirrorTests.Operation_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
|
|
37
|
+
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
38
|
+
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
39
|
+
*/
|
|
40
|
+
export const OperationEventTypes = {
|
|
41
|
+
Updated: 'OPERATION_UPDATED',
|
|
42
|
+
ResumeRequested: 'OPERATION_RESUME_REQUESTED',
|
|
43
|
+
/**
|
|
44
|
+
* A client asked to wait a running operation (generic-library audit finding 3, renamed from
|
|
45
|
+
* `PAUSE_REQUESTED`) — the owning module should call the host's `IOperation.Wait` once it has
|
|
46
|
+
* actually stopped. Not subscribed by {@link createOperationsStore} itself, same as
|
|
47
|
+
* {@link ResumeRequested}: it targets the owning module's own service, not the generic operations
|
|
48
|
+
* store.
|
|
49
|
+
*/
|
|
50
|
+
WaitRequested: 'OPERATION_WAIT_REQUESTED',
|
|
51
|
+
/**
|
|
52
|
+
* One or more operation ids left the host registry with no corresponding `Updated` snapshot —
|
|
53
|
+
* `MaxHistory` eviction, `CLEAR_FINISHED`, and the interrupted-entry drop on `RESUME` (Finding 4,
|
|
54
|
+
* generic-library audit). Payload is `{ operationIds: string[] }`; {@link createOperationsStore}
|
|
55
|
+
* folds it by deleting those ids, which is what let the two hand-written optimistic prunes
|
|
56
|
+
* (`clearFinished`/`resume` used to carry one each) be removed — one authoritative event that
|
|
57
|
+
* cannot diverge from what the host actually did, replacing two guesses that could.
|
|
58
|
+
*/
|
|
59
|
+
Removed: 'OPERATION_REMOVED',
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Mirrors the route names `Shenora.Ipc.OperationsFacade` switches on (its own
|
|
63
|
+
* `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
|
|
64
|
+
* `WireMirrorTests.Operation_route_names_match_the_hosts_facade`, same rationale as
|
|
65
|
+
* {@link OperationEventTypes}.
|
|
66
|
+
*/
|
|
67
|
+
export const OperationRoutes = {
|
|
68
|
+
List: 'LIST',
|
|
69
|
+
Cancel: 'CANCEL',
|
|
70
|
+
ClearFinished: 'CLEAR_FINISHED',
|
|
71
|
+
Resume: 'RESUME',
|
|
72
|
+
/** Decline a pending Waiting offer by id — mirrors {@link Cancel}'s shape. */
|
|
73
|
+
Dismiss: 'DISMISS',
|
|
74
|
+
/**
|
|
75
|
+
* Ask the owning module to wait a running operation by id (generic-library audit finding 3,
|
|
76
|
+
* renamed from `PAUSE`) — mirrors {@link Resume}'s shape (`{ operationId }` → `{ requested }`).
|
|
77
|
+
* Asking is not acting: the owning module's own `IOperation.Wait` is what actually stops the work
|
|
78
|
+
* and publishes the transition, same split as {@link Resume} vs the host's `IOperation.Resume`.
|
|
79
|
+
*/
|
|
80
|
+
Wait: 'WAIT',
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Default `Shenora.Ipc.OperationRegistryOptions.ModuleName` — pinned by
|
|
84
|
+
* `WireMirrorTests.The_default_operations_module_name_matches_the_host`.
|
|
85
|
+
*/
|
|
86
|
+
export const OperationModuleName = 'OPERATIONS';
|
|
87
|
+
/**
|
|
88
|
+
* Statuses that count as finished history. Deliberately excludes `waiting` — a resumable operation
|
|
89
|
+
* (whether a live `Wait()` or a crash-announced checkpoint) is a pending offer, "distinct from
|
|
90
|
+
* finished history" (`Shenora.Ipc.OperationStatus.Waiting`), not a terminal outcome.
|
|
91
|
+
*/
|
|
92
|
+
const TERMINAL_STATUSES = new Set([
|
|
93
|
+
OperationStatuses.Completed,
|
|
94
|
+
OperationStatuses.Failed,
|
|
95
|
+
OperationStatuses.Cancelled,
|
|
96
|
+
]);
|
|
97
|
+
function index(list) {
|
|
98
|
+
const byId = {};
|
|
99
|
+
for (const operation of list)
|
|
100
|
+
byId[operation.id] = operation;
|
|
101
|
+
return byId;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The one place `running`/`waiting`/`finished` are computed — wrap `byId` here, nowhere else.
|
|
105
|
+
*/
|
|
106
|
+
function makeState(byId) {
|
|
107
|
+
return {
|
|
108
|
+
byId,
|
|
109
|
+
get running() {
|
|
110
|
+
return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Running);
|
|
111
|
+
},
|
|
112
|
+
get waiting() {
|
|
113
|
+
return Object.values(byId).filter((operation) => operation.status === OperationStatuses.Waiting);
|
|
114
|
+
},
|
|
115
|
+
get finished() {
|
|
116
|
+
return Object.values(byId).filter((operation) => TERMINAL_STATUSES.has(operation.status));
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Build a store instance over the operations module — the factory {@link useShenoraOperations}
|
|
122
|
+
* itself is built from. Exposed (rather than only the ready-made hook) for the same reason
|
|
123
|
+
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
124
|
+
* (`operations.test.ts`), an app that renamed the host's `OperationRegistryOptions.ModuleName`
|
|
125
|
+
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
126
|
+
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
127
|
+
* stuck with the shared, unscoped default.
|
|
128
|
+
*/
|
|
129
|
+
export function createOperationsStore(options = {}) {
|
|
130
|
+
const module = options.module ?? OperationModuleName;
|
|
131
|
+
return createShenoraStore(module, {
|
|
132
|
+
initial: makeState({}),
|
|
133
|
+
// LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
|
|
134
|
+
// that mounts while work is already running gets it from here before folding any deltas.
|
|
135
|
+
// The payload carries `scope` so the initial load is filtered the SAME way the deltas are
|
|
136
|
+
// (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
|
|
137
|
+
// scoped store loads every scope once and then never sheds the out-of-scope rows.
|
|
138
|
+
snapshot: {
|
|
139
|
+
type: OperationRoutes.List,
|
|
140
|
+
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
141
|
+
apply: (_state, data) => makeState(index(data)),
|
|
142
|
+
},
|
|
143
|
+
on: {
|
|
144
|
+
// ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
|
|
145
|
+
// no ordering logic and no cross-type races.
|
|
146
|
+
[OperationEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
|
|
147
|
+
// The ONE removal delta (Finding 4, generic-library audit), replacing the two hand-written
|
|
148
|
+
// optimistic prunes `clearFinished`/`resume` used to carry (see their own docs below) — deletes
|
|
149
|
+
// exactly the ids the host named, regardless of status; an id this store never had is a no-op.
|
|
150
|
+
[OperationEventTypes.Removed]: (state, payload) => {
|
|
151
|
+
const byId = { ...state.byId };
|
|
152
|
+
for (const id of payload.operationIds)
|
|
153
|
+
delete byId[id];
|
|
154
|
+
return makeState(byId);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
actions: ({ post }) => ({
|
|
158
|
+
cancel: (operationId) => post(OperationRoutes.Cancel, { payload: { operationId } }),
|
|
159
|
+
dismiss: (operationId) => post(OperationRoutes.Dismiss, { payload: { operationId } }),
|
|
160
|
+
wait: (operationId) => post(OperationRoutes.Wait, { payload: { operationId } }),
|
|
161
|
+
clearFinished: () =>
|
|
162
|
+
// Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
|
|
163
|
+
// key the LIST snapshot payload already carries above. No local mutation here any more
|
|
164
|
+
// (Finding 4): the host's OPERATION_REMOVED is the ONLY thing that removes a row now, which
|
|
165
|
+
// is also what makes the scope threading safe to add — nothing here can diverge from what
|
|
166
|
+
// the host actually cleared.
|
|
167
|
+
post(OperationRoutes.ClearFinished, {
|
|
168
|
+
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
169
|
+
}),
|
|
170
|
+
resume: (operationId) => post(OperationRoutes.Resume, { payload: { operationId } }),
|
|
171
|
+
}),
|
|
172
|
+
scope: options.scope,
|
|
173
|
+
bridge: options.bridge,
|
|
174
|
+
bus: options.bus,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The client side of the operations primitive (design §4.6, `OperationsFacade` +
|
|
179
|
+
* `IOperationRegistry`): snapshots via `LIST` on first subscribe, then folds `OPERATION_UPDATED` by
|
|
180
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
181
|
+
* because the host is authoritative (the store primitive's own late-mounter case is now
|
|
182
|
+
* host-backed end to end). `running`/`waiting`/`finished` are selectors an activity panel or status
|
|
183
|
+
* bar reads directly: `useShenoraOperations((s) => s.waiting)` for the one "needs you" bucket
|
|
184
|
+
* (every entry in it reached the band the same way, so there is no sub-case to filter for; read
|
|
185
|
+
* `waitReason` for WHY it is waiting). Bound to the default module/no scope — use
|
|
186
|
+
* {@link createOperationsStore} directly for a renamed module or a scope-filtered instance.
|
|
187
|
+
*
|
|
188
|
+
* Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
|
|
189
|
+
* IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
|
|
190
|
+
*/
|
|
191
|
+
export const useShenoraOperations = createOperationsStore();
|
package/dist/store.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type ShenoraBridge, type PostOptions } from './bridge.js';
|
|
2
2
|
import { type ShenoraEventBus } from './eventBus.js';
|
|
3
3
|
import type { EventMessage } from './types.js';
|
|
4
|
-
/** The IPC handles a store's `actions` are built over. */
|
|
5
|
-
export interface ShenoraStoreIo {
|
|
4
|
+
/** The IPC handles + local-state seam a store's `actions` are built over. */
|
|
5
|
+
export interface ShenoraStoreIo<TState = unknown> {
|
|
6
6
|
/** Fire-and-forget send to this store's module. Returns the request id. */
|
|
7
7
|
post: <TPayload = unknown>(type: string, options?: PostOptions<TPayload>) => string;
|
|
8
8
|
/** Correlated request to this store's module — for calls that are quick AND UI-thread-safe. */
|
|
@@ -10,6 +10,17 @@ export interface ShenoraStoreIo {
|
|
|
10
10
|
payload?: TPayload;
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
}) => Promise<TData>;
|
|
13
|
+
/** Current state — for an action that needs to compute an OPTIMISTIC update from it. */
|
|
14
|
+
getState: () => TState;
|
|
15
|
+
/**
|
|
16
|
+
* Apply a local state change with NO host round trip and NO wire event — the seam for an
|
|
17
|
+
* optimistic update an action can fully decide by itself (e.g. dropping the rows a
|
|
18
|
+
* `CLEAR_FINISHED`-style action just told the host to drop). The host stays authoritative for
|
|
19
|
+
* everything a `snapshot`/`on` reducer already covers; this exists for the narrow case where the
|
|
20
|
+
* action already knows the answer and a host round trip would only be a delivery delay, not new
|
|
21
|
+
* information.
|
|
22
|
+
*/
|
|
23
|
+
setState: (reduce: (state: TState) => TState) => void;
|
|
13
24
|
}
|
|
14
25
|
/** How a store loads the state that already exists before anyone was watching. */
|
|
15
26
|
export interface ShenoraStoreSnapshot<TState> {
|
|
@@ -34,7 +45,7 @@ export interface ShenoraStoreOptions<TState, TActions> {
|
|
|
34
45
|
/** Event type (within this module) → PURE reducer over state. */
|
|
35
46
|
on?: Record<string, (state: TState, payload: never, event: EventMessage) => TState>;
|
|
36
47
|
/** Fire-and-forget senders / requests, exposed on the returned hook. */
|
|
37
|
-
actions?: (io: ShenoraStoreIo) => TActions;
|
|
48
|
+
actions?: (io: ShenoraStoreIo<TState>) => TActions;
|
|
38
49
|
/** Optional app-defined routing scope, applied to both the subscriptions and the sends. */
|
|
39
50
|
scope?: string;
|
|
40
51
|
/** Test/multi-transport seams. Default: the shared bridge and event bus. */
|
|
@@ -64,7 +75,7 @@ export interface ShenoraStore<TState, TActions> {
|
|
|
64
75
|
* A store fed by one module's host event stream, shared by every component that reads it.
|
|
65
76
|
*
|
|
66
77
|
* This is the shape a desktop app needs and the one three sibling apps each hand-built before it
|
|
67
|
-
* existed here — see
|
|
78
|
+
* existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
|
|
68
79
|
* because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
|
|
69
80
|
* progress strip want the same live state, and without a shared store each re-implements the wiring,
|
|
70
81
|
* each opens its own subscription, and each starts empty.
|
package/dist/store.js
CHANGED
|
@@ -5,7 +5,7 @@ import { eventBus as defaultEventBus } from './eventBus.js';
|
|
|
5
5
|
* A store fed by one module's host event stream, shared by every component that reads it.
|
|
6
6
|
*
|
|
7
7
|
* This is the shape a desktop app needs and the one three sibling apps each hand-built before it
|
|
8
|
-
* existed here — see
|
|
8
|
+
* existed here — see `.claude/knowledge/generic-library.md`'s worked example for that survey. It exists
|
|
9
9
|
* because status- and progress-driven UI is inherently MANY-WATCHERS: a full panel and a compact
|
|
10
10
|
* progress strip want the same live state, and without a shared store each re-implements the wiring,
|
|
11
11
|
* each opens its own subscription, and each starts empty.
|
|
@@ -116,6 +116,8 @@ export function createShenoraStore(module, options) {
|
|
|
116
116
|
const io = {
|
|
117
117
|
post: (type, postOptions) => bridge().post(module, type, { scope, ...postOptions }),
|
|
118
118
|
invoke: (type, invokeOptions) => bridge().invoke(module, type, { scope, ...invokeOptions }),
|
|
119
|
+
getState,
|
|
120
|
+
setState: (reduce) => setState(reduce(getState())),
|
|
119
121
|
};
|
|
120
122
|
function useStore(selector) {
|
|
121
123
|
// getSnapshot must return a STABLE value for an unchanged store, or React throws
|
package/dist/useDropZone.d.ts
CHANGED
|
@@ -42,6 +42,17 @@ export interface UseDropZoneOptions {
|
|
|
42
42
|
bus?: ShenoraEventBus;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
+
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
46
|
+
* it is not an alternative here, it is the thing this exists to replace.**
|
|
47
|
+
*
|
|
48
|
+
* A page-side `onDrop` gets a `File` handle whose only accessor is its CONTENT. In a shell
|
|
49
|
+
* architecture the page is UI and the host does the file work, so those bytes have to be read into
|
|
50
|
+
* the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
|
|
51
|
+
* at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
|
|
52
|
+
* extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
|
|
53
|
+
* host could have opened off the same disk. This hook gives you `string[]` paths instead — open
|
|
54
|
+
* lazily, stream, hash incrementally, move or link without copying, watch for changes.
|
|
55
|
+
*
|
|
45
56
|
* Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
|
|
46
57
|
* (its fix-history comments kept below): the host positions a transparent WinForms overlay
|
|
47
58
|
* over the element to capture REAL OS file paths — including drags started while the app is in
|
package/dist/useDropZone.js
CHANGED
|
@@ -6,6 +6,17 @@ import { debounce, randomId } from './internal.js';
|
|
|
6
6
|
export const DROP_ZONE_MODULE = 'DROP_ZONE';
|
|
7
7
|
const newZoneId = () => randomId('drop-zone-');
|
|
8
8
|
/**
|
|
9
|
+
* **The file-drop API for a Shenora page. Do not use the DOM's own drop event for files —
|
|
10
|
+
* it is not an alternative here, it is the thing this exists to replace.**
|
|
11
|
+
*
|
|
12
|
+
* A page-side `onDrop` gets a `File` handle whose only accessor is its CONTENT. In a shell
|
|
13
|
+
* architecture the page is UI and the host does the file work, so those bytes have to be read into
|
|
14
|
+
* the renderer and then pushed across the IPC boundary: a full copy of every dropped file, EAGERLY,
|
|
15
|
+
* at drop time, before the app knows whether it wants any of them. Drop 200 files to filter by
|
|
16
|
+
* extension and you pay for all 200; drop a multi-GB asset and you pay that, to reach a file the
|
|
17
|
+
* host could have opened off the same disk. This hook gives you `string[]` paths instead — open
|
|
18
|
+
* lazily, stream, hash incrementally, move or link without copying, watch for changes.
|
|
19
|
+
*
|
|
9
20
|
* Sync a native drop-zone overlay to a page element, ported from the primary desktop sibling
|
|
10
21
|
* (its fix-history comments kept below): the host positions a transparent WinForms overlay
|
|
11
22
|
* over the element to capture REAL OS file paths — including drags started while the app is in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shenora/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React client for Shenora desktop hosts: correlated invoke/send/subscribe over the WebView2 bridge, typed module services, hooks, and a browser fallback for pure-UI development.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jiarong Gu",
|