@cueloop/client 0.1.0-alpha.83 → 0.1.0-alpha.84
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.diff.test.tsx +4 -3
- package/src/App.export.test.tsx +1 -1
- package/src/App.readonly.test.tsx +1 -1
- package/src/App.submit-confirm.test.tsx +59 -17
- package/src/App.test.tsx +77 -3
- package/src/App.tsx +15 -7
- package/src/App.walk.test.tsx +1 -1
- package/src/components/ConfirmCard.tsx +11 -14
- package/src/components/DiffContentView.tsx +3 -2
- package/src/components/ProjectTreeView.test.tsx +17 -1
- package/src/components/ProjectTreeView.tsx +16 -2
- package/src/components/ThreadView.selection.test.tsx +31 -2
- package/src/components/ThreadView.tsx +4 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +23 -23
- package/src/config.ts +2 -2
- package/src/intent-dispatch.test.ts +2 -2
- package/src/intent-dispatch.ts +2 -4
- package/src/keymap.ts +0 -2
- package/src/thread-controller.curate.test.ts +36 -10
- package/src/thread-controller.switch-cache.test.ts +1 -1
- package/src/thread-controller.ts +175 -46
- package/src/tree-view.ts +6 -1
- package/src/use-annotation-surface.tsx +72 -12
- package/src/use-terminal-virtualizer.ts +26 -0
- package/src/view-plan.test.ts +66 -1
- package/src/view-plan.ts +281 -9
package/src/thread-controller.ts
CHANGED
|
@@ -12,18 +12,24 @@ import { createStore, type StoreApi } from "zustand/vanilla";
|
|
|
12
12
|
import { DaemonClient, type ThreadClient } from "@cueloop/daemon/client";
|
|
13
13
|
import {
|
|
14
14
|
applyPathView,
|
|
15
|
+
applyTextCuts,
|
|
16
|
+
blockCutSourceRange,
|
|
15
17
|
createBranch,
|
|
16
18
|
cutBlock,
|
|
19
|
+
cutTextRange,
|
|
17
20
|
detectHerdr,
|
|
18
21
|
labelTip,
|
|
19
22
|
MAIN_BRANCH,
|
|
20
23
|
makeAnchor,
|
|
24
|
+
mergeTextCut,
|
|
21
25
|
navigateTo,
|
|
22
26
|
newAnnotationId,
|
|
23
27
|
parseBlocks,
|
|
24
28
|
resolveAnchor,
|
|
25
29
|
restoreBlock,
|
|
26
30
|
restoreLine,
|
|
31
|
+
restoreTextCut,
|
|
32
|
+
sourceOffsetAt,
|
|
27
33
|
returnPaneFor,
|
|
28
34
|
switchBranch,
|
|
29
35
|
threadShareLinks,
|
|
@@ -50,7 +56,15 @@ import {
|
|
|
50
56
|
shareIdFromLine,
|
|
51
57
|
formatShareLine,
|
|
52
58
|
} from "./share";
|
|
53
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
baseRangeForRendered,
|
|
61
|
+
blockRuns,
|
|
62
|
+
buildDisplay,
|
|
63
|
+
nextWorkBlock,
|
|
64
|
+
renderedSpanToWork,
|
|
65
|
+
renderedText,
|
|
66
|
+
type DisplayBlock,
|
|
67
|
+
} from "./view-plan";
|
|
54
68
|
import { entryTarget, treeRows, type TreeRow } from "./tree-view";
|
|
55
69
|
import {
|
|
56
70
|
diffRowBlocks,
|
|
@@ -105,9 +119,6 @@ const EMPTY_REJECTED_ROWS: Set<number> = new Set();
|
|
|
105
119
|
/** Shared empty list so "nothing curated out" is a stable identity for renders. */
|
|
106
120
|
const EMPTY_CURATION_ITEMS: CurationItem[] = [];
|
|
107
121
|
|
|
108
|
-
/** Status shown when a curated-out removal (diff rejection or plan cut) is restored. */
|
|
109
|
-
const REMOVAL_RESTORED_STATUS = "removal restored";
|
|
110
|
-
|
|
111
122
|
export interface ToastState {
|
|
112
123
|
title?: string;
|
|
113
124
|
body: string;
|
|
@@ -144,10 +155,24 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
|
|
|
144
155
|
* reused rather than re-parsed. Content and working copy are strings, compared here by value.
|
|
145
156
|
*/
|
|
146
157
|
export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
|
|
158
|
+
const sameCuts =
|
|
159
|
+
previous.textCuts === next.textCuts ||
|
|
160
|
+
((previous.textCuts?.length ?? 0) === (next.textCuts?.length ?? 0) &&
|
|
161
|
+
(previous.textCuts ?? []).every((cut, index) => {
|
|
162
|
+
const candidate = next.textCuts?.[index];
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
candidate?.start === cut.start &&
|
|
166
|
+
candidate.end === cut.end &&
|
|
167
|
+
candidate.quote === cut.quote
|
|
168
|
+
);
|
|
169
|
+
}));
|
|
170
|
+
|
|
147
171
|
return (
|
|
148
172
|
previous.id === next.id &&
|
|
149
173
|
previous.artifact.content === next.artifact.content &&
|
|
150
|
-
previous.workingCopy === next.workingCopy
|
|
174
|
+
previous.workingCopy === next.workingCopy &&
|
|
175
|
+
sameCuts
|
|
151
176
|
);
|
|
152
177
|
}
|
|
153
178
|
|
|
@@ -293,8 +318,8 @@ export interface ReviewController {
|
|
|
293
318
|
renameSession(id: string, title: string): void;
|
|
294
319
|
/** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
|
|
295
320
|
setSelfName(name: string): void;
|
|
296
|
-
/** Cut the block under the cursor
|
|
297
|
-
cut(displayIndex: number): void;
|
|
321
|
+
/** Cut a marked range, or toggle the block under the cursor. */
|
|
322
|
+
cut(displayIndex: number, start?: number, end?: number, endDisplayIndex?: number): void;
|
|
298
323
|
/** Toggle rejection of the whole hunk under the diff cursor (owner curation). */
|
|
299
324
|
toggleRejectHunk(rowIndex: number): void;
|
|
300
325
|
/** Toggle rejection of the single change under the diff cursor (owner curation). */
|
|
@@ -606,7 +631,9 @@ class Controller implements ReviewController {
|
|
|
606
631
|
for (const file of files) fileContents.set(file.path, file);
|
|
607
632
|
|
|
608
633
|
return {
|
|
609
|
-
display: session
|
|
634
|
+
display: session
|
|
635
|
+
? buildDisplay(session.artifact.content, session.workingCopy, session.textCuts)
|
|
636
|
+
: [],
|
|
610
637
|
rows,
|
|
611
638
|
files: walkFiles(rows),
|
|
612
639
|
fileContents,
|
|
@@ -904,9 +931,8 @@ class Controller implements ReviewController {
|
|
|
904
931
|
// unreachable gateway never blocks the local delete (the blob's 30-day TTL is the backstop)
|
|
905
932
|
for (const link of this.linksFor(this.sessionRecord(id)))
|
|
906
933
|
void this.shareTransport.revoke(link.id).catch(() => {});
|
|
907
|
-
this.client
|
|
934
|
+
void this.client
|
|
908
935
|
?.sessionDelete(id)
|
|
909
|
-
.then(() => this.setStatus("thread deleted"))
|
|
910
936
|
.catch((cause: unknown) =>
|
|
911
937
|
this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
912
938
|
);
|
|
@@ -926,9 +952,8 @@ class Controller implements ReviewController {
|
|
|
926
952
|
|
|
927
953
|
renameSession(id: string, title: string): void {
|
|
928
954
|
if (this.readOnly) return this.setStatus("observer - read-only");
|
|
929
|
-
this.client
|
|
955
|
+
void this.client
|
|
930
956
|
?.sessionSetTitle(id, title)
|
|
931
|
-
.then(() => this.setStatus("thread renamed"))
|
|
932
957
|
.catch((cause: unknown) =>
|
|
933
958
|
this.setStatus(`rename failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
934
959
|
);
|
|
@@ -941,7 +966,7 @@ class Controller implements ReviewController {
|
|
|
941
966
|
this.apply(this.client!.sessionSetSelfName(session.id, name));
|
|
942
967
|
}
|
|
943
968
|
|
|
944
|
-
cut(displayIndex: number): void {
|
|
969
|
+
cut(displayIndex: number, start?: number, end?: number, endDisplayIndex = displayIndex): void {
|
|
945
970
|
const session = this.snapshot.session;
|
|
946
971
|
|
|
947
972
|
if (!session || session.status === "resolved") return;
|
|
@@ -950,20 +975,142 @@ class Controller implements ReviewController {
|
|
|
950
975
|
if (!block) return;
|
|
951
976
|
const working = this.working();
|
|
952
977
|
|
|
953
|
-
if (block.
|
|
978
|
+
if (start !== undefined && end !== undefined && block.work) {
|
|
979
|
+
const display = this.display();
|
|
980
|
+
const endBlock = display[endDisplayIndex];
|
|
981
|
+
|
|
982
|
+
if (!endBlock?.work) return;
|
|
983
|
+
const exactSourceRange = this.exactCutSourceRange(
|
|
984
|
+
session,
|
|
985
|
+
display,
|
|
986
|
+
displayIndex,
|
|
987
|
+
endDisplayIndex,
|
|
988
|
+
start,
|
|
989
|
+
end,
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
if (exactSourceRange) {
|
|
993
|
+
const existingCuts = session.textCuts ?? [];
|
|
994
|
+
const textCuts =
|
|
995
|
+
restoreTextCut(
|
|
996
|
+
session.artifact.content,
|
|
997
|
+
existingCuts,
|
|
998
|
+
exactSourceRange.start,
|
|
999
|
+
exactSourceRange.end,
|
|
1000
|
+
) ??
|
|
1001
|
+
mergeTextCut(
|
|
1002
|
+
session.artifact.content,
|
|
1003
|
+
existingCuts,
|
|
1004
|
+
exactSourceRange.start,
|
|
1005
|
+
exactSourceRange.end,
|
|
1006
|
+
);
|
|
1007
|
+
const content = applyTextCuts(session.artifact.content, textCuts);
|
|
1008
|
+
|
|
1009
|
+
if (content === working) return;
|
|
1010
|
+
const expected: Thread = { ...session, workingCopy: content, textCuts };
|
|
1011
|
+
|
|
1012
|
+
if (textCuts.length === 0) {
|
|
1013
|
+
delete expected.workingCopy;
|
|
1014
|
+
delete expected.textCuts;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
this.applyOptimistic(
|
|
1018
|
+
expected,
|
|
1019
|
+
this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const range = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
|
|
1025
|
+
const content = cutTextRange(working, block.work, range.start, endBlock.work, range.end);
|
|
1026
|
+
|
|
1027
|
+
if (content === working) return;
|
|
1028
|
+
this.setWorkingCopy(content);
|
|
1029
|
+
} else if (block.type === "del") {
|
|
954
1030
|
this.restoreDelBlock(block, displayIndex);
|
|
955
1031
|
} else if (block.work) {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1032
|
+
this.cutWholeBlock(session, block, working);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private cutWholeBlock(session: Thread, block: DisplayBlock, working: string): void {
|
|
1037
|
+
if (!block.work) return;
|
|
1038
|
+
const base = session.artifact.content;
|
|
1039
|
+
const existingCuts = session.textCuts ?? [];
|
|
959
1040
|
|
|
960
|
-
|
|
1041
|
+
if (existingCuts.length > 0 && block.base && working === applyTextCuts(base, existingCuts)) {
|
|
1042
|
+
const range = blockCutSourceRange(base, block.base);
|
|
1043
|
+
const textCuts = mergeTextCut(base, existingCuts, range.start, range.end);
|
|
1044
|
+
const content = applyTextCuts(base, textCuts);
|
|
1045
|
+
|
|
1046
|
+
if (content === working) return;
|
|
961
1047
|
this.applyOptimistic(
|
|
962
|
-
{ ...session, workingCopy:
|
|
963
|
-
this.client!.
|
|
1048
|
+
{ ...session, workingCopy: content, textCuts },
|
|
1049
|
+
this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const workIndex = parseBlocks(working).findIndex(
|
|
1055
|
+
(candidate) => candidate.lineStart === block.work!.lineStart,
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
if (workIndex === -1) return;
|
|
1059
|
+
this.applyOptimistic(
|
|
1060
|
+
{ ...session, workingCopy: cutBlock(working, block.work) },
|
|
1061
|
+
this.client!.sessionCutBlock(session.id, workIndex),
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private exactCutSourceRange(
|
|
1066
|
+
session: Thread,
|
|
1067
|
+
display: DisplayBlock[],
|
|
1068
|
+
displayIndex: number,
|
|
1069
|
+
endDisplayIndex: number,
|
|
1070
|
+
renderedStart: number,
|
|
1071
|
+
renderedEnd: number,
|
|
1072
|
+
): { start: number; end: number } | null {
|
|
1073
|
+
const base = session.artifact.content;
|
|
1074
|
+
const existingCuts = session.textCuts ?? [];
|
|
1075
|
+
|
|
1076
|
+
if ((session.workingCopy ?? base) !== applyTextCuts(base, existingCuts)) return null;
|
|
1077
|
+
const startBlock = display[displayIndex];
|
|
1078
|
+
const endBlock = display[endDisplayIndex];
|
|
1079
|
+
|
|
1080
|
+
if (!startBlock?.base || !endBlock?.base) return null;
|
|
1081
|
+
if (existingCuts.length === 0) {
|
|
1082
|
+
const workRange = renderedSpanToWork(
|
|
1083
|
+
display,
|
|
1084
|
+
displayIndex,
|
|
1085
|
+
endDisplayIndex,
|
|
1086
|
+
renderedStart,
|
|
1087
|
+
renderedEnd,
|
|
964
1088
|
);
|
|
965
|
-
|
|
1089
|
+
const sourceStart = sourceOffsetAt(base, startBlock.base, workRange.start);
|
|
1090
|
+
const sourceEnd = sourceOffsetAt(base, endBlock.base, workRange.end);
|
|
1091
|
+
|
|
1092
|
+
return sourceStart === undefined || sourceEnd === undefined
|
|
1093
|
+
? null
|
|
1094
|
+
: { start: sourceStart, end: sourceEnd };
|
|
966
1095
|
}
|
|
1096
|
+
const startRange = baseRangeForRendered(
|
|
1097
|
+
blockRuns(startBlock, true),
|
|
1098
|
+
renderedStart,
|
|
1099
|
+
displayIndex === endDisplayIndex ? renderedEnd : renderedText(startBlock).length,
|
|
1100
|
+
);
|
|
1101
|
+
const endRange =
|
|
1102
|
+
displayIndex === endDisplayIndex
|
|
1103
|
+
? startRange
|
|
1104
|
+
: baseRangeForRendered(blockRuns(endBlock, true), 0, renderedEnd);
|
|
1105
|
+
|
|
1106
|
+
if (!startRange || !endRange) return null;
|
|
1107
|
+
|
|
1108
|
+
const sourceStart = sourceOffsetAt(base, startBlock.base, startRange.start);
|
|
1109
|
+
const sourceEnd = sourceOffsetAt(base, endBlock.base, endRange.end);
|
|
1110
|
+
|
|
1111
|
+
return sourceStart === undefined || sourceEnd === undefined
|
|
1112
|
+
? null
|
|
1113
|
+
: { start: sourceStart, end: sourceEnd };
|
|
967
1114
|
}
|
|
968
1115
|
|
|
969
1116
|
/** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
|
|
@@ -989,7 +1136,6 @@ class Controller implements ReviewController {
|
|
|
989
1136
|
|
|
990
1137
|
if (expected.workingCopy === undefined) delete expected.workingCopy;
|
|
991
1138
|
this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
|
|
992
|
-
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
993
1139
|
}
|
|
994
1140
|
|
|
995
1141
|
// ── diff hunk curation ──────────────────────
|
|
@@ -1028,7 +1174,6 @@ class Controller implements ReviewController {
|
|
|
1028
1174
|
|
|
1029
1175
|
if (this.rejections.some(wholeHunk)) {
|
|
1030
1176
|
this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
|
|
1031
|
-
this.setStatus("hunk restored");
|
|
1032
1177
|
} else {
|
|
1033
1178
|
// a whole-hunk reject supersedes any change-level rejects inside it
|
|
1034
1179
|
const others = this.rejections.filter(
|
|
@@ -1037,7 +1182,6 @@ class Controller implements ReviewController {
|
|
|
1037
1182
|
);
|
|
1038
1183
|
|
|
1039
1184
|
this.curate([...others, target]);
|
|
1040
|
-
this.setStatus("hunk rejected - dropped from the working copy");
|
|
1041
1185
|
}
|
|
1042
1186
|
}
|
|
1043
1187
|
|
|
@@ -1055,10 +1199,8 @@ class Controller implements ReviewController {
|
|
|
1055
1199
|
if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
|
|
1056
1200
|
if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
|
|
1057
1201
|
this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
|
|
1058
|
-
this.setStatus("change restored");
|
|
1059
1202
|
} else {
|
|
1060
1203
|
this.curate([...this.rejections, target]);
|
|
1061
|
-
this.setStatus("change rejected - dropped from the working copy");
|
|
1062
1204
|
}
|
|
1063
1205
|
}
|
|
1064
1206
|
|
|
@@ -1116,7 +1258,6 @@ class Controller implements ReviewController {
|
|
|
1116
1258
|
|
|
1117
1259
|
if (kept.length === this.rejections.length) return;
|
|
1118
1260
|
this.curate(kept);
|
|
1119
|
-
this.setStatus(REMOVAL_RESTORED_STATUS);
|
|
1120
1261
|
|
|
1121
1262
|
return;
|
|
1122
1263
|
}
|
|
@@ -1197,10 +1338,7 @@ class Controller implements ReviewController {
|
|
|
1197
1338
|
try {
|
|
1198
1339
|
const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
|
|
1199
1340
|
|
|
1200
|
-
if (result.changed)
|
|
1201
|
-
this.saveEditedBody(result.content);
|
|
1202
|
-
this.setStatus("edits tracked - one diff");
|
|
1203
|
-
} else this.setStatus("no changes");
|
|
1341
|
+
if (result.changed) this.saveEditedBody(result.content);
|
|
1204
1342
|
} catch (err) {
|
|
1205
1343
|
this.setStatus(err instanceof Error ? err.message : String(err));
|
|
1206
1344
|
}
|
|
@@ -1444,7 +1582,6 @@ class Controller implements ReviewController {
|
|
|
1444
1582
|
|
|
1445
1583
|
if (!session) return;
|
|
1446
1584
|
this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
|
|
1447
|
-
this.setStatus("annotation deleted");
|
|
1448
1585
|
}
|
|
1449
1586
|
|
|
1450
1587
|
setWorkingCopy(content: string | undefined): void {
|
|
@@ -1516,6 +1653,11 @@ class Controller implements ReviewController {
|
|
|
1516
1653
|
// The completion overlay heading already states the message, so the
|
|
1517
1654
|
// status line stays empty here - only export/error messages fill it.
|
|
1518
1655
|
this.update({ session: resolved, status: "" });
|
|
1656
|
+
if (message === "comment") {
|
|
1657
|
+
this.setStatus("comment sent - thread stays open");
|
|
1658
|
+
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1519
1661
|
// notes-vault export: guarded by each exporter's policy (default manual = no-op)
|
|
1520
1662
|
for (const exporter of this.exporters) {
|
|
1521
1663
|
if (!exporter.runsOn(message)) continue;
|
|
@@ -1623,7 +1765,6 @@ class Controller implements ReviewController {
|
|
|
1623
1765
|
{ ...session, shares: next },
|
|
1624
1766
|
this.client.sessionSetShares(session.id, next),
|
|
1625
1767
|
);
|
|
1626
|
-
this.setStatus("link updated");
|
|
1627
1768
|
}
|
|
1628
1769
|
|
|
1629
1770
|
deleteShareLink(id: string): void {
|
|
@@ -1638,7 +1779,6 @@ class Controller implements ReviewController {
|
|
|
1638
1779
|
{ ...session, shares: remaining },
|
|
1639
1780
|
this.client.sessionSetShares(session.id, remaining),
|
|
1640
1781
|
);
|
|
1641
|
-
this.setStatus("link revoked");
|
|
1642
1782
|
}
|
|
1643
1783
|
|
|
1644
1784
|
copyShareLink(id: string): void {
|
|
@@ -1657,9 +1797,7 @@ class Controller implements ReviewController {
|
|
|
1657
1797
|
|
|
1658
1798
|
if (!session || links.length === 0) return this.setStatus("this thread is not shared");
|
|
1659
1799
|
this.stopShareSync();
|
|
1660
|
-
void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})))
|
|
1661
|
-
.then(() => this.setStatus("sharing stopped"))
|
|
1662
|
-
.catch(() => {});
|
|
1800
|
+
void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})));
|
|
1663
1801
|
void this.client?.sessionSetShares(session.id, []).catch(() => {});
|
|
1664
1802
|
}
|
|
1665
1803
|
|
|
@@ -1675,7 +1813,6 @@ class Controller implements ReviewController {
|
|
|
1675
1813
|
this.moveTree(session, switchBranch(session.history, target.branch), () =>
|
|
1676
1814
|
this.client!.sessionSwitch(session.id, target.branch),
|
|
1677
1815
|
);
|
|
1678
|
-
this.setStatus(`on branch ${target.branch}`);
|
|
1679
1816
|
|
|
1680
1817
|
return;
|
|
1681
1818
|
}
|
|
@@ -1688,11 +1825,6 @@ class Controller implements ReviewController {
|
|
|
1688
1825
|
this.moveTree(session, moved, () =>
|
|
1689
1826
|
this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
|
|
1690
1827
|
);
|
|
1691
|
-
this.setStatus(
|
|
1692
|
-
options.summary === undefined
|
|
1693
|
-
? "moved back - later entries stay in the tree"
|
|
1694
|
-
: "moved back with a summary",
|
|
1695
|
-
);
|
|
1696
1828
|
}
|
|
1697
1829
|
|
|
1698
1830
|
branch(name: string): void {
|
|
@@ -1706,7 +1838,6 @@ class Controller implements ReviewController {
|
|
|
1706
1838
|
this.moveTree(session, createBranch(session.history, branchName), () =>
|
|
1707
1839
|
this.client!.sessionBranch(session.id, branchName),
|
|
1708
1840
|
);
|
|
1709
|
-
this.setStatus(`on branch ${branchName}`);
|
|
1710
1841
|
}
|
|
1711
1842
|
|
|
1712
1843
|
labelTip(label: string): void {
|
|
@@ -1718,7 +1849,6 @@ class Controller implements ReviewController {
|
|
|
1718
1849
|
this.moveTree(session, labelTip(session.history, name), () =>
|
|
1719
1850
|
this.client!.sessionLabel(session.id, name),
|
|
1720
1851
|
);
|
|
1721
|
-
this.setStatus(`checkpoint ${name}`);
|
|
1722
1852
|
}
|
|
1723
1853
|
|
|
1724
1854
|
fork(): void {
|
|
@@ -1729,7 +1859,6 @@ class Controller implements ReviewController {
|
|
|
1729
1859
|
.sessionFork(session.id)
|
|
1730
1860
|
.then((fork) => {
|
|
1731
1861
|
this.update({ session: fork });
|
|
1732
|
-
this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
|
|
1733
1862
|
})
|
|
1734
1863
|
.catch((cause: unknown) =>
|
|
1735
1864
|
this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
package/src/tree-view.ts
CHANGED
|
@@ -57,7 +57,12 @@ export function describeEntry(entry: SessionEntry, round?: number): EntryDescrip
|
|
|
57
57
|
return { glyph: "·", text: "comment removed" };
|
|
58
58
|
case "message":
|
|
59
59
|
return {
|
|
60
|
-
glyph:
|
|
60
|
+
glyph:
|
|
61
|
+
entry.message.outcome === "approved"
|
|
62
|
+
? "✓"
|
|
63
|
+
: entry.message.outcome === "changes_requested"
|
|
64
|
+
? "✗"
|
|
65
|
+
: "·",
|
|
61
66
|
text: entry.message.outcome.replace("_", " "),
|
|
62
67
|
};
|
|
63
68
|
case "branch-summary":
|
|
@@ -104,10 +104,16 @@ export interface AnnotationSurfaceOptions {
|
|
|
104
104
|
onAnnotate: (span: TextSpan, body: string) => void;
|
|
105
105
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
106
106
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
107
|
+
/** The visible scroll viewport used to keep a held mouse mark moving at its edges. */
|
|
108
|
+
dragViewport?: () => {
|
|
109
|
+
top: number;
|
|
110
|
+
bottom: number;
|
|
111
|
+
scrollBy: (rows: -1 | 1) => boolean;
|
|
112
|
+
} | null;
|
|
107
113
|
/** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
|
|
108
114
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
109
115
|
/** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
|
|
110
|
-
onNavCommand?: (key: KeyEvent) => boolean;
|
|
116
|
+
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
111
117
|
onExit: () => void;
|
|
112
118
|
}
|
|
113
119
|
|
|
@@ -164,6 +170,14 @@ function firstAnnotatable(source: LineSource): number {
|
|
|
164
170
|
return 0;
|
|
165
171
|
}
|
|
166
172
|
|
|
173
|
+
/** The visual-row direction requested by an arrow or readline-style caret key. */
|
|
174
|
+
function verticalCaretDelta(key: KeyEvent): -1 | 0 | 1 {
|
|
175
|
+
if (key.name === "up" || (key.ctrl && key.name === "p")) return -1;
|
|
176
|
+
if (key.name === "down" || (key.ctrl && key.name === "n")) return 1;
|
|
177
|
+
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
export function useAnnotationSurface(options: AnnotationSurfaceOptions): AnnotationSurface {
|
|
168
182
|
const {
|
|
169
183
|
source,
|
|
@@ -183,6 +197,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
183
197
|
onAnnotate,
|
|
184
198
|
onReply,
|
|
185
199
|
onUpdateAnnotation,
|
|
200
|
+
dragViewport,
|
|
186
201
|
resolveAuthorLabel,
|
|
187
202
|
onNavCommand,
|
|
188
203
|
onExit,
|
|
@@ -199,6 +214,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
199
214
|
return { head: start, anchor: start };
|
|
200
215
|
});
|
|
201
216
|
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
217
|
+
const dragPointer = useRef<{ x: number; y: number } | null>(null);
|
|
218
|
+
const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
219
|
+
const dragViewportRef = useRef(dragViewport);
|
|
220
|
+
|
|
221
|
+
dragViewportRef.current = dragViewport;
|
|
202
222
|
const [navMode, setNavModeState] = useState(false);
|
|
203
223
|
const navModeRef = useRef(false);
|
|
204
224
|
const setNavMode = (value: boolean): void => {
|
|
@@ -316,6 +336,27 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
316
336
|
x: entry.renderable.x,
|
|
317
337
|
y: entry.renderable.y,
|
|
318
338
|
}));
|
|
339
|
+
const verticalTextPosition = (position: TextPosition, direction: -1 | 1): TextPosition | null => {
|
|
340
|
+
const lines = allGeometry()
|
|
341
|
+
.filter((line) => source.annotatable(line.blockIndex))
|
|
342
|
+
.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
343
|
+
const currentIndex = lines.findIndex(
|
|
344
|
+
(line) =>
|
|
345
|
+
line.blockIndex === position.blockIndex &&
|
|
346
|
+
line.start <= position.char &&
|
|
347
|
+
position.char <= line.end,
|
|
348
|
+
);
|
|
349
|
+
const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
|
|
350
|
+
|
|
351
|
+
if (!target) return null;
|
|
352
|
+
const current = lines[currentIndex]!;
|
|
353
|
+
const column = Math.max(0, position.char - current.start);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
blockIndex: target.blockIndex,
|
|
357
|
+
char: Math.min(target.end, target.start + column),
|
|
358
|
+
};
|
|
359
|
+
};
|
|
319
360
|
|
|
320
361
|
const openCompose = (state: ComposeState): void => {
|
|
321
362
|
// a view-only surface (a non-diff thread's live diff) never opens a draft
|
|
@@ -428,14 +469,34 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
428
469
|
// on screen - the mark follows the pointer across blocks.
|
|
429
470
|
const handleRootDrag = (event: TerminalMouseEvent): void => {
|
|
430
471
|
if (!dragging.current) return;
|
|
472
|
+
dragPointer.current = { x: event.x, y: event.y };
|
|
431
473
|
const position = positionAt(allGeometry(), event.x, event.y);
|
|
432
474
|
|
|
433
475
|
if (position) extendSelectionTo(position);
|
|
476
|
+
if (edgeScrollTimer.current === null) {
|
|
477
|
+
edgeScrollTimer.current = setInterval(() => {
|
|
478
|
+
const pointer = dragPointer.current;
|
|
479
|
+
const viewport = dragViewportRef.current?.();
|
|
480
|
+
|
|
481
|
+
if (!dragging.current || !pointer || !viewport) return;
|
|
482
|
+
const direction = pointer.y <= viewport.top ? -1 : pointer.y >= viewport.bottom ? 1 : 0;
|
|
483
|
+
|
|
484
|
+
if (direction === 0 || !viewport.scrollBy(direction)) return;
|
|
485
|
+
const next = positionAt(allGeometry(), pointer.x, pointer.y);
|
|
486
|
+
|
|
487
|
+
if (next) extendSelectionTo(next);
|
|
488
|
+
}, 100);
|
|
489
|
+
}
|
|
434
490
|
};
|
|
435
491
|
const endDrag = (): void => {
|
|
436
492
|
dragging.current = null;
|
|
493
|
+
dragPointer.current = null;
|
|
494
|
+
if (edgeScrollTimer.current !== null) clearInterval(edgeScrollTimer.current);
|
|
495
|
+
edgeScrollTimer.current = null;
|
|
437
496
|
};
|
|
438
497
|
|
|
498
|
+
useEffect(() => endDrag, []);
|
|
499
|
+
|
|
439
500
|
const jumpToDiscussion = (key: string): void => {
|
|
440
501
|
const target = discussions.find((discussion) => discussion.key === key);
|
|
441
502
|
|
|
@@ -611,22 +672,21 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
611
672
|
};
|
|
612
673
|
|
|
613
674
|
const handleCaretKey = (key: KeyEvent): boolean => {
|
|
614
|
-
const vertical =
|
|
615
|
-
key.name === "up" || (key.ctrl && key.name === "p")
|
|
616
|
-
? -1
|
|
617
|
-
: key.name === "down" || (key.ctrl && key.name === "n")
|
|
618
|
-
? 1
|
|
619
|
-
: 0;
|
|
675
|
+
const vertical = verticalCaretDelta(key);
|
|
620
676
|
|
|
621
677
|
if (vertical !== 0) {
|
|
622
|
-
const
|
|
678
|
+
const nextBlock = nearestAnnotatable(source, cursor, vertical);
|
|
679
|
+
const nextHead = verticalTextPosition(head, vertical) ?? {
|
|
680
|
+
blockIndex: nextBlock,
|
|
681
|
+
char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
|
|
682
|
+
};
|
|
623
683
|
|
|
624
684
|
setCaret({
|
|
625
|
-
head:
|
|
626
|
-
anchor:
|
|
685
|
+
head: nextHead,
|
|
686
|
+
anchor: key.shift ? caret.anchor : nextHead,
|
|
627
687
|
});
|
|
628
688
|
setFocusedDiscussion(null);
|
|
629
|
-
setCursor(
|
|
689
|
+
setCursor(nextHead.blockIndex);
|
|
630
690
|
|
|
631
691
|
return true;
|
|
632
692
|
}
|
|
@@ -682,7 +742,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
682
742
|
if (navModeRef.current) {
|
|
683
743
|
if (handleCaretKey(key)) return;
|
|
684
744
|
if (key.name === "z") return toggleFoldAtCursor();
|
|
685
|
-
if (onNavCommand?.(key)) return;
|
|
745
|
+
if (onNavCommand?.(key, heldSpan)) return;
|
|
686
746
|
if (key.name === "c") {
|
|
687
747
|
setNavMode(false);
|
|
688
748
|
|
|
@@ -31,6 +31,32 @@ export interface TerminalVirtualizer {
|
|
|
31
31
|
scrollToIndex: (index: number, align?: "auto" | "start" | "center" | "end") => void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Visible rows and one-row scrolling for a held mouse mark at a viewport edge. */
|
|
35
|
+
export interface ScrollDragViewport {
|
|
36
|
+
top: number;
|
|
37
|
+
bottom: number;
|
|
38
|
+
scrollBy: (rows: -1 | 1) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Adapt an OpenTUI scrollbox to the annotation surface's edge-scroll contract. */
|
|
42
|
+
export function scrollBoxDragViewport(
|
|
43
|
+
scrollbox: ScrollBoxRenderable | null,
|
|
44
|
+
): ScrollDragViewport | null {
|
|
45
|
+
if (!scrollbox) return null;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
top: scrollbox.viewport.screenY,
|
|
49
|
+
bottom: scrollbox.viewport.screenY + scrollbox.viewport.height - 1,
|
|
50
|
+
scrollBy: (rows) => {
|
|
51
|
+
const before = scrollbox.scrollTop;
|
|
52
|
+
|
|
53
|
+
scrollbox.scrollBy(rows);
|
|
54
|
+
|
|
55
|
+
return scrollbox.scrollTop !== before;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
/**
|
|
35
61
|
* The scroll container as the core reads it: the offset and the extents it clamps a scroll
|
|
36
62
|
* target with. These are the `Element` members of the same names, so the view passes as one.
|