@tenyokjs/devdevian-cli 0.4.0-alpha.1 → 0.5.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -28,6 +31,29 @@ 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
+ ## Themes and layout
35
+
36
+ Open **Appearance** in navigation, type `/appearance`, or press **,** from a reading/list screen. Use arrows to choose options and Tab to move between them. The entire interface previews each change, including code colors, the file picker and navigation. Wide terminals include a sample card next to the controls.
37
+
38
+ - **Midnight** preserves the original blue/purple design; **Nebula** uses a deeper violet surface; **Daylight** is light; **Contrast** emphasizes readable foregrounds.
39
+ - Choose a **blue, violet, cyan or rose accent**, comfortable/compact lists, automatic/hidden sidebar, automatic/stacked review panels, rounded/straight frames and subtle/reduced loading indicators.
40
+ - **Ctrl+S** saves locally. **Esc** cancels the preview. **0** restores the default preview; save to keep it, or cancel to retain existing settings.
41
+ - Hidden navigation stays available with **Tab**. Stacked review panes switch with **W**. Narrow terminals automatically avoid a permanent sidebar and split review layout.
42
+
43
+ Appearance settings belong to this local CLI installation and apply across its accounts/projects; credentials and server profiles are separate. Settings live in `appearance.json` alongside the credentials file, or under `DEVDEVIAN_CONFIG_DIR`. Saving replaces the settings file atomically with private file permissions. Invalid settings fall back to defaults with a warning and remain untouched until you save a new configuration.
44
+
45
+ Appearance can also be configured **offline, without logging in**:
46
+
47
+ ```sh
48
+ devdevian appearance # print settings as JSON
49
+ devdevian appearance --theme daylight --accent violet
50
+ devdevian appearance --density compact --sidebar hidden
51
+ devdevian appearance --review-layout stacked --motion reduced
52
+ devdevian appearance --reset # save defaults
53
+ ```
54
+
55
+ These options can be passed to `tui` or `open` for a **session-only override**, for example `devdevian tui --theme contrast`. Overrides become persistent only if you save in Appearance. Preference changes do not write to the Devdevian API or modify credentials. Current presets use fixed, tested color combinations; the settings file is validated rather than interpreted as terminal code.
56
+
31
57
  ## Code and discussion, together
32
58
 
33
59
  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.
