@tenyokjs/devdevian-cli 0.1.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/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
+ }
@@ -0,0 +1,202 @@
1
+ export const navigation = [
2
+ ["inbox", "Inbox", "Replies & mentions"],
3
+ ["feed", "Public feed", "Developer commons"],
4
+ ["open", "Open conversation", "Jump to a post"],
5
+ ["ask", "Ask a question", "Find a way forward"],
6
+ ["review", "Request code review", "Another pair of eyes"],
7
+ ["drafts", "Local drafts", "Resume your writing"],
8
+ ["communities", "Communities", "Your private spaces"],
9
+ ["search", "Search", "Find a conversation"],
10
+ ["devices", "Connected terminals", "Manage CLI access"],
11
+ ["help", "Help", "Keys & commands"],
12
+ ];
13
+ export async function loadPage(api, route, signal) {
14
+ const get = (path) => api(path, { signal }),
15
+ page = route.page || 1;
16
+ if (route.view === "feed")
17
+ return {
18
+ ...(await get("/posts?limit=12&page=" + page)),
19
+ title: "Public feed",
20
+ subtitle: "Questions, ideas and code worth discussing.",
21
+ };
22
+ if (route.view === "inbox")
23
+ return {
24
+ ...(await get("/notifications?page=" + page)),
25
+ title: "Inbox",
26
+ subtitle: "Your replies, mentions and review updates.",
27
+ };
28
+ if (route.view === "communities")
29
+ return {
30
+ ...(await get("/communities?limit=12&page=" + page)),
31
+ title: "Communities",
32
+ subtitle: "Private spaces, shared context. Enter opens a community.",
33
+ };
34
+ if (route.view === "search")
35
+ return {
36
+ ...(await get(
37
+ "/search?type=" +
38
+ (route.type || "posts") +
39
+ "&q=" +
40
+ encodeURIComponent(route.query || "") +
41
+ "&page=" +
42
+ page,
43
+ )),
44
+ title: "Search",
45
+ subtitle: route.query
46
+ ? "Results for “" + route.query + "”"
47
+ : "Press / and type /search followed by your query.",
48
+ };
49
+ if (route.view === "devices")
50
+ return {
51
+ ...(await get("/cli/sessions")),
52
+ title: "Connected terminals",
53
+ subtitle: "Enter to revoke a terminal. Revoking this one signs it out.",
54
+ };
55
+ if (route.view === "community") {
56
+ const community = await get("/communities/" + encodeURIComponent(route.id));
57
+ const posts = community.is_member
58
+ ? await get(
59
+ "/posts?limit=12&community=" +
60
+ encodeURIComponent(route.id) +
61
+ "&page=" +
62
+ page,
63
+ )
64
+ : { items: [] };
65
+ return {
66
+ ...posts,
67
+ community,
68
+ title: community.name,
69
+ subtitle: community.is_member
70
+ ? "Members-only conversations. N creates a conversation here."
71
+ : community.requested
72
+ ? "Your access request is pending."
73
+ : "Private community. J requests access.",
74
+ };
75
+ }
76
+ if (route.view === "post") {
77
+ const post = await get("/posts/" + route.id + "?reply_page=" + page);
78
+ let items = post.replies,
79
+ pagination = post.reply_pagination;
80
+ if (route.tab === "feedback") {
81
+ const feedback = await get(
82
+ "/posts/" + route.id + "/code-reviews?page=" + page,
83
+ );
84
+ items = feedback.items;
85
+ pagination = feedback.pagination;
86
+ }
87
+ return {
88
+ post,
89
+ items,
90
+ pagination,
91
+ title: post.title,
92
+ subtitle:
93
+ (post.community_id ? "Private community" : "Public feed") +
94
+ " · " +
95
+ post.kind +
96
+ " · by " +
97
+ post.name,
98
+ };
99
+ }
100
+ if (route.view === "thread") {
101
+ const post = await get("/posts/" + route.postId),
102
+ replies = await get(
103
+ `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
104
+ );
105
+ return {
106
+ post,
107
+ ...replies,
108
+ title: "Reply thread",
109
+ subtitle: "Replying to " + route.parent.name,
110
+ };
111
+ }
112
+ if (route.view === "feedback") {
113
+ const [post, notes] = await Promise.all([
114
+ get("/posts/" + route.postId),
115
+ get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
116
+ ]);
117
+ const note = notes.items.find((item) => item.id === Number(route.id));
118
+ if (!note) throw Error("This feedback is no longer available.");
119
+ const replies = await get(
120
+ `/posts/${route.postId}/code-reviews/${route.id}/replies?page=${page}${route.targetReply ? "&target_id=" + route.targetReply : ""}`,
121
+ );
122
+ return {
123
+ post,
124
+ note,
125
+ items: [
126
+ { ...note, entryType: "note" },
127
+ ...replies.items.map((item) => ({
128
+ ...item,
129
+ entryType: "feedbackReply",
130
+ })),
131
+ ],
132
+ pagination: replies.pagination,
133
+ title: "Code feedback",
134
+ subtitle: `Lines ${note.start_line}–${note.end_line} · ${note.resolved ? "resolved" : "open"}`,
135
+ };
136
+ }
137
+ return { items: [], title: "Your workspace", subtitle: "" };
138
+ }
139
+ export function contentLines(route, data) {
140
+ if (route.view === "help")
141
+ return [
142
+ "A developer workspace, in your terminal.",
143
+ "",
144
+ "Tab Switch navigation / content / command bar",
145
+ "↑ ↓ Select a section, conversation or reply",
146
+ "Enter Open the selected item",
147
+ "Esc Back / leave an input",
148
+ "← → Switch conversation tabs",
149
+ "Page Up / Down Scroll a conversation or list",
150
+ "N / A New conversation / ask a question",
151
+ "R Reply in a conversation; refresh a list",
152
+ "C Add feedback on code lines",
153
+ "S Resolve / reopen selected code feedback",
154
+ "E / D Edit / delete your own feedback or reply",
155
+ "O Open this conversation in your browser",
156
+ "/ Focus the command / search bar",
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",
160
+ "Ctrl+C Exit and restore the original terminal",
161
+ "",
162
+ "Commands: /feed /inbox /search words /open 142",
163
+ " /ask /review /communities /devices /logout /quit",
164
+ "",
165
+ "Text fields: arrows move the caret; Home/End move within a line.",
166
+ "Multiline fields: Enter adds a line; Tab changes field.",
167
+ "Nothing is published until you confirm the preview.",
168
+ "A -> Z: your browser account and this terminal use the same data.",
169
+ ];
170
+ if (route.view === "community" && !data.community?.is_member)
171
+ return [
172
+ data.community?.description || "",
173
+ "",
174
+ data.community?.requested
175
+ ? "Access requested. The owner or validators will review it."
176
+ : "Press J to request access.",
177
+ ];
178
+ if (route.view === "post" && (!route.tab || route.tab === "overview")) {
179
+ const p = data.post;
180
+ if (!p) return [];
181
+ const lines = [p.body || ""];
182
+ if (p.review_focus) lines.push("", "REVIEW FOCUS", p.review_focus);
183
+ if (p.code)
184
+ lines.push(
185
+ "",
186
+ "CODE SNIPPET",
187
+ ...p.code
188
+ .split("\n")
189
+ .map((line, index) => String(index + 1).padStart(4) + " " + line),
190
+ );
191
+ if (p.links?.length)
192
+ lines.push(
193
+ "",
194
+ "LINKS",
195
+ ...p.links.map((link) => link.label + " · " + link.url),
196
+ );
197
+ if (p.archived_at || p.locked_at)
198
+ lines.push("", "Replies are paused for this conversation.");
199
+ return lines;
200
+ }
201
+ return [];
202
+ }