@tenyokjs/devdevian-cli 0.3.1-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 +15 -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 +27 -1
- package/lib/tui.mjs +413 -59
- 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 ordinary replies or code 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
|
|
|
@@ -28,6 +28,20 @@ Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R.
|
|
|
28
28
|
|
|
29
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
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
|
+
|
|
31
45
|
## Project files and Git changes
|
|
32
46
|
|
|
33
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,
|
|
@@ -128,6 +147,7 @@ export async function loadPage(api, route, signal) {
|
|
|
128
147
|
get("/posts/" + route.postId),
|
|
129
148
|
get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
|
|
130
149
|
]);
|
|
150
|
+
assertSnippet(post.code, notes.snippet_hash);
|
|
131
151
|
const note = notes.items.find((item) => item.id === Number(route.id));
|
|
132
152
|
if (!note) throw Error("This feedback is no longer available.");
|
|
133
153
|
const replies = await get(
|
|
@@ -136,6 +156,7 @@ export async function loadPage(api, route, signal) {
|
|
|
136
156
|
return {
|
|
137
157
|
post,
|
|
138
158
|
note,
|
|
159
|
+
snippet_hash: notes.snippet_hash,
|
|
139
160
|
items: [
|
|
140
161
|
{ ...note, entryType: "note" },
|
|
141
162
|
...replies.items.map((item) => ({
|
|
@@ -163,6 +184,11 @@ export function contentLines(route, data) {
|
|
|
163
184
|
"Page Up / Down Scroll a conversation or list",
|
|
164
185
|
"N / A New conversation / ask a question",
|
|
165
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",
|
|
166
192
|
"C Add feedback on code lines",
|
|
167
193
|
"S Resolve / reopen selected code feedback",
|
|
168
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(
|
|
@@ -335,7 +333,10 @@ function listRows(route, data, index, width) {
|
|
|
335
333
|
? " · original remark"
|
|
336
334
|
: item.entryType === "parent"
|
|
337
335
|
? " · parent reply"
|
|
338
|
-
: " · reply #" + item.id)
|
|
336
|
+
: " · reply #" + item.id) +
|
|
337
|
+
(data.post?.accepted_reply_id === item.id
|
|
338
|
+
? " · accepted solution"
|
|
339
|
+
: "");
|
|
339
340
|
meta =
|
|
340
341
|
stamp(item.created) +
|
|
341
342
|
((item.edited_at || item.updated_at) && !item.deleted_at
|
|
@@ -355,7 +356,7 @@ function listRows(route, data, index, width) {
|
|
|
355
356
|
const selected = i === index;
|
|
356
357
|
return [
|
|
357
358
|
{
|
|
358
|
-
text: (selected ? "
|
|
359
|
+
text: (selected ? "▸ " : " ") + clip(title, width - 3),
|
|
359
360
|
color: selected ? colors.blue : colors.text,
|
|
360
361
|
bold: selected,
|
|
361
362
|
selected,
|
|
@@ -366,7 +367,7 @@ function listRows(route, data, index, width) {
|
|
|
366
367
|
color: selected ? colors.text : colors.muted,
|
|
367
368
|
selected,
|
|
368
369
|
},
|
|
369
|
-
"",
|
|
370
|
+
{ text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
|
|
370
371
|
];
|
|
371
372
|
});
|
|
372
373
|
}
|
|
@@ -402,7 +403,18 @@ function App({ initialConfig, flags, root }) {
|
|
|
402
403
|
[tick, setTick] = useState(0),
|
|
403
404
|
[reading, setReading] = useState(null),
|
|
404
405
|
[picker, setPicker] = useState(null),
|
|
405
|
-
[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);
|
|
406
418
|
const cache = useRef(new PageCache()),
|
|
407
419
|
history = useRef([]),
|
|
408
420
|
busy = useRef(false),
|
|
@@ -432,6 +444,88 @@ function App({ initialConfig, flags, root }) {
|
|
|
432
444
|
const key = JSON.stringify(route),
|
|
433
445
|
current = data?.key === key ? data : null;
|
|
434
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]);
|
|
435
529
|
const resetAccount = () => {
|
|
436
530
|
cache.current.clear();
|
|
437
531
|
history.current = [];
|
|
@@ -459,6 +553,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
459
553
|
if (!replace) history.current.push({ route, index, scroll });
|
|
460
554
|
if (history.current.length > 30) history.current.shift();
|
|
461
555
|
setReading(null);
|
|
556
|
+
if (next.view === "feedback") setReviewFocus("notes");
|
|
462
557
|
setRoute(next);
|
|
463
558
|
setIndex(0);
|
|
464
559
|
setScroll(0);
|
|
@@ -805,8 +900,25 @@ function App({ initialConfig, flags, root }) {
|
|
|
805
900
|
setError("Replies are paused.");
|
|
806
901
|
return;
|
|
807
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
|
+
}
|
|
808
919
|
await task(async () => {
|
|
809
920
|
const notes = await api(`/posts/${p.id}/code-reviews`);
|
|
921
|
+
assertSnippet(p.code, notes.snippet_hash);
|
|
810
922
|
openForm({
|
|
811
923
|
kind: "line",
|
|
812
924
|
title: "Comment on code lines",
|
|
@@ -815,7 +927,11 @@ function App({ initialConfig, flags, root }) {
|
|
|
815
927
|
postId: p.id,
|
|
816
928
|
snippetHash: notes.snippet_hash,
|
|
817
929
|
lineCount: p.code.split("\n").length,
|
|
818
|
-
values: {
|
|
930
|
+
values: {
|
|
931
|
+
start_line: String(range.start),
|
|
932
|
+
end_line: String(range.end),
|
|
933
|
+
body: "",
|
|
934
|
+
},
|
|
819
935
|
});
|
|
820
936
|
});
|
|
821
937
|
}
|
|
@@ -1006,11 +1122,14 @@ function App({ initialConfig, flags, root }) {
|
|
|
1006
1122
|
}
|
|
1007
1123
|
if (route.view === "inbox") {
|
|
1008
1124
|
await task(async () => {
|
|
1009
|
-
if (!item.read_at)
|
|
1125
|
+
if (!item.read_at) {
|
|
1126
|
+
notificationGeneration.current++;
|
|
1010
1127
|
await api("/notifications/" + item.id + "/read", {
|
|
1011
1128
|
method: "PATCH",
|
|
1012
1129
|
body: {},
|
|
1013
1130
|
});
|
|
1131
|
+
setUnread((v) => Math.max(0, v - 1));
|
|
1132
|
+
}
|
|
1014
1133
|
cache.current.clear();
|
|
1015
1134
|
if (item.event_id) {
|
|
1016
1135
|
openBrowser(
|
|
@@ -1176,6 +1295,81 @@ function App({ initialConfig, flags, root }) {
|
|
|
1176
1295
|
invalidate(note.resolved ? "Feedback reopened." : "Feedback resolved.");
|
|
1177
1296
|
});
|
|
1178
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
|
+
}
|
|
1179
1373
|
async function runCommand() {
|
|
1180
1374
|
const value = command.trim();
|
|
1181
1375
|
if (!value) return;
|
|
@@ -1249,6 +1443,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
1249
1443
|
);
|
|
1250
1444
|
}
|
|
1251
1445
|
useInput((input, keyEvent) => {
|
|
1446
|
+
activity.current = Date.now();
|
|
1252
1447
|
if (picker) return;
|
|
1253
1448
|
if (keyEvent.ctrl && input === "c") {
|
|
1254
1449
|
if (dialog) {
|
|
@@ -1291,6 +1486,15 @@ function App({ initialConfig, flags, root }) {
|
|
|
1291
1486
|
return;
|
|
1292
1487
|
}
|
|
1293
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
|
+
}
|
|
1294
1498
|
if (reading) {
|
|
1295
1499
|
const lines = wrap(
|
|
1296
1500
|
reading.deleted_at
|
|
@@ -1478,6 +1682,80 @@ function App({ initialConfig, flags, root }) {
|
|
|
1478
1682
|
else if (keyEvent.rightArrow) setFocus("content");
|
|
1479
1683
|
return;
|
|
1480
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
|
+
}
|
|
1481
1759
|
const prose =
|
|
1482
1760
|
route.view === "help" ||
|
|
1483
1761
|
(route.view === "post" && (!route.tab || route.tab === "overview")) ||
|
|
@@ -1572,15 +1850,16 @@ function App({ initialConfig, flags, root }) {
|
|
|
1572
1850
|
invalidate("Updated.");
|
|
1573
1851
|
return;
|
|
1574
1852
|
}
|
|
1575
|
-
if (input === "c") {
|
|
1853
|
+
if (input === "c" || input === "C") {
|
|
1576
1854
|
lineFeedback();
|
|
1577
1855
|
return;
|
|
1578
1856
|
}
|
|
1579
|
-
if (input === "s") {
|
|
1580
|
-
|
|
1857
|
+
if (input === "s" || input === "S") {
|
|
1858
|
+
if (route.view === "thread" || route.tab === "replies") acceptSolution();
|
|
1859
|
+
else resolveSelected();
|
|
1581
1860
|
return;
|
|
1582
1861
|
}
|
|
1583
|
-
if (input === "e") {
|
|
1862
|
+
if (input === "e" || input === "E") {
|
|
1584
1863
|
editSelected();
|
|
1585
1864
|
return;
|
|
1586
1865
|
}
|
|
@@ -1609,25 +1888,12 @@ function App({ initialConfig, flags, root }) {
|
|
|
1609
1888
|
return;
|
|
1610
1889
|
}
|
|
1611
1890
|
if (input === "!" && ["feedback", "post"].includes(route.view)) {
|
|
1612
|
-
|
|
1613
|
-
if (!target?.feedback || target.row.deleted_at) {
|
|
1614
|
-
setError("Select an existing code remark or feedback reply to report.");
|
|
1615
|
-
return;
|
|
1616
|
-
}
|
|
1617
|
-
const noteId = route.view === "feedback" ? route.id : target.row.id;
|
|
1618
|
-
openForm({
|
|
1619
|
-
kind: "report",
|
|
1620
|
-
feedback: true,
|
|
1621
|
-
title: "Report selected feedback",
|
|
1622
|
-
url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
|
|
1623
|
-
replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
|
|
1624
|
-
audience: "Assigned moderation team",
|
|
1625
|
-
values: { reason: "" },
|
|
1626
|
-
});
|
|
1891
|
+
reportSelected();
|
|
1627
1892
|
}
|
|
1628
1893
|
});
|
|
1629
1894
|
const status =
|
|
1630
1895
|
error ||
|
|
1896
|
+
liveStatus ||
|
|
1631
1897
|
notice ||
|
|
1632
1898
|
(pending
|
|
1633
1899
|
? "Saving…"
|
|
@@ -1826,6 +2092,61 @@ function App({ initialConfig, flags, root }) {
|
|
|
1826
2092
|
{ color: colors.muted },
|
|
1827
2093
|
"Opening " + labelOf(route).toLowerCase() + " " + spin,
|
|
1828
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
|
+
});
|
|
1829
2150
|
else {
|
|
1830
2151
|
const lines = contentLines(route, current || {}).flatMap((line) =>
|
|
1831
2152
|
wrap(line, contentWidth),
|
|
@@ -1941,11 +2262,19 @@ function App({ initialConfig, flags, root }) {
|
|
|
1941
2262
|
? colors.blue
|
|
1942
2263
|
: colors.muted,
|
|
1943
2264
|
bold: (route.tab || "overview") === id,
|
|
2265
|
+
backgroundColor:
|
|
2266
|
+
(route.tab || "overview") === id
|
|
2267
|
+
? colors.selected
|
|
2268
|
+
: undefined,
|
|
1944
2269
|
},
|
|
1945
2270
|
" " + label + " ",
|
|
1946
2271
|
),
|
|
1947
2272
|
),
|
|
1948
|
-
h(
|
|
2273
|
+
h(
|
|
2274
|
+
Text,
|
|
2275
|
+
{ color: colors.muted },
|
|
2276
|
+
reviewing ? " W focus · F " + (route.status || "all") : " ← →",
|
|
2277
|
+
),
|
|
1949
2278
|
)
|
|
1950
2279
|
: h(TextLine, {
|
|
1951
2280
|
text: form
|
|
@@ -1981,7 +2310,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
1981
2310
|
paddingX: 1,
|
|
1982
2311
|
overflow: "hidden",
|
|
1983
2312
|
},
|
|
1984
|
-
h(Text, { color: colors.
|
|
2313
|
+
h(Text, { color: colors.purple, bold: true }, "YOUR SPACE"),
|
|
1985
2314
|
h(Text, null, " "),
|
|
1986
2315
|
...navigation
|
|
1987
2316
|
.slice(
|
|
@@ -2005,9 +2334,16 @@ function App({ initialConfig, flags, root }) {
|
|
|
2005
2334
|
backgroundColor: selected ? colors.selected : undefined,
|
|
2006
2335
|
wrap: "truncate",
|
|
2007
2336
|
},
|
|
2008
|
-
(selected ? "› " : " ") +
|
|
2337
|
+
(selected ? "› " : route.view === id ? "● " : " ") +
|
|
2338
|
+
label +
|
|
2339
|
+
(id === "inbox" && unread ? " · " + unread : ""),
|
|
2009
2340
|
);
|
|
2010
2341
|
}),
|
|
2342
|
+
h(
|
|
2343
|
+
Text,
|
|
2344
|
+
{ color: colors.muted, dimColor: true },
|
|
2345
|
+
" Tab sections · / find",
|
|
2346
|
+
),
|
|
2011
2347
|
)
|
|
2012
2348
|
: null;
|
|
2013
2349
|
center = h(
|
|
@@ -2017,21 +2353,26 @@ function App({ initialConfig, flags, root }) {
|
|
|
2017
2353
|
compact && focus === "nav" ? null : pane,
|
|
2018
2354
|
);
|
|
2019
2355
|
}
|
|
2020
|
-
const hint =
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
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";
|
|
2035
2376
|
return h(
|
|
2036
2377
|
Box,
|
|
2037
2378
|
{
|
|
@@ -2054,12 +2395,22 @@ function App({ initialConfig, flags, root }) {
|
|
|
2054
2395
|
h(
|
|
2055
2396
|
Text,
|
|
2056
2397
|
{ color: colors.purple, bold: true },
|
|
2057
|
-
clip("◈ DEVDEVIAN
|
|
2398
|
+
clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
|
|
2058
2399
|
),
|
|
2059
2400
|
h(
|
|
2060
2401
|
Text,
|
|
2061
2402
|
{ color: colors.blue },
|
|
2062
|
-
clip(
|
|
2403
|
+
clip(
|
|
2404
|
+
user
|
|
2405
|
+
? "@" +
|
|
2406
|
+
user.username +
|
|
2407
|
+
" · " +
|
|
2408
|
+
unread +
|
|
2409
|
+
" unread · " +
|
|
2410
|
+
(live ? "● Live" : "○ Paused")
|
|
2411
|
+
: "CLI · ALPHA",
|
|
2412
|
+
32,
|
|
2413
|
+
),
|
|
2063
2414
|
),
|
|
2064
2415
|
),
|
|
2065
2416
|
center,
|
|
@@ -2087,7 +2438,10 @@ function App({ initialConfig, flags, root }) {
|
|
|
2087
2438
|
: h(
|
|
2088
2439
|
Text,
|
|
2089
2440
|
{ color: colors.muted },
|
|
2090
|
-
clip(
|
|
2441
|
+
clip(
|
|
2442
|
+
"Search or /command · Press / to type · X actions",
|
|
2443
|
+
width - 6,
|
|
2444
|
+
),
|
|
2091
2445
|
),
|
|
2092
2446
|
),
|
|
2093
2447
|
h(TextLine, { text: hint, width, color: colors.muted }),
|