@tenyokjs/devdevian-cli 0.4.0-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 +3 -0
- package/VERSIONS.md +57 -0
- package/bin/devdevian.mjs +5 -25
- package/lib/client.mjs +12 -4
- package/lib/external-editor.mjs +111 -0
- package/lib/problems.mjs +29 -0
- package/lib/review-workspace.mjs +10 -4
- package/lib/rich-text.mjs +96 -0
- package/lib/tui-data.mjs +42 -1
- package/lib/tui.mjs +234 -42
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ devdevian open # a separate terminal window
|
|
|
10
10
|
|
|
11
11
|
Login opens a browser. Compare the code shown in the terminal, sign in to Devdevian, then authorize the terminal. Google, GitHub and email login happen on the website; no provider credentials are collected by this package. Use `--no-open` to open the printed address yourself, including when connecting over SSH.
|
|
12
12
|
|
|
13
|
+
Version-by-version changes and the customization roadmap: [CLI version history](VERSIONS.md).
|
|
14
|
+
|
|
13
15
|
## Live workspace
|
|
14
16
|
|
|
15
17
|
The default `devdevian` command and `devdevian tui` open an alternate terminal screen. Navigation, conversations, inline forms, previews and search update in that screen; exiting restores your normal terminal. The blue and purple layout adapts to the terminal size, with compact navigation below 90 columns. Minimum size: 60 columns × 18 rows.
|
|
@@ -19,6 +21,7 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
|
|
|
19
21
|
- **←/→** switch conversation tabs; **Space** reads the selected reply/remark in full. **[ / ]** paginate; **Page Up/Down** scroll.
|
|
20
22
|
- **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
|
|
21
23
|
- **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.
|
|
24
|
+
- **Ctrl+E** opens the current multiline field in `$VISUAL` / `$EDITOR` (use `code --wait` for VS Code). Closing the editor restores the form; publishing still requires preview and confirmation. **Ctrl+L** saves an active draft and returns to sign-in.
|
|
22
25
|
- 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
26
|
- **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
|
|
24
27
|
|
package/VERSIONS.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Devdevian CLI version history
|
|
2
|
+
|
|
3
|
+
The CLI is a separately versioned Devdevian client. Its npm version does not have to match the web app or API version. All versions below use the same hosted account and permission model.
|
|
4
|
+
|
|
5
|
+
Every published npm artifact has a unique immutable version. We record each version here, even when several ship in one working session. A GitHub release can summarize a day's work and link to the individual entries; it does not need to be created for every npm publication. An unfinished change stays under **Unreleased** until its package version is assigned. Alpha packages use the `alpha` npm tag.
|
|
6
|
+
|
|
7
|
+
## Roadmap
|
|
8
|
+
|
|
9
|
+
- **0.5.0 — Themes and interface customization:** visual presets, appearance preferences and layout customization. Planned, not shipped; exact settings will be agreed before implementation.
|
|
10
|
+
- Projects and Events parity is deferred; it is not the 0.5.0 milestone.
|
|
11
|
+
|
|
12
|
+
## 0.4.1-alpha.1 — Everyday writing and navigation
|
|
13
|
+
|
|
14
|
+
**Changes**
|
|
15
|
+
|
|
16
|
+
- `Ctrl+E` edits a multiline form field using `VISUAL`, then `EDITOR`, then the platform default. Ink temporarily hands over the terminal and restores it afterward. Original and returned text are saved locally before publication; failed, oversized or invalid editor output preserves the original form.
|
|
17
|
+
- Notifications link to the exact ordinary reply, including nested replies on later pages. Back navigation restores the selected item by ID and retains reading/review position where content still exists.
|
|
18
|
+
- Conversation bodies and full-text replies render headings, lists, quotes, bold text, inline code, visible link destinations and fenced code with basic lexical highlighting. HTML is displayed as text; terminal control sequences are removed. This is a lightweight renderer, not full CommonMark.
|
|
19
|
+
- HTTP status is preserved for non-JSON failures. Connection, timeout, permission, missing-content, rate-limit and server failures suggest a concrete next action. Session expiry during writing keeps the form; `Ctrl+L` saves the draft and returns to connection setup.
|
|
20
|
+
|
|
21
|
+
**Compatibility and checks**
|
|
22
|
+
|
|
23
|
+
No API/schema migration. Existing browser and CLI credentials remain compatible. Regression coverage includes real pseudo-terminal editor handoff/restoration, reply deep links, permission/error states and text rendering. Desktop editor behavior still depends on the configured editor; GUI editors such as VS Code need a wait flag (`code --wait`).
|
|
24
|
+
|
|
25
|
+
## 0.4.0-alpha.1 — Review workspace and live Inbox
|
|
26
|
+
|
|
27
|
+
- Side-by-side code and remarks on wide terminals; `W` focus switching on compact layouts. Select a line range and press `C` to comment; `F` filters all/open/resolved remarks. Markers describe the current page. Old remarks display their saved quote.
|
|
28
|
+
- `X` context menu for available edits, deletion, reports, resolving remarks and accepted solutions. Destructive/solution actions require confirmation and server permission checks.
|
|
29
|
+
- Unread counter and serial live polling: 45 seconds active, three minutes idle, failure backoff and pause controls. Active forms and selected items are preserved.
|
|
30
|
+
- Shared blue/purple styling, rounded frames, clearer tabs, cards and project identity. No API/schema migration.
|
|
31
|
+
|
|
32
|
+
## 0.3.1-alpha.1 — Reply deletion
|
|
33
|
+
|
|
34
|
+
- `D/d` deletes the author's ordinary reply, including full-text and parent-reply views, with confirmation and revision checks.
|
|
35
|
+
- API tombstones remove text and attachments from access, preserve descendant replies and clear the deleted reply's solution mark. Deleted replies cannot be edited or accepted again. Existing metadata storage is used; no schema migration.
|
|
36
|
+
|
|
37
|
+
## 0.3.0-alpha.1 — Project files, Git diffs and drafts
|
|
38
|
+
|
|
39
|
+
- Root-relative file explorer, filename filtering, code preview and line-range selection. Sensitive paths, ignored files, nested symlinks and outside-root traversal are excluded.
|
|
40
|
+
- Read-only staged/unstaged Git diff selection, including individual hunks. No staging, commits or project helper execution. Changed source requires reselection before publication.
|
|
41
|
+
- Scoped local autosaved drafts, restore/delete controls and bounded retention. Snippet source is reread at preview time; selected source files are not copied into draft storage.
|
|
42
|
+
|
|
43
|
+
## 0.2.0-alpha.1 — Live terminal workspace
|
|
44
|
+
|
|
45
|
+
- Replaced the numbered scrolling menu with an alternate-screen interface, responsive navigation, forms, previews and search.
|
|
46
|
+
- Paginated conversations, replies, code-feedback threads, edit/delete/report actions and connected-terminal controls.
|
|
47
|
+
- `devdevian tui` runs in the current terminal; `devdevian open` launches a supported desktop terminal. Unicode editing, cancellable reads, bounded caching and terminal restoration checks.
|
|
48
|
+
|
|
49
|
+
## 0.1.0-alpha.1 — First terminal client
|
|
50
|
+
|
|
51
|
+
- Browser-approved terminal authentication with scoped, expiring, revocable CLI credentials.
|
|
52
|
+
- Command-based feed, Inbox, search, questions, replies, file-based reviews, line remarks and community discovery using the existing Devdevian API.
|
|
53
|
+
- Explicit publication confirmation, JSON output for scripts, local private credential storage and terminal-output sanitization.
|
|
54
|
+
|
|
55
|
+
## Preparing a combined GitHub release
|
|
56
|
+
|
|
57
|
+
Choose a product release title and list the exact npm versions it includes. Summarize user-visible changes, installation, compatibility and known limitations; link to the relevant headings in this file. Do not rename or republish existing npm artifacts, and do not imply that a planned milestone has shipped. GitHub releases are created when the maintainer decides to close the session or milestone.
|
package/bin/devdevian.mjs
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import packageInfo from "../package.json" with { type: "json" };
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { readFile
|
|
6
|
-
import { hostname
|
|
7
|
-
import {
|
|
8
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { hostname } from "node:os";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
9
8
|
import { createInterface } from "node:readline/promises";
|
|
10
9
|
import { setTimeout as delay } from "node:timers/promises";
|
|
11
10
|
import {
|
|
@@ -16,6 +15,7 @@ import {
|
|
|
16
15
|
removeConfig,
|
|
17
16
|
clean,
|
|
18
17
|
} from "../lib/client.mjs";
|
|
18
|
+
import { editExternal } from "../lib/external-editor.mjs";
|
|
19
19
|
const version = packageInfo.version;
|
|
20
20
|
const options = {
|
|
21
21
|
host: { type: "string" },
|
|
@@ -111,30 +111,10 @@ async function editor(initial = "") {
|
|
|
111
111
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
112
112
|
throw Error("--editor needs an interactive terminal.");
|
|
113
113
|
terminal?.pause();
|
|
114
|
-
const directory = await mkdtemp(join(tmpdir(), "devdevian-editor-")),
|
|
115
|
-
file = join(directory, "message.md");
|
|
116
114
|
try {
|
|
117
|
-
await
|
|
118
|
-
const command =
|
|
119
|
-
process.env.VISUAL ||
|
|
120
|
-
process.env.EDITOR ||
|
|
121
|
-
(process.platform === "win32" ? "notepad" : "vi");
|
|
122
|
-
// No shell: metacharacters in content or filenames cannot become commands.
|
|
123
|
-
const parts =
|
|
124
|
-
command
|
|
125
|
-
.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)
|
|
126
|
-
?.map((p) => p.replace(/^(['"])(.*)\1$/, "$2")) || [];
|
|
127
|
-
if (!parts.length) throw Error("Set EDITOR to your text editor.");
|
|
128
|
-
const result = spawnSync(parts[0], [...parts.slice(1), file], {
|
|
129
|
-
stdio: "inherit",
|
|
130
|
-
shell: false,
|
|
131
|
-
});
|
|
132
|
-
if (result.error || result.status !== 0)
|
|
133
|
-
throw Error("The editor did not finish successfully.");
|
|
134
|
-
return await readFile(file, "utf8");
|
|
115
|
+
return await editExternal(initial);
|
|
135
116
|
} finally {
|
|
136
117
|
terminal?.resume();
|
|
137
|
-
await rm(directory, { recursive: true, force: true });
|
|
138
118
|
}
|
|
139
119
|
}
|
|
140
120
|
async function boundedFile(file, max) {
|
package/lib/client.mjs
CHANGED
|
@@ -110,8 +110,13 @@ export function client(origin, token = null) {
|
|
|
110
110
|
});
|
|
111
111
|
} catch (error) {
|
|
112
112
|
if (signal?.aborted) throw error;
|
|
113
|
-
throw
|
|
114
|
-
|
|
113
|
+
throw Object.assign(
|
|
114
|
+
Error(
|
|
115
|
+
error.name === "TimeoutError"
|
|
116
|
+
? "Connection timed out. Check your connection and server address."
|
|
117
|
+
: "Could not reach Devdevian. Check your connection and server address.",
|
|
118
|
+
),
|
|
119
|
+
{ code: error.name === "TimeoutError" ? "TIMEOUT" : "NETWORK" },
|
|
115
120
|
);
|
|
116
121
|
}
|
|
117
122
|
const chunks = [];
|
|
@@ -127,8 +132,11 @@ export function client(origin, token = null) {
|
|
|
127
132
|
try {
|
|
128
133
|
data = JSON.parse(text);
|
|
129
134
|
} catch {
|
|
130
|
-
throw
|
|
131
|
-
|
|
135
|
+
throw Object.assign(
|
|
136
|
+
Error(
|
|
137
|
+
`Devdevian returned an unexpected response (HTTP ${response.status}). Try again later.`,
|
|
138
|
+
),
|
|
139
|
+
{ status: response.status },
|
|
132
140
|
);
|
|
133
141
|
}
|
|
134
142
|
if (!response.ok) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, writeFile, open, rm, lstat } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Parse argv, never shell code. Quoting supports editor paths and --wait flags.
|
|
8
|
+
export function editorArgs(command) {
|
|
9
|
+
const args = [];
|
|
10
|
+
let value = "",
|
|
11
|
+
quote = null,
|
|
12
|
+
started = false;
|
|
13
|
+
for (const char of command.trim()) {
|
|
14
|
+
if (quote) {
|
|
15
|
+
if (char === quote) quote = null;
|
|
16
|
+
else value += char;
|
|
17
|
+
} else if (char === '"' || char === "'") {
|
|
18
|
+
quote = char;
|
|
19
|
+
started = true;
|
|
20
|
+
} else if (/\s/.test(char)) {
|
|
21
|
+
if (started) {
|
|
22
|
+
args.push(value);
|
|
23
|
+
value = "";
|
|
24
|
+
started = false;
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
value += char;
|
|
28
|
+
started = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (quote) throw Error("Close the quote in VISUAL / EDITOR.");
|
|
32
|
+
if (started) args.push(value);
|
|
33
|
+
if (!args[0])
|
|
34
|
+
throw Error(
|
|
35
|
+
"Set VISUAL or EDITOR to an editor command (for example: code --wait).",
|
|
36
|
+
);
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
export async function editExternal(
|
|
40
|
+
initial,
|
|
41
|
+
{
|
|
42
|
+
max = 15000,
|
|
43
|
+
command = process.env.VISUAL ||
|
|
44
|
+
process.env.EDITOR ||
|
|
45
|
+
(process.platform === "win32" ? "notepad" : "vi"),
|
|
46
|
+
} = {},
|
|
47
|
+
) {
|
|
48
|
+
const [executable, ...args] = editorArgs(command);
|
|
49
|
+
const directory = await mkdtemp(join(tmpdir(), "devdevian-editor-")),
|
|
50
|
+
file = join(directory, "message.md");
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(file, initial, { mode: 0o600, flag: "wx" });
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const child = spawn(executable, [...args, file], {
|
|
55
|
+
stdio: "inherit",
|
|
56
|
+
shell: false,
|
|
57
|
+
});
|
|
58
|
+
child.once("error", () =>
|
|
59
|
+
reject(
|
|
60
|
+
Error(
|
|
61
|
+
"Could not start the editor. Check VISUAL / EDITOR; your original text is unchanged.",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
child.once("exit", (code) =>
|
|
66
|
+
code === 0
|
|
67
|
+
? resolve()
|
|
68
|
+
: reject(
|
|
69
|
+
Error(
|
|
70
|
+
"Editor cancelled or failed; your original text is unchanged.",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
const infoBeforeOpen = await lstat(file);
|
|
76
|
+
if (infoBeforeOpen.isSymbolicLink() || !infoBeforeOpen.isFile())
|
|
77
|
+
throw Error("Editor output must be a regular file. Original text kept.");
|
|
78
|
+
const handle = await open(
|
|
79
|
+
file,
|
|
80
|
+
constants.O_RDONLY |
|
|
81
|
+
(constants.O_NOFOLLOW || 0) |
|
|
82
|
+
(constants.O_NONBLOCK || 0),
|
|
83
|
+
);
|
|
84
|
+
try {
|
|
85
|
+
const info = await handle.stat();
|
|
86
|
+
if (!info.isFile() || info.size > max * 4)
|
|
87
|
+
throw Error(
|
|
88
|
+
`Editor output must be a UTF-8 text file with at most ${max} characters. Original text kept.`,
|
|
89
|
+
);
|
|
90
|
+
const buffer = Buffer.alloc(max * 4 + 1),
|
|
91
|
+
{ bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
95
|
+
buffer.subarray(0, bytesRead),
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
throw Error("Editor output must be UTF-8. Original text kept.");
|
|
99
|
+
}
|
|
100
|
+
if (bytesRead > max * 4 || text.length > max || text.includes("\0"))
|
|
101
|
+
throw Error(
|
|
102
|
+
`Editor output exceeds ${max} characters or contains binary data. Original text kept.`,
|
|
103
|
+
);
|
|
104
|
+
return text.replace(/\r\n/g, "\n");
|
|
105
|
+
} finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(directory, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
package/lib/problems.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { clean } from "./client.mjs";
|
|
2
|
+
export function problemMessage(error) {
|
|
3
|
+
const detail = clean(error.message || "Unexpected error.");
|
|
4
|
+
if (error.status === 401)
|
|
5
|
+
return "Session expired or revoked. Ctrl+L reconnects; your draft is kept locally.";
|
|
6
|
+
if (error.status === 403)
|
|
7
|
+
return (
|
|
8
|
+
"Access denied. Check your community membership or account permissions. " +
|
|
9
|
+
detail
|
|
10
|
+
);
|
|
11
|
+
if (error.status === 404)
|
|
12
|
+
return (
|
|
13
|
+
"Content unavailable: it may have been removed or is private. Esc goes back. " +
|
|
14
|
+
detail
|
|
15
|
+
);
|
|
16
|
+
if (error.status === 429)
|
|
17
|
+
return (
|
|
18
|
+
"Rate limit reached. Wait before trying again; writes are not retried automatically. " +
|
|
19
|
+
detail
|
|
20
|
+
);
|
|
21
|
+
if (error.status >= 500)
|
|
22
|
+
return (
|
|
23
|
+
"Server temporarily unavailable. Shift+R retries a view; check before retrying a write. " +
|
|
24
|
+
detail
|
|
25
|
+
);
|
|
26
|
+
if (error.code === "NETWORK" || error.code === "TIMEOUT")
|
|
27
|
+
return detail + " Shift+R retries a view. Your draft remains editable.";
|
|
28
|
+
return detail;
|
|
29
|
+
}
|
package/lib/review-workspace.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { clip, graphemes } from "./tui-model.mjs";
|
|
4
|
+
import { syntaxSpans } from "./rich-text.mjs";
|
|
4
5
|
import { colors as c } from "./theme.mjs";
|
|
5
6
|
const h = React.createElement;
|
|
6
7
|
export function codeRange(line, anchor = null) {
|
|
@@ -96,6 +97,10 @@ export function ReviewWorkspace({
|
|
|
96
97
|
number >= selectedNote.start_line &&
|
|
97
98
|
number <= selectedNote.end_line);
|
|
98
99
|
const text = lines[n];
|
|
100
|
+
const rendered = clip(
|
|
101
|
+
`${marked ? "●" : " "}${String(number).padStart(4)} ${graphemes(text.replaceAll("\t", " ")).slice(horizontal).join("")}`,
|
|
102
|
+
codeWidth,
|
|
103
|
+
);
|
|
99
104
|
return h(
|
|
100
105
|
Text,
|
|
101
106
|
{
|
|
@@ -107,10 +112,11 @@ export function ReviewWorkspace({
|
|
|
107
112
|
? c.red
|
|
108
113
|
: c.text,
|
|
109
114
|
},
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
...(text.startsWith("+") || text.startsWith("-")
|
|
116
|
+
? [rendered]
|
|
117
|
+
: syntaxSpans(rendered).map((span, j) =>
|
|
118
|
+
h(Text, { key: j, color: span.color }, span.text),
|
|
119
|
+
)),
|
|
114
120
|
);
|
|
115
121
|
}),
|
|
116
122
|
);
|
|
@@ -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/tui-data.mjs
CHANGED
|
@@ -21,6 +21,39 @@ export const navigation = [
|
|
|
21
21
|
["devices", "Connected terminals", "Manage CLI access"],
|
|
22
22
|
["help", "Help", "Keys & commands"],
|
|
23
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
|
+
}
|
|
24
57
|
export async function loadPage(api, route, signal) {
|
|
25
58
|
const get = (path) => api(path, { signal }),
|
|
26
59
|
page = route.page || 1;
|
|
@@ -85,7 +118,13 @@ export async function loadPage(api, route, signal) {
|
|
|
85
118
|
};
|
|
86
119
|
}
|
|
87
120
|
if (route.view === "post") {
|
|
88
|
-
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
|
+
);
|
|
89
128
|
let items = post.replies,
|
|
90
129
|
pagination = post.reply_pagination;
|
|
91
130
|
let snippet_hash;
|
|
@@ -194,6 +233,8 @@ export function contentLines(route, data) {
|
|
|
194
233
|
"E / D Edit / delete your own feedback or reply",
|
|
195
234
|
"O Open this conversation in your browser",
|
|
196
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",
|
|
197
238
|
"Ctrl+S Preview the current form",
|
|
198
239
|
"Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
|
|
199
240
|
"/drafts Restore locally saved drafts for this account/project",
|
package/lib/tui.mjs
CHANGED
|
@@ -29,11 +29,15 @@ import {
|
|
|
29
29
|
loadPage,
|
|
30
30
|
contentLines,
|
|
31
31
|
assertSnippet,
|
|
32
|
+
notificationRoute,
|
|
32
33
|
} from "./tui-data.mjs";
|
|
33
34
|
import { basename } from "node:path";
|
|
34
35
|
import { colors } from "./theme.mjs";
|
|
35
36
|
import { liveLoop, anchoredIndex } from "./live.mjs";
|
|
36
37
|
import { contextActions } from "./context-actions.mjs";
|
|
38
|
+
import { editExternal } from "./external-editor.mjs";
|
|
39
|
+
import { markdownRows } from "./rich-text.mjs";
|
|
40
|
+
import { problemMessage } from "./problems.mjs";
|
|
37
41
|
import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
|
|
38
42
|
const h = React.createElement;
|
|
39
43
|
const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
|
|
@@ -194,13 +198,30 @@ function FrameRows({ rows, width, height, offset = 0 }) {
|
|
|
194
198
|
Box,
|
|
195
199
|
{ flexDirection: "column", height, overflow: "hidden" },
|
|
196
200
|
...rows.slice(offset, offset + height).map((row, i) =>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
201
|
+
row.spans
|
|
202
|
+
? h(
|
|
203
|
+
Text,
|
|
204
|
+
{
|
|
205
|
+
key: i,
|
|
206
|
+
color: row.color || colors.text,
|
|
207
|
+
bold: row.bold,
|
|
208
|
+
wrap: "truncate",
|
|
209
|
+
},
|
|
210
|
+
...row.spans.map((span, n) =>
|
|
211
|
+
h(
|
|
212
|
+
Text,
|
|
213
|
+
{ key: n, color: span.color, bold: span.bold || row.bold },
|
|
214
|
+
span.text,
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
: h(TextLine, {
|
|
219
|
+
key: i,
|
|
220
|
+
text: typeof row === "string" ? row : row.text,
|
|
221
|
+
width,
|
|
222
|
+
color: row.color || colors.text,
|
|
223
|
+
bold: row.bold || false,
|
|
224
|
+
}),
|
|
204
225
|
),
|
|
205
226
|
);
|
|
206
227
|
}
|
|
@@ -371,8 +392,47 @@ function listRows(route, data, index, width) {
|
|
|
371
392
|
];
|
|
372
393
|
});
|
|
373
394
|
}
|
|
395
|
+
function documentRows(route, data, width) {
|
|
396
|
+
if (route.view !== "post" || (route.tab && route.tab !== "overview"))
|
|
397
|
+
return contentLines(route, data).flatMap((line) => wrap(line, width));
|
|
398
|
+
const post = data?.post;
|
|
399
|
+
if (!post) return [];
|
|
400
|
+
const rows = markdownRows(post.body || "", width);
|
|
401
|
+
if (post.review_focus)
|
|
402
|
+
rows.push(
|
|
403
|
+
{ text: "" },
|
|
404
|
+
{ text: "REVIEW FOCUS", color: colors.purple, bold: true },
|
|
405
|
+
...markdownRows(post.review_focus, width),
|
|
406
|
+
);
|
|
407
|
+
if (post.code) {
|
|
408
|
+
// Pick a fence longer than any backtick run in the snippet.
|
|
409
|
+
const fence = "`".repeat(
|
|
410
|
+
Math.max(
|
|
411
|
+
3,
|
|
412
|
+
...(post.code.match(/`+/g) || []).map((run) => run.length + 1),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
rows.push(
|
|
416
|
+
{ text: "" },
|
|
417
|
+
...markdownRows(fence + "\n" + post.code + "\n" + fence, width),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (post.links?.length)
|
|
421
|
+
rows.push(
|
|
422
|
+
{ text: "LINKS", bold: true },
|
|
423
|
+
...post.links.flatMap((link) =>
|
|
424
|
+
wrap(link.label + " · " + link.url, width),
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
if (post.archived_at || post.locked_at)
|
|
428
|
+
rows.push({
|
|
429
|
+
text: "Replies are paused for this conversation.",
|
|
430
|
+
color: colors.muted,
|
|
431
|
+
});
|
|
432
|
+
return rows;
|
|
433
|
+
}
|
|
374
434
|
function App({ initialConfig, flags, root }) {
|
|
375
|
-
const { exit } = useApp(),
|
|
435
|
+
const { exit, suspendTerminal } = useApp(),
|
|
376
436
|
{ columns, rows } = useWindowSize();
|
|
377
437
|
const [config, setConfig] = useState(initialConfig),
|
|
378
438
|
[phase, setPhase] = useState(
|
|
@@ -417,6 +477,8 @@ function App({ initialConfig, flags, root }) {
|
|
|
417
477
|
notificationGeneration = useRef(0);
|
|
418
478
|
const cache = useRef(new PageCache()),
|
|
419
479
|
history = useRef([]),
|
|
480
|
+
restoreSelection = useRef(null),
|
|
481
|
+
seenSnippet = useRef(null),
|
|
420
482
|
busy = useRef(false),
|
|
421
483
|
alive = useRef(true),
|
|
422
484
|
authController = useRef(null),
|
|
@@ -522,9 +584,18 @@ function App({ initialConfig, flags, root }) {
|
|
|
522
584
|
if (current?.unread_count !== undefined) setUnread(current.unread_count);
|
|
523
585
|
}, [current]);
|
|
524
586
|
useEffect(() => {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
587
|
+
const post = current?.post;
|
|
588
|
+
if (!post?.code) return;
|
|
589
|
+
if (
|
|
590
|
+
seenSnippet.current?.id === post.id &&
|
|
591
|
+
seenSnippet.current.code !== post.code
|
|
592
|
+
) {
|
|
593
|
+
setReviewLine(0);
|
|
594
|
+
setReviewAnchor(null);
|
|
595
|
+
setReviewHorizontal(0);
|
|
596
|
+
} else
|
|
597
|
+
setReviewLine((line) => Math.min(line, post.code.split("\n").length - 1));
|
|
598
|
+
seenSnippet.current = { id: post.id, code: post.code };
|
|
528
599
|
}, [current?.post?.id, current?.post?.code]);
|
|
529
600
|
const resetAccount = () => {
|
|
530
601
|
cache.current.clear();
|
|
@@ -535,24 +606,55 @@ function App({ initialConfig, flags, root }) {
|
|
|
535
606
|
setPreview(null);
|
|
536
607
|
setDevice(null);
|
|
537
608
|
setUser(null);
|
|
609
|
+
setReading(null);
|
|
610
|
+
setActionIndex(null);
|
|
611
|
+
setUnread(0);
|
|
612
|
+
setLiveStatus("");
|
|
613
|
+
notificationGeneration.current++;
|
|
538
614
|
setRoute({ view: "feed", page: 1 });
|
|
539
615
|
setError("");
|
|
540
616
|
};
|
|
541
617
|
function fail(e) {
|
|
542
618
|
if (!alive.current) return;
|
|
543
|
-
if (e.status === 401) {
|
|
619
|
+
if (e.status === 401 && form) {
|
|
620
|
+
setLive(false);
|
|
621
|
+
setError(problemMessage(e));
|
|
622
|
+
} else if (e.status === 401) {
|
|
544
623
|
resetAccount();
|
|
545
624
|
setPhase("auth");
|
|
546
625
|
setConfig(null);
|
|
547
626
|
setError(
|
|
548
627
|
"Your session expired or was revoked. Press Enter to reconnect.",
|
|
549
628
|
);
|
|
550
|
-
} else setError(
|
|
629
|
+
} else setError(problemMessage(e));
|
|
551
630
|
}
|
|
552
631
|
function navigate(next, { replace = false } = {}) {
|
|
553
|
-
if (!replace)
|
|
632
|
+
if (!replace)
|
|
633
|
+
history.current.push({
|
|
634
|
+
route,
|
|
635
|
+
index,
|
|
636
|
+
scroll,
|
|
637
|
+
selectedId: selected?.id,
|
|
638
|
+
entryType: selected?.entryType,
|
|
639
|
+
reviewFocus,
|
|
640
|
+
reviewLine,
|
|
641
|
+
reviewAnchor,
|
|
642
|
+
reviewHorizontal,
|
|
643
|
+
});
|
|
644
|
+
restoreSelection.current = {
|
|
645
|
+
key: JSON.stringify(next),
|
|
646
|
+
selectedId: next.targetReply,
|
|
647
|
+
entryType:
|
|
648
|
+
next.view === "feedback" && next.targetReply
|
|
649
|
+
? "feedbackReply"
|
|
650
|
+
: undefined,
|
|
651
|
+
index: 0,
|
|
652
|
+
};
|
|
554
653
|
if (history.current.length > 30) history.current.shift();
|
|
555
654
|
setReading(null);
|
|
655
|
+
setReviewLine(0);
|
|
656
|
+
setReviewAnchor(null);
|
|
657
|
+
setReviewHorizontal(0);
|
|
556
658
|
if (next.view === "feedback") setReviewFocus("notes");
|
|
557
659
|
setRoute(next);
|
|
558
660
|
setIndex(0);
|
|
@@ -564,6 +666,15 @@ function App({ initialConfig, flags, root }) {
|
|
|
564
666
|
function back() {
|
|
565
667
|
const previous = history.current.pop();
|
|
566
668
|
if (previous) {
|
|
669
|
+
restoreSelection.current = {
|
|
670
|
+
...previous,
|
|
671
|
+
key: JSON.stringify(previous.route),
|
|
672
|
+
};
|
|
673
|
+
setReading(null);
|
|
674
|
+
setReviewFocus(previous.reviewFocus);
|
|
675
|
+
setReviewLine(previous.reviewLine);
|
|
676
|
+
setReviewAnchor(previous.reviewAnchor);
|
|
677
|
+
setReviewHorizontal(previous.reviewHorizontal);
|
|
567
678
|
setRoute(previous.route);
|
|
568
679
|
setIndex(previous.index);
|
|
569
680
|
setScroll(previous.scroll);
|
|
@@ -704,6 +815,23 @@ function App({ initialConfig, flags, root }) {
|
|
|
704
815
|
cached = cache.current.get(key);
|
|
705
816
|
setData(cached ? { ...cached, key } : null);
|
|
706
817
|
setLoading(!cached);
|
|
818
|
+
if (cached && restoreSelection.current?.key === key) {
|
|
819
|
+
const saved = restoreSelection.current;
|
|
820
|
+
const found = cached.items?.findIndex(
|
|
821
|
+
(item) =>
|
|
822
|
+
item.id === saved.selectedId &&
|
|
823
|
+
(saved.entryType === undefined || item.entryType === saved.entryType),
|
|
824
|
+
);
|
|
825
|
+
setIndex(
|
|
826
|
+
found >= 0
|
|
827
|
+
? found
|
|
828
|
+
: Math.min(
|
|
829
|
+
saved.index || 0,
|
|
830
|
+
Math.max(0, (cached.items?.length || 0) - 1),
|
|
831
|
+
),
|
|
832
|
+
);
|
|
833
|
+
restoreSelection.current = null;
|
|
834
|
+
}
|
|
707
835
|
setError("");
|
|
708
836
|
if (!cached)
|
|
709
837
|
(route.view === "drafts"
|
|
@@ -719,9 +847,27 @@ function App({ initialConfig, flags, root }) {
|
|
|
719
847
|
if (!active) return;
|
|
720
848
|
cache.current.set(key, result);
|
|
721
849
|
setData({ ...result, key });
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
850
|
+
const saved = restoreSelection.current;
|
|
851
|
+
if (saved?.key === key) {
|
|
852
|
+
const found = result.items?.findIndex(
|
|
853
|
+
(item) =>
|
|
854
|
+
item.id === saved.selectedId &&
|
|
855
|
+
(saved.entryType === undefined ||
|
|
856
|
+
item.entryType === saved.entryType),
|
|
857
|
+
);
|
|
858
|
+
setIndex(
|
|
859
|
+
found >= 0
|
|
860
|
+
? found
|
|
861
|
+
: Math.min(
|
|
862
|
+
saved.index || 0,
|
|
863
|
+
Math.max(0, (result.items?.length || 0) - 1),
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
restoreSelection.current = null;
|
|
867
|
+
} else
|
|
868
|
+
setIndex((i) =>
|
|
869
|
+
Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
|
|
870
|
+
);
|
|
725
871
|
})
|
|
726
872
|
.catch((e) => {
|
|
727
873
|
if (active) fail(e);
|
|
@@ -890,6 +1036,46 @@ function App({ initialConfig, flags, root }) {
|
|
|
890
1036
|
values: { body: "" },
|
|
891
1037
|
});
|
|
892
1038
|
}
|
|
1039
|
+
async function externalEdit() {
|
|
1040
|
+
const descriptor = fieldsFor(form)[field];
|
|
1041
|
+
if (!descriptor?.multiline) {
|
|
1042
|
+
setNotice("Ctrl+E opens the editor for a multiline text field.");
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const original = form;
|
|
1046
|
+
await task(async () => {
|
|
1047
|
+
clearTimeout(draftTimer.current);
|
|
1048
|
+
await queueDraft(() => drafts.save(original));
|
|
1049
|
+
let value;
|
|
1050
|
+
await suspendTerminal(async () => {
|
|
1051
|
+
value = await editExternal(original.values[descriptor.key] || "", {
|
|
1052
|
+
max: descriptor.max,
|
|
1053
|
+
});
|
|
1054
|
+
});
|
|
1055
|
+
const updated = {
|
|
1056
|
+
...original,
|
|
1057
|
+
values: { ...original.values, [descriptor.key]: value },
|
|
1058
|
+
};
|
|
1059
|
+
setForm(updated);
|
|
1060
|
+
setCursor(graphemes(value).length);
|
|
1061
|
+
await queueDraft(() => drafts.save(updated));
|
|
1062
|
+
setDraftSaved(true);
|
|
1063
|
+
setNotice("Text returned from editor. Preview before publishing.");
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
async function reconnect() {
|
|
1067
|
+
await task(async () => {
|
|
1068
|
+
clearTimeout(draftTimer.current);
|
|
1069
|
+
draftGeneration.current++;
|
|
1070
|
+
if (form && drafts) await queueDraft(() => drafts.save(form));
|
|
1071
|
+
resetAccount();
|
|
1072
|
+
setConfig(null);
|
|
1073
|
+
setPhase("auth");
|
|
1074
|
+
setNotice(
|
|
1075
|
+
"Draft saved locally. Enter reconnects; restore it from Local drafts after signing in.",
|
|
1076
|
+
);
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
893
1079
|
async function lineFeedback() {
|
|
894
1080
|
const p = current?.post;
|
|
895
1081
|
if (!p?.code) {
|
|
@@ -1138,21 +1324,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
1138
1324
|
setNotice("Event opened in your browser.");
|
|
1139
1325
|
return;
|
|
1140
1326
|
}
|
|
1141
|
-
|
|
1142
|
-
navigate({
|
|
1143
|
-
view: "feedback",
|
|
1144
|
-
postId: item.post_id,
|
|
1145
|
-
id: item.code_review_id,
|
|
1146
|
-
page: 1,
|
|
1147
|
-
targetReply: item.feedback_reply_id || null,
|
|
1148
|
-
});
|
|
1149
|
-
else
|
|
1150
|
-
navigate({
|
|
1151
|
-
view: "post",
|
|
1152
|
-
id: item.post_id,
|
|
1153
|
-
tab: item.reply_id ? "replies" : "overview",
|
|
1154
|
-
page: 1,
|
|
1155
|
-
});
|
|
1327
|
+
navigate(await notificationRoute(api, item));
|
|
1156
1328
|
});
|
|
1157
1329
|
return;
|
|
1158
1330
|
}
|
|
@@ -1486,6 +1658,10 @@ function App({ initialConfig, flags, root }) {
|
|
|
1486
1658
|
return;
|
|
1487
1659
|
}
|
|
1488
1660
|
if (busy.current) return;
|
|
1661
|
+
if (keyEvent.ctrl && input === "l") {
|
|
1662
|
+
reconnect();
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1489
1665
|
if (actionIndex !== null) {
|
|
1490
1666
|
if (keyEvent.escape || input === "x" || input === "X")
|
|
1491
1667
|
setActionIndex(null);
|
|
@@ -1496,7 +1672,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
1496
1672
|
return;
|
|
1497
1673
|
}
|
|
1498
1674
|
if (reading) {
|
|
1499
|
-
const lines =
|
|
1675
|
+
const lines = markdownRows(
|
|
1500
1676
|
reading.deleted_at
|
|
1501
1677
|
? "[deleted — discussion preserved]"
|
|
1502
1678
|
: reading.body || "",
|
|
@@ -1549,6 +1725,10 @@ function App({ initialConfig, flags, root }) {
|
|
|
1549
1725
|
return;
|
|
1550
1726
|
}
|
|
1551
1727
|
if (form) {
|
|
1728
|
+
if (keyEvent.ctrl && input === "e") {
|
|
1729
|
+
externalEdit();
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1552
1732
|
if (
|
|
1553
1733
|
keyEvent.ctrl &&
|
|
1554
1734
|
["o", "g"].includes(input) &&
|
|
@@ -1774,9 +1954,8 @@ function App({ initialConfig, flags, root }) {
|
|
|
1774
1954
|
if (prose) {
|
|
1775
1955
|
const maximum = Math.max(
|
|
1776
1956
|
0,
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
).length - contentHeight,
|
|
1957
|
+
documentRows(route, current || {}, contentWidth).length -
|
|
1958
|
+
contentHeight,
|
|
1780
1959
|
);
|
|
1781
1960
|
const amount =
|
|
1782
1961
|
keyEvent.pageUp || keyEvent.pageDown
|
|
@@ -2018,7 +2197,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
2018
2197
|
} else {
|
|
2019
2198
|
let body;
|
|
2020
2199
|
if (reading) {
|
|
2021
|
-
const lines =
|
|
2200
|
+
const lines = markdownRows(
|
|
2022
2201
|
reading.deleted_at
|
|
2023
2202
|
? "[deleted — discussion preserved]"
|
|
2024
2203
|
: reading.body || "",
|
|
@@ -2147,10 +2326,23 @@ function App({ initialConfig, flags, root }) {
|
|
|
2147
2326
|
horizontal: reviewHorizontal,
|
|
2148
2327
|
rows: (w) => listRows(route, current, index, w),
|
|
2149
2328
|
});
|
|
2329
|
+
else if (error && !current)
|
|
2330
|
+
body = h(FrameRows, {
|
|
2331
|
+
rows: [
|
|
2332
|
+
{ text: "Could not open this view", bold: true, color: colors.red },
|
|
2333
|
+
"",
|
|
2334
|
+
...wrap(error, contentWidth),
|
|
2335
|
+
"",
|
|
2336
|
+
...wrap(
|
|
2337
|
+
"Shift+R retries this view · Esc goes back · Ctrl+L reconnects",
|
|
2338
|
+
contentWidth,
|
|
2339
|
+
),
|
|
2340
|
+
],
|
|
2341
|
+
width: contentWidth,
|
|
2342
|
+
height: contentHeight,
|
|
2343
|
+
});
|
|
2150
2344
|
else {
|
|
2151
|
-
const lines =
|
|
2152
|
-
wrap(line, contentWidth),
|
|
2153
|
-
);
|
|
2345
|
+
const lines = documentRows(route, current || {}, contentWidth);
|
|
2154
2346
|
if (lines.length)
|
|
2155
2347
|
body = h(FrameRows, {
|
|
2156
2348
|
rows: lines,
|
|
@@ -2278,7 +2470,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
2278
2470
|
)
|
|
2279
2471
|
: h(TextLine, {
|
|
2280
2472
|
text: form
|
|
2281
|
-
? "Tab next field · Ctrl+S preview" +
|
|
2473
|
+
? "Tab next field · Ctrl+E editor · Ctrl+S preview" +
|
|
2282
2474
|
(draftSaved ? " · Draft saved locally" : "")
|
|
2283
2475
|
: preview
|
|
2284
2476
|
? "Scroll to review · ← → choose · Enter confirms"
|
|
@@ -2365,7 +2557,7 @@ function App({ initialConfig, flags, root }) {
|
|
|
2365
2557
|
: preview
|
|
2366
2558
|
? "← → choose · Enter confirm · Esc edit"
|
|
2367
2559
|
: form
|
|
2368
|
-
? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview
|
|
2560
|
+
? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
|
|
2369
2561
|
: phase !== "ready"
|
|
2370
2562
|
? "Enter connect / open browser · Ctrl+C exit"
|
|
2371
2563
|
: route.view === "feedback"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenyokjs/devdevian-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1-alpha.1",
|
|
4
4
|
"description": "Devdevian in your terminal: discussions, questions and human code reviews.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"bin/",
|
|
11
11
|
"lib/",
|
|
12
12
|
"README.md",
|
|
13
|
+
"VERSIONS.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|