package/VERSIONS.md ADDED
@@ -0,0 +1,71 @@
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
+ ## Current milestone
8
+
9
+ **0.5.0 focuses on themes, interface customization and local appearance preferences.** Projects and Events parity remains a separate future milestone.
10
+
11
+ ## 0.5.0-alpha.1 — Make the terminal yours
12
+
13
+ **Changes**
14
+
15
+ - Appearance workspace with immediate whole-interface previews, a sample panel on wide terminals and explicit save/cancel/default controls. Open it with `/appearance`, navigation or **,**.
16
+ - Midnight, Nebula, Daylight and Contrast themes, with blue/violet/cyan/rose accents shared by navigation, Markdown, code review, forms and the local file/Git picker.
17
+ - Comfortable/compact list density, automatic/hidden navigation, automatic/stacked review panels, rounded/straight frames and reduced loading motion. Compact layouts remain keyboard-accessible.
18
+ - Atomic, validated local preference storage, independent of account credentials and production data. Invalid configuration falls back visibly without overwriting the file.
19
+ - Offline `devdevian appearance` commands and temporary `tui`/`open` appearance flags. Theme text and selection contrast are checked across all preset/accent combinations.
20
+ - Rapid option-arrow changes no longer overwrite each other when input events are batched. Small terminal forms retain the active option in the viewport.
21
+
22
+ **Compatibility and checks**
23
+
24
+ No API/schema changes. Existing credentials and local drafts remain compatible; missing preferences use Midnight defaults. Regression checks cover save/restart, preview cancellation, reset, corrupt files, credential isolation, palette contrast, compact lists, hidden navigation, stacked reviews and 60×18 resizing in a real pseudo-terminal. Preferences apply to the local CLI installation, across its accounts and projects.
25
+
26
+ ## 0.4.1-alpha.1 — Everyday writing and navigation
27
+
28
+ **Changes**
29
+
30
+ - `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.
31
+ - 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.
32
+ - 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.
33
+ - 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.
34
+
35
+ **Compatibility and checks**
36
+
37
+ 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`).
38
+
39
+ ## 0.4.0-alpha.1 — Review workspace and live Inbox
40
+
41
+ - 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.
42
+ - `X` context menu for available edits, deletion, reports, resolving remarks and accepted solutions. Destructive/solution actions require confirmation and server permission checks.
43
+ - Unread counter and serial live polling: 45 seconds active, three minutes idle, failure backoff and pause controls. Active forms and selected items are preserved.
44
+ - Shared blue/purple styling, rounded frames, clearer tabs, cards and project identity. No API/schema migration.
45
+
46
+ ## 0.3.1-alpha.1 — Reply deletion
47
+
48
+ - `D/d` deletes the author's ordinary reply, including full-text and parent-reply views, with confirmation and revision checks.
49
+ - 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.
50
+
51
+ ## 0.3.0-alpha.1 — Project files, Git diffs and drafts
52
+
53
+ - Root-relative file explorer, filename filtering, code preview and line-range selection. Sensitive paths, ignored files, nested symlinks and outside-root traversal are excluded.
54
+ - Read-only staged/unstaged Git diff selection, including individual hunks. No staging, commits or project helper execution. Changed source requires reselection before publication.
55
+ - 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.
56
+
57
+ ## 0.2.0-alpha.1 — Live terminal workspace
58
+
59
+ - Replaced the numbered scrolling menu with an alternate-screen interface, responsive navigation, forms, previews and search.
60
+ - Paginated conversations, replies, code-feedback threads, edit/delete/report actions and connected-terminal controls.
61
+ - `devdevian tui` runs in the current terminal; `devdevian open` launches a supported desktop terminal. Unicode editing, cancellable reads, bounded caching and terminal restoration checks.
62
+
63
+ ## 0.1.0-alpha.1 — First terminal client
64
+
65
+ - Browser-approved terminal authentication with scoped, expiring, revocable CLI credentials.
66
+ - Command-based feed, Inbox, search, questions, replies, file-based reviews, line remarks and community discovery using the existing Devdevian API.
67
+ - Explicit publication confirmation, JSON output for scripts, local private credential storage and terminal-output sanitization.
68
+
69
+ ## Preparing a combined GitHub release
70
+
71
+ 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,14 @@ import {
16
15
  removeConfig,
17
16
  clean,
18
17
  } from "../lib/client.mjs";
18
+ import { editExternal } from "../lib/external-editor.mjs";
19
+ import {
20
+ appearanceFields,
21
+ appearanceFlags,
22
+ defaultAppearance,
23
+ loadAppearance,
24
+ saveAppearance,
25
+ } from "../lib/appearance.mjs";
19
26
  const version = packageInfo.version;
