@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +120 -84
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +4 -96
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +322 -68
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/view-plan.ts +29 -12
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
package/src/diff-hunk-curate.ts
CHANGED
|
@@ -13,16 +13,12 @@
|
|
|
13
13
|
* the stored git patch and the model split hunks differently.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
16
|
+
import type { FileDiffMetadata } from "@pierre/diffs";
|
|
17
|
+
import type { HunkRejection } from "@cueloop/schema";
|
|
18
18
|
import type { DiffRow } from "./view-diff";
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
export
|
|
22
|
-
path: string;
|
|
23
|
-
hunkIndex: number;
|
|
24
|
-
changeIndex?: number;
|
|
25
|
-
}
|
|
20
|
+
export { curateDiff, parseFileDiff } from "@cueloop/daemon/curate";
|
|
21
|
+
export type { HunkRejection } from "@cueloop/schema";
|
|
26
22
|
|
|
27
23
|
/** Which side of the diff a line number addresses. */
|
|
28
24
|
type DiffSide = "addition" | "deletion";
|
|
@@ -34,24 +30,6 @@ export interface LocatedLine {
|
|
|
34
30
|
changeIndex?: number;
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
/**
|
|
38
|
-
* Context lines around each change. Matching git's default (3) makes the model
|
|
39
|
-
* split hunks like the rendered working-tree patch does; git's coalescing can
|
|
40
|
-
* still merge adjacent hunks differently, but the row is mapped by absolute line
|
|
41
|
-
* number (not by counting hunks), so a whole-hunk reject can only ever span the
|
|
42
|
-
* model hunk it lands in - never the wrong file or a crash.
|
|
43
|
-
*/
|
|
44
|
-
const DIFF_CONTEXT = 3;
|
|
45
|
-
|
|
46
|
-
/** Parse one file's full contents into a non-partial diff model. */
|
|
47
|
-
export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
|
|
48
|
-
return parseDiffFromFile(
|
|
49
|
-
{ name: file.path, contents: file.oldContents },
|
|
50
|
-
{ name: file.path, contents: file.newContents },
|
|
51
|
-
{ context: DIFF_CONTEXT },
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
33
|
/**
|
|
56
34
|
* Resolve a 1-based file line on one side to its hunk and, when the line is
|
|
57
35
|
* part of a change block, that block's `hunkContent` index.
|
|
@@ -173,81 +151,3 @@ export function isRowRejected(
|
|
|
173
151
|
(rejection.changeIndex === undefined || rejection.changeIndex === located.changeIndex),
|
|
174
152
|
);
|
|
175
153
|
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Apply one file's rejections and return its curated patch, or null when the
|
|
179
|
-
* curated content equals the old content (every change rejected - no patch).
|
|
180
|
-
* Whole-hunk rejections supersede change-level ones for the same hunk. Because
|
|
181
|
-
* `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
|
|
182
|
-
* indices stay valid across calls; they are applied high-to-low regardless, so
|
|
183
|
-
* the order is deterministic.
|
|
184
|
-
*/
|
|
185
|
-
function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
|
|
186
|
-
let model = parseFileDiff(file);
|
|
187
|
-
const wholeHunks = new Set(
|
|
188
|
-
rejections
|
|
189
|
-
.filter((rejection) => rejection.changeIndex === undefined)
|
|
190
|
-
.map((rejection) => rejection.hunkIndex),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
|
|
194
|
-
model = diffAcceptRejectHunk(model, hunkIndex, "reject");
|
|
195
|
-
}
|
|
196
|
-
const changeRejections = rejections
|
|
197
|
-
.filter(
|
|
198
|
-
(rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
|
|
199
|
-
)
|
|
200
|
-
.sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
|
|
201
|
-
|
|
202
|
-
for (const rejection of changeRejections) {
|
|
203
|
-
model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
|
|
204
|
-
type: "reject",
|
|
205
|
-
changeIndex: rejection.changeIndex!,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
const curatedNew = model.additionLines.join("");
|
|
209
|
-
|
|
210
|
-
if (curatedNew === file.oldContents) return null;
|
|
211
|
-
const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
|
|
212
|
-
|
|
213
|
-
if (patch === null) return null;
|
|
214
|
-
|
|
215
|
-
return withFileStateHeaders(patch, file.path, file.status, curatedNew);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Point the diff headers at /dev/null for a created or fully-deleted file so the
|
|
220
|
-
* curated patch applies. git's status - not empty contents - decides existence,
|
|
221
|
-
* so editing an existing empty file stays a modify; a deletion whose removals
|
|
222
|
-
* were partly restored also falls back to a modify.
|
|
223
|
-
*/
|
|
224
|
-
function withFileStateHeaders(
|
|
225
|
-
patch: string,
|
|
226
|
-
path: string,
|
|
227
|
-
status: DiffFileStatus,
|
|
228
|
-
curatedNew: string,
|
|
229
|
-
): string {
|
|
230
|
-
if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
|
|
231
|
-
if (status === "deleted" && curatedNew === "") {
|
|
232
|
-
return patch.replace(`+++ b/${path}`, "+++ /dev/null");
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return patch;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* The curated unified-diff patch of the accepted changes across every file.
|
|
240
|
-
* Files with no net change (unchanged, or with all changes rejected) drop out.
|
|
241
|
-
*/
|
|
242
|
-
export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
|
|
243
|
-
const parts: string[] = [];
|
|
244
|
-
|
|
245
|
-
for (const file of files) {
|
|
246
|
-
const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
|
|
247
|
-
const patch = curateFilePatch(file, fileRejections);
|
|
248
|
-
|
|
249
|
-
if (patch) parts.push(patch);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return parts.join("\n");
|
|
253
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/sc
|
|
|
11
11
|
import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
|
|
12
12
|
import type { ControllerSnapshot, CurationItem, ReviewController } from "./session-controller";
|
|
13
13
|
import type { DisplayBlock } from "./view-plan";
|
|
14
|
+
import type { TreeRow } from "./tree-view";
|
|
14
15
|
|
|
15
16
|
function block(text: string): DisplayBlock {
|
|
16
17
|
return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
|
|
@@ -56,7 +57,7 @@ const EMPTY_SNAPSHOT: ControllerSnapshot = {
|
|
|
56
57
|
walk: null,
|
|
57
58
|
};
|
|
58
59
|
|
|
59
|
-
/** A controller where every
|
|
60
|
+
/** A controller where every primitive is a mock; annotate returns undefined by default. */
|
|
60
61
|
function baseController(): ReviewController {
|
|
61
62
|
return {
|
|
62
63
|
readOnly: false,
|
|
@@ -83,6 +84,7 @@ function baseController(): ReviewController {
|
|
|
83
84
|
restoreCuration: mock(),
|
|
84
85
|
edit: mock(),
|
|
85
86
|
annotate: mock(() => undefined),
|
|
87
|
+
reply: mock(() => undefined),
|
|
86
88
|
annotatePrototype: mock(() => undefined),
|
|
87
89
|
updateAnnotation: mock(),
|
|
88
90
|
removeAnnotation: mock(),
|
|
@@ -93,8 +95,14 @@ function baseController(): ReviewController {
|
|
|
93
95
|
walkLeave: mock(),
|
|
94
96
|
submit: mock(),
|
|
95
97
|
share: mock(),
|
|
98
|
+
treeRows: mock(() => []),
|
|
99
|
+
goToEntry: mock(),
|
|
100
|
+
branch: mock(),
|
|
101
|
+
labelTip: mock(),
|
|
102
|
+
fork: mock(),
|
|
103
|
+
forkAndShare: mock(),
|
|
96
104
|
pullShared: mock(() => Promise.resolve()),
|
|
97
|
-
|
|
105
|
+
startShareSync: mock(() => () => {}),
|
|
98
106
|
finishReview: mock(),
|
|
99
107
|
dismissCompletion: mock(),
|
|
100
108
|
optInAutoClose: mock(),
|
|
@@ -120,18 +128,20 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
|
|
|
120
128
|
terminalWidth: 120,
|
|
121
129
|
focusedAnnotationId: undefined,
|
|
122
130
|
selectedCurationId: undefined,
|
|
131
|
+
railTab: "review",
|
|
132
|
+
selectedEntryId: undefined,
|
|
123
133
|
authorNames: {},
|
|
124
134
|
quickActions: [],
|
|
125
135
|
renameAuthor: mock(),
|
|
126
136
|
liveInput: { current: "" },
|
|
127
137
|
reviewWidthRef: { current: 34 },
|
|
128
|
-
planSheetRef: { current: null },
|
|
129
138
|
setCursor: mock(),
|
|
130
139
|
setInboxCursor: mock(),
|
|
131
140
|
setMode: mock(),
|
|
132
141
|
setReviewMode: mock(),
|
|
133
142
|
setReviewWidth: mock(),
|
|
134
143
|
setRailTab: mock(),
|
|
144
|
+
setSelectedEntryId: mock(),
|
|
135
145
|
setFocusedAnnotationId: mock(),
|
|
136
146
|
setSelectedCurationId: mock(),
|
|
137
147
|
setPulsedAnnotationId: mock(),
|
|
@@ -544,3 +554,126 @@ describe("collaborator self-name", () => {
|
|
|
544
554
|
expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
545
555
|
});
|
|
546
556
|
});
|
|
557
|
+
|
|
558
|
+
describe("tree intents", () => {
|
|
559
|
+
const rows: TreeRow[] = [
|
|
560
|
+
{
|
|
561
|
+
entryId: "e1",
|
|
562
|
+
depth: 0,
|
|
563
|
+
glyph: "◉",
|
|
564
|
+
text: "revision 1",
|
|
565
|
+
onPath: true,
|
|
566
|
+
tips: [],
|
|
567
|
+
isCurrentTip: false,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
entryId: "e2",
|
|
571
|
+
depth: 0,
|
|
572
|
+
glyph: "·",
|
|
573
|
+
text: "comment",
|
|
574
|
+
onPath: true,
|
|
575
|
+
tips: ["main"],
|
|
576
|
+
isCurrentTip: false,
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
entryId: "e3",
|
|
580
|
+
depth: 1,
|
|
581
|
+
glyph: "·",
|
|
582
|
+
text: "comment",
|
|
583
|
+
onPath: false,
|
|
584
|
+
tips: ["alt"],
|
|
585
|
+
isCurrentTip: true,
|
|
586
|
+
},
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
function treeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
|
|
590
|
+
const deps = makeDeps({ railTab: "tree", ...overrides });
|
|
591
|
+
|
|
592
|
+
deps.controller.treeRows = mock(() => rows);
|
|
593
|
+
|
|
594
|
+
return deps;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
test("toggleTree shows the Tree tab and hides it again", () => {
|
|
598
|
+
// Arrange
|
|
599
|
+
const shown = makeDeps({ railTab: "review" });
|
|
600
|
+
const hidden = makeDeps({ railTab: "tree" });
|
|
601
|
+
|
|
602
|
+
// Act
|
|
603
|
+
createIntentDispatch(shown)({ type: "toggleTree" });
|
|
604
|
+
createIntentDispatch(hidden)({ type: "toggleTree" });
|
|
605
|
+
|
|
606
|
+
// Assert
|
|
607
|
+
expect(shown.setRailTab).toHaveBeenCalledWith("tree");
|
|
608
|
+
expect(hidden.setRailTab).toHaveBeenCalledWith("review");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("treeMove starts from the current tip and stays inside the rows", () => {
|
|
612
|
+
// Arrange
|
|
613
|
+
const fromTip = treeDeps();
|
|
614
|
+
const atTop = treeDeps({ selectedEntryId: "e1" });
|
|
615
|
+
|
|
616
|
+
// Act
|
|
617
|
+
createIntentDispatch(fromTip)({ type: "treeMove", direction: -1 });
|
|
618
|
+
createIntentDispatch(atTop)({ type: "treeMove", direction: -1 });
|
|
619
|
+
|
|
620
|
+
// Assert
|
|
621
|
+
expect(fromTip.setSelectedEntryId).toHaveBeenCalledWith("e2");
|
|
622
|
+
expect(atTop.setSelectedEntryId).toHaveBeenCalledWith("e1");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("treeGo: the tip answers, a tip of another branch switches, an inner entry asks for a summary", () => {
|
|
626
|
+
// Arrange
|
|
627
|
+
const atTip = treeDeps({ selectedEntryId: "e3" });
|
|
628
|
+
const toTip = treeDeps({ selectedEntryId: "e2" });
|
|
629
|
+
const inner = treeDeps({ selectedEntryId: "e1" });
|
|
630
|
+
|
|
631
|
+
// Act
|
|
632
|
+
createIntentDispatch(atTip)({ type: "treeGo" });
|
|
633
|
+
createIntentDispatch(toTip)({ type: "treeGo" });
|
|
634
|
+
createIntentDispatch(inner)({ type: "treeGo" });
|
|
635
|
+
|
|
636
|
+
// Assert
|
|
637
|
+
expect(atTip.controller.setStatus).toHaveBeenCalledWith("already at the tip");
|
|
638
|
+
expect(toTip.controller.goToEntry).toHaveBeenCalledWith("e2");
|
|
639
|
+
expect(inner.setMode).toHaveBeenCalledWith({
|
|
640
|
+
type: "treePrompt",
|
|
641
|
+
ask: "navigate",
|
|
642
|
+
entryId: "e1",
|
|
643
|
+
text: "",
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("the prompts confirm into branch, label, and a move back with the typed summary", () => {
|
|
648
|
+
// Arrange
|
|
649
|
+
const branch = treeDeps({ mode: { type: "treePrompt", ask: "branch", text: "alt" } });
|
|
650
|
+
const label = treeDeps({ mode: { type: "treePrompt", ask: "label", text: "start" } });
|
|
651
|
+
const navigate = treeDeps({
|
|
652
|
+
mode: { type: "treePrompt", ask: "navigate", entryId: "e1", text: " too early " },
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// Act
|
|
656
|
+
createIntentDispatch(branch)({ type: "confirmDialog" });
|
|
657
|
+
createIntentDispatch(label)({ type: "confirmDialog" });
|
|
658
|
+
createIntentDispatch(navigate)({ type: "confirmDialog" });
|
|
659
|
+
|
|
660
|
+
// Assert
|
|
661
|
+
expect(branch.controller.branch).toHaveBeenCalledWith("alt");
|
|
662
|
+
expect(label.controller.labelTip).toHaveBeenCalledWith("start");
|
|
663
|
+
expect(navigate.controller.goToEntry).toHaveBeenCalledWith("e1", "too early");
|
|
664
|
+
expect(navigate.setMode).toHaveBeenCalledWith({ type: "normal" });
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("treeFork and treeForkShare reach the controller", () => {
|
|
668
|
+
// Arrange
|
|
669
|
+
const deps = treeDeps();
|
|
670
|
+
|
|
671
|
+
// Act
|
|
672
|
+
createIntentDispatch(deps)({ type: "treeFork" });
|
|
673
|
+
createIntentDispatch(deps)({ type: "treeForkShare" });
|
|
674
|
+
|
|
675
|
+
// Assert
|
|
676
|
+
expect(deps.controller.fork).toHaveBeenCalledTimes(1);
|
|
677
|
+
expect(deps.controller.forkAndShare).toHaveBeenCalledTimes(1);
|
|
678
|
+
});
|
|
679
|
+
});
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* into React beyond the setters it is given.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { Dispatch, MutableRefObject,
|
|
10
|
+
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
11
11
|
import { isAddressed, isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
12
12
|
import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
|
|
13
13
|
import type { DiffRow } from "./view-diff";
|
|
14
14
|
import type { ReviewController } from "./session-controller";
|
|
15
15
|
import type { Intent } from "./keymap";
|
|
16
|
+
import type { TreeRow } from "./tree-view";
|
|
17
|
+
import type { RailTab } from "./components/ReviewRail";
|
|
16
18
|
import { quickActionBody, type QuickAction } from "./config";
|
|
17
|
-
import type { PlanSheetHandle } from "./components/PlanSheet";
|
|
18
19
|
import { VERDICTS } from "./components/ConfirmCard";
|
|
19
20
|
import {
|
|
20
21
|
REVIEW_RESIZE_STEP,
|
|
@@ -40,7 +41,11 @@ export type Mode =
|
|
|
40
41
|
| { type: "submit"; verdict: VerdictKind; summary: string }
|
|
41
42
|
| { type: "confirmDelete"; sessionId: string; title: string }
|
|
42
43
|
| { type: "rename"; authorId: string; text: string }
|
|
43
|
-
| { type: "nameSelf"; text: string }
|
|
44
|
+
| { type: "nameSelf"; text: string }
|
|
45
|
+
| { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
|
|
46
|
+
|
|
47
|
+
/** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
|
|
48
|
+
export type TreeAsk = "branch" | "label" | "navigate";
|
|
44
49
|
|
|
45
50
|
/** The marked span for span mode and its quick-actions sub-mode; null otherwise. */
|
|
46
51
|
export function activeSpanState(mode: Mode): SpanState | null {
|
|
@@ -88,6 +93,9 @@ export interface IntentDispatchDeps {
|
|
|
88
93
|
focusedAnnotationId: string | undefined;
|
|
89
94
|
/** The curation item selected in the rail, if any; the undo target when set. */
|
|
90
95
|
selectedCurationId: string | undefined;
|
|
96
|
+
railTab: RailTab;
|
|
97
|
+
/** The tree row selected in the rail's Tree tab; the target of go. */
|
|
98
|
+
selectedEntryId: string | undefined;
|
|
91
99
|
/** Planner-local author renames, for seeding the rename prompt. */
|
|
92
100
|
authorNames: Record<string, string>;
|
|
93
101
|
/** Marker-popover quick actions, in list order; picking one inserts a preset comment. */
|
|
@@ -97,14 +105,14 @@ export interface IntentDispatchDeps {
|
|
|
97
105
|
|
|
98
106
|
liveInput: MutableRefObject<string>;
|
|
99
107
|
reviewWidthRef: MutableRefObject<number>;
|
|
100
|
-
planSheetRef: RefObject<PlanSheetHandle | null>;
|
|
101
108
|
|
|
102
109
|
setCursor: Dispatch<SetStateAction<number>>;
|
|
103
110
|
setInboxCursor: Dispatch<SetStateAction<number>>;
|
|
104
111
|
setMode: Dispatch<SetStateAction<Mode>>;
|
|
105
112
|
setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
|
|
106
113
|
setReviewWidth: Dispatch<SetStateAction<number>>;
|
|
107
|
-
setRailTab: Dispatch<SetStateAction<
|
|
114
|
+
setRailTab: Dispatch<SetStateAction<RailTab>>;
|
|
115
|
+
setSelectedEntryId: Dispatch<SetStateAction<string | undefined>>;
|
|
108
116
|
setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
|
|
109
117
|
setSelectedCurationId: Dispatch<SetStateAction<string | undefined>>;
|
|
110
118
|
setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
|
|
@@ -187,6 +195,7 @@ function handleConfirmDialog(
|
|
|
187
195
|
if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
|
|
188
196
|
else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
|
|
189
197
|
else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
|
|
198
|
+
else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
|
|
190
199
|
deps.setMode({ type: "normal" });
|
|
191
200
|
}
|
|
192
201
|
|
|
@@ -307,24 +316,17 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
|
|
|
307
316
|
text: "",
|
|
308
317
|
});
|
|
309
318
|
} else {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
kind: intent.kind,
|
|
322
|
-
displayIndex: deps.cursor,
|
|
323
|
-
start: 0,
|
|
324
|
-
end: displayText(block).length,
|
|
325
|
-
text: "",
|
|
326
|
-
});
|
|
327
|
-
}
|
|
319
|
+
const block = deps.display[deps.cursor];
|
|
320
|
+
|
|
321
|
+
if (block)
|
|
322
|
+
deps.setMode({
|
|
323
|
+
type: "compose",
|
|
324
|
+
kind: intent.kind,
|
|
325
|
+
displayIndex: deps.cursor,
|
|
326
|
+
start: 0,
|
|
327
|
+
end: displayText(block).length,
|
|
328
|
+
text: "",
|
|
329
|
+
});
|
|
328
330
|
}
|
|
329
331
|
}
|
|
330
332
|
|
|
@@ -429,7 +431,6 @@ function handleRemoveAnnotation(
|
|
|
429
431
|
}
|
|
430
432
|
|
|
431
433
|
function handleDeselect(_intent: IntentOfType<"deselect">, deps: IntentDispatchDeps): void {
|
|
432
|
-
deps.planSheetRef.current?.clearSelection();
|
|
433
434
|
deps.setFocusedAnnotationId(undefined);
|
|
434
435
|
deps.setPulsedAnnotationId(null);
|
|
435
436
|
}
|
|
@@ -526,6 +527,66 @@ function handleResizeReviewPanel(
|
|
|
526
527
|
deps.controller.saveReviewPanel({ width: next });
|
|
527
528
|
}
|
|
528
529
|
|
|
530
|
+
function confirmTreePrompt(
|
|
531
|
+
mode: Extract<Mode, { type: "treePrompt" }>,
|
|
532
|
+
deps: IntentDispatchDeps,
|
|
533
|
+
): void {
|
|
534
|
+
if (mode.ask === "branch") deps.controller.branch(mode.text);
|
|
535
|
+
else if (mode.ask === "label") deps.controller.labelTip(mode.text);
|
|
536
|
+
else if (mode.entryId !== undefined) deps.controller.goToEntry(mode.entryId, mode.text.trim());
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function handleToggleTree(_intent: IntentOfType<"toggleTree">, deps: IntentDispatchDeps): void {
|
|
540
|
+
deps.setRailTab(deps.railTab === "tree" ? "review" : "tree");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The tree rows in drawing order; the selection falls back to the current tip. */
|
|
544
|
+
function selectedTreeIndex(deps: IntentDispatchDeps, rows: TreeRow[]): number {
|
|
545
|
+
const byId = rows.findIndex((row) => row.entryId === deps.selectedEntryId);
|
|
546
|
+
|
|
547
|
+
return byId === -1 ? rows.findIndex((row) => row.isCurrentTip) : byId;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function handleTreeMove(intent: IntentOfType<"treeMove">, deps: IntentDispatchDeps): void {
|
|
551
|
+
const rows = deps.controller.treeRows();
|
|
552
|
+
|
|
553
|
+
if (rows.length === 0) return;
|
|
554
|
+
const current = selectedTreeIndex(deps, rows);
|
|
555
|
+
const next = Math.min(rows.length - 1, Math.max(0, current + intent.direction));
|
|
556
|
+
|
|
557
|
+
deps.setSelectedEntryId(rows[next]!.entryId);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function handleTreeGo(_intent: IntentOfType<"treeGo">, deps: IntentDispatchDeps): void {
|
|
561
|
+
const rows = deps.controller.treeRows();
|
|
562
|
+
const row = rows[selectedTreeIndex(deps, rows)];
|
|
563
|
+
|
|
564
|
+
if (!row) return deps.controller.setStatus("select an entry in the tree first");
|
|
565
|
+
if (row.isCurrentTip) return deps.controller.setStatus("already at the tip");
|
|
566
|
+
// a switch needs no summary; a move back may leave one, so it asks
|
|
567
|
+
if (row.tips.length > 0) return deps.controller.goToEntry(row.entryId);
|
|
568
|
+
deps.setMode({ type: "treePrompt", ask: "navigate", entryId: row.entryId, text: "" });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function handleTreeBranch(_intent: IntentOfType<"treeBranch">, deps: IntentDispatchDeps): void {
|
|
572
|
+
deps.setMode({ type: "treePrompt", ask: "branch", text: "" });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function handleTreeLabel(_intent: IntentOfType<"treeLabel">, deps: IntentDispatchDeps): void {
|
|
576
|
+
deps.setMode({ type: "treePrompt", ask: "label", text: "" });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function handleTreeFork(_intent: IntentOfType<"treeFork">, deps: IntentDispatchDeps): void {
|
|
580
|
+
deps.controller.fork();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function handleTreeForkShare(
|
|
584
|
+
_intent: IntentOfType<"treeForkShare">,
|
|
585
|
+
deps: IntentDispatchDeps,
|
|
586
|
+
): void {
|
|
587
|
+
deps.controller.forkAndShare();
|
|
588
|
+
}
|
|
589
|
+
|
|
529
590
|
type IntentHandlers = {
|
|
530
591
|
[Kind in Intent["type"]]: (intent: IntentOfType<Kind>, deps: IntentDispatchDeps) => void;
|
|
531
592
|
};
|
|
@@ -572,6 +633,13 @@ const intentHandlers: IntentHandlers = {
|
|
|
572
633
|
dismissCompletion: handleDismissCompletion,
|
|
573
634
|
cycleReviewPanel: handleCycleReviewPanel,
|
|
574
635
|
resizeReviewPanel: handleResizeReviewPanel,
|
|
636
|
+
toggleTree: handleToggleTree,
|
|
637
|
+
treeMove: handleTreeMove,
|
|
638
|
+
treeGo: handleTreeGo,
|
|
639
|
+
treeBranch: handleTreeBranch,
|
|
640
|
+
treeLabel: handleTreeLabel,
|
|
641
|
+
treeFork: handleTreeFork,
|
|
642
|
+
treeForkShare: handleTreeForkShare,
|
|
575
643
|
};
|
|
576
644
|
|
|
577
645
|
function dispatchIntent<Kind extends Intent["type"]>(
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -43,7 +43,7 @@ describe("grammar-layer resolution", () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
describe("mode layers own their keys", () => {
|
|
46
|
-
test("span mode resolves the span
|
|
46
|
+
test("span mode resolves the span primitives, not the grammar", () => {
|
|
47
47
|
// Arrange
|
|
48
48
|
const resolver = bindings({ spanMode: true });
|
|
49
49
|
|
package/src/keymap.test.ts
CHANGED
|
@@ -224,7 +224,7 @@ describe("plan normal mode", () => {
|
|
|
224
224
|
});
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
test("resolved sessions guard the mutating
|
|
227
|
+
test("resolved sessions guard the mutating primitives", () => {
|
|
228
228
|
// Arrange
|
|
229
229
|
const resolvedState = state({ resolved: true });
|
|
230
230
|
|
|
@@ -386,9 +386,9 @@ describe("diff mode", () => {
|
|
|
386
386
|
["p", [{ type: "prevAnnotation" }]],
|
|
387
387
|
["backspace", [{ type: "removeAnnotation" }]],
|
|
388
388
|
["return", [{ type: "openSubmit" }]],
|
|
389
|
-
["v", [{ type: "status", message: "plan-only
|
|
389
|
+
["v", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
|
|
390
390
|
["x", [{ type: "rejectChange" }]],
|
|
391
|
-
["e", [{ type: "status", message: "plan-only
|
|
391
|
+
["e", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
|
|
392
392
|
["q", [{ type: "exit" }]],
|
|
393
393
|
];
|
|
394
394
|
|
|
@@ -514,7 +514,7 @@ describe("review panel controls", () => {
|
|
|
514
514
|
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
515
515
|
});
|
|
516
516
|
|
|
517
|
-
test("span mode still owns b as a span
|
|
517
|
+
test("span mode still owns b as a span primitive, not a panel cycle", () => {
|
|
518
518
|
// Assert
|
|
519
519
|
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
|
|
520
520
|
{ type: "spanKey", name: "b" },
|
package/src/keymap.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The keyboard grammar as a pure reducer: view state in, intents out.
|
|
3
3
|
* App builds a KeyState per key event, calls reduceKey, and dispatches the
|
|
4
|
-
* intents to controller
|
|
4
|
+
* intents to controller primitives and view-state setters - no branch bodies live
|
|
5
5
|
* in the key handler. Diff and plan reviews share one path for annotation
|
|
6
6
|
* navigation, deletion, and submit; the read-only rule is one gate here.
|
|
7
7
|
*/
|
|
@@ -49,7 +49,14 @@ export type Intent =
|
|
|
49
49
|
| { type: "optInAutoClose" }
|
|
50
50
|
| { type: "dismissCompletion" }
|
|
51
51
|
| { type: "cycleReviewPanel" }
|
|
52
|
-
| { type: "resizeReviewPanel"; direction: -1 | 1 }
|
|
52
|
+
| { type: "resizeReviewPanel"; direction: -1 | 1 }
|
|
53
|
+
| { type: "toggleTree" }
|
|
54
|
+
| { type: "treeMove"; direction: -1 | 1 }
|
|
55
|
+
| { type: "treeGo" }
|
|
56
|
+
| { type: "treeBranch" }
|
|
57
|
+
| { type: "treeLabel" }
|
|
58
|
+
| { type: "treeFork" }
|
|
59
|
+
| { type: "treeForkShare" };
|
|
53
60
|
|
|
54
61
|
export interface KeyInput {
|
|
55
62
|
name: string;
|
|
@@ -63,7 +70,7 @@ export interface KeyState {
|
|
|
63
70
|
keys: Record<string, string[]>;
|
|
64
71
|
readOnly: boolean;
|
|
65
72
|
/**
|
|
66
|
-
* Owner-only
|
|
73
|
+
* Owner-only primitives a share collaborator lacks (undefined = owner, allowed).
|
|
67
74
|
* A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
|
|
68
75
|
* the gateway) or submit an agent verdict (there is no agent on a share).
|
|
69
76
|
*/
|
|
@@ -95,7 +102,7 @@ export interface KeyState {
|
|
|
95
102
|
cursorAnnotatable: boolean;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
/**
|
|
105
|
+
/** Primitives that write session state; an observer never reaches their handlers. */
|
|
99
106
|
const MUTATING_ACTIONS = new Set([
|
|
100
107
|
"comment",
|
|
101
108
|
"cut",
|
|
@@ -136,7 +143,7 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
136
143
|
|
|
137
144
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
138
145
|
|
|
139
|
-
// share is a session-level
|
|
146
|
+
// share is a session-level primitive: it works from any view, owner only
|
|
140
147
|
if (action === "share") {
|
|
141
148
|
if (state.canShare === false) return status("only the plan owner can share");
|
|
142
149
|
|
|
@@ -305,7 +312,7 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
305
312
|
|
|
306
313
|
if (shared) return shared;
|
|
307
314
|
if (action === "span" || action === "edit") {
|
|
308
|
-
return status("plan-only
|
|
315
|
+
return status("plan-only primitive - diff review uses c on a line");
|
|
309
316
|
}
|
|
310
317
|
|
|
311
318
|
return [];
|
|
@@ -356,7 +363,7 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
356
363
|
// the annotation and edit rewrites the card body in place
|
|
357
364
|
if (state.hasFocusedAnnotation)
|
|
358
365
|
return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
359
|
-
// editing the plan itself is the owner's
|
|
366
|
+
// editing the plan itself is the owner's primitive; a share viewer only annotates,
|
|
360
367
|
// and has no edit affordance, so the key is silent rather than a nag
|
|
361
368
|
if (state.canEditPlan === false) return [];
|
|
362
369
|
|