@tenyokjs/devdevian-cli 0.2.0-alpha.1 → 0.3.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 +27 -3
- package/bin/devdevian.mjs +3 -1
- package/lib/drafts.mjs +119 -0
- package/lib/picker.mjs +421 -0
- package/lib/tui-data.mjs +3 -0
- package/lib/tui-model.mjs +2 -1
- package/lib/tui.mjs +259 -68
- package/lib/workspace.mjs +386 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,17 +15,41 @@ Login opens a browser. Compare the code shown in the terminal, sign in to Devdev
|
|
|
15
15
|
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.
|
|
16
16
|
|
|
17
17
|
- **Tab** switches between navigation, content and the command bar. **↑/↓** select; **Enter** opens; **Esc** goes back.
|
|
18
|
-
- **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
|
|
18
|
+
- **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/drafts`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
|
|
19
19
|
- **←/→** switch conversation tabs; **Space** reads the selected reply/remark in full. **[ / ]** paginate; **Page Up/Down** scroll.
|
|
20
20
|
- **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
|
|
21
21
|
- **C** comments on snippet lines; **E/D** edit/delete your own feedback; **S** resolves/reopens a code remark as the publication author; **!** reports selected code feedback.
|
|
22
|
-
- 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
|
|
23
|
-
- **Ctrl+C** exits. With an unsent draft, confirm leaving or press Ctrl+C again.
|
|
22
|
+
- 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
|
+
- **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
|
|
24
24
|
|
|
25
25
|
`devdevian open` launches Terminal on macOS, Windows Terminal on Windows, or GNOME Terminal/Konsole/xterm on a Linux desktop. A graphical desktop and an installed supported terminal are required; over SSH and inside an editor, use `devdevian tui`. Window launchers still need platform-specific user testing; terminal rendering and input are covered by macOS/Linux pseudo-terminal checks.
|
|
26
26
|
|
|
27
27
|
Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R. Network errors stay inside the workspace; failed writes are never automatically retried. Existing web permissions and server validation apply to every operation.
|
|
28
28
|
|
|
29
|
+
## Project files and Git changes
|
|
30
|
+
|
|
31
|
+
Start inside your project (`cd PROJECT1 && devdevian tui`) or use `devdevian tui --project /path/to/PROJECT1`. `devdevian open --project /path/to/PROJECT1` carries the same project into a new terminal window. The project root stays fixed for the session.
|
|
32
|
+
|
|
33
|
+
In a **Code review** form:
|
|
34
|
+
|
|
35
|
+
1. Press **Ctrl+O** to browse project files, or **Ctrl+G** to browse Git changes. These shortcuts work from any review field. Enter on an empty Code file field also opens the explorer.
|
|
36
|
+
2. **↑/↓** select entries; **Enter** opens a directory or previews a file; **←/Backspace** goes to the parent directory. **/** filters names in the current directory, **H** shows hidden files and **G** switches Files/Git changes. Wide terminals show the file list next to the preview; compact terminals use the available width for code.
|
|
37
|
+
3. In a file preview, use **↑/↓** or **Page Up/Down** to move. **Space** starts a line selection; move to extend it, or press Space again to reset. **←/→** scroll long lines horizontally. **Enter** uses the file/range; **Esc** returns to the list.
|
|
38
|
+
4. In Git changes, **T** switches **unstaged/staged**. The preview initially includes all hunks for one file; **[ / ]** choose the whole patch or an individual hunk. **Enter** uses that selection. Untracked files are selected through Files; binary diffs/submodules are excluded.
|
|
39
|
+
5. Fill in title, focus and context. **Ctrl+S** shows the exact code and audience to publish. Publication requires a separate confirmation.
|
|
40
|
+
|
|
41
|
+
The explorer excludes `.git`, dependency/build directories, symlinks, common environment/credential/key filenames and (inside Git repositories) ignored paths. No content scanner can identify every secret: inspect the publication preview before confirming. Git is required for changes and `.gitignore` detection; ordinary file selection also works without Git. Git reads disable external diff/textconv/fsmonitor helpers and never stage, commit, reset or execute project code.
|
|
42
|
+
|
|
43
|
+
File previews accept UTF-8 text up to **512 KiB**; a published snippet/patch is **10–20,000 characters**. Large files can be narrowed to a range and large patches to one hunk. Git patches appear as ordinary review snippets on the website; review line numbers count the **published patch including headers**, not the original source file. Source paths are relative to the project. If the selected patch changes before the publication preview, selection must be repeated. Publication uses the confirmed preview snapshot.
|
|
44
|
+
|
|
45
|
+
## Local drafts
|
|
46
|
+
|
|
47
|
+
Publication, reply, feedback, edit and community forms save locally after a **450 ms** pause. Explicit draft discard removes that draft; successful publication removes it too. `/drafts` lists saved drafts for the current **server, account and project directory**. Nothing is uploaded by autosave. Switching accounts or projects does not expose another scope's drafts.
|
|
48
|
+
|
|
49
|
+
Draft files live under the CLI credentials directory in `drafts/<scope>/`, with Unix file permissions `0600` and new directories `0700`. They contain your text and file/diff selection descriptors, **not copies of selected source files or patches**. They are local JSON files, not encrypted; protect your OS account and do not commit/share this directory. The history retains up to **20 drafts per scope for 30 days**. An abrupt termination may lose the most recent input before autosave; these drafts are not a backup service.
|
|
50
|
+
|
|
51
|
+
Restoring a review rereads the local source at the publication preview. Changed Git selections require reselection. A private audience that is no longer accessible is preserved as unavailable until you explicitly choose an available audience; it never silently becomes public.
|
|
52
|
+
|
|
29
53
|
## Everyday commands
|
|
30
54
|
|
|
31
55
|
```sh
|
package/bin/devdevian.mjs
CHANGED
|
@@ -34,6 +34,7 @@ const options = {
|
|
|
34
34
|
revision: { type: "string" },
|
|
35
35
|
"reply-id": { type: "string" },
|
|
36
36
|
name: { type: "string" },
|
|
37
|
+
project: { type: "string" },
|
|
37
38
|
yes: { type: "boolean" },
|
|
38
39
|
json: { type: "boolean" },
|
|
39
40
|
editor: { type: "boolean" },
|
|
@@ -94,6 +95,7 @@ const help = `Devdevian CLI ${version} · alpha
|
|
|
94
95
|
devdevian review reopen POST_ID REVIEW_ID
|
|
95
96
|
devdevian communities [--page 2]
|
|
96
97
|
|
|
98
|
+
--project PATH Root directory for the live file/diff picker (default: current directory)
|
|
97
99
|
--community ID Publish to an approved private community
|
|
98
100
|
--editor Write text in $VISUAL / $EDITOR
|
|
99
101
|
--json Machine-readable output
|
|
@@ -472,7 +474,7 @@ try {
|
|
|
472
474
|
await (await import("../lib/tui.mjs")).startTui(values);
|
|
473
475
|
else if (positionals[0] === "open") {
|
|
474
476
|
const args = [];
|
|
475
|
-
for (const key of ["host", "name"])
|
|
477
|
+
for (const key of ["host", "name", "project"])
|
|
476
478
|
if (values[key]) args.push("--" + key, values[key]);
|
|
477
479
|
if (values["no-open"]) args.push("--no-open");
|
|
478
480
|
await (
|
package/lib/drafts.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
lstat,
|
|
4
|
+
readdir,
|
|
5
|
+
readFile,
|
|
6
|
+
writeFile,
|
|
7
|
+
rename,
|
|
8
|
+
unlink,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
|
+
import { configFile } from "./client.mjs";
|
|
13
|
+
const digest = (value) =>
|
|
14
|
+
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
15
|
+
const MAX = 128 * 1024,
|
|
16
|
+
TTL = 30 * 86400000;
|
|
17
|
+
export function draftIdentity(form) {
|
|
18
|
+
return /^[a-f0-9]{64}$/.test(form.draftId || "")
|
|
19
|
+
? form.draftId
|
|
20
|
+
: digest([
|
|
21
|
+
form.kind,
|
|
22
|
+
form.postId || "",
|
|
23
|
+
form.url || "",
|
|
24
|
+
form.parentId || "",
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
export class DraftStore {
|
|
28
|
+
constructor({ origin, userId, root, env = process.env }) {
|
|
29
|
+
this.scope = digest([origin, userId, root]);
|
|
30
|
+
this.directory = join(dirname(configFile(env)), "drafts", this.scope);
|
|
31
|
+
this.root = root;
|
|
32
|
+
}
|
|
33
|
+
async ensure() {
|
|
34
|
+
const base = dirname(dirname(this.directory));
|
|
35
|
+
await mkdir(base, { recursive: true, mode: 0o700 });
|
|
36
|
+
for (const directory of [base, dirname(this.directory), this.directory]) {
|
|
37
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
38
|
+
const info = await lstat(directory);
|
|
39
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
40
|
+
throw Error("The draft directory must be a real directory.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async read(id) {
|
|
44
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw Error("Invalid draft ID.");
|
|
45
|
+
const file = join(this.directory, id + ".json");
|
|
46
|
+
const info = await lstat(file);
|
|
47
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX)
|
|
48
|
+
throw Error("Invalid draft file.");
|
|
49
|
+
if (process.platform !== "win32" && info.mode & 0o077)
|
|
50
|
+
throw Error("Draft file permissions must be 600.");
|
|
51
|
+
const record = JSON.parse(await readFile(file, "utf8"));
|
|
52
|
+
if (
|
|
53
|
+
!Number.isFinite(record.savedAt) ||
|
|
54
|
+
record.version !== 1 ||
|
|
55
|
+
record.scope !== this.scope ||
|
|
56
|
+
record.id !== id ||
|
|
57
|
+
Date.now() - record.savedAt > TTL ||
|
|
58
|
+
record.savedAt > Date.now() + 60000 ||
|
|
59
|
+
!["publication", "reply", "line", "edit", "community"].includes(
|
|
60
|
+
record.form?.kind,
|
|
61
|
+
) ||
|
|
62
|
+
!record.form?.values ||
|
|
63
|
+
typeof record.form.values !== "object" ||
|
|
64
|
+
Array.isArray(record.form.values)
|
|
65
|
+
)
|
|
66
|
+
throw Error("Draft expired or is invalid.");
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
async list() {
|
|
70
|
+
await this.ensure();
|
|
71
|
+
const names = await readdir(this.directory),
|
|
72
|
+
rows = [];
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
75
|
+
try {
|
|
76
|
+
rows.push(await this.read(name.slice(0, -5)));
|
|
77
|
+
} catch {
|
|
78
|
+
/* A corrupt or expired local draft never prevents opening the workspace. */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return rows.sort((a, b) => b.savedAt - a.savedAt).slice(0, 20);
|
|
82
|
+
}
|
|
83
|
+
async save(form) {
|
|
84
|
+
if (
|
|
85
|
+
["open", "report"].includes(form.kind) ||
|
|
86
|
+
!["title", "body", "name", "description", "file", "review_focus"].some(
|
|
87
|
+
(key) => String(form.values[key] || "").trim(),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return;
|
|
91
|
+
await this.ensure();
|
|
92
|
+
const id = draftIdentity(form),
|
|
93
|
+
record = { version: 1, id, scope: this.scope, savedAt: Date.now(), form };
|
|
94
|
+
const body = JSON.stringify(record);
|
|
95
|
+
if (Buffer.byteLength(body) > MAX)
|
|
96
|
+
throw Error("This draft is too large to save locally.");
|
|
97
|
+
const temp = join(this.directory, "." + randomBytes(12).toString("hex"));
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(temp, body, { mode: 0o600, flag: "wx" });
|
|
100
|
+
await rename(temp, join(this.directory, id + ".json"));
|
|
101
|
+
} finally {
|
|
102
|
+
await unlink(temp).catch((e) => {
|
|
103
|
+
if (e.code !== "ENOENT") throw e;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const rows = await this.list();
|
|
107
|
+
const keep = new Set(rows.map((row) => row.id));
|
|
108
|
+
for (const file of await readdir(this.directory))
|
|
109
|
+
if (/^[a-f0-9]{64}\.json$/.test(file) && !keep.has(file.slice(0, -5)))
|
|
110
|
+
await this.remove(file.slice(0, -5));
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
async remove(id) {
|
|
114
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw Error("Invalid draft ID.");
|
|
115
|
+
await unlink(join(this.directory, id + ".json")).catch((e) => {
|
|
116
|
+
if (e.code !== "ENOENT") throw e;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
package/lib/picker.mjs
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import { clip, editText, graphemes } from "./tui-model.mjs";
|
|
5
|
+
import {
|
|
6
|
+
listFiles,
|
|
7
|
+
listChanges,
|
|
8
|
+
readCodeFile,
|
|
9
|
+
readDiff,
|
|
10
|
+
selectLines,
|
|
11
|
+
} from "./workspace.mjs";
|
|
12
|
+
const h = React.createElement,
|
|
13
|
+
c = {
|
|
14
|
+
text: "#e0e7ff",
|
|
15
|
+
muted: "#8190af",
|
|
16
|
+
blue: "#79b8ff",
|
|
17
|
+
purple: "#b29aff",
|
|
18
|
+
green: "#70d6b0",
|
|
19
|
+
red: "#ff9baf",
|
|
20
|
+
bg: "#12192b",
|
|
21
|
+
selected: "#29345b",
|
|
22
|
+
};
|
|
23
|
+
export function WorkspacePicker({
|
|
24
|
+
root,
|
|
25
|
+
width,
|
|
26
|
+
height,
|
|
27
|
+
initial = "files",
|
|
28
|
+
onSelect,
|
|
29
|
+
onCancel,
|
|
30
|
+
}) {
|
|
31
|
+
const [mode, setMode] = useState(initial),
|
|
32
|
+
[folder, setFolder] = useState("."),
|
|
33
|
+
[hidden, setHidden] = useState(false),
|
|
34
|
+
[staged, setStaged] = useState(false),
|
|
35
|
+
[query, setQuery] = useState(""),
|
|
36
|
+
[queryCursor, setQueryCursor] = useState(0),
|
|
37
|
+
[searching, setSearching] = useState(false),
|
|
38
|
+
[items, setItems] = useState([]),
|
|
39
|
+
[index, setIndex] = useState(0),
|
|
40
|
+
[loading, setLoading] = useState(false),
|
|
41
|
+
[error, setError] = useState(""),
|
|
42
|
+
[selected, setSelected] = useState(null),
|
|
43
|
+
[line, setLine] = useState(0),
|
|
44
|
+
[anchor, setAnchor] = useState(null),
|
|
45
|
+
[hunk, setHunk] = useState(-1),
|
|
46
|
+
[horizontal, setHorizontal] = useState(0);
|
|
47
|
+
const alive = useRef(true),
|
|
48
|
+
selectionController = useRef(null);
|
|
49
|
+
useEffect(
|
|
50
|
+
() => () => {
|
|
51
|
+
alive.current = false;
|
|
52
|
+
selectionController.current?.abort();
|
|
53
|
+
},
|
|
54
|
+
[],
|
|
55
|
+
);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
setLoading(true);
|
|
59
|
+
setError("");
|
|
60
|
+
setItems([]);
|
|
61
|
+
setIndex(0);
|
|
62
|
+
const timer = setTimeout(
|
|
63
|
+
() => {
|
|
64
|
+
const load =
|
|
65
|
+
mode === "files"
|
|
66
|
+
? listFiles(root, folder, {
|
|
67
|
+
hidden,
|
|
68
|
+
query,
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
})
|
|
71
|
+
: listChanges(root, { staged, query, signal: controller.signal });
|
|
72
|
+
load
|
|
73
|
+
.then((rows) => {
|
|
74
|
+
if (!controller.signal.aborted) setItems(rows);
|
|
75
|
+
})
|
|
76
|
+
.catch((e) => {
|
|
77
|
+
if (!controller.signal.aborted) setError(e.message);
|
|
78
|
+
})
|
|
79
|
+
.finally(() => {
|
|
80
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
query ? 120 : 0,
|
|
84
|
+
);
|
|
85
|
+
return () => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
controller.abort();
|
|
88
|
+
};
|
|
89
|
+
}, [root, folder, hidden, mode, staged, query]);
|
|
90
|
+
const content = selected
|
|
91
|
+
? selected.kind === "diff"
|
|
92
|
+
? hunk < 0
|
|
93
|
+
? selected.patch
|
|
94
|
+
: selected.hunks[hunk].patch
|
|
95
|
+
: selected.code
|
|
96
|
+
: "",
|
|
97
|
+
lines = content.split("\n"),
|
|
98
|
+
panelHeight = Math.max(6, height - 2),
|
|
99
|
+
viewHeight = Math.max(1, panelHeight - 6),
|
|
100
|
+
split = !!selected && width >= 110,
|
|
101
|
+
listWidth = split ? 36 : width,
|
|
102
|
+
previewWidth = split ? width - listWidth - 1 : width;
|
|
103
|
+
function clearSearch() {
|
|
104
|
+
setQuery("");
|
|
105
|
+
setQueryCursor(0);
|
|
106
|
+
setSearching(false);
|
|
107
|
+
}
|
|
108
|
+
async function choose() {
|
|
109
|
+
const item = items[index];
|
|
110
|
+
if (!item || loading) return;
|
|
111
|
+
if (item.directory) {
|
|
112
|
+
setFolder(item.path);
|
|
113
|
+
clearSearch();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
setLoading(true);
|
|
117
|
+
setError("");
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
selectionController.current = controller;
|
|
120
|
+
try {
|
|
121
|
+
const data =
|
|
122
|
+
mode === "files"
|
|
123
|
+
? await readCodeFile(root, item.path)
|
|
124
|
+
: await readDiff(root, item.path, {
|
|
125
|
+
staged,
|
|
126
|
+
signal: controller.signal,
|
|
127
|
+
});
|
|
128
|
+
if (!alive.current) return;
|
|
129
|
+
setSelected({ ...data, kind: mode === "files" ? "file" : "diff" });
|
|
130
|
+
setLine(0);
|
|
131
|
+
setAnchor(null);
|
|
132
|
+
setHunk(-1);
|
|
133
|
+
setHorizontal(0);
|
|
134
|
+
setSearching(false);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (alive.current) setError(e.message);
|
|
137
|
+
} finally {
|
|
138
|
+
if (alive.current) setLoading(false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function accept() {
|
|
142
|
+
try {
|
|
143
|
+
if (selected.kind === "file") {
|
|
144
|
+
const start = anchor === null ? null : Math.min(anchor, line) + 1,
|
|
145
|
+
end = anchor === null ? null : Math.max(anchor, line) + 1;
|
|
146
|
+
selectLines(selected.code, start || 1, end || lines.length);
|
|
147
|
+
onSelect({ kind: "file", path: selected.path, start, end });
|
|
148
|
+
} else {
|
|
149
|
+
const part = hunk < 0 ? selected : selected.hunks[hunk];
|
|
150
|
+
selectLines(part.patch);
|
|
151
|
+
onSelect({
|
|
152
|
+
kind: "diff",
|
|
153
|
+
path: selected.path,
|
|
154
|
+
staged: selected.staged,
|
|
155
|
+
hunk: hunk < 0 ? null : hunk,
|
|
156
|
+
hash: part.hash,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
setError(e.message);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
useInput((input, key) => {
|
|
164
|
+
if (key.ctrl && input === "c") {
|
|
165
|
+
onCancel();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (searching) {
|
|
169
|
+
if (key.escape) {
|
|
170
|
+
setSearching(false);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (key.return) {
|
|
174
|
+
setSearching(false);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const next = editText(query, queryCursor, input, key, { max: 160 });
|
|
178
|
+
setQuery(next.value);
|
|
179
|
+
setQueryCursor(next.cursor);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (selected) {
|
|
183
|
+
if (key.escape || key.backspace) {
|
|
184
|
+
setSelected(null);
|
|
185
|
+
setError("");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (key.upArrow || key.downArrow || key.pageUp || key.pageDown) {
|
|
189
|
+
const step = key.pageUp || key.pageDown ? viewHeight : 1,
|
|
190
|
+
delta = key.upArrow || key.pageUp ? -step : step;
|
|
191
|
+
setLine((v) => Math.max(0, Math.min(lines.length - 1, v + delta)));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (key.leftArrow || key.rightArrow) {
|
|
195
|
+
setHorizontal((v) => Math.max(0, v + (key.leftArrow ? -8 : 8)));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (input === " " && selected.kind === "file") {
|
|
199
|
+
setAnchor((v) => (v === null ? line : null));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if ((input === "[" || input === "]") && selected.kind === "diff") {
|
|
203
|
+
setHunk((v) =>
|
|
204
|
+
Math.max(
|
|
205
|
+
-1,
|
|
206
|
+
Math.min(selected.hunks.length - 1, v + (input === "]" ? 1 : -1)),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
setLine(0);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (key.return) accept();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (key.escape) {
|
|
216
|
+
onCancel();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (loading) return;
|
|
220
|
+
if (key.upArrow || key.downArrow || key.pageUp || key.pageDown) {
|
|
221
|
+
const step = key.pageUp || key.pageDown ? viewHeight : 1,
|
|
222
|
+
delta = key.upArrow || key.pageUp ? -step : step;
|
|
223
|
+
setIndex((v) => Math.max(0, Math.min(items.length - 1, v + delta)));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (key.return) {
|
|
227
|
+
choose();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (key.leftArrow || key.backspace) {
|
|
231
|
+
setFolder(folder === "." ? "." : dirname(folder));
|
|
232
|
+
clearSearch();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (input === "/") {
|
|
236
|
+
setSearching(true);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (input === "g") {
|
|
240
|
+
setMode((v) => (v === "files" ? "changes" : "files"));
|
|
241
|
+
clearSearch();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (input === "t" && mode === "changes") {
|
|
245
|
+
setStaged((v) => !v);
|
|
246
|
+
clearSearch();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (input === "h" && mode === "files") setHidden((v) => !v);
|
|
250
|
+
});
|
|
251
|
+
function row(text, color = c.text, key, bg) {
|
|
252
|
+
return h(
|
|
253
|
+
Box,
|
|
254
|
+
{ key, width: "100%", backgroundColor: bg },
|
|
255
|
+
h(
|
|
256
|
+
Text,
|
|
257
|
+
{ color, wrap: "truncate" },
|
|
258
|
+
clip(
|
|
259
|
+
text,
|
|
260
|
+
(selected && (!split || key?.startsWith?.("code"))
|
|
261
|
+
? previewWidth
|
|
262
|
+
: listWidth) - 4,
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const visible = viewHeight,
|
|
268
|
+
first = Math.max(0, index - visible + 1);
|
|
269
|
+
const browser = h(
|
|
270
|
+
Box,
|
|
271
|
+
{
|
|
272
|
+
width: listWidth,
|
|
273
|
+
height: panelHeight,
|
|
274
|
+
key: "browser",
|
|
275
|
+
flexDirection: "column",
|
|
276
|
+
borderStyle: "round",
|
|
277
|
+
borderColor: c.purple,
|
|
278
|
+
paddingX: 1,
|
|
279
|
+
overflow: "hidden",
|
|
280
|
+
},
|
|
281
|
+
row("PROJECT · " + basename(root), c.purple, "title"),
|
|
282
|
+
row(
|
|
283
|
+
mode === "files"
|
|
284
|
+
? "Files · " + folder
|
|
285
|
+
: "Git changes · " + (staged ? "staged" : "unstaged"),
|
|
286
|
+
c.blue,
|
|
287
|
+
"mode",
|
|
288
|
+
),
|
|
289
|
+
row(
|
|
290
|
+
searching ? "Find: " + query + "▏" : query ? "Filter: " + query : " ",
|
|
291
|
+
c.muted,
|
|
292
|
+
"query",
|
|
293
|
+
),
|
|
294
|
+
h(
|
|
295
|
+
Box,
|
|
296
|
+
{ height: visible, flexDirection: "column", overflow: "hidden" },
|
|
297
|
+
...(items.length
|
|
298
|
+
? items
|
|
299
|
+
.slice(first, first + visible)
|
|
300
|
+
.map((item, i) =>
|
|
301
|
+
row(
|
|
302
|
+
(first + i === index ? "› " : " ") +
|
|
303
|
+
(item.directory ? "▸ " : "") +
|
|
304
|
+
item.name +
|
|
305
|
+
(mode === "changes"
|
|
306
|
+
? ` +${item.added} −${item.removed}`
|
|
307
|
+
: ""),
|
|
308
|
+
first + i === index ? c.blue : c.text,
|
|
309
|
+
"entry" + i,
|
|
310
|
+
first + i === index ? c.selected : undefined,
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
: [
|
|
314
|
+
row(
|
|
315
|
+
loading ? "Reading project…" : "No matching files or changes.",
|
|
316
|
+
c.muted,
|
|
317
|
+
"empty",
|
|
318
|
+
),
|
|
319
|
+
]),
|
|
320
|
+
),
|
|
321
|
+
row(
|
|
322
|
+
`${Math.min(index + 1, items.length)} / ${items.length}`,
|
|
323
|
+
c.muted,
|
|
324
|
+
"count",
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
const start = Math.max(
|
|
328
|
+
0,
|
|
329
|
+
Math.min(line - Math.floor(viewHeight / 2), lines.length - viewHeight),
|
|
330
|
+
),
|
|
331
|
+
from = Math.min(anchor ?? line, line),
|
|
332
|
+
to = Math.max(anchor ?? line, line);
|
|
333
|
+
const preview = selected
|
|
334
|
+
? h(
|
|
335
|
+
Box,
|
|
336
|
+
{
|
|
337
|
+
width: previewWidth,
|
|
338
|
+
height: panelHeight,
|
|
339
|
+
key: "preview",
|
|
340
|
+
flexDirection: "column",
|
|
341
|
+
borderStyle: "round",
|
|
342
|
+
borderColor: c.blue,
|
|
343
|
+
paddingX: 1,
|
|
344
|
+
overflow: "hidden",
|
|
345
|
+
},
|
|
346
|
+
row(selected.path, c.purple, "code-title"),
|
|
347
|
+
row(
|
|
348
|
+
selected.kind === "diff"
|
|
349
|
+
? (staged ? "Staged" : "Unstaged") +
|
|
350
|
+
" · " +
|
|
351
|
+
(hunk < 0
|
|
352
|
+
? "All hunks"
|
|
353
|
+
: `Hunk ${hunk + 1}/${selected.hunks.length}`)
|
|
354
|
+
: anchor === null
|
|
355
|
+
? "Whole file · Space selects a range"
|
|
356
|
+
: `Selected lines ${from + 1}–${to + 1}`,
|
|
357
|
+
c.blue,
|
|
358
|
+
"code-mode",
|
|
359
|
+
),
|
|
360
|
+
row(" ", c.muted, "code-blank"),
|
|
361
|
+
h(
|
|
362
|
+
Box,
|
|
363
|
+
{ height: viewHeight, flexDirection: "column", overflow: "hidden" },
|
|
364
|
+
...lines.slice(start, start + viewHeight).map((text, i) => {
|
|
365
|
+
const n = start + i,
|
|
366
|
+
highlight =
|
|
367
|
+
n === line || (anchor !== null && n >= from && n <= to),
|
|
368
|
+
color =
|
|
369
|
+
selected.kind === "diff"
|
|
370
|
+
? text.startsWith("+")
|
|
371
|
+
? c.green
|
|
372
|
+
: text.startsWith("-")
|
|
373
|
+
? c.red
|
|
374
|
+
: c.text
|
|
375
|
+
: c.text;
|
|
376
|
+
return row(
|
|
377
|
+
(n === line ? "›" : " ") +
|
|
378
|
+
String(n + 1).padStart(4) +
|
|
379
|
+
" " +
|
|
380
|
+
graphemes(text).slice(horizontal).join(""),
|
|
381
|
+
color,
|
|
382
|
+
"code" + i,
|
|
383
|
+
highlight ? c.selected : undefined,
|
|
384
|
+
);
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
row(
|
|
388
|
+
`${line + 1}/${lines.length} · Enter uses selection`,
|
|
389
|
+
c.muted,
|
|
390
|
+
"code-footer",
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
: null;
|
|
394
|
+
return h(
|
|
395
|
+
Box,
|
|
396
|
+
{
|
|
397
|
+
flexDirection: "column",
|
|
398
|
+
height,
|
|
399
|
+
overflow: "hidden",
|
|
400
|
+
backgroundColor: c.bg,
|
|
401
|
+
},
|
|
402
|
+
h(
|
|
403
|
+
Box,
|
|
404
|
+
{ height: height - 2, flexDirection: "row", gap: split ? 1 : 0 },
|
|
405
|
+
selected ? (split ? [browser, preview] : preview) : browser,
|
|
406
|
+
),
|
|
407
|
+
h(
|
|
408
|
+
Text,
|
|
409
|
+
{ color: error ? c.red : c.muted, wrap: "truncate" },
|
|
410
|
+
clip(
|
|
411
|
+
error ||
|
|
412
|
+
(selected
|
|
413
|
+
? selected.kind === "file"
|
|
414
|
+
? "↑↓ move · Space start/reset range · Enter use · Esc files"
|
|
415
|
+
: "[ ] all / individual hunks · Enter use · Esc changes"
|
|
416
|
+
: "G files / git diff · T staged / unstaged · H hidden · / filter · Esc cancel"),
|
|
417
|
+
width,
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
}
|
package/lib/tui-data.mjs
CHANGED
|
@@ -4,6 +4,7 @@ export const navigation = [
|
|
|
4
4
|
["open", "Open conversation", "Jump to a post"],
|
|
5
5
|
["ask", "Ask a question", "Find a way forward"],
|
|
6
6
|
["review", "Request code review", "Another pair of eyes"],
|
|
7
|
+
["drafts", "Local drafts", "Resume your writing"],
|
|
7
8
|
["communities", "Communities", "Your private spaces"],
|
|
8
9
|
["search", "Search", "Find a conversation"],
|
|
9
10
|
["devices", "Connected terminals", "Manage CLI access"],
|
|
@@ -154,6 +155,8 @@ export function contentLines(route, data) {
|
|
|
154
155
|
"O Open this conversation in your browser",
|
|
155
156
|
"/ Focus the command / search bar",
|
|
156
157
|
"Ctrl+S Preview the current form",
|
|
158
|
+
"Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
|
|
159
|
+
"/drafts Restore locally saved drafts for this account/project",
|
|
157
160
|
"Ctrl+C Exit and restore the original terminal",
|
|
158
161
|
"",
|
|
159
162
|
"Commands: /feed /inbox /search words /open 142",
|
package/lib/tui-model.mjs
CHANGED
|
@@ -128,6 +128,7 @@ export function parseCommand(text) {
|
|
|
128
128
|
"review",
|
|
129
129
|
"help",
|
|
130
130
|
"devices",
|
|
131
|
+
"drafts",
|
|
131
132
|
].includes(cmd)
|
|
132
133
|
)
|
|
133
134
|
return { command: cmd, arg };
|
|
@@ -139,7 +140,7 @@ export function parseCommand(text) {
|
|
|
139
140
|
return { command: cmd, arg: Number(arg) };
|
|
140
141
|
if (["quit", "logout"].includes(cmd)) return { command: cmd };
|
|
141
142
|
throw Error(
|
|
142
|
-
"Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /help or /quit.",
|
|
143
|
+
"Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /help or /quit.",
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
export class PageCache {
|