@immediately-run/grove 0.2.2 → 0.2.3
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/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @immediately-run/grove — the viewer kit for directory-as-content wikis
|
|
2
2
|
|
|
3
|
-
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.2.
|
|
3
|
+
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.2.3)
|
|
4
4
|
|
|
5
5
|
Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
|
|
6
6
|
spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@immediately-run/grove",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/main.tsx",
|
|
6
6
|
"immediately.run": {
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
"version": "^1"
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
+
"opens": [
|
|
21
|
+
{
|
|
22
|
+
"app": "self",
|
|
23
|
+
"version": "^1"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
20
26
|
"requests": {
|
|
21
27
|
"task:invoke": {},
|
|
22
28
|
"llm:chat": {}
|
package/src/App.tsx
CHANGED
|
@@ -107,7 +107,7 @@ export default function App() {
|
|
|
107
107
|
// hackability). §2's "single-map merge, never provider nesting" was written against
|
|
108
108
|
// patching a provider in AFTER a partial render; nesting it INSIDE the gate above is
|
|
109
109
|
// that same merge, composed before anything paints.
|
|
110
|
-
const wiki = <GroveWiki
|
|
110
|
+
const wiki = <GroveWiki rejectedComponents={contentComponents.rejected} />;
|
|
111
111
|
const withComponents =
|
|
112
112
|
contentComponents.status === 'ready' && Object.keys(contentComponents.components).length > 0 ? (
|
|
113
113
|
<MDXProvider components={contentComponents.components}>{wiki}</MDXProvider>
|
package/src/GroveWiki.tsx
CHANGED
|
@@ -80,10 +80,8 @@ function bundleHref(bundlePath: string): string {
|
|
|
80
80
|
* (R3-169: under dispatch the root is a runtime value, not `/app/content/`).
|
|
81
81
|
*/
|
|
82
82
|
export default function GroveWiki({
|
|
83
|
-
readOnly = false,
|
|
84
83
|
rejectedComponents = [],
|
|
85
84
|
}: {
|
|
86
|
-
readOnly?: boolean;
|
|
87
85
|
/** Corpus component declarations that could not be loaded (R3-174). Shown, not
|
|
88
86
|
* swallowed: a `<RoadmapBoard/>` that silently never appears is indistinguishable from
|
|
89
87
|
* one nobody wrote, and the author has no other channel to learn which it was. */
|
|
@@ -166,7 +164,7 @@ export default function GroveWiki({
|
|
|
166
164
|
// `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
|
|
167
165
|
// mount change so a live role downgrade hides the affordance instead of producing
|
|
168
166
|
// `EROFS` on click.
|
|
169
|
-
const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance(
|
|
167
|
+
const { writable, busy: editBusy, refused: editRefused, openEditor, editHint } = useEditAffordance();
|
|
170
168
|
|
|
171
169
|
const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
|
|
172
170
|
// The site brand is a wiki-wide constant, so read it from the home entry's
|
|
@@ -126,7 +126,7 @@ export default function EntryBody({ entryKey }: { entryKey: string }) {
|
|
|
126
126
|
* from the live mount set (the hook only runs on this path — no double
|
|
127
127
|
* subscription on the stock page). */
|
|
128
128
|
function StandaloneHeader({ entryKey, mins }: { entryKey: string; mins: number }) {
|
|
129
|
-
const local = useEditAffordance(
|
|
129
|
+
const local = useEditAffordance();
|
|
130
130
|
return (
|
|
131
131
|
<EntryHeader
|
|
132
132
|
entryKey={entryKey}
|
|
@@ -288,7 +288,7 @@ describe('R3-608 — the composer stops the run; a refusal surfaces, a cancel do
|
|
|
288
288
|
const { useEditAffordance } = await import('../hooks/useEditAffordance');
|
|
289
289
|
const refusedStates: boolean[] = [];
|
|
290
290
|
function Probe() {
|
|
291
|
-
const aff = useEditAffordance(
|
|
291
|
+
const aff = useEditAffordance();
|
|
292
292
|
refusedStates.push(aff.refused);
|
|
293
293
|
return (
|
|
294
294
|
<div>
|
|
@@ -3,36 +3,51 @@
|
|
|
3
3
|
// delegation: `requestEdit({ bundleFile })`, with the refusal contract pinned:
|
|
4
4
|
// `read-only` hides the affordance until the next mount announcement, `cancelled`
|
|
5
5
|
// stays silent, `forbidden` (and anything else) sets `refused`.
|
|
6
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from
|
|
7
|
-
import { act, useEffect } from
|
|
8
|
-
import { createRoot, type Root } from
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
+
import { act, useEffect } from "react";
|
|
8
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
9
9
|
|
|
10
|
-
import type { EditAffordance } from
|
|
10
|
+
import type { EditAffordance } from "./useEditAffordance";
|
|
11
11
|
|
|
12
12
|
const requestEditMock = vi.fn<(t?: unknown) => Promise<void>>();
|
|
13
13
|
const invokeTaskMock = vi.fn<(a: unknown) => Promise<void>>();
|
|
14
14
|
const useMountsMock = vi.fn<() => unknown[]>();
|
|
15
15
|
|
|
16
|
-
vi.mock(
|
|
16
|
+
vi.mock("@immediately-run/sdk", () => ({
|
|
17
17
|
requestEdit: (a?: unknown) => requestEditMock(a),
|
|
18
18
|
invokeTask: (a: unknown) => invokeTaskMock(a),
|
|
19
|
-
capFile: (ref: unknown, opts: unknown) => ({
|
|
19
|
+
capFile: (ref: unknown, opts: unknown) => ({
|
|
20
|
+
...(ref as object),
|
|
21
|
+
...(opts as object),
|
|
22
|
+
}),
|
|
20
23
|
useMounts: () => useMountsMock(),
|
|
21
24
|
}));
|
|
22
25
|
|
|
23
26
|
// The corpus identity comes from the real contentRoot module, driven through its
|
|
24
27
|
// own producer (R3-877 round 1, R2) — never a hand-typed shape.
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
import {
|
|
29
|
+
getContentRoot,
|
|
30
|
+
resetContentRoot,
|
|
31
|
+
setContentRoot,
|
|
32
|
+
} from "../lib/contentRoot";
|
|
33
|
+
|
|
34
|
+
import { useEditAffordance } from "./useEditAffordance";
|
|
35
|
+
|
|
36
|
+
(
|
|
37
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
38
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
39
|
+
|
|
40
|
+
const RO_MOUNT = {
|
|
41
|
+
type: "task-delegation",
|
|
42
|
+
path: "/task/t1/dir",
|
|
43
|
+
id: "/task/t1/dir",
|
|
44
|
+
mode: "ro",
|
|
45
|
+
readerCanEdit: true,
|
|
46
|
+
};
|
|
32
47
|
|
|
33
48
|
let latest: EditAffordance | null = null;
|
|
34
49
|
function Probe() {
|
|
35
|
-
const affordance = useEditAffordance(
|
|
50
|
+
const affordance = useEditAffordance();
|
|
36
51
|
useEffect(() => {
|
|
37
52
|
latest = affordance;
|
|
38
53
|
});
|
|
@@ -43,12 +58,12 @@ let container: HTMLDivElement;
|
|
|
43
58
|
let root: Root;
|
|
44
59
|
|
|
45
60
|
beforeEach(() => {
|
|
46
|
-
setContentRoot(
|
|
61
|
+
setContentRoot("/task/t1/dir", { mountId: "/task/t1/dir" });
|
|
47
62
|
requestEditMock.mockReset().mockResolvedValue(undefined);
|
|
48
63
|
invokeTaskMock.mockReset().mockResolvedValue(undefined);
|
|
49
64
|
useMountsMock.mockReset().mockReturnValue([RO_MOUNT]);
|
|
50
65
|
latest = null;
|
|
51
|
-
container = document.createElement(
|
|
66
|
+
container = document.createElement("div");
|
|
52
67
|
document.body.appendChild(container);
|
|
53
68
|
root = createRoot(container);
|
|
54
69
|
act(() => root.render(<Probe />));
|
|
@@ -69,23 +84,42 @@ const open = async (key = `${getContentRoot()}plot/the-rail.mdx`) => {
|
|
|
69
84
|
rerender();
|
|
70
85
|
};
|
|
71
86
|
|
|
72
|
-
describe(
|
|
73
|
-
it(
|
|
87
|
+
describe("useEditAffordance — the workbench delivery (R3-877)", () => {
|
|
88
|
+
it("an ro delegation offers Edit and delivers it as requestEdit({ bundleFile })", async () => {
|
|
74
89
|
expect(latest!.writable).toBe(true);
|
|
75
90
|
await open();
|
|
76
|
-
expect(requestEditMock).toHaveBeenCalledWith({
|
|
91
|
+
expect(requestEditMock).toHaveBeenCalledWith({
|
|
92
|
+
bundleFile: "/plot/the-rail.mdx",
|
|
93
|
+
});
|
|
77
94
|
expect(invokeTaskMock).not.toHaveBeenCalled();
|
|
78
95
|
expect(latest!.refused).toBe(false);
|
|
79
96
|
});
|
|
80
97
|
|
|
81
|
-
it(
|
|
98
|
+
it("the ro corpus latch (the boot path of every opener delegation) does not hide the offer — R3-878 regression", async () => {
|
|
99
|
+
// The bug the venue leg caught: the boot latches the corpus's `ro` mode as the
|
|
100
|
+
// contentRoot readOnly flag, and the pre-fix hook gated `writable` on it — so an
|
|
101
|
+
// app-declared opener's chroot (always `ro`, APP_CUSTOMIZATION §5a) never showed
|
|
102
|
+
// the pencil, although the reader's authority may well edit the source.
|
|
103
|
+
resetContentRoot();
|
|
104
|
+
setContentRoot("/task/t1/dir", { readOnly: true, mountId: "/task/t1/dir" });
|
|
105
|
+
rerender();
|
|
106
|
+
expect(latest!.writable).toBe(true);
|
|
107
|
+
await open();
|
|
108
|
+
expect(requestEditMock).toHaveBeenCalledWith({
|
|
109
|
+
bundleFile: "/plot/the-rail.mdx",
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("an ro delegation with `readerCanEdit: false` does not offer the control", () => {
|
|
82
114
|
useMountsMock.mockReturnValue([{ ...RO_MOUNT, readerCanEdit: false }]);
|
|
83
115
|
rerender();
|
|
84
116
|
expect(latest!.writable).toBe(false);
|
|
85
117
|
});
|
|
86
118
|
|
|
87
|
-
it(
|
|
88
|
-
requestEditMock.mockRejectedValue(
|
|
119
|
+
it("a `read-only` refusal hides the affordance until the next mount announcement", async () => {
|
|
120
|
+
requestEditMock.mockRejectedValue(
|
|
121
|
+
Object.assign(new Error("no"), { code: "read-only" }),
|
|
122
|
+
);
|
|
89
123
|
await open();
|
|
90
124
|
expect(latest!.refused).toBe(false); // not a render-in-place refusal
|
|
91
125
|
expect(latest!.writable).toBe(false); // hidden…
|
|
@@ -94,15 +128,19 @@ describe('useEditAffordance — the workbench delivery (R3-877)', () => {
|
|
|
94
128
|
expect(latest!.writable).toBe(true); // …unlatches it.
|
|
95
129
|
});
|
|
96
130
|
|
|
97
|
-
it(
|
|
98
|
-
requestEditMock.mockRejectedValue(
|
|
131
|
+
it("a `cancelled` rejection stays silent (the reader closed the editor)", async () => {
|
|
132
|
+
requestEditMock.mockRejectedValue(
|
|
133
|
+
Object.assign(new Error("closed"), { code: "cancelled" }),
|
|
134
|
+
);
|
|
99
135
|
await open();
|
|
100
136
|
expect(latest!.refused).toBe(false);
|
|
101
137
|
expect(latest!.writable).toBe(true);
|
|
102
138
|
});
|
|
103
139
|
|
|
104
|
-
it(
|
|
105
|
-
requestEditMock.mockRejectedValue(
|
|
140
|
+
it("a `forbidden` refusal sets `refused` (rendered as text in place) and never retries via edit-file", async () => {
|
|
141
|
+
requestEditMock.mockRejectedValue(
|
|
142
|
+
Object.assign(new Error("no"), { code: "forbidden" }),
|
|
143
|
+
);
|
|
106
144
|
await open();
|
|
107
145
|
expect(latest!.refused).toBe(true);
|
|
108
146
|
expect(invokeTaskMock).not.toHaveBeenCalled();
|
|
@@ -13,10 +13,19 @@
|
|
|
13
13
|
// than producing `EROFS` on click — and a `readerCanEdit: false` hint or a `read-only`
|
|
14
14
|
// refusal hides the offer — and it hands back one `openEditor(entryKey)` the chrome
|
|
15
15
|
// calls without knowing which packaging it is in.
|
|
16
|
-
import { useCallback, useMemo, useState } from
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
import { useCallback, useMemo, useState } from "react";
|
|
17
|
+
import {
|
|
18
|
+
capFile,
|
|
19
|
+
invokeTask,
|
|
20
|
+
requestEdit,
|
|
21
|
+
useMounts,
|
|
22
|
+
} from "@immediately-run/sdk";
|
|
23
|
+
import {
|
|
24
|
+
getContentRoot,
|
|
25
|
+
getCorpusMountId,
|
|
26
|
+
isDispatched,
|
|
27
|
+
} from "../lib/contentRoot";
|
|
28
|
+
import { corpusWritable, editTarget } from "../lib/editTarget";
|
|
20
29
|
|
|
21
30
|
export interface EditAffordance {
|
|
22
31
|
/** Whether to render an edit affordance at all — the MOUNT's answer, live. */
|
|
@@ -41,7 +50,7 @@ export interface EditAffordance {
|
|
|
41
50
|
editHint: string;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
export function useEditAffordance(
|
|
53
|
+
export function useEditAffordance(): EditAffordance {
|
|
45
54
|
const mounts = useMounts();
|
|
46
55
|
const [busy, setBusy] = useState(false);
|
|
47
56
|
const [refused, setRefused] = useState(false);
|
|
@@ -54,7 +63,12 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
54
63
|
const corpus = useMemo(() => {
|
|
55
64
|
const mountId = getCorpusMountId();
|
|
56
65
|
const mount = mounts?.find((m) => (m.id ?? m.path) === mountId);
|
|
57
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
dispatched: isDispatched(),
|
|
68
|
+
contentRoot: getContentRoot(),
|
|
69
|
+
mountId,
|
|
70
|
+
mountMode: mount?.mode ?? null,
|
|
71
|
+
};
|
|
58
72
|
}, [mounts]);
|
|
59
73
|
|
|
60
74
|
// R3-877: a `read-only` refusal (the reader cannot edit the source) hides the
|
|
@@ -69,12 +83,18 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
69
83
|
setReaderReadOnly(false);
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
|
|
86
|
+
// No `readOnly` veto here (there was one until R3-878's live leg): the boot-time
|
|
87
|
+
// read-only latch is the corpus delegation's `ro` mode, so gating on it hid the
|
|
88
|
+
// affordance in exactly the one case the workbench delivery exists for — an
|
|
89
|
+
// app-declared opener's chroot is always `ro` (APP_CUSTOMIZATION §5a). The live
|
|
90
|
+
// mount list already answers writability (`corpusWritable`), re-read on every
|
|
91
|
+
// announcement, and a `read-only` refusal latches the offer off above.
|
|
92
|
+
const writable = !readerReadOnly && corpusWritable(mounts, corpus);
|
|
73
93
|
|
|
74
94
|
// A refusal surfaces where the affordance was offered (3.3.1, R3-608);
|
|
75
95
|
// `cancelled` — the reader closing the editor — stays silent by contract.
|
|
76
96
|
const refusedUnlessCancelled = (e: unknown): undefined => {
|
|
77
|
-
if ((e as { code?: string } | null)?.code !==
|
|
97
|
+
if ((e as { code?: string } | null)?.code !== "cancelled") setRefused(true);
|
|
78
98
|
return undefined;
|
|
79
99
|
};
|
|
80
100
|
|
|
@@ -85,10 +105,12 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
85
105
|
setBusy(true);
|
|
86
106
|
setRefused(false);
|
|
87
107
|
const done = () => setBusy(false);
|
|
88
|
-
if (target.via ===
|
|
108
|
+
if (target.via === "self") {
|
|
89
109
|
// The fork: the present→edit transition on our own source. Self-scoped by
|
|
90
110
|
// contract, which is exactly right when the corpus IS our repo.
|
|
91
|
-
requestEdit({ path: target.path })
|
|
111
|
+
requestEdit({ path: target.path })
|
|
112
|
+
.catch(refusedUnlessCancelled)
|
|
113
|
+
.finally(done);
|
|
92
114
|
return;
|
|
93
115
|
}
|
|
94
116
|
// Dispatch, read-only delegation: ask the workbench to open the entry's source
|
|
@@ -97,12 +119,12 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
97
119
|
// which this click is. `cancelled` stays silent; `read-only` hides the
|
|
98
120
|
// affordance until the next mount announcement; anything else renders in place
|
|
99
121
|
// as text (the `refused` flag) — and is never retried through `edit-file`.
|
|
100
|
-
if (target.via ===
|
|
122
|
+
if (target.via === "workbench") {
|
|
101
123
|
requestEdit({ bundleFile: target.relPath })
|
|
102
124
|
.catch((e: unknown) => {
|
|
103
125
|
const code = (e as { code?: string } | null)?.code;
|
|
104
|
-
if (code ===
|
|
105
|
-
if (code ===
|
|
126
|
+
if (code === "cancelled") return;
|
|
127
|
+
if (code === "read-only") {
|
|
106
128
|
setReaderReadOnly(true);
|
|
107
129
|
return;
|
|
108
130
|
}
|
|
@@ -116,8 +138,11 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
116
138
|
// already hold the directory, and `edit-file` is one hop further along a chain
|
|
117
139
|
// §5.7.1 bounds at depth 4. The host resolves the cap against our grants, so
|
|
118
140
|
// this can only ever narrow.
|
|
119
|
-
invokeTask(
|
|
120
|
-
file: capFile(
|
|
141
|
+
invokeTask("edit-file", {
|
|
142
|
+
file: capFile(
|
|
143
|
+
{ mountId: target.mountId, relPath: target.relPath },
|
|
144
|
+
{ mode: "rw" },
|
|
145
|
+
),
|
|
121
146
|
})
|
|
122
147
|
.catch(refusedUnlessCancelled) // `cancelled` is how a reader closes the editor
|
|
123
148
|
.finally(done);
|
|
@@ -126,8 +151,8 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
126
151
|
);
|
|
127
152
|
|
|
128
153
|
const editHint = corpus.dispatched
|
|
129
|
-
?
|
|
130
|
-
:
|
|
154
|
+
? "Edits save to the mounted content, and can be proposed back to its repository as a PR."
|
|
155
|
+
: "Edit this entry";
|
|
131
156
|
|
|
132
157
|
return { writable, busy, refused, openEditor, editHint };
|
|
133
158
|
}
|