revspec 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -68
- package/bin/revspec.ts +4 -38
- package/package.json +15 -1
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +122 -58
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +145 -108
- package/src/tui/comment-input.ts +9 -13
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +13 -16
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -6
- package/src/tui/thread-list.ts +62 -22
- package/src/tui/ui/keymap.ts +55 -0
- package/.github/workflows/ci.yml +0 -18
- package/CLAUDE.md +0 -29
- package/bun.lock +0 -216
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
- package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
- package/scripts/install-skill.sh +0 -20
- package/scripts/release.sh +0 -52
- package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
- package/test/e2e/fixtures/spec.md +0 -36
- package/test/e2e/harness.ts +0 -80
- package/test/e2e/snapshot.test.ts +0 -182
- package/test/integration/cli-reply.test.ts +0 -140
- package/test/integration/cli-watch.test.ts +0 -216
- package/test/integration/cli.test.ts +0 -160
- package/test/integration/e2e-live.test.ts +0 -171
- package/test/integration/live-interaction.test.ts +0 -398
- package/test/integration/opentui-smoke.test.ts +0 -12
- package/test/unit/protocol/live-events.test.ts +0 -509
- package/test/unit/protocol/live-merge.test.ts +0 -167
- package/test/unit/protocol/merge.test.ts +0 -100
- package/test/unit/protocol/read.test.ts +0 -92
- package/test/unit/protocol/types.test.ts +0 -95
- package/test/unit/protocol/write.test.ts +0 -72
- package/test/unit/state/review-state.test.ts +0 -399
- package/test/unit/tui/pager.test.ts +0 -159
- package/test/unit/tui/ui/keybinds.test.ts +0 -71
- package/tsconfig.json +0 -14
package/src/cli/watch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
|
|
2
2
|
import { watch as fsWatch } from "fs";
|
|
3
3
|
import { resolve, dirname, basename, join } from "path";
|
|
4
4
|
import {
|
|
@@ -8,6 +8,13 @@ import {
|
|
|
8
8
|
import type { Thread } from "../protocol/types";
|
|
9
9
|
import { replayEventsToThreads } from "../protocol/live-events";
|
|
10
10
|
|
|
11
|
+
/** Atomic write: write to .tmp then rename (POSIX rename is atomic) */
|
|
12
|
+
function atomicWriteFileSync(filePath: string, content: string): void {
|
|
13
|
+
const tmpPath = filePath + ".tmp";
|
|
14
|
+
writeFileSync(tmpPath, content, "utf8");
|
|
15
|
+
renameSync(tmpPath, filePath);
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export async function runWatch(specFile: string): Promise<void> {
|
|
12
19
|
// Resolve and validate spec path
|
|
13
20
|
const specPath = resolve(specFile);
|
|
@@ -19,10 +26,9 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
19
26
|
// Derive paths
|
|
20
27
|
const dir = dirname(specPath);
|
|
21
28
|
const base = basename(specPath, ".md");
|
|
22
|
-
const jsonlPath = join(dir, `${base}.review.
|
|
23
|
-
const offsetPath = join(dir, `${base}.review.
|
|
24
|
-
const lockPath = join(dir, `${base}.review.
|
|
25
|
-
const reviewPath = join(dir, `${base}.review.json`);
|
|
29
|
+
const jsonlPath = join(dir, `${base}.review.jsonl`);
|
|
30
|
+
const offsetPath = join(dir, `${base}.review.offset`);
|
|
31
|
+
const lockPath = join(dir, `${base}.review.lock`);
|
|
26
32
|
|
|
27
33
|
// Handle lock file
|
|
28
34
|
if (existsSync(lockPath)) {
|
|
@@ -46,14 +52,21 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
46
52
|
// Create/overwrite lock with current PID
|
|
47
53
|
writeFileSync(lockPath, String(process.pid), "utf8");
|
|
48
54
|
|
|
49
|
-
// Read offset from offset file
|
|
55
|
+
// Read offset and last processed submit timestamp from offset file
|
|
50
56
|
let offset = 0;
|
|
57
|
+
let lastSubmitTs = 0;
|
|
51
58
|
if (existsSync(offsetPath)) {
|
|
52
|
-
const
|
|
53
|
-
const parsed = parseInt(
|
|
59
|
+
const lines = readFileSync(offsetPath, "utf8").trim().split("\n");
|
|
60
|
+
const parsed = parseInt(lines[0], 10);
|
|
54
61
|
if (!isNaN(parsed)) {
|
|
55
62
|
offset = parsed;
|
|
56
63
|
}
|
|
64
|
+
if (lines.length > 1) {
|
|
65
|
+
const parsedTs = parseInt(lines[1], 10);
|
|
66
|
+
if (!isNaN(parsedTs)) {
|
|
67
|
+
lastSubmitTs = parsedTs;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
// Read spec lines for context
|
|
@@ -66,14 +79,14 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
66
79
|
const result = processNewEvents(
|
|
67
80
|
jsonlPath,
|
|
68
81
|
offsetPath,
|
|
69
|
-
lockPath,
|
|
70
|
-
reviewPath,
|
|
71
82
|
specPath,
|
|
72
83
|
specLines,
|
|
73
|
-
offset
|
|
84
|
+
offset,
|
|
85
|
+
lastSubmitTs,
|
|
86
|
+
true // always check recovery in non-blocking (one-shot)
|
|
74
87
|
);
|
|
75
88
|
if (result.approved) {
|
|
76
|
-
console.log(
|
|
89
|
+
console.log("Review approved.");
|
|
77
90
|
cleanupFiles(lockPath, offsetPath);
|
|
78
91
|
} else if (result.output) {
|
|
79
92
|
process.stdout.write(result.output);
|
|
@@ -83,23 +96,25 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
83
96
|
|
|
84
97
|
// Blocking mode: watch for JSONL changes
|
|
85
98
|
let processing = false;
|
|
99
|
+
let firstPoll = true; // only run crash recovery on first poll
|
|
86
100
|
|
|
87
|
-
const handleChange =
|
|
101
|
+
const handleChange = () => {
|
|
88
102
|
if (processing) return;
|
|
89
103
|
processing = true;
|
|
90
104
|
try {
|
|
91
105
|
const result = processNewEvents(
|
|
92
106
|
jsonlPath,
|
|
93
107
|
offsetPath,
|
|
94
|
-
lockPath,
|
|
95
|
-
reviewPath,
|
|
96
108
|
specPath,
|
|
97
109
|
specLines,
|
|
98
|
-
offset
|
|
110
|
+
offset,
|
|
111
|
+
lastSubmitTs,
|
|
112
|
+
firstPoll
|
|
99
113
|
);
|
|
114
|
+
firstPoll = false;
|
|
100
115
|
|
|
101
116
|
if (result.approved) {
|
|
102
|
-
console.log(
|
|
117
|
+
console.log("Review approved.");
|
|
103
118
|
cleanupFiles(lockPath, offsetPath);
|
|
104
119
|
process.exit(0);
|
|
105
120
|
}
|
|
@@ -110,8 +125,15 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
110
125
|
process.exit(0);
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
// Update
|
|
128
|
+
// Update state for next poll iteration
|
|
114
129
|
offset = result.newOffset;
|
|
130
|
+
if (existsSync(offsetPath)) {
|
|
131
|
+
const lines = readFileSync(offsetPath, "utf8").trim().split("\n");
|
|
132
|
+
if (lines.length > 1) {
|
|
133
|
+
const parsedTs = parseInt(lines[1], 10);
|
|
134
|
+
if (!isNaN(parsedTs)) lastSubmitTs = parsedTs;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
115
137
|
} finally {
|
|
116
138
|
processing = false;
|
|
117
139
|
}
|
|
@@ -135,7 +157,7 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
135
157
|
|
|
136
158
|
// Also watch the directory for the JSONL file to appear
|
|
137
159
|
const dirWatcher = fsWatch(dir, (eventType, filename) => {
|
|
138
|
-
if (filename && filename.endsWith(".
|
|
160
|
+
if (filename && filename.endsWith(".jsonl")) {
|
|
139
161
|
setupWatcher();
|
|
140
162
|
}
|
|
141
163
|
});
|
|
@@ -168,11 +190,11 @@ interface ProcessResult {
|
|
|
168
190
|
function processNewEvents(
|
|
169
191
|
jsonlPath: string,
|
|
170
192
|
offsetPath: string,
|
|
171
|
-
lockPath: string,
|
|
172
|
-
reviewPath: string,
|
|
173
193
|
specPath: string,
|
|
174
194
|
specLines: string[],
|
|
175
|
-
offset: number
|
|
195
|
+
offset: number,
|
|
196
|
+
lastSubmitTs: number,
|
|
197
|
+
checkRecovery: boolean
|
|
176
198
|
): ProcessResult {
|
|
177
199
|
if (!existsSync(jsonlPath)) {
|
|
178
200
|
return { approved: false, output: "", newOffset: offset };
|
|
@@ -180,12 +202,41 @@ function processNewEvents(
|
|
|
180
202
|
|
|
181
203
|
const { events, newOffset } = readEventsFromOffset(jsonlPath, offset);
|
|
182
204
|
|
|
205
|
+
// Recovery: detect pending unprocessed submit (only on first poll to avoid
|
|
206
|
+
// re-reading the entire JSONL every 500ms in blocking mode)
|
|
207
|
+
if (events.length === 0 && checkRecovery) {
|
|
208
|
+
const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
|
|
209
|
+
const lastSubmitIdx = allEvents.findLastIndex(e => e.type === "submit");
|
|
210
|
+
if (lastSubmitIdx >= 0) {
|
|
211
|
+
const lastSubmitEvent = allEvents[lastSubmitIdx];
|
|
212
|
+
// If we already output this submit, skip (not a crash)
|
|
213
|
+
if (lastSubmitEvent.ts === lastSubmitTs) {
|
|
214
|
+
return { approved: false, output: "", newOffset: offset };
|
|
215
|
+
}
|
|
216
|
+
const afterSubmit = allEvents.slice(lastSubmitIdx + 1);
|
|
217
|
+
const hasNewActivity = afterSubmit.some(e =>
|
|
218
|
+
e.type === "comment" || e.type === "reply" ||
|
|
219
|
+
e.type === "approve" || e.type === "session-end"
|
|
220
|
+
);
|
|
221
|
+
if (!hasNewActivity) {
|
|
222
|
+
const roundStart = findCurrentRoundStartIndex(allEvents);
|
|
223
|
+
const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
|
|
224
|
+
const resolved = currentRoundThreads.filter(t => t.status === "resolved");
|
|
225
|
+
const output = formatSubmitOutput(resolved, specPath);
|
|
226
|
+
// Record this submit's ts so we don't re-output it
|
|
227
|
+
atomicWriteFileSync(offsetPath, `${offset}\n${lastSubmitEvent.ts}`);
|
|
228
|
+
return { approved: false, output, newOffset: offset };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { approved: false, output: "", newOffset: offset };
|
|
232
|
+
}
|
|
233
|
+
|
|
183
234
|
if (events.length === 0) {
|
|
184
235
|
return { approved: false, output: "", newOffset: offset };
|
|
185
236
|
}
|
|
186
237
|
|
|
187
238
|
// Save new offset
|
|
188
|
-
|
|
239
|
+
atomicWriteFileSync(offsetPath, String(newOffset));
|
|
189
240
|
|
|
190
241
|
// Check for approve event
|
|
191
242
|
const hasApprove = events.some((e) => e.type === "approve");
|
|
@@ -193,6 +244,19 @@ function processNewEvents(
|
|
|
193
244
|
return { approved: true, output: "", newOffset };
|
|
194
245
|
}
|
|
195
246
|
|
|
247
|
+
// Check for submit event — priority over session-end
|
|
248
|
+
const submitEvent = events.findLast((e) => e.type === "submit");
|
|
249
|
+
if (submitEvent) {
|
|
250
|
+
const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
|
|
251
|
+
const roundStart = findCurrentRoundStartIndex(allEvents);
|
|
252
|
+
const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
|
|
253
|
+
const resolved = currentRoundThreads.filter(t => t.status === "resolved");
|
|
254
|
+
const output = formatSubmitOutput(resolved, specPath);
|
|
255
|
+
// Record submit ts for crash recovery dedup
|
|
256
|
+
atomicWriteFileSync(offsetPath, `${newOffset}\n${submitEvent.ts}`);
|
|
257
|
+
return { approved: false, output, newOffset };
|
|
258
|
+
}
|
|
259
|
+
|
|
196
260
|
// Check for session-end — TUI exited, break the loop
|
|
197
261
|
const hasSessionEnd = events.some((e) => e.type === "session-end");
|
|
198
262
|
if (hasSessionEnd) {
|
|
@@ -233,8 +297,6 @@ function formatWatchOutput(
|
|
|
233
297
|
// Group events by type
|
|
234
298
|
const newCommentThreadIds: string[] = [];
|
|
235
299
|
const replyThreadIds: string[] = [];
|
|
236
|
-
const resolvedThreadIds: string[] = [];
|
|
237
|
-
const deletedThreadIds: string[] = [];
|
|
238
300
|
|
|
239
301
|
const seen = new Set<string>();
|
|
240
302
|
|
|
@@ -249,14 +311,6 @@ function formatWatchOutput(
|
|
|
249
311
|
if (!replyThreadIds.includes(tid)) {
|
|
250
312
|
replyThreadIds.push(tid);
|
|
251
313
|
}
|
|
252
|
-
} else if (event.type === "resolve") {
|
|
253
|
-
if (!resolvedThreadIds.includes(tid)) {
|
|
254
|
-
resolvedThreadIds.push(tid);
|
|
255
|
-
}
|
|
256
|
-
} else if (event.type === "delete") {
|
|
257
|
-
if (!deletedThreadIds.includes(tid)) {
|
|
258
|
-
deletedThreadIds.push(tid);
|
|
259
|
-
}
|
|
260
314
|
}
|
|
261
315
|
}
|
|
262
316
|
|
|
@@ -315,31 +369,6 @@ function formatWatchOutput(
|
|
|
315
369
|
}
|
|
316
370
|
}
|
|
317
371
|
|
|
318
|
-
// Resolved threads
|
|
319
|
-
if (resolvedThreadIds.length > 0) {
|
|
320
|
-
lines.push("=== Resolved ===");
|
|
321
|
-
for (const tid of resolvedThreadIds) {
|
|
322
|
-
const thread = threadsById.get(tid);
|
|
323
|
-
if (!thread) continue;
|
|
324
|
-
lines.push(`Thread: ${tid} (line ${thread.line}) — resolved`);
|
|
325
|
-
lines.push("");
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Deleted threads
|
|
330
|
-
if (deletedThreadIds.length > 0) {
|
|
331
|
-
lines.push("=== Deleted ===");
|
|
332
|
-
for (const tid of deletedThreadIds) {
|
|
333
|
-
const thread = threadsById.get(tid);
|
|
334
|
-
if (thread) {
|
|
335
|
-
lines.push(`Thread: ${tid} (line ${thread.line}) — deleted`);
|
|
336
|
-
} else {
|
|
337
|
-
lines.push(`Thread: ${tid} — deleted`);
|
|
338
|
-
}
|
|
339
|
-
lines.push("");
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
372
|
// Add footer instruction
|
|
344
373
|
const hasActionable = newCommentThreadIds.length > 0 || replyThreadIds.length > 0;
|
|
345
374
|
if (hasActionable) {
|
|
@@ -368,6 +397,41 @@ function getContext(
|
|
|
368
397
|
return result;
|
|
369
398
|
}
|
|
370
399
|
|
|
400
|
+
function findCurrentRoundStartIndex(events: LiveEvent[]): number {
|
|
401
|
+
let count = 0;
|
|
402
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
403
|
+
if (events[i].type === "submit") {
|
|
404
|
+
count++;
|
|
405
|
+
if (count === 2) return i + 1;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function formatSubmitOutput(
|
|
412
|
+
resolvedThreads: Thread[],
|
|
413
|
+
specPath: string
|
|
414
|
+
): string {
|
|
415
|
+
const lines: string[] = [];
|
|
416
|
+
lines.push("=== Submit: Rewrite Requested ===");
|
|
417
|
+
lines.push("");
|
|
418
|
+
if (resolvedThreads.length > 0) {
|
|
419
|
+
lines.push("Resolved threads:");
|
|
420
|
+
for (const t of resolvedThreads) {
|
|
421
|
+
const reviewerMsgs = t.messages.filter(m => m.author === "reviewer");
|
|
422
|
+
const ownerMsgs = t.messages.filter(m => m.author === "owner");
|
|
423
|
+
lines.push(` ${t.id} (line ${t.line}): "${reviewerMsgs.map(m => m.text).join("; ")}"`);
|
|
424
|
+
if (ownerMsgs.length > 0) {
|
|
425
|
+
lines.push(` → AI: "${ownerMsgs.map(m => m.text).join("; ")}"`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
lines.push("");
|
|
429
|
+
}
|
|
430
|
+
lines.push(`Rewrite the spec incorporating the above, then run: revspec watch ${basename(specPath)}`);
|
|
431
|
+
lines.push("");
|
|
432
|
+
return lines.join("\n");
|
|
433
|
+
}
|
|
434
|
+
|
|
371
435
|
function cleanupFiles(lockPath: string, offsetPath: string): void {
|
|
372
436
|
if (existsSync(lockPath)) unlinkSync(lockPath);
|
|
373
437
|
if (existsSync(offsetPath)) unlinkSync(offsetPath);
|
|
@@ -9,7 +9,8 @@ export type LiveEventType =
|
|
|
9
9
|
| "approve"
|
|
10
10
|
| "delete"
|
|
11
11
|
| "round"
|
|
12
|
-
| "session-end"
|
|
12
|
+
| "session-end"
|
|
13
|
+
| "submit";
|
|
13
14
|
|
|
14
15
|
export interface LiveEvent {
|
|
15
16
|
type: LiveEventType;
|
|
@@ -30,6 +31,7 @@ const VALID_LIVE_EVENT_TYPES: readonly LiveEventType[] = [
|
|
|
30
31
|
"delete",
|
|
31
32
|
"round",
|
|
32
33
|
"session-end",
|
|
34
|
+
"submit",
|
|
33
35
|
];
|
|
34
36
|
|
|
35
37
|
export function isValidLiveEvent(value: unknown): value is LiveEvent {
|
|
@@ -49,7 +51,7 @@ export function isValidLiveEvent(value: unknown): value is LiveEvent {
|
|
|
49
51
|
if (typeof v.author !== "string") return false;
|
|
50
52
|
|
|
51
53
|
// threadId required for all except approve, round, and session-end
|
|
52
|
-
if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end") {
|
|
54
|
+
if (v.type !== "approve" && v.type !== "round" && v.type !== "session-end" && v.type !== "submit") {
|
|
53
55
|
if (typeof v.threadId !== "string") return false;
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -186,25 +188,13 @@ export function replayEventsToThreads(events: LiveEvent[]): Thread[] {
|
|
|
186
188
|
|
|
187
189
|
case "delete": {
|
|
188
190
|
if (!event.threadId) break;
|
|
189
|
-
|
|
190
|
-
if (!thread) break;
|
|
191
|
-
// Remove the last reviewer message
|
|
192
|
-
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
193
|
-
if (thread.messages[i].author === "reviewer") {
|
|
194
|
-
thread.messages.splice(i, 1);
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
// Re-derive status from the new last message
|
|
199
|
-
const lastMsg = thread.messages[thread.messages.length - 1];
|
|
200
|
-
if (lastMsg) {
|
|
201
|
-
thread.status = lastMsg.author === "owner" ? "pending" : "open";
|
|
202
|
-
}
|
|
191
|
+
threadsMap.delete(event.threadId);
|
|
203
192
|
break;
|
|
204
193
|
}
|
|
205
194
|
|
|
206
195
|
case "approve":
|
|
207
196
|
case "round":
|
|
197
|
+
case "submit":
|
|
208
198
|
// Skip these event types
|
|
209
199
|
break;
|
|
210
200
|
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
1
2
|
import type { Thread, Message } from "../protocol/types";
|
|
2
3
|
|
|
4
|
+
function nanoid(size = 8): string {
|
|
5
|
+
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
6
|
+
const bytes = randomBytes(size);
|
|
7
|
+
let id = "";
|
|
8
|
+
for (let i = 0; i < size; i++) {
|
|
9
|
+
id += alphabet[bytes[i] % alphabet.length];
|
|
10
|
+
}
|
|
11
|
+
return id;
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
export class ReviewState {
|
|
4
15
|
specLines: string[];
|
|
5
16
|
threads: Thread[];
|
|
@@ -20,12 +31,7 @@ export class ReviewState {
|
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
nextThreadId(): string {
|
|
23
|
-
|
|
24
|
-
const highest = this.threads.reduce((max, t) => {
|
|
25
|
-
const n = parseInt(t.id.replace(/^t/, ""), 10);
|
|
26
|
-
return isNaN(n) ? max : Math.max(max, n);
|
|
27
|
-
}, 0);
|
|
28
|
-
return `t${highest + 1}`;
|
|
34
|
+
return nanoid();
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
addComment(line: number, text: string): void {
|
|
@@ -64,40 +70,47 @@ export class ReviewState {
|
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
resolveAll(): void {
|
|
74
|
+
for (const thread of this.threads) {
|
|
75
|
+
if (thread.status !== "resolved" && thread.status !== "outdated") {
|
|
76
|
+
thread.status = "resolved";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
reset(newSpecLines: string[]): void {
|
|
82
|
+
this.specLines = newSpecLines;
|
|
83
|
+
this.threads = [];
|
|
84
|
+
this.cursorLine = 1;
|
|
85
|
+
this._unreadThreadIds.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
threadAtLine(line: number): Thread | null {
|
|
68
89
|
return this.threads.find((t) => t.line === line) ?? null;
|
|
69
90
|
}
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
(t) => t.status === "open" || t.status === "pending"
|
|
74
|
-
);
|
|
75
|
-
if (active.length === 0) return null;
|
|
92
|
+
nextThread(): number | null {
|
|
93
|
+
if (this.threads.length === 0) return null;
|
|
76
94
|
|
|
77
|
-
|
|
78
|
-
const after = active.filter((t) => t.line > this.cursorLine);
|
|
95
|
+
const after = this.threads.filter((t) => t.line > this.cursorLine);
|
|
79
96
|
if (after.length > 0) {
|
|
80
97
|
return after.reduce((min, t) => (t.line < min.line ? t : min)).line;
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
// Wrap: return the lowest-line
|
|
84
|
-
return
|
|
100
|
+
// Wrap: return the lowest-line thread
|
|
101
|
+
return this.threads.reduce((min, t) => (t.line < min.line ? t : min)).line;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
(t) => t.status === "open" || t.status === "pending"
|
|
90
|
-
);
|
|
91
|
-
if (active.length === 0) return null;
|
|
104
|
+
prevThread(): number | null {
|
|
105
|
+
if (this.threads.length === 0) return null;
|
|
92
106
|
|
|
93
|
-
|
|
94
|
-
const before = active.filter((t) => t.line < this.cursorLine);
|
|
107
|
+
const before = this.threads.filter((t) => t.line < this.cursorLine);
|
|
95
108
|
if (before.length > 0) {
|
|
96
109
|
return before.reduce((max, t) => (t.line > max.line ? t : max)).line;
|
|
97
110
|
}
|
|
98
111
|
|
|
99
|
-
// Wrap: return the highest-line
|
|
100
|
-
return
|
|
112
|
+
// Wrap: return the highest-line thread
|
|
113
|
+
return this.threads.reduce((max, t) => (t.line > max.line ? t : max)).line;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
canApprove(): boolean {
|