@tenyokjs/devdevian-cli 0.3.1-alpha.1 → 0.4.1-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 +18 -1
- package/VERSIONS.md +57 -0
- package/bin/devdevian.mjs +8 -25
- package/lib/client.mjs +12 -4
- package/lib/context-actions.mjs +53 -0
- package/lib/external-editor.mjs +111 -0
- package/lib/live.mjs +52 -0
- package/lib/picker.mjs +2 -10
- package/lib/problems.mjs +29 -0
- package/lib/review-workspace.mjs +163 -0
- package/lib/rich-text.mjs +96 -0
- package/lib/theme.mjs +13 -0
- package/lib/tui-data.mjs +69 -2
- package/lib/tui.mjs +643 -97
- package/package.json +2 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { clean } from "./client.mjs";
|
|
2
|
+
import { wrap } from "./tui-model.mjs";
|
|
3
|
+
import { colors as c } from "./theme.mjs";
|
|
4
|
+
|
|
5
|
+
// Small, predictable terminal renderer. HTML and terminal hyperlinks are never executed.
|
|
6
|
+
export function syntaxSpans(text) {
|
|
7
|
+
const pattern =
|
|
8
|
+
/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\/\/.*$|#.*$|\b(?:async|await|function|return|const|let|var|if|else|throw|new|class|import|from|export|def|None|True|False|true|false|null|try|catch|for|while)\b|\b\d+(?:\.\d+)?\b)/g;
|
|
9
|
+
const parts = [];
|
|
10
|
+
let start = 0;
|
|
11
|
+
for (const match of text.matchAll(pattern)) {
|
|
12
|
+
if (match.index > start)
|
|
13
|
+
parts.push({ text: text.slice(start, match.index) });
|
|
14
|
+
const word = match[0];
|
|
15
|
+
parts.push({
|
|
16
|
+
text: word,
|
|
17
|
+
color: /^(\/\/|#)/.test(word)
|
|
18
|
+
? c.muted
|
|
19
|
+
: /^["'`]/.test(word)
|
|
20
|
+
? c.green
|
|
21
|
+
: /^\d/.test(word)
|
|
22
|
+
? c.blue
|
|
23
|
+
: c.purple,
|
|
24
|
+
});
|
|
25
|
+
start = match.index + word.length;
|
|
26
|
+
}
|
|
27
|
+
if (start < text.length) parts.push({ text: text.slice(start) });
|
|
28
|
+
return parts;
|
|
29
|
+
}
|
|
30
|
+
export function markdownRows(value, width) {
|
|
31
|
+
let fence = null,
|
|
32
|
+
number = 0;
|
|
33
|
+
const rows = [];
|
|
34
|
+
for (const raw of clean(value).replace(/\t/g, " ").split("\n")) {
|
|
35
|
+
const marker = raw.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
|
36
|
+
if (
|
|
37
|
+
marker &&
|
|
38
|
+
(!fence ||
|
|
39
|
+
(marker[1][0] === fence[0] &&
|
|
40
|
+
marker[1].length >= fence.length &&
|
|
41
|
+
!marker[2].trim()))
|
|
42
|
+
) {
|
|
43
|
+
if (fence) {
|
|
44
|
+
fence = null;
|
|
45
|
+
rows.push({ text: "", color: c.muted });
|
|
46
|
+
} else {
|
|
47
|
+
fence = marker[1];
|
|
48
|
+
number = 0;
|
|
49
|
+
rows.push({
|
|
50
|
+
text:
|
|
51
|
+
"CODE SNIPPET" + (marker[2].trim() ? " · " + marker[2].trim() : ""),
|
|
52
|
+
color: c.blue,
|
|
53
|
+
bold: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (fence) {
|
|
59
|
+
const prefix = String(++number).padStart(4) + " ";
|
|
60
|
+
for (const [i, line] of wrap(raw, Math.max(1, width - 6)).entries()) {
|
|
61
|
+
const text = (i ? " " : prefix) + line;
|
|
62
|
+
rows.push({ text, spans: syntaxSpans(text) });
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const heading = /^#{1,6}\s+/.test(raw),
|
|
67
|
+
quote = /^>\s?/.test(raw);
|
|
68
|
+
const text = raw
|
|
69
|
+
.replace(/^#{1,6}\s+/, "")
|
|
70
|
+
.replace(/^>\s?/, "│ ")
|
|
71
|
+
.replace(/^\s*[-*+]\s+/, "• ")
|
|
72
|
+
.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, "$1 <$2>");
|
|
73
|
+
for (const line of wrap(text, width)) {
|
|
74
|
+
const parts = [];
|
|
75
|
+
let start = 0;
|
|
76
|
+
for (const match of line.matchAll(/\*\*([^*]+)\*\*|`([^`]+)`/g)) {
|
|
77
|
+
if (match.index > start)
|
|
78
|
+
parts.push({ text: line.slice(start, match.index) });
|
|
79
|
+
parts.push({
|
|
80
|
+
text: match[1] || match[2],
|
|
81
|
+
bold: !!match[1],
|
|
82
|
+
color: match[2] ? c.blue : undefined,
|
|
83
|
+
});
|
|
84
|
+
start = match.index + match[0].length;
|
|
85
|
+
}
|
|
86
|
+
parts.push({ text: line.slice(start) });
|
|
87
|
+
rows.push({
|
|
88
|
+
text: parts.map((p) => p.text).join(""),
|
|
89
|
+
spans: parts,
|
|
90
|
+
bold: heading,
|
|
91
|
+
color: heading ? c.purple : quote ? c.muted : c.text,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return rows;
|
|
96
|
+
}
|
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"],
|
|
@@ -10,6 +21,39 @@ export const navigation = [
|
|
|
10
21
|
["devices", "Connected terminals", "Manage CLI access"],
|
|
11
22
|
["help", "Help", "Keys & commands"],
|
|
12
23
|
];
|
|
24
|
+
export async function notificationRoute(api, item) {
|
|
25
|
+
if (item.code_review_id)
|
|
26
|
+
return {
|
|
27
|
+
view: "feedback",
|
|
28
|
+
postId: item.post_id,
|
|
29
|
+
id: item.code_review_id,
|
|
30
|
+
page: 1,
|
|
31
|
+
targetReply: item.feedback_reply_id || null,
|
|
32
|
+
};
|
|
33
|
+
if (!item.reply_id)
|
|
34
|
+
return { view: "post", id: item.post_id, tab: "overview", page: 1 };
|
|
35
|
+
const post = await api(`/posts/${item.post_id}?reply_id=${item.reply_id}`);
|
|
36
|
+
const target = post.reply_path?.at(-1);
|
|
37
|
+
if (!target || post.reply_target_missing)
|
|
38
|
+
throw Object.assign(Error("The linked reply is no longer available."), {
|
|
39
|
+
status: 404,
|
|
40
|
+
});
|
|
41
|
+
return target.parent_id
|
|
42
|
+
? {
|
|
43
|
+
view: "thread",
|
|
44
|
+
postId: item.post_id,
|
|
45
|
+
parent: { id: target.parent_id },
|
|
46
|
+
page: target.page,
|
|
47
|
+
targetReply: item.reply_id,
|
|
48
|
+
}
|
|
49
|
+
: {
|
|
50
|
+
view: "post",
|
|
51
|
+
id: item.post_id,
|
|
52
|
+
tab: "replies",
|
|
53
|
+
page: target.page,
|
|
54
|
+
targetReply: item.reply_id,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
13
57
|
export async function loadPage(api, route, signal) {
|
|
14
58
|
const get = (path) => api(path, { signal }),
|
|
15
59
|
page = route.page || 1;
|
|
@@ -74,18 +118,32 @@ export async function loadPage(api, route, signal) {
|
|
|
74
118
|
};
|
|
75
119
|
}
|
|
76
120
|
if (route.view === "post") {
|
|
77
|
-
const post = await get(
|
|
121
|
+
const post = await get(
|
|
122
|
+
"/posts/" +
|
|
123
|
+
route.id +
|
|
124
|
+
(route.targetReply
|
|
125
|
+
? "?reply_id=" + route.targetReply
|
|
126
|
+
: "?reply_page=" + page),
|
|
127
|
+
);
|
|
78
128
|
let items = post.replies,
|
|
79
129
|
pagination = post.reply_pagination;
|
|
130
|
+
let snippet_hash;
|
|
80
131
|
if (route.tab === "feedback") {
|
|
81
132
|
const feedback = await get(
|
|
82
|
-
"/posts/" +
|
|
133
|
+
"/posts/" +
|
|
134
|
+
route.id +
|
|
135
|
+
"/code-reviews?page=" +
|
|
136
|
+
page +
|
|
137
|
+
(route.status ? "&status=" + route.status : ""),
|
|
83
138
|
);
|
|
139
|
+
assertSnippet(post.code, feedback.snippet_hash);
|
|
140
|
+
snippet_hash = feedback.snippet_hash;
|
|
84
141
|
items = feedback.items;
|
|
85
142
|
pagination = feedback.pagination;
|
|
86
143
|
}
|
|
87
144
|
return {
|
|
88
145
|
post,
|
|
146
|
+
snippet_hash,
|
|
89
147
|
items,
|
|
90
148
|
pagination,
|
|
91
149
|
title: post.title,
|
|
@@ -128,6 +186,7 @@ export async function loadPage(api, route, signal) {
|
|
|
128
186
|
get("/posts/" + route.postId),
|
|
129
187
|
get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
|
|
130
188
|
]);
|
|
189
|
+
assertSnippet(post.code, notes.snippet_hash);
|
|
131
190
|
const note = notes.items.find((item) => item.id === Number(route.id));
|
|
132
191
|
if (!note) throw Error("This feedback is no longer available.");
|
|
133
192
|
const replies = await get(
|
|
@@ -136,6 +195,7 @@ export async function loadPage(api, route, signal) {
|
|
|
136
195
|
return {
|
|
137
196
|
post,
|
|
138
197
|
note,
|
|
198
|
+
snippet_hash: notes.snippet_hash,
|
|
139
199
|
items: [
|
|
140
200
|
{ ...note, entryType: "note" },
|
|
141
201
|
...replies.items.map((item) => ({
|
|
@@ -163,11 +223,18 @@ export function contentLines(route, data) {
|
|
|
163
223
|
"Page Up / Down Scroll a conversation or list",
|
|
164
224
|
"N / A New conversation / ask a question",
|
|
165
225
|
"R Reply in a conversation; refresh a list",
|
|
226
|
+
"V / W Open review workspace / switch code and discussion",
|
|
227
|
+
"Space / C Select a line range / comment on selected code",
|
|
228
|
+
"F Filter remarks: all, open, resolved",
|
|
229
|
+
"X / ? Context actions (edit, delete, solution, live updates)",
|
|
230
|
+
"Live updates 45s active / 3min idle; pause in X actions or --no-live",
|
|
166
231
|
"C Add feedback on code lines",
|
|
167
232
|
"S Resolve / reopen selected code feedback",
|
|
168
233
|
"E / D Edit / delete your own feedback or reply",
|
|
169
234
|
"O Open this conversation in your browser",
|
|
170
235
|
"/ Focus the command / search bar",
|
|
236
|
+
"Ctrl+E Edit a multiline field in VISUAL / EDITOR",
|
|
237
|
+
"Ctrl+L Save a draft and reconnect an expired session",
|
|
171
238
|
"Ctrl+S Preview the current form",
|
|
172
239
|
"Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
|
|
173
240
|
"/drafts Restore locally saved drafts for this account/project",
|