@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 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, writeFile, mkdtemp, rm } from "node:fs/promises";
6
- import { hostname, tmpdir } from "node:os";
7
- import { join } from "node:path";
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 writeFile(file, initial, { mode: 0o600 });
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 Error(
114
- "Could not reach Devdevian. Check your connection and server address.",
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 Error(
131
- `Devdevian returned an unexpected response (HTTP ${response.status}). Try again later.`,
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
+ }
@@ -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
+ }
@@ -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
- clip(
111
- `${marked ? "●" : " "}${String(number).padStart(4)} ${graphemes(text.replaceAll("\t", " ")).slice(horizontal).join("")}`,
112
- codeWidth,
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("/posts/" + route.id + "?reply_page=" + page);
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
- h(TextLine, {
198
- key: i,
199
- text: typeof row === "string" ? row : row.text,
200
- width,
201
- color: row.color || colors.text,
202
- bold: row.bold || false,
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
- setReviewLine(0);
526
- setReviewAnchor(null);
527
- setReviewHorizontal(0);
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(clean(e.message));
629
+ } else setError(problemMessage(e));
551
630
  }
552
631
  function navigate(next, { replace = false } = {}) {
553
- if (!replace) history.current.push({ route, index, scroll });
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
- setIndex((i) =>
723
- Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
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
- if (item.code_review_id)
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 = wrap(
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
- contentLines(route, current || {}).flatMap((line) =>
1778
- wrap(line, contentWidth),
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 = wrap(
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 = contentLines(route, current || {}).flatMap((line) =>
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 · Esc discard"
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.0-alpha.1",
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": {