@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 +26 -0
- package/VERSIONS.md +71 -0
- package/bin/devdevian.mjs +51 -26
- package/lib/appearance.mjs +179 -0
- package/lib/client.mjs +12 -4
- package/lib/external-editor.mjs +111 -0
- package/lib/picker.mjs +6 -5
- package/lib/problems.mjs +29 -0
- package/lib/review-workspace.mjs +17 -8
- package/lib/rich-text.mjs +96 -0
- package/lib/theme.mjs +66 -13
- package/lib/tui-data.mjs +44 -1
- package/lib/tui-model.mjs +2 -1
- package/lib/tui.mjs +528 -163
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ devdevian open # a separate terminal window
|
|
|
10
10
|
|
|
11
11
|
Login opens a browser. Compare the code shown in the terminal, sign in to Devdevian, then authorize the terminal. Google, GitHub and email login happen on the website; no provider credentials are collected by this package. Use `--no-open` to open the printed address yourself, including when connecting over SSH.
|
|
12
12
|
|
|
13
|
+
Version-by-version changes and the customization roadmap: [CLI version history](VERSIONS.md).
|
|
14
|
+
|
|
13
15
|
## Live workspace
|
|
14
16
|
|
|
15
17
|
The default `devdevian` command and `devdevian tui` open an alternate terminal screen. Navigation, conversations, inline forms, previews and search update in that screen; exiting restores your normal terminal. The blue and purple layout adapts to the terminal size, with compact navigation below 90 columns. Minimum size: 60 columns × 18 rows.
|
|
@@ -19,6 +21,7 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
|
|
|
19
21
|
- **←/→** switch conversation tabs; **Space** reads the selected reply/remark in full. **[ / ]** paginate; **Page Up/Down** scroll.
|
|
20
22
|
- **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
|
|
21
23
|
- **C** comments on snippet lines; **E/D** edit/delete your own ordinary replies or code feedback; **S** resolves/reopens a code remark or accepts/removes a reply as the solution (question/review author only); **!** reports selected code feedback.
|
|
24
|
+
- **Ctrl+E** opens the current multiline field in `$VISUAL` / `$EDITOR` (use `code --wait` for VS Code). Closing the editor restores the form; publishing still requires preview and confirmation. **Ctrl+L** saves an active draft and returns to sign-in.
|
|
22
25
|
- In forms, **Tab/Shift+Tab** change fields, arrows choose options or move the text cursor, **Enter** adds a line, **Ctrl+S** opens the preview. Publication requires a separate confirmation; Escape asks before discarding a draft. Drafts auto-save locally after a short pause and can be restored from **Local drafts** or `/drafts`.
|
|
23
26
|
- **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
|
|
24
27
|
|
|
@@ -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
|
|
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,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
|
|
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 [
|
|
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
|
|
114
|
-
|
|
113
|
+
throw Object.assign(
|
|
114
|
+
Error(
|
|
115
|
+
error.name === "TimeoutError"
|
|
116
|
+
? "Connection timed out. Check your connection and server address."
|
|
117
|
+
: "Could not reach Devdevian. Check your connection and server address.",
|
|
118
|
+
),
|
|
119
|
+
{ code: error.name === "TimeoutError" ? "TIMEOUT" : "NETWORK" },
|
|
115
120
|
);
|
|
116
121
|
}
|
|
117
122
|
const chunks = [];
|
|
@@ -127,8 +132,11 @@ export function client(origin, token = null) {
|
|
|
127
132
|
try {
|
|
128
133
|
data = JSON.parse(text);
|
|
129
134
|
} catch {
|
|
130
|
-
throw
|
|
131
|
-
|
|
135
|
+
throw Object.assign(
|
|
136
|
+
Error(
|
|
137
|
+
`Devdevian returned an unexpected response (HTTP ${response.status}). Try again later.`,
|
|
138
|
+
),
|
|
139
|
+
{ status: response.status },
|
|
132
140
|
);
|
|
133
141
|
}
|
|
134
142
|
if (!response.ok) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, writeFile, open, rm, lstat } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Parse argv, never shell code. Quoting supports editor paths and --wait flags.
|
|
8
|
+
export function editorArgs(command) {
|
|
9
|
+
const args = [];
|
|
10
|
+
let value = "",
|
|
11
|
+
quote = null,
|
|
12
|
+
started = false;
|
|
13
|
+
for (const char of command.trim()) {
|
|
14
|
+
if (quote) {
|
|
15
|
+
if (char === quote) quote = null;
|
|
16
|
+
else value += char;
|
|
17
|
+
} else if (char === '"' || char === "'") {
|
|
18
|
+
quote = char;
|
|
19
|
+
started = true;
|
|
20
|
+
} else if (/\s/.test(char)) {
|
|
21
|
+
if (started) {
|
|
22
|
+
args.push(value);
|
|
23
|
+
value = "";
|
|
24
|
+
started = false;
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
value += char;
|
|
28
|
+
started = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (quote) throw Error("Close the quote in VISUAL / EDITOR.");
|
|
32
|
+
if (started) args.push(value);
|
|
33
|
+
if (!args[0])
|
|
34
|
+
throw Error(
|
|
35
|
+
"Set VISUAL or EDITOR to an editor command (for example: code --wait).",
|
|
36
|
+
);
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
export async function editExternal(
|
|
40
|
+
initial,
|
|
41
|
+
{
|
|
42
|
+
max = 15000,
|
|
43
|
+
command = process.env.VISUAL ||
|
|
44
|
+
process.env.EDITOR ||
|
|
45
|
+
(process.platform === "win32" ? "notepad" : "vi"),
|
|
46
|
+
} = {},
|
|
47
|
+
) {
|
|
48
|
+
const [executable, ...args] = editorArgs(command);
|
|
49
|
+
const directory = await mkdtemp(join(tmpdir(), "devdevian-editor-")),
|
|
50
|
+
file = join(directory, "message.md");
|
|
51
|
+
try {
|
|
52
|
+
await writeFile(file, initial, { mode: 0o600, flag: "wx" });
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const child = spawn(executable, [...args, file], {
|
|
55
|
+
stdio: "inherit",
|
|
56
|
+
shell: false,
|
|
57
|
+
});
|
|
58
|
+
child.once("error", () =>
|
|
59
|
+
reject(
|
|
60
|
+
Error(
|
|
61
|
+
"Could not start the editor. Check VISUAL / EDITOR; your original text is unchanged.",
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
child.once("exit", (code) =>
|
|
66
|
+
code === 0
|
|
67
|
+
? resolve()
|
|
68
|
+
: reject(
|
|
69
|
+
Error(
|
|
70
|
+
"Editor cancelled or failed; your original text is unchanged.",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
const infoBeforeOpen = await lstat(file);
|
|
76
|
+
if (infoBeforeOpen.isSymbolicLink() || !infoBeforeOpen.isFile())
|
|
77
|
+
throw Error("Editor output must be a regular file. Original text kept.");
|
|
78
|
+
const handle = await open(
|
|
79
|
+
file,
|
|
80
|
+
constants.O_RDONLY |
|
|
81
|
+
(constants.O_NOFOLLOW || 0) |
|
|
82
|
+
(constants.O_NONBLOCK || 0),
|
|
83
|
+
);
|
|
84
|
+
try {
|
|
85
|
+
const info = await handle.stat();
|
|
86
|
+
if (!info.isFile() || info.size > max * 4)
|
|
87
|
+
throw Error(
|
|
88
|
+
`Editor output must be a UTF-8 text file with at most ${max} characters. Original text kept.`,
|
|
89
|
+
);
|
|
90
|
+
const buffer = Buffer.alloc(max * 4 + 1),
|
|
91
|
+
{ bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
92
|
+
let text;
|
|
93
|
+
try {
|
|
94
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
95
|
+
buffer.subarray(0, bytesRead),
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
throw Error("Editor output must be UTF-8. Original text kept.");
|
|
99
|
+
}
|
|
100
|
+
if (bytesRead > max * 4 || text.length > max || text.includes("\0"))
|
|
101
|
+
throw Error(
|
|
102
|
+
`Editor output exceeds ${max} characters or contains binary data. Original text kept.`,
|
|
103
|
+
);
|
|
104
|
+
return text.replace(/\r\n/g, "\n");
|
|
105
|
+
} finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(directory, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
package/lib/picker.mjs
CHANGED
|
@@ -9,9 +9,8 @@ import {
|
|
|
9
9
|
readDiff,
|
|
10
10
|
selectLines,
|
|
11
11
|
} from "./workspace.mjs";
|
|
12
|
-
import {
|
|
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:
|
|
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:
|
|
334
|
+
borderStyle: colors.frame,
|
|
334
335
|
borderColor: c.blue,
|
|
335
336
|
paddingX: 1,
|
|
336
337
|
overflow: "hidden",
|
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
|
+
}
|