20
27
  const options = {
21
28
  host: { type: "string" },
@@ -40,6 +47,13 @@ const options = {
40
47
  editor: { type: "boolean" },
41
48
  "no-open": { type: "boolean" },
42
49
  "no-live": { type: "boolean" },
50
+ ...Object.fromEntries(
51
+ appearanceFields.map((field) => [
52
+ field.key === "reviewLayout" ? "review-layout" : field.key,
53
+ { type: "string" },
54
+ ]),
55
+ ),
56
+ reset: { type: "boolean" },
43
57
  help: { type: "boolean", short: "h" },
44
58
  version: { type: "boolean", short: "v" },
45
59
  };
@@ -80,6 +94,9 @@ const help = `Devdevian CLI ${version} · alpha
80
94
  devdevian open Open the workspace in a new terminal window
81
95
  devdevian login Authorize in your browser
82
96
  devdevian logout Revoke this terminal
97
+ devdevian appearance Show local theme and layout preferences
98
+ devdevian appearance --theme daylight --accent violet
99
+ devdevian appearance --reset Restore the default appearance
83
100
  devdevian whoami
84
101
  devdevian inbox [--page 2]
85
102
  devdevian feed [--topic web] [--page 2]
@@ -96,6 +113,13 @@ const help = `Devdevian CLI ${version} · alpha
96
113
  devdevian review reopen POST_ID REVIEW_ID
97
114
  devdevian communities [--page 2]
98
115
 
116
+ --theme NAME midnight | nebula | daylight | contrast
117
+ --accent NAME blue | violet | cyan | rose
118
+ --density NAME comfortable | compact
119
+ --sidebar MODE auto | hidden (Tab opens navigation)
120
+ --review-layout MODE auto | stacked
121
+ --frame STYLE round | single
122
+ --motion MODE subtle | reduced
99
123
  --no-live Disable background reads in the live workspace
100
124
  --project PATH Root directory for the live file/diff picker (default: current directory)
101
125
  --community ID Publish to an approved private community
@@ -111,30 +135,10 @@ async function editor(initial = "") {
111
135
  if (!process.stdin.isTTY || !process.stdout.isTTY)
112
136
  throw Error("--editor needs an interactive terminal.");
113
137
  terminal?.pause();
114
- const directory = await mkdtemp(join(tmpdir(), "devdevian-editor-")),
115
- file = join(directory, "message.md");
116
138
  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");
139
+ return await editExternal(initial);
135
140
  } finally {
136
141
  terminal?.resume();
137
- await rm(directory, { recursive: true, force: true });
138
142
  }
139
143
  }
