@tenyokjs/devdevian-cli 0.3.0-alpha.1 → 0.4.0-alpha.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 +17 -1
- package/bin/devdevian.mjs +3 -0
- package/lib/context-actions.mjs +53 -0
- package/lib/live.mjs +52 -0
- package/lib/picker.mjs +2 -10
- package/lib/review-workspace.mjs +157 -0
- package/lib/theme.mjs +13 -0
- package/lib/tui-data.mjs +45 -5
- package/lib/tui.mjs +445 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
|
|
|
18
18
|
- **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/drafts`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
|
|
19
19
|
- **←/→** switch conversation tabs; **Space** reads the selected reply/remark in full. **[ / ]** paginate; **Page Up/Down** scroll.
|
|
20
20
|
- **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
|
|
21
|
-
- **C** comments on snippet lines; **E/D** edit/delete your own feedback; **S** resolves/reopens a code remark as the
|
|
21
|
+
- **C** comments on snippet lines; **E/D** edit/delete your own ordinary replies or code feedback; **S** resolves/reopens a code remark or accepts/removes a reply as the solution (question/review author only); **!** reports selected code feedback.
|
|
22
22
|
- In forms, **Tab/Shift+Tab** change fields, arrows choose options or move the text cursor, **Enter** adds a line, **Ctrl+S** opens the preview. Publication requires a separate confirmation; Escape asks before discarding a draft. Drafts auto-save locally after a short pause and can be restored from **Local drafts** or `/drafts`.
|
|
23
23
|
- **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
|
|
24
24
|
|
|
@@ -26,6 +26,22 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
|
|
|
26
26
|
|
|
27
27
|
Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R. Network errors stay inside the workspace; failed writes are never automatically retried. Existing web permissions and server validation apply to every operation.
|
|
28
28
|
|
|
29
|
+
Ordinary reply deletion replaces your text with a placeholder and preserves nested replies. Its attachments become unavailable. Deleting an accepted answer reopens the question. D/d works on a selected reply or while reading it in full; confirmation is required.
|
|
30
|
+
|
|
31
|
+
## Code and discussion, together
|
|
32
|
+
|
|
33
|
+
Press **V** in a review to open the code workspace. Wide terminals show the snippet alongside its discussion; narrow terminals show one pane at a time. **W** switches focus. In the code pane, arrows move between lines (left/right scroll horizontally), **Space** anchors a range and **C** opens a prefilled comment form. Remarks may cover at most 50 lines. **Enter** selects a remark covering the current line, if it is on the current page. Markers describe the current page only; **F** cycles all/open/resolved remarks and **[ / ]** page through them.
|
|
34
|
+
|
|
35
|
+
Selecting a remark highlights its range. A remark on an earlier snippet shows its **saved quote**, not unrelated current lines. Switch to current code with **W** before adding a new remark. Git patch line numbers still include patch headers.
|
|
36
|
+
|
|
37
|
+
**X** or **?** opens a keyboard action menu for the selected item. It shows edits/deletion for your own content, resolve/reopen for review authors and accepted solutions for question/review authors. Deletion and accepting/removing a solution require confirmation. Feedback reports go to the existing moderation queue. The server checks permissions independently.
|
|
38
|
+
|
|
39
|
+
## Inbox and live updates
|
|
40
|
+
|
|
41
|
+
The header shows unread notifications. Live reads run every **45 seconds**, slowing to **3 minutes** after two minutes without input; failures back off up to five minutes. An expired/revoked session pauses updates. Pause/resume from **X → Pause/Resume live updates**, or start with `devdevian tui --no-live`.
|
|
42
|
+
|
|
43
|
+
Inbox and open discussion views refresh without moving a selected item that still exists. While you type, use a picker, read full text or confirm an action, background reads update only the unread count; they do not replace the view or draft. Polling never marks notifications as read. **X → Mark received notifications as read** affects only notifications through the last loaded boundary; newer arrivals remain unread. Shift+R refreshes manually.
|
|
44
|
+
|
|
29
45
|
## Project files and Git changes
|
|
30
46
|
|
|
31
47
|
Start inside your project (`cd PROJECT1 && devdevian tui`) or use `devdevian tui --project /path/to/PROJECT1`. `devdevian open --project /path/to/PROJECT1` carries the same project into a new terminal window. The project root stays fixed for the session.
|
package/bin/devdevian.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const options = {
|
|
|
39
39
|
json: { type: "boolean" },
|
|
40
40
|
editor: { type: "boolean" },
|
|
41
41
|
"no-open": { type: "boolean" },
|
|
42
|
+
"no-live": { type: "boolean" },
|
|
42
43
|
help: { type: "boolean", short: "h" },
|
|
43
44
|
version: { type: "boolean", short: "v" },
|
|
44
45
|
};
|
|
@@ -95,6 +96,7 @@ const help = `Devdevian CLI ${version} · alpha
|
|
|
95
96
|
devdevian review reopen POST_ID REVIEW_ID
|
|
96
97
|
devdevian communities [--page 2]
|
|
97
98
|
|
|
99
|
+
--no-live Disable background reads in the live workspace
|
|
98
100
|
--project PATH Root directory for the live file/diff picker (default: current directory)
|
|
99
101
|
--community ID Publish to an approved private community
|
|
100
102
|
--editor Write text in $VISUAL / $EDITOR
|
|
@@ -477,6 +479,7 @@ try {
|
|
|
477
479
|
for (const key of ["host", "name", "project"])
|
|
478
480
|
if (values[key]) args.push("--" + key, values[key]);
|
|
479
481
|
if (values["no-open"]) args.push("--no-open");
|
|
482
|
+
if (values["no-live"]) args.push("--no-live");
|
|
480
483
|
await (
|
|
481
484
|
await import("../lib/window.mjs")
|
|
482
485
|
).openWindow({ entry: fileURLToPath(import.meta.url), args });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Visibility is a convenience; the API remains the permission authority.
|
|
2
|
+
export function contextActions({ route, data, selected, user, live }) {
|
|
3
|
+
const actions = [],
|
|
4
|
+
post = data?.post;
|
|
5
|
+
const add = (id, label) => actions.push({ id, label });
|
|
6
|
+
const feedback =
|
|
7
|
+
route.view === "feedback" ||
|
|
8
|
+
(route.view === "post" && route.tab === "feedback");
|
|
9
|
+
const reply =
|
|
10
|
+
route.view === "thread" ||
|
|
11
|
+
(route.view === "post" && route.tab === "replies");
|
|
12
|
+
if (selected) add("read", "Read selected item");
|
|
13
|
+
if (post && !post.archived_at && !post.locked_at) {
|
|
14
|
+
add("reply", "Write a reply");
|
|
15
|
+
if (post.code) add("comment", "Comment on selected code lines");
|
|
16
|
+
}
|
|
17
|
+
if (
|
|
18
|
+
(reply || feedback) &&
|
|
19
|
+
selected &&
|
|
20
|
+
user &&
|
|
21
|
+
selected.user_id === user.id &&
|
|
22
|
+
!selected.deleted_at
|
|
23
|
+
) {
|
|
24
|
+
add("edit", "Edit your reply / remark");
|
|
25
|
+
add("delete", "Delete your reply / remark…");
|
|
26
|
+
}
|
|
27
|
+
if (post && user && post.user_id === user.id) {
|
|
28
|
+
const note =
|
|
29
|
+
route.view === "feedback" ? data.note : feedback ? selected : null;
|
|
30
|
+
if (note && !note.deleted_at)
|
|
31
|
+
add("resolve", note.resolved ? "Reopen remark" : "Resolve remark");
|
|
32
|
+
if (
|
|
33
|
+
reply &&
|
|
34
|
+
selected &&
|
|
35
|
+
!selected.deleted_at &&
|
|
36
|
+
["question", "review"].includes(post.kind)
|
|
37
|
+
)
|
|
38
|
+
add(
|
|
39
|
+
"solution",
|
|
40
|
+
post.accepted_reply_id === selected.id
|
|
41
|
+
? "Remove accepted solution…"
|
|
42
|
+
: "Accept as solution…",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (feedback && selected && !selected.deleted_at)
|
|
46
|
+
add("report", "Report selected feedback…");
|
|
47
|
+
if (post) add("browser", "Open conversation in browser");
|
|
48
|
+
if (route.view === "inbox" && data?.unread_count > 0)
|
|
49
|
+
add("read-all", "Mark received notifications as read");
|
|
50
|
+
add("sync", "Refresh this view");
|
|
51
|
+
add("live", live ? "Pause live updates" : "Resume live updates");
|
|
52
|
+
return actions;
|
|
53
|
+
}
|
package/lib/live.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Serial, cancellable polling: network failures never retry a mutation.
|
|
2
|
+
export function liveLoop(
|
|
3
|
+
work,
|
|
4
|
+
{
|
|
5
|
+
schedule = setTimeout,
|
|
6
|
+
cancel = clearTimeout,
|
|
7
|
+
now = Date.now,
|
|
8
|
+
activity = () => now(),
|
|
9
|
+
active = 45000,
|
|
10
|
+
idle = 180000,
|
|
11
|
+
onError = () => {},
|
|
12
|
+
} = {},
|
|
13
|
+
) {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let timer,
|
|
16
|
+
failures = 0,
|
|
17
|
+
stopped = false;
|
|
18
|
+
async function run() {
|
|
19
|
+
try {
|
|
20
|
+
await work(controller.signal);
|
|
21
|
+
failures = 0;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (stopped) return;
|
|
24
|
+
failures++;
|
|
25
|
+
onError(error);
|
|
26
|
+
if (error.status === 401 || error.status === 403) {
|
|
27
|
+
stop();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (stopped) return;
|
|
32
|
+
const base = now() - activity() > 120000 ? idle : active;
|
|
33
|
+
timer = schedule(run, Math.min(300000, base * 2 ** Math.min(failures, 3)));
|
|
34
|
+
}
|
|
35
|
+
function stop() {
|
|
36
|
+
stopped = true;
|
|
37
|
+
cancel(timer);
|
|
38
|
+
controller.abort();
|
|
39
|
+
}
|
|
40
|
+
timer = schedule(run, 0);
|
|
41
|
+
return stop;
|
|
42
|
+
}
|
|
43
|
+
export function anchoredIndex(previous, next, index) {
|
|
44
|
+
const row = previous?.[index];
|
|
45
|
+
if (row) {
|
|
46
|
+
const found = next.findIndex(
|
|
47
|
+
(item) => item.id === row.id && item.entryType === row.entryType,
|
|
48
|
+
);
|
|
49
|
+
if (found >= 0) return found;
|
|
50
|
+
}
|
|
51
|
+
return Math.max(0, Math.min(index, next.length - 1));
|
|
52
|
+
}
|
package/lib/picker.mjs
CHANGED
|
@@ -9,17 +9,9 @@ import {
|
|
|
9
9
|
readDiff,
|
|
10
10
|
selectLines,
|
|
11
11
|
} from "./workspace.mjs";
|
|
12
|
+
import { colors } from "./theme.mjs";
|
|
12
13
|
const h = React.createElement,
|
|
13
|
-
c = {
|
|
14
|
-
text: "#e0e7ff",
|
|
15
|
-
muted: "#8190af",
|
|
16
|
-
blue: "#79b8ff",
|
|
17
|
-
purple: "#b29aff",
|
|
18
|
-
green: "#70d6b0",
|
|
19
|
-
red: "#ff9baf",
|
|
20
|
-
bg: "#12192b",
|
|
21
|
-
selected: "#29345b",
|
|
22
|
-
};
|
|
14
|
+
c = { ...colors, bg: colors.panel };
|
|
23
15
|
export function WorkspacePicker({
|
|
24
16
|
root,
|
|
25
17
|
width,
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { clip, graphemes } from "./tui-model.mjs";
|
|
4
|
+
import { colors as c } from "./theme.mjs";
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
export function codeRange(line, anchor = null) {
|
|
7
|
+
return {
|
|
8
|
+
start: Math.min(line, anchor ?? line) + 1,
|
|
9
|
+
end: Math.max(line, anchor ?? line) + 1,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function reviewExcerpt(post, note) {
|
|
13
|
+
// Never attach an old remark to unrelated lines in the new snippet.
|
|
14
|
+
if (note?.outdated)
|
|
15
|
+
return {
|
|
16
|
+
text: note.quote || "Earlier snippet unavailable",
|
|
17
|
+
first: note.start_line || 1,
|
|
18
|
+
old: true,
|
|
19
|
+
};
|
|
20
|
+
return { text: post?.code || "", first: 1, old: false };
|
|
21
|
+
}
|
|
22
|
+
export function ReviewWorkspace({
|
|
23
|
+
post,
|
|
24
|
+
note,
|
|
25
|
+
items,
|
|
26
|
+
index,
|
|
27
|
+
width,
|
|
28
|
+
height,
|
|
29
|
+
focus,
|
|
30
|
+
line,
|
|
31
|
+
anchor,
|
|
32
|
+
horizontal = 0,
|
|
33
|
+
rows,
|
|
34
|
+
}) {
|
|
35
|
+
const split = width >= 78,
|
|
36
|
+
codeWidth = split ? Math.floor(width * 0.55) : width;
|
|
37
|
+
const selectedNote = note || items[index];
|
|
38
|
+
const excerpt = reviewExcerpt(post, focus === "code" ? null : selectedNote),
|
|
39
|
+
lines = excerpt.text.split("\n");
|
|
40
|
+
const range = codeRange(line, anchor);
|
|
41
|
+
const target = excerpt.old
|
|
42
|
+
? 0
|
|
43
|
+
: focus === "code"
|
|
44
|
+
? line
|
|
45
|
+
: Math.max(0, (selectedNote?.start_line || 1) - 1);
|
|
46
|
+
const header = height >= 4 ? 2 : 1,
|
|
47
|
+
count = Math.max(1, height - header),
|
|
48
|
+
top = Math.max(
|
|
49
|
+
0,
|
|
50
|
+
Math.min(lines.length - count, target - Math.floor(count / 3)),
|
|
51
|
+
);
|
|
52
|
+
const code = h(
|
|
53
|
+
Box,
|
|
54
|
+
{ width: codeWidth, height, flexDirection: "column", overflow: "hidden" },
|
|
55
|
+
h(
|
|
56
|
+
Text,
|
|
57
|
+
{ color: focus === "code" ? c.blue : c.purple, bold: true },
|
|
58
|
+
clip(
|
|
59
|
+
excerpt.old
|
|
60
|
+
? "Earlier snippet · saved quote"
|
|
61
|
+
: "Code · W focus · C comment",
|
|
62
|
+
codeWidth,
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
header === 2
|
|
66
|
+
? h(
|
|
67
|
+
Text,
|
|
68
|
+
{ color: c.muted },
|
|
69
|
+
clip(
|
|
70
|
+
focus === "code"
|
|
71
|
+
? `Lines ${range.start}–${range.end} · Space select`
|
|
72
|
+
: "Markers: remarks on this page",
|
|
73
|
+
codeWidth,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
: null,
|
|
77
|
+
...Array.from({ length: count }, (_, i) => {
|
|
78
|
+
const n = top + i,
|
|
79
|
+
number = excerpt.first + n;
|
|
80
|
+
if (n >= lines.length) return h(Text, { key: i }, " ");
|
|
81
|
+
const marked =
|
|
82
|
+
!excerpt.old &&
|
|
83
|
+
items.some(
|
|
84
|
+
(item) =>
|
|
85
|
+
!item.outdated &&
|
|
86
|
+
!item.deleted_at &&
|
|
87
|
+
number >= item.start_line &&
|
|
88
|
+
number <= item.end_line,
|
|
89
|
+
);
|
|
90
|
+
const highlighted =
|
|
91
|
+
excerpt.old ||
|
|
92
|
+
(focus === "code"
|
|
93
|
+
? number >= range.start && number <= range.end
|
|
94
|
+
: selectedNote &&
|
|
95
|
+
!selectedNote.outdated &&
|
|
96
|
+
number >= selectedNote.start_line &&
|
|
97
|
+
number <= selectedNote.end_line);
|
|
98
|
+
const text = lines[n];
|
|
99
|
+
return h(
|
|
100
|
+
Text,
|
|
101
|
+
{
|
|
102
|
+
key: i,
|
|
103
|
+
backgroundColor: highlighted ? c.selected : undefined,
|
|
104
|
+
color: text.startsWith("+")
|
|
105
|
+
? c.green
|
|
106
|
+
: text.startsWith("-")
|
|
107
|
+
? c.red
|
|
108
|
+
: c.text,
|
|
109
|
+
},
|
|
110
|
+
clip(
|
|
111
|
+
`${marked ? "●" : " "}${String(number).padStart(4)} ${graphemes(text.replaceAll("\t", " ")).slice(horizontal).join("")}`,
|
|
112
|
+
codeWidth,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
const notesWidth = split ? width - codeWidth - 2 : width;
|
|
118
|
+
const visible = Math.max(1, Math.floor((height - 2) / 4)),
|
|
119
|
+
first = Math.max(0, index - visible + 1);
|
|
120
|
+
const notes = h(
|
|
121
|
+
Box,
|
|
122
|
+
{ width: notesWidth, height, flexDirection: "column", overflow: "hidden" },
|
|
123
|
+
h(
|
|
124
|
+
Text,
|
|
125
|
+
{ color: focus === "notes" ? c.blue : c.purple, bold: true },
|
|
126
|
+
"Discussion · W focus",
|
|
127
|
+
),
|
|
128
|
+
header === 2
|
|
129
|
+
? h(
|
|
130
|
+
Text,
|
|
131
|
+
{ color: c.muted },
|
|
132
|
+
clip("Enter thread · Space read · X actions", notesWidth),
|
|
133
|
+
)
|
|
134
|
+
: null,
|
|
135
|
+
...(items.length
|
|
136
|
+
? rows(notesWidth)
|
|
137
|
+
.slice(first * 4, first * 4 + height - header)
|
|
138
|
+
.map((row, i) =>
|
|
139
|
+
h(
|
|
140
|
+
Text,
|
|
141
|
+
{
|
|
142
|
+
key: i,
|
|
143
|
+
color: row.color || c.text,
|
|
144
|
+
bold: row.bold,
|
|
145
|
+
backgroundColor: row.selected ? c.selected : undefined,
|
|
146
|
+
},
|
|
147
|
+
clip(typeof row === "string" ? row : row.text, notesWidth),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
: [h(Text, { key: "empty", color: c.muted }, "No remarks. C adds one.")]),
|
|
151
|
+
);
|
|
152
|
+
return h(
|
|
153
|
+
Box,
|
|
154
|
+
{ height, width, gap: split ? 2 : 0, overflow: "hidden" },
|
|
155
|
+
...(split ? [code, notes] : [focus === "code" ? code : notes]),
|
|
156
|
+
);
|
|
157
|
+
}
|
package/lib/theme.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// One palette for the workspace, file picker and review panes.
|
|
2
|
+
export const colors = Object.freeze({
|
|
3
|
+
bg: "#0b1020",
|
|
4
|
+
panel: "#11182b",
|
|
5
|
+
line: "#303d60",
|
|
6
|
+
muted: "#8e9ab8",
|
|
7
|
+
text: "#e5eaff",
|
|
8
|
+
blue: "#86baff",
|
|
9
|
+
purple: "#b5a0ff",
|
|
10
|
+
green: "#7bdcbd",
|
|
11
|
+
red: "#ffa0b4",
|
|
12
|
+
selected: "#29365b",
|
|
13
|
+
});
|
package/lib/tui-data.mjs
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function assertSnippet(code, hash) {
|
|
3
|
+
if (
|
|
4
|
+
createHash("sha256")
|
|
5
|
+
.update(code || "")
|
|
6
|
+
.digest("hex") !== hash
|
|
7
|
+
)
|
|
8
|
+
throw Error(
|
|
9
|
+
"Code changed while loading. Press Shift+R to refresh before reviewing.",
|
|
10
|
+
);
|
|
11
|
+
}
|
|
1
12
|
export const navigation = [
|
|
2
13
|
["inbox", "Inbox", "Replies & mentions"],
|
|
3
14
|
["feed", "Public feed", "Developer commons"],
|
|
@@ -77,15 +88,23 @@ export async function loadPage(api, route, signal) {
|
|
|
77
88
|
const post = await get("/posts/" + route.id + "?reply_page=" + page);
|
|
78
89
|
let items = post.replies,
|
|
79
90
|
pagination = post.reply_pagination;
|
|
91
|
+
let snippet_hash;
|
|
80
92
|
if (route.tab === "feedback") {
|
|
81
93
|
const feedback = await get(
|
|
82
|
-
"/posts/" +
|
|
94
|
+
"/posts/" +
|
|
95
|
+
route.id +
|
|
96
|
+
"/code-reviews?page=" +
|
|
97
|
+
page +
|
|
98
|
+
(route.status ? "&status=" + route.status : ""),
|
|
83
99
|
);
|
|
100
|
+
assertSnippet(post.code, feedback.snippet_hash);
|
|
101
|
+
snippet_hash = feedback.snippet_hash;
|
|
84
102
|
items = feedback.items;
|
|
85
103
|
pagination = feedback.pagination;
|
|
86
104
|
}
|
|
87
105
|
return {
|
|
88
106
|
post,
|
|
107
|
+
snippet_hash,
|
|
89
108
|
items,
|
|
90
109
|
pagination,
|
|
91
110
|
title: post.title,
|
|
@@ -98,15 +117,29 @@ export async function loadPage(api, route, signal) {
|
|
|
98
117
|
};
|
|
99
118
|
}
|
|
100
119
|
if (route.view === "thread") {
|
|
101
|
-
const post = await get(
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
const post = await get(
|
|
121
|
+
"/posts/" + route.postId + "?reply_id=" + route.parent.id,
|
|
122
|
+
);
|
|
123
|
+
const target = post.reply_path?.at(-1);
|
|
124
|
+
let parent = post.replies?.find((r) => r.id === route.parent.id);
|
|
125
|
+
if (!parent && target) {
|
|
126
|
+
const siblings = await get(
|
|
127
|
+
`/posts/${route.postId}/replies?parent_id=${target.parent_id || ""}&page=${target.page}`,
|
|
104
128
|
);
|
|
129
|
+
parent = siblings.items.find((r) => r.id === route.parent.id);
|
|
130
|
+
}
|
|
131
|
+
const replies = await get(
|
|
132
|
+
`/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
|
|
133
|
+
);
|
|
105
134
|
return {
|
|
106
135
|
post,
|
|
107
136
|
...replies,
|
|
137
|
+
items: [
|
|
138
|
+
...(parent ? [{ ...parent, entryType: "parent" }] : []),
|
|
139
|
+
...replies.items,
|
|
140
|
+
],
|
|
108
141
|
title: "Reply thread",
|
|
109
|
-
subtitle: "Replying to " + route.parent.name,
|
|
142
|
+
subtitle: "Replying to " + (parent?.name || route.parent.name),
|
|
110
143
|
};
|
|
111
144
|
}
|
|
112
145
|
if (route.view === "feedback") {
|
|
@@ -114,6 +147,7 @@ export async function loadPage(api, route, signal) {
|
|
|
114
147
|
get("/posts/" + route.postId),
|
|
115
148
|
get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
|
|
116
149
|
]);
|
|
150
|
+
assertSnippet(post.code, notes.snippet_hash);
|
|
117
151
|
const note = notes.items.find((item) => item.id === Number(route.id));
|
|
118
152
|
if (!note) throw Error("This feedback is no longer available.");
|
|
119
153
|
const replies = await get(
|
|
@@ -122,6 +156,7 @@ export async function loadPage(api, route, signal) {
|
|
|
122
156
|
return {
|
|
123
157
|
post,
|
|
124
158
|
note,
|
|
159
|
+
snippet_hash: notes.snippet_hash,
|
|
125
160
|
items: [
|
|
126
161
|
{ ...note, entryType: "note" },
|
|
127
162
|
...replies.items.map((item) => ({
|
|
@@ -149,6 +184,11 @@ export function contentLines(route, data) {
|
|
|
149
184
|
"Page Up / Down Scroll a conversation or list",
|
|
150
185
|
"N / A New conversation / ask a question",
|
|
151
186
|
"R Reply in a conversation; refresh a list",
|
|
187
|
+
"V / W Open review workspace / switch code and discussion",
|
|
188
|
+
"Space / C Select a line range / comment on selected code",
|
|
189
|
+
"F Filter remarks: all, open, resolved",
|
|
190
|
+
"X / ? Context actions (edit, delete, solution, live updates)",
|
|
191
|
+
"Live updates 45s active / 3min idle; pause in X actions or --no-live",
|
|
152
192
|
"C Add feedback on code lines",
|
|
153
193
|
"S Resolve / reopen selected code feedback",
|
|
154
194
|
"E / D Edit / delete your own feedback or reply",
|
package/lib/tui.mjs
CHANGED
|
@@ -24,20 +24,18 @@ import {
|
|
|
24
24
|
parseCommand,
|
|
25
25
|
PageCache,
|
|
26
26
|
} from "./tui-model.mjs";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
navigation,
|
|
29
|
+
loadPage,
|
|
30
|
+
contentLines,
|
|
31
|
+
assertSnippet,
|
|
32
|
+
} from "./tui-data.mjs";
|
|
33
|
+
import { basename } from "node:path";
|
|
34
|
+
import { colors } from "./theme.mjs";
|
|
35
|
+
import { liveLoop, anchoredIndex } from "./live.mjs";
|
|
36
|
+
import { contextActions } from "./context-actions.mjs";
|
|
37
|
+
import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
|
|
28
38
|
const h = React.createElement;
|
|
29
|
-
const colors = {
|
|
30
|
-
bg: "#0d1222",
|
|
31
|
-
panel: "#12192b",
|
|
32
|
-
line: "#354266",
|
|
33
|
-
muted: "#8190af",
|
|
34
|
-
text: "#e0e7ff",
|
|
35
|
-
blue: "#79b8ff",
|
|
36
|
-
purple: "#b29aff",
|
|
37
|
-
green: "#70d6b0",
|
|
38
|
-
red: "#ff9baf",
|
|
39
|
-
selected: "#29345b",
|
|
40
|
-
};
|
|
41
39
|
const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
|
|
42
40
|
const stamp = (value) => {
|
|
43
41
|
const date = new Date(
|
|
@@ -333,8 +331,17 @@ function listRows(route, data, index, width) {
|
|
|
333
331
|
item.name +
|
|
334
332
|
(item.entryType === "note"
|
|
335
333
|
? " · original remark"
|
|
336
|
-
:
|
|
337
|
-
|
|
334
|
+
: item.entryType === "parent"
|
|
335
|
+
? " · parent reply"
|
|
336
|
+
: " · reply #" + item.id) +
|
|
337
|
+
(data.post?.accepted_reply_id === item.id
|
|
338
|
+
? " · accepted solution"
|
|
339
|
+
: "");
|
|
340
|
+
meta =
|
|
341
|
+
stamp(item.created) +
|
|
342
|
+
((item.edited_at || item.updated_at) && !item.deleted_at
|
|
343
|
+
? " · edited"
|
|
344
|
+
: "");
|
|
338
345
|
body = item.deleted_at ? "[deleted — discussion preserved]" : item.body;
|
|
339
346
|
} else {
|
|
340
347
|
meta = [
|
|
@@ -349,7 +356,7 @@ function listRows(route, data, index, width) {
|
|
|
349
356
|
const selected = i === index;
|
|
350
357
|
return [
|
|
351
358
|
{
|
|
352
|
-
text: (selected ? "
|
|
359
|
+
text: (selected ? "▸ " : " ") + clip(title, width - 3),
|
|
353
360
|
color: selected ? colors.blue : colors.text,
|
|
354
361
|
bold: selected,
|
|
355
362
|
selected,
|
|
@@ -360,7 +367,7 @@ function listRows(route, data, index, width) {
|
|
|
360
367
|
color: selected ? colors.text : colors.muted,
|
|
361
368
|
selected,
|
|
362
369
|
},
|
|
363
|
-
"",
|
|
370
|
+
{ text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
|
|
364
371
|
];
|
|
365
372
|
});
|
|
366
373
|
}
|
|
@@ -396,7 +403,18 @@ function App({ initialConfig, flags, root }) {
|
|
|
396
403
|
[tick, setTick] = useState(0),
|
|
397
404
|
[reading, setReading] = useState(null),
|
|
398
405
|
[picker, setPicker] = useState(null),
|
|
399
|
-
[draftSaved, setDraftSaved] = useState(false)
|
|
406
|
+
[draftSaved, setDraftSaved] = useState(false),
|
|
407
|
+
[actionIndex, setActionIndex] = useState(null),
|
|
408
|
+
[reviewFocus, setReviewFocus] = useState("notes"),
|
|
409
|
+
[reviewLine, setReviewLine] = useState(0),
|
|
410
|
+
[reviewAnchor, setReviewAnchor] = useState(null),
|
|
411
|
+
[reviewHorizontal, setReviewHorizontal] = useState(0),
|
|
412
|
+
[live, setLive] = useState(!flags["no-live"]),
|
|
413
|
+
[unread, setUnread] = useState(0),
|
|
414
|
+
[liveStatus, setLiveStatus] = useState("");
|
|
415
|
+
const activity = useRef(Date.now()),
|
|
416
|
+
liveView = useRef(null),
|
|
417
|
+
notificationGeneration = useRef(0);
|
|
400
418
|
const cache = useRef(new PageCache()),
|
|
401
419
|
history = useRef([]),
|
|
402
420
|
busy = useRef(false),
|
|
@@ -422,10 +440,92 @@ function App({ initialConfig, flags, root }) {
|
|
|
422
440
|
sidebar = compact ? 0 : 25,
|
|
423
441
|
bodyHeight = Math.max(1, height - 9),
|
|
424
442
|
contentWidth = Math.max(1, width - sidebar - 6),
|
|
425
|
-
contentHeight = Math.max(1, bodyHeight -
|
|
443
|
+
contentHeight = Math.max(1, bodyHeight - 6);
|
|
426
444
|
const key = JSON.stringify(route),
|
|
427
445
|
current = data?.key === key ? data : null;
|
|
428
446
|
const selected = current?.items?.[index];
|
|
447
|
+
const reviewing =
|
|
448
|
+
!!current?.post?.code &&
|
|
449
|
+
(route.view === "feedback" ||
|
|
450
|
+
(route.view === "post" && route.tab === "feedback"));
|
|
451
|
+
const actions = contextActions({
|
|
452
|
+
route,
|
|
453
|
+
data: current,
|
|
454
|
+
selected,
|
|
455
|
+
user,
|
|
456
|
+
live,
|
|
457
|
+
});
|
|
458
|
+
liveView.current = {
|
|
459
|
+
route,
|
|
460
|
+
key,
|
|
461
|
+
current,
|
|
462
|
+
index,
|
|
463
|
+
blocked: !!(
|
|
464
|
+
form ||
|
|
465
|
+
preview ||
|
|
466
|
+
picker ||
|
|
467
|
+
dialog ||
|
|
468
|
+
reading ||
|
|
469
|
+
actionIndex !== null ||
|
|
470
|
+
loading ||
|
|
471
|
+
pending ||
|
|
472
|
+
focus === "prompt"
|
|
473
|
+
),
|
|
474
|
+
};
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (phase !== "ready" || !api || !live) return;
|
|
477
|
+
return liveLoop(
|
|
478
|
+
async (signal) => {
|
|
479
|
+
const generation = notificationGeneration.current;
|
|
480
|
+
const count = await api("/notifications?limit=1", { signal });
|
|
481
|
+
if (signal.aborted || generation !== notificationGeneration.current)
|
|
482
|
+
return;
|
|
483
|
+
setUnread(count.unread_count || 0);
|
|
484
|
+
const before = liveView.current;
|
|
485
|
+
if (
|
|
486
|
+
before.blocked ||
|
|
487
|
+
!["inbox", "post", "feedback", "thread"].includes(before.route.view)
|
|
488
|
+
) {
|
|
489
|
+
setLiveStatus("");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const next = await loadPage(api, before.route, signal);
|
|
493
|
+
const after = liveView.current;
|
|
494
|
+
if (
|
|
495
|
+
signal.aborted ||
|
|
496
|
+
after.key !== before.key ||
|
|
497
|
+
after.current !== before.current ||
|
|
498
|
+
after.blocked
|
|
499
|
+
)
|
|
500
|
+
return;
|
|
501
|
+
cache.current.set(before.key, next);
|
|
502
|
+
setIndex(
|
|
503
|
+
anchoredIndex(after.current?.items, next.items || [], after.index),
|
|
504
|
+
);
|
|
505
|
+
setData({ ...next, key: before.key });
|
|
506
|
+
setLiveStatus("");
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
activity: () => activity.current,
|
|
510
|
+
onError: (e) => {
|
|
511
|
+
setLiveStatus(
|
|
512
|
+
e.status === 401 || e.status === 403
|
|
513
|
+
? "Live updates paused: sign in again. Local draft preserved."
|
|
514
|
+
: "Live updates delayed · automatic retry",
|
|
515
|
+
);
|
|
516
|
+
if (e.status === 401 || e.status === 403) setLive(false);
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
}, [api, phase, live]);
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
if (current?.unread_count !== undefined) setUnread(current.unread_count);
|
|
523
|
+
}, [current]);
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
setReviewLine(0);
|
|
526
|
+
setReviewAnchor(null);
|
|
527
|
+
setReviewHorizontal(0);
|
|
528
|
+
}, [current?.post?.id, current?.post?.code]);
|
|
429
529
|
const resetAccount = () => {
|
|
430
530
|
cache.current.clear();
|
|
431
531
|
history.current = [];
|
|
@@ -453,6 +553,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
453
553
|
if (!replace) history.current.push({ route, index, scroll });
|
|
454
554
|
if (history.current.length > 30) history.current.shift();
|
|
455
555
|
setReading(null);
|
|
556
|
+
if (next.view === "feedback") setReviewFocus("notes");
|
|
456
557
|
setRoute(next);
|
|
457
558
|
setIndex(0);
|
|
458
559
|
setScroll(0);
|
|
@@ -799,8 +900,25 @@ function App({ initialConfig, flags, root }) {
|
|
|
799
900
|
setError("Replies are paused.");
|
|
800
901
|
return;
|
|
801
902
|
}
|
|
903
|
+
const note = route.view === "feedback" ? current.note : selected;
|
|
904
|
+
const range = reviewing
|
|
905
|
+
? reviewFocus === "notes" && note?.start_line
|
|
906
|
+
? { start: note.start_line, end: note.end_line }
|
|
907
|
+
: codeRange(reviewLine, reviewAnchor)
|
|
908
|
+
: { start: 1, end: 1 };
|
|
909
|
+
if (reviewing && reviewFocus === "notes" && note?.outdated) {
|
|
910
|
+
setError(
|
|
911
|
+
"This remark belongs to an earlier snippet. W switches to current code before adding a new remark.",
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (range.end - range.start >= 50) {
|
|
916
|
+
setError("Select at most 50 code lines for a remark.");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
802
919
|
await task(async () => {
|
|
803
920
|
const notes = await api(`/posts/${p.id}/code-reviews`);
|
|
921
|
+
assertSnippet(p.code, notes.snippet_hash);
|
|
804
922
|
openForm({
|
|
805
923
|
kind: "line",
|
|
806
924
|
title: "Comment on code lines",
|
|
@@ -809,7 +927,11 @@ function App({ initialConfig, flags, root }) {
|
|
|
809
927
|
postId: p.id,
|
|
810
928
|
snippetHash: notes.snippet_hash,
|
|
811
929
|
lineCount: p.code.split("\n").length,
|
|
812
|
-
values: {
|
|
930
|
+
values: {
|
|
931
|
+
start_line: String(range.start),
|
|
932
|
+
end_line: String(range.end),
|
|
933
|
+
body: "",
|
|
934
|
+
},
|
|
813
935
|
});
|
|
814
936
|
});
|
|
815
937
|
}
|
|
@@ -1000,11 +1122,14 @@ function App({ initialConfig, flags, root }) {
|
|
|
1000
1122
|
}
|
|
1001
1123
|
if (route.view === "inbox") {
|
|
1002
1124
|
await task(async () => {
|
|
1003
|
-
if (!item.read_at)
|
|
1125
|
+
if (!item.read_at) {
|
|
1126
|
+
notificationGeneration.current++;
|
|
1004
1127
|
await api("/notifications/" + item.id + "/read", {
|
|
1005
1128
|
method: "PATCH",
|
|
1006
1129
|
body: {},
|
|
1007
1130
|
});
|
|
1131
|
+
setUnread((v) => Math.max(0, v - 1));
|
|
1132
|
+
}
|
|
1008
1133
|
cache.current.clear();
|
|
1009
1134
|
if (item.event_id) {
|
|
1010
1135
|
openBrowser(
|
|
@@ -1035,6 +1160,11 @@ function App({ initialConfig, flags, root }) {
|
|
|
1035
1160
|
navigate({ view: "feedback", postId: route.id, id: item.id, page: 1 });
|
|
1036
1161
|
return;
|
|
1037
1162
|
}
|
|
1163
|
+
if (route.view === "thread" && item.entryType === "parent") {
|
|
1164
|
+
setReading(item);
|
|
1165
|
+
setScroll(0);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1038
1168
|
if (
|
|
1039
1169
|
(route.view === "post" && route.tab === "replies") ||
|
|
1040
1170
|
route.view === "thread"
|
|
@@ -1116,24 +1246,30 @@ function App({ initialConfig, flags, root }) {
|
|
|
1116
1246
|
return;
|
|
1117
1247
|
}
|
|
1118
1248
|
const target = editable();
|
|
1119
|
-
if (
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
) {
|
|
1124
|
-
setError("Select your own line feedback or feedback reply to delete.");
|
|
1249
|
+
if (!target || target.row.user_id !== user.id || target.row.deleted_at) {
|
|
1250
|
+
setError(
|
|
1251
|
+
"Select your own reply or code feedback in the Replies / Code feedback tab to delete.",
|
|
1252
|
+
);
|
|
1125
1253
|
return;
|
|
1126
1254
|
}
|
|
1127
1255
|
askConfirm(
|
|
1128
|
-
"Delete your feedback?",
|
|
1129
|
-
"Your text is removed. Other members’ replies are preserved."
|
|
1256
|
+
target.feedback ? "Delete your feedback?" : "Delete your reply?",
|
|
1257
|
+
"Your text is removed. Other members’ replies are preserved." +
|
|
1258
|
+
(!target.feedback && current.post?.accepted_reply_id === target.row.id
|
|
1259
|
+
? " Its accepted-solution mark is cleared."
|
|
1260
|
+
: ""),
|
|
1130
1261
|
() =>
|
|
1131
1262
|
task(async () => {
|
|
1132
1263
|
await api(target.url, {
|
|
1133
1264
|
method: "DELETE",
|
|
1134
1265
|
body: { revision: target.row.revision },
|
|
1135
1266
|
});
|
|
1136
|
-
|
|
1267
|
+
setReading(null);
|
|
1268
|
+
invalidate(
|
|
1269
|
+
target.feedback
|
|
1270
|
+
? "Feedback deleted; the discussion is preserved."
|
|
1271
|
+
: "Reply deleted; other members’ replies are preserved.",
|
|
1272
|
+
);
|
|
1137
1273
|
}),
|
|
1138
1274
|
);
|
|
1139
1275
|
}
|
|
@@ -1159,6 +1295,81 @@ function App({ initialConfig, flags, root }) {
|
|
|
1159
1295
|
invalidate(note.resolved ? "Feedback reopened." : "Feedback resolved.");
|
|
1160
1296
|
});
|
|
1161
1297
|
}
|
|
1298
|
+
function acceptSolution() {
|
|
1299
|
+
const post = current?.post;
|
|
1300
|
+
if (
|
|
1301
|
+
!selected ||
|
|
1302
|
+
selected.deleted_at ||
|
|
1303
|
+
!post ||
|
|
1304
|
+
post.user_id !== user.id ||
|
|
1305
|
+
!["question", "review"].includes(post.kind) ||
|
|
1306
|
+
!(route.view === "thread" || route.tab === "replies")
|
|
1307
|
+
)
|
|
1308
|
+
return;
|
|
1309
|
+
const accepted = post.accepted_reply_id === selected.id;
|
|
1310
|
+
askConfirm(
|
|
1311
|
+
accepted
|
|
1312
|
+
? "Remove accepted solution?"
|
|
1313
|
+
: "Accept this reply as the solution?",
|
|
1314
|
+
"This changes the solution shown to everyone in the conversation.",
|
|
1315
|
+
() =>
|
|
1316
|
+
task(async () => {
|
|
1317
|
+
await api(`/posts/${post.id}/solution`, {
|
|
1318
|
+
method: "PATCH",
|
|
1319
|
+
body: { reply_id: accepted ? null : selected.id },
|
|
1320
|
+
});
|
|
1321
|
+
invalidate(accepted ? "Solution removed." : "Solution accepted.");
|
|
1322
|
+
}),
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
function reportSelected() {
|
|
1326
|
+
const target = editable();
|
|
1327
|
+
if (!target?.feedback || target.row.deleted_at) {
|
|
1328
|
+
setError("Select an existing code remark or feedback reply to report.");
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
const noteId = route.view === "feedback" ? route.id : target.row.id;
|
|
1332
|
+
openForm({
|
|
1333
|
+
kind: "report",
|
|
1334
|
+
feedback: true,
|
|
1335
|
+
title: "Report selected feedback",
|
|
1336
|
+
url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
|
|
1337
|
+
replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
|
|
1338
|
+
audience: "Assigned moderation team",
|
|
1339
|
+
values: { reason: "" },
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
function runAction(id) {
|
|
1343
|
+
setActionIndex(null);
|
|
1344
|
+
if (id === "read") {
|
|
1345
|
+
if (current?.post) {
|
|
1346
|
+
setReading(selected);
|
|
1347
|
+
setScroll(0);
|
|
1348
|
+
} else openSelected();
|
|
1349
|
+
} else if (id === "reply") reply();
|
|
1350
|
+
else if (id === "comment") lineFeedback();
|
|
1351
|
+
else if (id === "edit") editSelected();
|
|
1352
|
+
else if (id === "delete") deleteSelected();
|
|
1353
|
+
else if (id === "resolve") resolveSelected();
|
|
1354
|
+
else if (id === "solution") acceptSolution();
|
|
1355
|
+
else if (id === "report") reportSelected();
|
|
1356
|
+
else if (id === "browser")
|
|
1357
|
+
openBrowser(config.origin + "/app#post/" + current.post.id);
|
|
1358
|
+
else if (id === "sync") invalidate("Updated.");
|
|
1359
|
+
else if (id === "live") {
|
|
1360
|
+
setLive((v) => !v);
|
|
1361
|
+
setLiveStatus("");
|
|
1362
|
+
} else if (id === "read-all")
|
|
1363
|
+
task(async () => {
|
|
1364
|
+
notificationGeneration.current++;
|
|
1365
|
+
await api("/notifications/read-all", {
|
|
1366
|
+
method: "PATCH",
|
|
1367
|
+
body: { through_id: current.latest_id },
|
|
1368
|
+
});
|
|
1369
|
+
setUnread(0);
|
|
1370
|
+
invalidate("Received notifications marked as read.");
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1162
1373
|
async function runCommand() {
|
|
1163
1374
|
const value = command.trim();
|
|
1164
1375
|
if (!value) return;
|
|
@@ -1232,6 +1443,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
1232
1443
|
);
|
|
1233
1444
|
}
|
|
1234
1445
|
useInput((input, keyEvent) => {
|
|
1446
|
+
activity.current = Date.now();
|
|
1235
1447
|
if (picker) return;
|
|
1236
1448
|
if (keyEvent.ctrl && input === "c") {
|
|
1237
1449
|
if (dialog) {
|
|
@@ -1274,6 +1486,15 @@ function App({ initialConfig, flags, root }) {
|
|
|
1274
1486
|
return;
|
|
1275
1487
|
}
|
|
1276
1488
|
if (busy.current) return;
|
|
1489
|
+
if (actionIndex !== null) {
|
|
1490
|
+
if (keyEvent.escape || input === "x" || input === "X")
|
|
1491
|
+
setActionIndex(null);
|
|
1492
|
+
else if (keyEvent.upArrow) setActionIndex((v) => Math.max(0, v - 1));
|
|
1493
|
+
else if (keyEvent.downArrow)
|
|
1494
|
+
setActionIndex((v) => Math.min(actions.length - 1, v + 1));
|
|
1495
|
+
else if (keyEvent.return) runAction(actions[actionIndex]?.id);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1277
1498
|
if (reading) {
|
|
1278
1499
|
const lines = wrap(
|
|
1279
1500
|
reading.deleted_at
|
|
@@ -1298,9 +1519,11 @@ function App({ initialConfig, flags, root }) {
|
|
|
1298
1519
|
else if (input === "r") {
|
|
1299
1520
|
setReading(null);
|
|
1300
1521
|
reply();
|
|
1301
|
-
} else if (input === "e") {
|
|
1522
|
+
} else if (input === "e" || input === "E") {
|
|
1302
1523
|
setReading(null);
|
|
1303
1524
|
editSelected();
|
|
1525
|
+
} else if (input === "d" || input === "D") {
|
|
1526
|
+
deleteSelected();
|
|
1304
1527
|
}
|
|
1305
1528
|
return;
|
|
1306
1529
|
}
|
|
@@ -1459,6 +1682,80 @@ function App({ initialConfig, flags, root }) {
|
|
|
1459
1682
|
else if (keyEvent.rightArrow) setFocus("content");
|
|
1460
1683
|
return;
|
|
1461
1684
|
}
|
|
1685
|
+
if (input === "x" || input === "X" || input === "?") {
|
|
1686
|
+
setActionIndex(0);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if ((input === "v" || input === "V") && current?.post?.code) {
|
|
1690
|
+
navigate({ view: "post", id: current.post.id, tab: "feedback", page: 1 });
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (reviewing && (input === "w" || input === "W")) {
|
|
1694
|
+
setReviewFocus((v) => (v === "code" ? "notes" : "code"));
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
if (
|
|
1698
|
+
reviewing &&
|
|
1699
|
+
(input === "f" || input === "F") &&
|
|
1700
|
+
route.view === "post"
|
|
1701
|
+
) {
|
|
1702
|
+
const filters = [undefined, "open", "resolved"];
|
|
1703
|
+
navigate(
|
|
1704
|
+
{
|
|
1705
|
+
...route,
|
|
1706
|
+
status: filters[(filters.indexOf(route.status) + 1) % filters.length],
|
|
1707
|
+
page: 1,
|
|
1708
|
+
},
|
|
1709
|
+
{ replace: true },
|
|
1710
|
+
);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
if (reviewing && reviewFocus === "code") {
|
|
1714
|
+
if (
|
|
1715
|
+
keyEvent.upArrow ||
|
|
1716
|
+
keyEvent.downArrow ||
|
|
1717
|
+
keyEvent.pageUp ||
|
|
1718
|
+
keyEvent.pageDown
|
|
1719
|
+
) {
|
|
1720
|
+
const delta =
|
|
1721
|
+
(keyEvent.upArrow || keyEvent.pageUp ? -1 : 1) *
|
|
1722
|
+
(keyEvent.pageUp || keyEvent.pageDown
|
|
1723
|
+
? Math.max(1, contentHeight - 2)
|
|
1724
|
+
: 1);
|
|
1725
|
+
setReviewLine((v) =>
|
|
1726
|
+
Math.max(
|
|
1727
|
+
0,
|
|
1728
|
+
Math.min(current.post.code.split("\n").length - 1, v + delta),
|
|
1729
|
+
),
|
|
1730
|
+
);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (keyEvent.leftArrow || keyEvent.rightArrow) {
|
|
1734
|
+
setReviewHorizontal((v) =>
|
|
1735
|
+
Math.max(0, v + (keyEvent.leftArrow ? -8 : 8)),
|
|
1736
|
+
);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (input === " ") {
|
|
1740
|
+
setReviewAnchor((v) => (v === null ? reviewLine : null));
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (keyEvent.return) {
|
|
1744
|
+
const i = current.items.findIndex(
|
|
1745
|
+
(n) =>
|
|
1746
|
+
!n.outdated &&
|
|
1747
|
+
!n.deleted_at &&
|
|
1748
|
+
reviewLine + 1 >= n.start_line &&
|
|
1749
|
+
reviewLine + 1 <= n.end_line,
|
|
1750
|
+
);
|
|
1751
|
+
if (i >= 0) {
|
|
1752
|
+
setIndex(i);
|
|
1753
|
+
setReviewFocus("notes");
|
|
1754
|
+
} else
|
|
1755
|
+
setNotice("No remark on this line on the current page. C adds one.");
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1462
1759
|
const prose =
|
|
1463
1760
|
route.view === "help" ||
|
|
1464
1761
|
(route.view === "post" && (!route.tab || route.tab === "overview")) ||
|
|
@@ -1553,19 +1850,20 @@ function App({ initialConfig, flags, root }) {
|
|
|
1553
1850
|
invalidate("Updated.");
|
|
1554
1851
|
return;
|
|
1555
1852
|
}
|
|
1556
|
-
if (input === "c") {
|
|
1853
|
+
if (input === "c" || input === "C") {
|
|
1557
1854
|
lineFeedback();
|
|
1558
1855
|
return;
|
|
1559
1856
|
}
|
|
1560
|
-
if (input === "s") {
|
|
1561
|
-
|
|
1857
|
+
if (input === "s" || input === "S") {
|
|
1858
|
+
if (route.view === "thread" || route.tab === "replies") acceptSolution();
|
|
1859
|
+
else resolveSelected();
|
|
1562
1860
|
return;
|
|
1563
1861
|
}
|
|
1564
|
-
if (input === "e") {
|
|
1862
|
+
if (input === "e" || input === "E") {
|
|
1565
1863
|
editSelected();
|
|
1566
1864
|
return;
|
|
1567
1865
|
}
|
|
1568
|
-
if (input === "d") {
|
|
1866
|
+
if (input === "d" || input === "D") {
|
|
1569
1867
|
deleteSelected();
|
|
1570
1868
|
return;
|
|
1571
1869
|
}
|
|
@@ -1590,25 +1888,12 @@ function App({ initialConfig, flags, root }) {
|
|
|
1590
1888
|
return;
|
|
1591
1889
|
}
|
|
1592
1890
|
if (input === "!" && ["feedback", "post"].includes(route.view)) {
|
|
1593
|
-
|
|
1594
|
-
if (!target?.feedback || target.row.deleted_at) {
|
|
1595
|
-
setError("Select an existing code remark or feedback reply to report.");
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
const noteId = route.view === "feedback" ? route.id : target.row.id;
|
|
1599
|
-
openForm({
|
|
1600
|
-
kind: "report",
|
|
1601
|
-
feedback: true,
|
|
1602
|
-
title: "Report selected feedback",
|
|
1603
|
-
url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
|
|
1604
|
-
replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
|
|
1605
|
-
audience: "Assigned moderation team",
|
|
1606
|
-
values: { reason: "" },
|
|
1607
|
-
});
|
|
1891
|
+
reportSelected();
|
|
1608
1892
|
}
|
|
1609
1893
|
});
|
|
1610
1894
|
const status =
|
|
1611
1895
|
error ||
|
|
1896
|
+
liveStatus ||
|
|
1612
1897
|
notice ||
|
|
1613
1898
|
(pending
|
|
1614
1899
|
? "Saving…"
|
|
@@ -1807,6 +2092,61 @@ function App({ initialConfig, flags, root }) {
|
|
|
1807
2092
|
{ color: colors.muted },
|
|
1808
2093
|
"Opening " + labelOf(route).toLowerCase() + " " + spin,
|
|
1809
2094
|
);
|
|
2095
|
+
else if (actionIndex !== null) {
|
|
2096
|
+
const first = Math.max(
|
|
2097
|
+
0,
|
|
2098
|
+
actionIndex - Math.max(1, contentHeight - 2) + 1,
|
|
2099
|
+
);
|
|
2100
|
+
body = h(
|
|
2101
|
+
Box,
|
|
2102
|
+
{ height: contentHeight, flexDirection: "column", overflow: "hidden" },
|
|
2103
|
+
h(
|
|
2104
|
+
Text,
|
|
2105
|
+
{ bold: true, color: colors.blue },
|
|
2106
|
+
"Actions · ↑↓ choose · Enter run · Esc close",
|
|
2107
|
+
),
|
|
2108
|
+
contentHeight >= 4
|
|
2109
|
+
? h(
|
|
2110
|
+
Text,
|
|
2111
|
+
{ color: colors.muted },
|
|
2112
|
+
"Available for this selection and your role",
|
|
2113
|
+
)
|
|
2114
|
+
: null,
|
|
2115
|
+
...actions
|
|
2116
|
+
.slice(
|
|
2117
|
+
first,
|
|
2118
|
+
first + Math.max(1, contentHeight - (contentHeight >= 4 ? 2 : 1)),
|
|
2119
|
+
)
|
|
2120
|
+
.map((action, i) =>
|
|
2121
|
+
h(
|
|
2122
|
+
Text,
|
|
2123
|
+
{
|
|
2124
|
+
key: action.id,
|
|
2125
|
+
color: i + first === actionIndex ? colors.blue : colors.text,
|
|
2126
|
+
backgroundColor:
|
|
2127
|
+
i + first === actionIndex ? colors.selected : undefined,
|
|
2128
|
+
},
|
|
2129
|
+
clip(
|
|
2130
|
+
(i + first === actionIndex ? "› " : " ") + action.label,
|
|
2131
|
+
contentWidth,
|
|
2132
|
+
),
|
|
2133
|
+
),
|
|
2134
|
+
),
|
|
2135
|
+
);
|
|
2136
|
+
} else if (reviewing)
|
|
2137
|
+
body = h(ReviewWorkspace, {
|
|
2138
|
+
post: current.post,
|
|
2139
|
+
note: route.view === "feedback" ? current.note : null,
|
|
2140
|
+
items: current.items || [],
|
|
2141
|
+
index,
|
|
2142
|
+
width: contentWidth,
|
|
2143
|
+
height: contentHeight,
|
|
2144
|
+
focus: reviewFocus,
|
|
2145
|
+
line: reviewLine,
|
|
2146
|
+
anchor: reviewAnchor,
|
|
2147
|
+
horizontal: reviewHorizontal,
|
|
2148
|
+
rows: (w) => listRows(route, current, index, w),
|
|
2149
|
+
});
|
|
1810
2150
|
else {
|
|
1811
2151
|
const lines = contentLines(route, current || {}).flatMap((line) =>
|
|
1812
2152
|
wrap(line, contentWidth),
|
|
@@ -1922,11 +2262,19 @@ function App({ initialConfig, flags, root }) {
|
|
|
1922
2262
|
? colors.blue
|
|
1923
2263
|
: colors.muted,
|
|
1924
2264
|
bold: (route.tab || "overview") === id,
|
|
2265
|
+
backgroundColor:
|
|
2266
|
+
(route.tab || "overview") === id
|
|
2267
|
+
? colors.selected
|
|
2268
|
+
: undefined,
|
|
1925
2269
|
},
|
|
1926
2270
|
" " + label + " ",
|
|
1927
2271
|
),
|
|
1928
2272
|
),
|
|
1929
|
-
h(
|
|
2273
|
+
h(
|
|
2274
|
+
Text,
|
|
2275
|
+
{ color: colors.muted },
|
|
2276
|
+
reviewing ? " W focus · F " + (route.status || "all") : " ← →",
|
|
2277
|
+
),
|
|
1930
2278
|
)
|
|
1931
2279
|
: h(TextLine, {
|
|
1932
2280
|
text: form
|
|
@@ -1962,7 +2310,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
1962
2310
|
paddingX: 1,
|
|
1963
2311
|
overflow: "hidden",
|
|
1964
2312
|
},
|
|
1965
|
-
h(Text, { color: colors.
|
|
2313
|
+
h(Text, { color: colors.purple, bold: true }, "YOUR SPACE"),
|
|
1966
2314
|
h(Text, null, " "),
|
|
1967
2315
|
...navigation
|
|
1968
2316
|
.slice(
|
|
@@ -1986,9 +2334,16 @@ function App({ initialConfig, flags, root }) {
|
|
|
1986
2334
|
backgroundColor: selected ? colors.selected : undefined,
|
|
1987
2335
|
wrap: "truncate",
|
|
1988
2336
|
},
|
|
1989
|
-
(selected ? "› " : " ") +
|
|
2337
|
+
(selected ? "› " : route.view === id ? "● " : " ") +
|
|
2338
|
+
label +
|
|
2339
|
+
(id === "inbox" && unread ? " · " + unread : ""),
|
|
1990
2340
|
);
|
|
1991
2341
|
}),
|
|
2342
|
+
h(
|
|
2343
|
+
Text,
|
|
2344
|
+
{ color: colors.muted, dimColor: true },
|
|
2345
|
+
" Tab sections · / find",
|
|
2346
|
+
),
|
|
1992
2347
|
)
|
|
1993
2348
|
: null;
|
|
1994
2349
|
center = h(
|
|
@@ -1998,21 +2353,26 @@ function App({ initialConfig, flags, root }) {
|
|
|
1998
2353
|
compact && focus === "nav" ? null : pane,
|
|
1999
2354
|
);
|
|
2000
2355
|
}
|
|
2001
|
-
const hint =
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2356
|
+
const hint =
|
|
2357
|
+
actionIndex !== null
|
|
2358
|
+
? "Actions are scoped to the selected item · Esc closes"
|
|
2359
|
+
: reviewing && !form && !preview && !reading
|
|
2360
|
+
? "W code / discussion · Space select · C comment · X actions · F filter"
|
|
2361
|
+
: picker
|
|
2362
|
+
? "Project files stay local until publication is confirmed"
|
|
2363
|
+
: reading
|
|
2364
|
+
? "↑↓ scroll · R reply · E edit · D delete · Esc close"
|
|
2365
|
+
: preview
|
|
2366
|
+
? "← → choose · Enter confirm · Esc edit"
|
|
2367
|
+
: form
|
|
2368
|
+
? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview · Esc discard"
|
|
2369
|
+
: phase !== "ready"
|
|
2370
|
+
? "Enter connect / open browser · Ctrl+C exit"
|
|
2371
|
+
: route.view === "feedback"
|
|
2372
|
+
? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
|
|
2373
|
+
: route.view === "post"
|
|
2374
|
+
? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
|
|
2375
|
+
: "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
|
|
2016
2376
|
return h(
|
|
2017
2377
|
Box,
|
|
2018
2378
|
{
|
|
@@ -2035,12 +2395,22 @@ function App({ initialConfig, flags, root }) {
|
|
|
2035
2395
|
h(
|
|
2036
2396
|
Text,
|
|
2037
2397
|
{ color: colors.purple, bold: true },
|
|
2038
|
-
clip("◈ DEVDEVIAN
|
|
2398
|
+
clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
|
|
2039
2399
|
),
|
|
2040
2400
|
h(
|
|
2041
2401
|
Text,
|
|
2042
2402
|
{ color: colors.blue },
|
|
2043
|
-
clip(
|
|
2403
|
+
clip(
|
|
2404
|
+
user
|
|
2405
|
+
? "@" +
|
|
2406
|
+
user.username +
|
|
2407
|
+
" · " +
|
|
2408
|
+
unread +
|
|
2409
|
+
" unread · " +
|
|
2410
|
+
(live ? "● Live" : "○ Paused")
|
|
2411
|
+
: "CLI · ALPHA",
|
|
2412
|
+
32,
|
|
2413
|
+
),
|
|
2044
2414
|
),
|
|
2045
2415
|
),
|
|
2046
2416
|
center,
|
|
@@ -2068,7 +2438,10 @@ function App({ initialConfig, flags, root }) {
|
|
|
2068
2438
|
: h(
|
|
2069
2439
|
Text,
|
|
2070
2440
|
{ color: colors.muted },
|
|
2071
|
-
clip(
|
|
2441
|
+
clip(
|
|
2442
|
+
"Search or /command · Press / to type · X actions",
|
|
2443
|
+
width - 6,
|
|
2444
|
+
),
|
|
2072
2445
|
),
|
|
2073
2446
|
),
|
|
2074
2447
|
h(TextLine, { text: hint, width, color: colors.muted }),
|