@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
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.
|
|
@@ -18,7 +20,8 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
|
|
|
18
20
|
- **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/drafts`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
|
|
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
|
-
- **C** comments on snippet lines; **E/D** edit/delete your own ordinary replies or code feedback; **S** resolves/reopens a code remark as the
|
|
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
|
|
|
@@ -28,6 +31,20 @@ Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R.
|
|
|
28
31
|
|
|
29
32
|
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
33
|
|
|
34
|
+
## Code and discussion, together
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
**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.
|
|
41
|
+
|
|
42
|
+
## Inbox and live updates
|
|
43
|
+
|
|
44
|
+
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`.
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
31
48
|
## Project files and Git changes
|
|
32
49
|
|
|
33
50
|
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/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" },
|
|
@@ -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
|
|
@@ -109,30 +111,10 @@ async function editor(initial = "") {
|
|
|
109
111
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
110
112
|
throw Error("--editor needs an interactive terminal.");
|
|
111
113
|
terminal?.pause();
|
|
112
|
-
const directory = await mkdtemp(join(tmpdir(), "devdevian-editor-")),
|
|
113
|
-
file = join(directory, "message.md");
|
|
114
114
|
try {
|
|
115
|
-
await
|
|
116
|
-
const command =
|
|
117
|
-
process.env.VISUAL ||
|
|
118
|
-
process.env.EDITOR ||
|
|
119
|
-
(process.platform === "win32" ? "notepad" : "vi");
|
|
120
|
-
// No shell: metacharacters in content or filenames cannot become commands.
|
|
121
|
-
const parts =
|
|
122
|
-
command
|
|
123
|
-
.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)
|
|
124
|
-
?.map((p) => p.replace(/^(['"])(.*)\1$/, "$2")) || [];
|
|
125
|
-
if (!parts.length) throw Error("Set EDITOR to your text editor.");
|
|
126
|
-
const result = spawnSync(parts[0], [...parts.slice(1), file], {
|
|
127
|
-
stdio: "inherit",
|
|
128
|
-
shell: false,
|
|
129
|
-
});
|
|
130
|
-
if (result.error || result.status !== 0)
|
|
131
|
-
throw Error("The editor did not finish successfully.");
|
|
132
|
-
return await readFile(file, "utf8");
|
|
115
|
+
return await editExternal(initial);
|
|
133
116
|
} finally {
|
|
134
117
|
terminal?.resume();
|
|
135
|
-
await rm(directory, { recursive: true, force: true });
|
|
136
118
|
}
|
|
137
119
|
}
|
|
138
120
|
async function boundedFile(file, max) {
|
|
@@ -477,6 +459,7 @@ try {
|
|
|
477
459
|
for (const key of ["host", "name", "project"])
|
|
478
460
|
if (values[key]) args.push("--" + key, values[key]);
|
|
479
461
|
if (values["no-open"]) args.push("--no-open");
|
|
462
|
+
if (values["no-live"]) args.push("--no-live");
|
|
480
463
|
await (
|
|
481
464
|
await import("../lib/window.mjs")
|
|
482
465
|
).openWindow({ entry: fileURLToPath(import.meta.url), args });
|
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,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
|
+
}
|
|
@@ -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/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,
|
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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { clip, graphemes } from "./tui-model.mjs";
|
|
4
|
+
import { syntaxSpans } from "./rich-text.mjs";
|
|
5
|
+
import { colors as c } from "./theme.mjs";
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
export function codeRange(line, anchor = null) {
|
|
8
|
+
return {
|
|
9
|
+
start: Math.min(line, anchor ?? line) + 1,
|
|
10
|
+
end: Math.max(line, anchor ?? line) + 1,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function reviewExcerpt(post, note) {
|
|
14
|
+
// Never attach an old remark to unrelated lines in the new snippet.
|
|
15
|
+
if (note?.outdated)
|
|
16
|
+
return {
|
|
17
|
+
text: note.quote || "Earlier snippet unavailable",
|
|
18
|
+
first: note.start_line || 1,
|
|
19
|
+
old: true,
|
|
20
|
+
};
|
|
21
|
+
return { text: post?.code || "", first: 1, old: false };
|
|
22
|
+
}
|
|
23
|
+
export function ReviewWorkspace({
|
|
24
|
+
post,
|
|
25
|
+
note,
|
|
26
|
+
items,
|
|
27
|
+
index,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
focus,
|
|
31
|
+
line,
|
|
32
|
+
anchor,
|
|
33
|
+
horizontal = 0,
|
|
34
|
+
rows,
|
|
35
|
+
}) {
|
|
36
|
+
const split = width >= 78,
|
|
37
|
+
codeWidth = split ? Math.floor(width * 0.55) : width;
|
|
38
|
+
const selectedNote = note || items[index];
|
|
39
|
+
const excerpt = reviewExcerpt(post, focus === "code" ? null : selectedNote),
|
|
40
|
+
lines = excerpt.text.split("\n");
|
|
41
|
+
const range = codeRange(line, anchor);
|
|
42
|
+
const target = excerpt.old
|
|
43
|
+
? 0
|
|
44
|
+
: focus === "code"
|
|
45
|
+
? line
|
|
46
|
+
: Math.max(0, (selectedNote?.start_line || 1) - 1);
|
|
47
|
+
const header = height >= 4 ? 2 : 1,
|
|
48
|
+
count = Math.max(1, height - header),
|
|
49
|
+
top = Math.max(
|
|
50
|
+
0,
|
|
51
|
+
Math.min(lines.length - count, target - Math.floor(count / 3)),
|
|
52
|
+
);
|
|
53
|
+
const code = h(
|
|
54
|
+
Box,
|
|
55
|
+
{ width: codeWidth, height, flexDirection: "column", overflow: "hidden" },
|
|
56
|
+
h(
|
|
57
|
+
Text,
|
|
58
|
+
{ color: focus === "code" ? c.blue : c.purple, bold: true },
|
|
59
|
+
clip(
|
|
60
|
+
excerpt.old
|
|
61
|
+
? "Earlier snippet · saved quote"
|
|
62
|
+
: "Code · W focus · C comment",
|
|
63
|
+
codeWidth,
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
header === 2
|
|
67
|
+
? h(
|
|
68
|
+
Text,
|
|
69
|
+
{ color: c.muted },
|
|
70
|
+
clip(
|
|
71
|
+
focus === "code"
|
|
72
|
+
? `Lines ${range.start}–${range.end} · Space select`
|
|
73
|
+
: "Markers: remarks on this page",
|
|
74
|
+
codeWidth,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
: null,
|
|
78
|
+
...Array.from({ length: count }, (_, i) => {
|
|
79
|
+
const n = top + i,
|
|
80
|
+
number = excerpt.first + n;
|
|
81
|
+
if (n >= lines.length) return h(Text, { key: i }, " ");
|
|
82
|
+
const marked =
|
|
83
|
+
!excerpt.old &&
|
|
84
|
+
items.some(
|
|
85
|
+
(item) =>
|
|
86
|
+
!item.outdated &&
|
|
87
|
+
!item.deleted_at &&
|
|
88
|
+
number >= item.start_line &&
|
|
89
|
+
number <= item.end_line,
|
|
90
|
+
);
|
|
91
|
+
const highlighted =
|
|
92
|
+
excerpt.old ||
|
|
93
|
+
(focus === "code"
|
|
94
|
+
? number >= range.start && number <= range.end
|
|
95
|
+
: selectedNote &&
|
|
96
|
+
!selectedNote.outdated &&
|
|
97
|
+
number >= selectedNote.start_line &&
|
|
98
|
+
number <= selectedNote.end_line);
|
|
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
|
+
);
|
|
104
|
+
return h(
|
|
105
|
+
Text,
|
|
106
|
+
{
|
|
107
|
+
key: i,
|
|
108
|
+
backgroundColor: highlighted ? c.selected : undefined,
|
|
109
|
+
color: text.startsWith("+")
|
|
110
|
+
? c.green
|
|
111
|
+
: text.startsWith("-")
|
|
112
|
+
? c.red
|
|
113
|
+
: c.text,
|
|
114
|
+
},
|
|
115
|
+
...(text.startsWith("+") || text.startsWith("-")
|
|
116
|
+
? [rendered]
|
|
117
|
+
: syntaxSpans(rendered).map((span, j) =>
|
|
118
|
+
h(Text, { key: j, color: span.color }, span.text),
|
|
119
|
+
)),
|
|
120
|
+
);
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
const notesWidth = split ? width - codeWidth - 2 : width;
|
|
124
|
+
const visible = Math.max(1, Math.floor((height - 2) / 4)),
|
|
125
|
+
first = Math.max(0, index - visible + 1);
|
|
126
|
+
const notes = h(
|
|
127
|
+
Box,
|
|
128
|
+
{ width: notesWidth, height, flexDirection: "column", overflow: "hidden" },
|
|
129
|
+
h(
|
|
130
|
+
Text,
|
|
131
|
+
{ color: focus === "notes" ? c.blue : c.purple, bold: true },
|
|
132
|
+
"Discussion · W focus",
|
|
133
|
+
),
|
|
134
|
+
header === 2
|
|
135
|
+
? h(
|
|
136
|
+
Text,
|
|
137
|
+
{ color: c.muted },
|
|
138
|
+
clip("Enter thread · Space read · X actions", notesWidth),
|
|
139
|
+
)
|
|
140
|
+
: null,
|
|
141
|
+
...(items.length
|
|
142
|
+
? rows(notesWidth)
|
|
143
|
+
.slice(first * 4, first * 4 + height - header)
|
|
144
|
+
.map((row, i) =>
|
|
145
|
+
h(
|
|
146
|
+
Text,
|
|
147
|
+
{
|
|
148
|
+
key: i,
|
|
149
|
+
color: row.color || c.text,
|
|
150
|
+
bold: row.bold,
|
|
151
|
+
backgroundColor: row.selected ? c.selected : undefined,
|
|
152
|
+
},
|
|
153
|
+
clip(typeof row === "string" ? row : row.text, notesWidth),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
: [h(Text, { key: "empty", color: c.muted }, "No remarks. C adds one.")]),
|
|
157
|
+
);
|
|
158
|
+
return h(
|
|
159
|
+
Box,
|
|
160
|
+
{ height, width, gap: split ? 2 : 0, overflow: "hidden" },
|
|
161
|
+
...(split ? [code, notes] : [focus === "code" ? code : notes]),
|
|
162
|
+
);
|
|
163
|
+
}
|