140
144
  async function boundedFile(file, max) {
@@ -260,6 +264,20 @@ async function execute(args, flags) {
260
264
  return;
261
265
  }
262
266
  const command = args[0];
267
+ if (command === "appearance") {
268
+ const loaded = await loadAppearance(),
269
+ updates = appearanceFlags(flags);
270
+ if (flags.reset && Object.keys(updates).length)
271
+ throw Error("Use --reset on its own, or choose appearance options.");
272
+ if (loaded.warning) process.stderr.write(loaded.warning + "\n");
273
+ const settings = flags.reset
274
+ ? await saveAppearance(defaultAppearance)
275
+ : Object.keys(updates).length
276
+ ? await saveAppearance({ ...loaded.settings, ...updates })
277
+ : loaded.settings;
278
+ out(JSON.stringify(settings, null, 2));
279
+ return;
280
+ }
263
281
  if (command === "login") return login(flags);
264
282
  const config = await loadConfig();
265
283
  if (!config) throw Error("Sign in first: devdevian login");
@@ -476,7 +494,14 @@ try {
476
494
  await (await import("../lib/tui.mjs")).startTui(values);
477
495
  else if (positionals[0] === "open") {
478
496
  const args = [];
479
- for (const key of ["host", "name", "project"])
497
+ for (const key of [
498
+ "host",
499
+ "name",
500
+ "project",
501
+ ...appearanceFields.map((field) =>
502
+ field.key === "reviewLayout" ? "review-layout" : field.key,
503
+ ),
504
+ ])
480
505
  if (values[key]) args.push("--" + key, values[key]);
481
506
  if (values["no-open"]) args.push("--no-open");
482
507
  if (values["no-live"]) args.push("--no-live");
@@ -0,0 +1,179 @@
1
+ import { dirname, join } from "node:path";
2
+ import {
3
+ mkdir,
4
+ lstat,
5
+ open,
6
+ writeFile,
7
+ rename,
8
+ unlink,
9
+ } from "node:fs/promises";
10
+ import { constants } from "node:fs";
11
+ import { randomBytes } from "node:crypto";
12
+ import { configFile } from "./client.mjs";
13
+
14
+ export const appearanceFields = [
15
+ {
16
+ key: "theme",
17
+ label: "Theme",
18
+ options: [
19
+ ["midnight", "Midnight · original"],
20
+ ["nebula", "Nebula · deep violet"],
21
+ ["daylight", "Daylight · light"],
22
+ ["contrast", "Contrast · high contrast"],
23
+ ],
24
+ },
25
+ {
26
+ key: "accent",
27
+ label: "Accent",
28
+ options: [
29
+ ["blue", "Blue"],
30
+ ["violet", "Violet"],
31
+ ["cyan", "Cyan"],
32
+ ["rose", "Rose"],
33
+ ],
34
+ },
35
+ {
36
+ key: "density",
37
+ label: "List density",
38
+ options: [
39
+ ["comfortable", "Comfortable"],
40
+ ["compact", "Compact"],
41
+ ],
42
+ },
43
+ {
44
+ key: "sidebar",
45
+ label: "Navigation",
46
+ options: [
47
+ ["auto", "Sidebar on wide terminals"],
48
+ ["hidden", "Hidden · Tab opens navigation"],
49
+ ],
50
+ },
51
+ {
52
+ key: "reviewLayout",
53
+ label: "Review panels",
54
+ options: [
55
+ ["auto", "Side by side when space allows"],
56
+ ["stacked", "One panel · W switches"],
57
+ ],
58
+ },
59
+ {
60
+ key: "frame",
61
+ label: "Frames",
62
+ options: [
63
+ ["round", "Rounded"],
64
+ ["single", "Straight"],
65
+ ],
66
+ },
67
+ {
68
+ key: "motion",
69
+ label: "Motion",
70
+ options: [
71
+ ["subtle", "Subtle loading indicators"],
72
+ ["reduced", "Reduced · static indicators"],
73
+ ],
74
+ },
75
+ ];
76
+ export const defaultAppearance = Object.freeze(
77
+ Object.fromEntries(
78
+ appearanceFields.map((field) => [field.key, field.options[0][0]]),
79
+ ),
80
+ );
81
+ export function normalizeAppearance(value = {}) {
82
+ if (!value || typeof value !== "object" || Array.isArray(value))
83
+ throw Error("Appearance settings must be an object.");
84
+ for (const key of Object.keys(value))
85
+ if (!appearanceFields.some((field) => field.key === key))
86
+ throw Error("Unknown appearance option: " + key);
87
+ const settings = { ...defaultAppearance, ...value };
88
+ for (const field of appearanceFields)
89
+ if (!field.options.some(([id]) => id === settings[field.key]))
90
+ throw Error(
91
+ `Invalid ${field.key}. Choose: ${field.options.map(([id]) => id).join(", ")}.`,
92
+ );
93
+ return settings;
94
+ }
95
+ export function appearanceFlags(flags) {
96
+ const updates = {};
97
+ for (const field of appearanceFields) {
98
+ const flag = field.key === "reviewLayout" ? "review-layout" : field.key;
99
+ if (flags[flag] !== undefined) updates[field.key] = flags[flag];
100
+ }
101
+ normalizeAppearance(updates);
102
+ return updates;
103
+ }
104
+ export const appearanceFile = (env = process.env) =>
105
+ join(dirname(configFile(env)), "appearance.json");
106
+ async function safeDirectory(path) {
107
+ const info = await lstat(path);
108
+ if (!info.isDirectory() || info.isSymbolicLink())
109
+ throw Error("The appearance directory must be a real directory.");
110
+ }
111
+ export async function loadAppearance(env = process.env) {
112
+ const file = appearanceFile(env);
113
+ try {
114
+ await safeDirectory(dirname(file));
115
+ const info = await lstat(file);
116
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 4096)
117
+ throw Error("Invalid appearance file.");
118
+ const handle = await open(
119
+ file,
120
+ constants.O_RDONLY |
121
+ (constants.O_NOFOLLOW || 0) |
122
+ (constants.O_NONBLOCK || 0),
123
+ );
124
+ let record;
125
+ try {
126
+ if (!(await handle.stat()).isFile())
127
+ throw Error("Invalid appearance file.");
128
+ const bytes = Buffer.alloc(4097),
129
+ { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
130
+ if (bytesRead > 4096) throw Error("Appearance file is too large.");
131
+ record = JSON.parse(
132
+ new TextDecoder("utf-8", { fatal: true }).decode(
133
+ bytes.subarray(0, bytesRead),
134
+ ),
135
+ );
136
+ } finally {
137
+ await handle.close();
138
+ }
139
+ if (
140
+ record.version !== 1 ||
141
+ !record.settings ||
142
+ typeof record.settings !== "object"
143
+ )
144
+ throw Error("Unsupported appearance file version.");
145
+ return { settings: normalizeAppearance(record.settings), warning: "" };
146
+ } catch (error) {
147
+ return {
148
+ settings: { ...defaultAppearance },
149
+ warning:
150
+ error.code === "ENOENT"
151
+ ? ""
152
+ : "Could not load appearance settings. Using defaults; /appearance can save a fresh configuration.",
153
+ };
154
+ }
155
+ }
156
+ export async function saveAppearance(value, env = process.env) {
157
+ const settings = normalizeAppearance(value),
158
+ file = appearanceFile(env),
159
+ directory = dirname(file);
160
+ await mkdir(directory, { recursive: true, mode: 0o700 });
161
+ await safeDirectory(directory);
162
+ const temporary = join(
163
+ directory,
164
+ ".appearance-" + randomBytes(12).toString("hex"),
165
+ );
166
+ try {
167
+ await writeFile(
168
+ temporary,
169
+ JSON.stringify({ version: 1, settings }, null, 2) + "\n",
170
+ { flag: "wx", mode: 0o600 },
171
+ );
172
+ await rename(temporary, file);
173
+ } finally {
174
+ await unlink(temporary).catch((error) => {
175
+ if (error.code !== "ENOENT") throw error;
176
+ });
177
+ }
178
+ return settings;
179
+ }
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
+ }
package/lib/picker.mjs CHANGED
@@ -9,9 +9,8 @@ import {
9
9
  readDiff,
10
10
  selectLines,
11
11
  } from "./workspace.mjs";
12
- import { colors } from "./theme.mjs";
13
- const h = React.createElement,
14
- c = { ...colors, bg: colors.panel };
12
+ import { useTheme } from "./theme.mjs";
13
+ const h = React.createElement;
15
14
  export function WorkspacePicker({
16
15
  root,
17
16
  width,
@@ -20,6 +19,8 @@ export function WorkspacePicker({
20
19
  onSelect,
21
20
  onCancel,
22
21
  }) {
22
+ const colors = useTheme(),
23
+ c = { ...colors, bg: colors.panel };
23
24
  const [mode, setMode] = useState(initial),
24
25
  [folder, setFolder] = useState("."),
25
26
  [hidden, setHidden] = useState(false),
@@ -265,7 +266,7 @@ export function WorkspacePicker({
265
266
  height: panelHeight,
266
267
  key: "browser",
267
268
  flexDirection: "column",
268
- borderStyle: "round",
269
+ borderStyle: colors.frame,
269
270
  borderColor: c.purple,
270
271
  paddingX: 1,
271
272
  overflow: "hidden",
@@ -330,7 +331,7 @@ export function WorkspacePicker({
330
331
  height: panelHeight,
331
332
  key: "preview",
332
333
  flexDirection: "column",
333
- borderStyle: "round",
334
+ borderStyle: colors.frame,
334
335
  borderColor: c.blue,
335
336
  paddingX: 1,
336
337
  overflow: "hidden",
@@ -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
+ }