@tenyokjs/devdevian-cli 0.2.0-alpha.1 → 0.3.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tui.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import React, { useState, useEffect, useMemo, useRef } from "react";
2
2
  import { render, Box, Text, useApp, useInput, useWindowSize } from "ink";
3
- import { readFile, stat } from "node:fs/promises";
3
+ import { randomBytes } from "node:crypto";
4
4
  import { homedir, hostname } from "node:os";
5
- import { resolve } from "node:path";
5
+ import { workspaceRoot, reviewSource } from "./workspace.mjs";
6
+ import { WorkspacePicker } from "./picker.mjs";
7
+ import { DraftStore, draftIdentity } from "./drafts.mjs";
6
8
  import { spawn } from "node:child_process";
7
9
  import { setTimeout as delay } from "node:timers/promises";
8
10
  import {
@@ -98,17 +100,21 @@ function fieldsFor(form) {
98
100
  ? [
99
101
  {
100
102
  key: "file",
101
- label: "Code file",
103
+ label: form.source?.kind === "diff" ? "Git diff" : "Code file",
102
104
  min: 1,
103
105
  max: 1500,
104
- hint: "Explicit file path; no automatic project collection.",
105
- },
106
- {
107
- key: "lines",
108
- label: "File lines (optional)",
109
- max: 30,
110
- hint: "START:END; published snippet line numbers start at 1.",
106
+ hint: "Ctrl+O file explorer · Ctrl+G Git diff · Enter browses an empty field.",
111
107
  },
108
+ ...(form.source?.kind === "diff"
109
+ ? []
110
+ : [
111
+ {
112
+ key: "lines",
113
+ label: "File lines (optional)",
114
+ max: 30,
115
+ hint: "START:END; published snippet line numbers start at 1.",
116
+ },
117
+ ]),
112
118
  {
113
119
  key: "review_focus",
114
120
  label: "Review focus",
@@ -283,7 +289,15 @@ function listRows(route, data, index, width) {
283
289
  let title = item.title || item.name || "",
284
290
  meta = "",
285
291
  body = "";
286
- if (route.view === "inbox") {
292
+ if (route.view === "drafts") {
293
+ title = item.form.values.title || item.form.title;
294
+ meta = "Local draft · " + stamp(item.savedAt);
295
+ body =
296
+ item.form.values.body ||
297
+ item.form.values.description ||
298
+ item.form.values.file ||
299
+ "";
300
+ } else if (route.view === "inbox") {
287
301
  title =
288
302
  item.actor_name +
289
303
  " · " +
@@ -319,8 +333,14 @@ function listRows(route, data, index, width) {
319
333
  item.name +
320
334
  (item.entryType === "note"
321
335
  ? " · original remark"
322
- : " · reply #" + item.id);
323
- meta = stamp(item.created) + (item.edited_at ? " · edited" : "");
336
+ : item.entryType === "parent"
337
+ ? " · parent reply"
338
+ : " · reply #" + item.id);
339
+ meta =
340
+ stamp(item.created) +
341
+ ((item.edited_at || item.updated_at) && !item.deleted_at
342
+ ? " · edited"
343
+ : "");
324
344
  body = item.deleted_at ? "[deleted — discussion preserved]" : item.body;
325
345
  } else {
326
346
  meta = [
@@ -350,7 +370,7 @@ function listRows(route, data, index, width) {
350
370
  ];
351
371
  });
352
372
  }
353
- function App({ initialConfig, flags }) {
373
+ function App({ initialConfig, flags, root }) {
354
374
  const { exit } = useApp(),
355
375
  { columns, rows } = useWindowSize();
356
376
  const [config, setConfig] = useState(initialConfig),
@@ -380,12 +400,24 @@ function App({ initialConfig, flags }) {
380
400
  [yes, setYes] = useState(false),
381
401
  [pending, setPending] = useState(false),
382
402
  [tick, setTick] = useState(0),
383
- [reading, setReading] = useState(null);
403
+ [reading, setReading] = useState(null),
404
+ [picker, setPicker] = useState(null),
405
+ [draftSaved, setDraftSaved] = useState(false);
384
406
  const cache = useRef(new PageCache()),
385
407
  history = useRef([]),
386
408
  busy = useRef(false),
387
409
  alive = useRef(true),
388
- authController = useRef(null);
410
+ authController = useRef(null),
411
+ draftQueue = useRef(Promise.resolve()),
412
+ draftTimer = useRef(null),
413
+ draftGeneration = useRef(0);
414
+ const drafts = useMemo(
415
+ () =>
416
+ config && user
417
+ ? new DraftStore({ origin: config.origin, userId: user.id, root })
418
+ : null,
419
+ [config?.origin, user?.id, root],
420
+ );
389
421
  const api = useMemo(
390
422
  () => (config ? client(config.origin, config.token) : null),
391
423
  [config?.origin, config?.token],
@@ -396,7 +428,7 @@ function App({ initialConfig, flags }) {
396
428
  sidebar = compact ? 0 : 25,
397
429
  bodyHeight = Math.max(1, height - 9),
398
430
  contentWidth = Math.max(1, width - sidebar - 6),
399
- contentHeight = Math.max(1, bodyHeight - 5);
431
+ contentHeight = Math.max(1, bodyHeight - 6);
400
432
  const key = JSON.stringify(route),
401
433
  current = data?.key === key ? data : null;
402
434
  const selected = current?.items?.[index];
@@ -405,6 +437,7 @@ function App({ initialConfig, flags }) {
405
437
  history.current = [];
406
438
  setData(null);
407
439
  setForm(null);
440
+ setPicker(null);
408
441
  setPreview(null);
409
442
  setDevice(null);
410
443
  setUser(null);
@@ -462,6 +495,79 @@ function App({ initialConfig, flags }) {
462
495
  setRefresh((n) => n + 1);
463
496
  if (message) setNotice(message);
464
497
  }
498
+ function queueDraft(work) {
499
+ const result = draftQueue.current.catch(() => {}).then(work);
500
+ draftQueue.current = result;
501
+ return result;
502
+ }
503
+ async function forgetDraft(value) {
504
+ clearTimeout(draftTimer.current);
505
+ cache.current.clear();
506
+ if (drafts && value)
507
+ await queueDraft(() => drafts.remove(draftIdentity(value)));
508
+ }
509
+ async function restoreDraft(item) {
510
+ await task(async () => {
511
+ const saved = await drafts.read(item.id),
512
+ next = { ...saved.form, draftId: item.id };
513
+ if (next.kind === "publication") {
514
+ const [topics, communities] = await Promise.all([
515
+ api("/topics"),
516
+ api("/communities?joined=1&limit=50"),
517
+ ]);
518
+ next.topics = itemsOf(topics).map((t) => [t.id, t.name]);
519
+ next.audiences = [
520
+ ["", "Public feed · everyone"],
521
+ ...itemsOf(communities).map((c) => [c.id, "Private · " + c.name]),
522
+ ];
523
+ if (
524
+ next.values.community_id &&
525
+ !next.audiences.some(
526
+ (c) => String(c[0]) === String(next.values.community_id),
527
+ )
528
+ ) {
529
+ let space;
530
+ try {
531
+ space = await api(
532
+ "/communities/" + encodeURIComponent(next.values.community_id),
533
+ );
534
+ } catch (e) {
535
+ if (![403, 404].includes(e.status)) throw e;
536
+ }
537
+ if (space?.is_member)
538
+ next.audiences.push([space.id, "Private · " + space.name]);
539
+ else {
540
+ next.unavailableAudience = next.values.community_id;
541
+ next.audiences.push([
542
+ next.values.community_id,
543
+ "Unavailable private audience · choose another",
544
+ ]);
545
+ }
546
+ }
547
+ }
548
+ openForm(next);
549
+ setNotice(
550
+ "Restored local draft. Check the code selection before publishing.",
551
+ );
552
+ });
553
+ }
554
+ useEffect(() => {
555
+ const generation = ++draftGeneration.current;
556
+ if (!drafts || !form) return;
557
+ setDraftSaved(false);
558
+ draftTimer.current = setTimeout(() => {
559
+ queueDraft(() => drafts.save(form))
560
+ .then((id) => {
561
+ if (id && alive.current && generation === draftGeneration.current)
562
+ setDraftSaved(true);
563
+ })
564
+ .catch((e) => {
565
+ if (alive.current)
566
+ setError("Could not save local draft: " + clean(e.message));
567
+ });
568
+ }, 450);
569
+ return () => clearTimeout(draftTimer.current);
570
+ }, [drafts, form]);
465
571
  function askConfirm(title, description, action) {
466
572
  setYes(false);
467
573
  setDialog({ title, description, action });
@@ -505,7 +611,15 @@ function App({ initialConfig, flags }) {
505
611
  setLoading(!cached);
506
612
  setError("");
507
613
  if (!cached)
508
- loadPage(api, route, controller.signal)
614
+ (route.view === "drafts"
615
+ ? drafts.list().then((items) => ({
616
+ items,
617
+ title: "Local drafts",
618
+ subtitle:
619
+ "Private to this account and project · Enter restore · D delete",
620
+ }))
621
+ : loadPage(api, route, controller.signal)
622
+ )
509
623
  .then((result) => {
510
624
  if (!active) return;
511
625
  cache.current.set(key, result);
@@ -605,7 +719,11 @@ function App({ initialConfig, flags }) {
605
719
  }
606
720
  }
607
721
  function openForm(next) {
608
- setForm(next);
722
+ setForm({
723
+ ...next,
724
+ draftId: next.draftId || randomBytes(32).toString("hex"),
725
+ });
726
+ setDraftSaved(false);
609
727
  setField(0);
610
728
  setCursor(graphemes(next.values[fieldsFor(next)[0].key] || "").length);
611
729
  setPreview(null);
@@ -704,6 +822,13 @@ function App({ initialConfig, flags }) {
704
822
  async function prepare() {
705
823
  if (!form) return;
706
824
  await task(async () => {
825
+ if (
826
+ form.unavailableAudience &&
827
+ String(form.values.community_id) === String(form.unavailableAudience)
828
+ )
829
+ throw Error(
830
+ "Choose an available audience explicitly before publishing this draft.",
831
+ );
707
832
  const fields = fieldsFor(form);
708
833
  for (let i = 0; i < fields.length; i++) {
709
834
  const f = fields[i],
@@ -736,36 +861,31 @@ function App({ initialConfig, flags }) {
736
861
  };
737
862
  url = "/posts";
738
863
  if (payload.kind === "review") {
739
- const filename = resolve(
740
- form.values.file.replace(/^~(?=\/)/, homedir()),
741
- ),
742
- info = await stat(filename);
743
- if (!info.isFile() || info.size > 80000)
744
- throw Error(
745
- "Choose a UTF-8 code file of at most 20,000 characters.",
746
- );
747
- let code = await readFile(filename, "utf8");
748
- if (
749
- code.length > 20000 ||
750
- code.includes("\u0000") ||
751
- code.includes("\ufffd")
752
- )
753
- throw Error(
754
- "Choose a UTF-8 code file of at most 20,000 characters.",
755
- );
756
- if (form.values.lines) {
757
- const match = form.values.lines.match(/^([1-9]\d*):([1-9]\d*)$/);
758
- if (!match) throw Error("Use START:END for file lines.");
759
- const first = Number(match[1]),
760
- last = Number(match[2]),
761
- lines = code.split("\n");
762
- if (last < first || last > lines.length)
763
- throw Error("The range is outside the selected file.");
764
- code = lines.slice(first - 1, last).join("\n");
864
+ let source = form.source;
865
+ if (source?.kind !== "diff") {
866
+ source = {
867
+ kind: "file",
868
+ path: form.values.file.replace(/^~(?=\/)/, homedir()),
869
+ };
870
+ if (form.values.lines) {
871
+ const match = form.values.lines.match(/^([1-9]\d*):([1-9]\d*)$/);
872
+ if (!match) throw Error("Use START:END for file lines.");
873
+ source.start = Number(match[1]);
874
+ source.end = Number(match[2]);
875
+ }
876
+ }
877
+ const chosen = await reviewSource(root, source);
878
+ payload.code = chosen.code;
879
+ if (chosen.kind === "diff") {
880
+ payload.body +=
881
+ "\n\nReview source: " +
882
+ clean(chosen.label).replace(/\n/g, " ") +
883
+ "\nReview line numbers refer to the published patch, including its headers.";
884
+ if (payload.body.length > 15000)
885
+ throw Error(
886
+ "Shorten the context to leave room for the Git diff source label.",
887
+ );
765
888
  }
766
- if (code.trim().length < 10)
767
- throw Error("The selected snippet needs at least 10 characters.");
768
- payload.code = code;
769
889
  payload.review_focus = form.values.review_focus;
770
890
  }
771
891
  if (payload.kind === "showcase")
@@ -823,10 +943,16 @@ function App({ initialConfig, flags }) {
823
943
  body: preview.payload,
824
944
  });
825
945
  cache.current.clear();
946
+ let localWarning = "";
947
+ try {
948
+ await forgetDraft(form);
949
+ } catch {
950
+ localWarning =
951
+ " Published successfully; the local draft could not be removed. Do not publish it again.";
952
+ }
826
953
  setPreview(null);
827
954
  setForm(null);
828
955
  setScroll(0);
829
- setNotice("Saved. Your web account sees the same update.");
830
956
  if (preview.url === "/posts")
831
957
  navigate({ view: "post", id: response.id, tab: "overview", page: 1 });
832
958
  else if (preview.url === "/communities")
@@ -841,12 +967,17 @@ function App({ initialConfig, flags }) {
841
967
  page: 1,
842
968
  });
843
969
  }
970
+ setNotice("Saved. Your web account sees the same update." + localWarning);
844
971
  });
845
972
  }
846
973
  async function openSelected() {
847
974
  if (route.view === "community" && !current?.community?.is_member) return;
848
975
  const item = selected;
849
976
  if (!item) return;
977
+ if (route.view === "drafts") {
978
+ await restoreDraft(item);
979
+ return;
980
+ }
850
981
  if (route.view === "devices") {
851
982
  askConfirm("Revoke this terminal?", item.name, () =>
852
983
  task(async () => {
@@ -910,6 +1041,11 @@ function App({ initialConfig, flags }) {
910
1041
  navigate({ view: "feedback", postId: route.id, id: item.id, page: 1 });
911
1042
  return;
912
1043
  }
1044
+ if (route.view === "thread" && item.entryType === "parent") {
1045
+ setReading(item);
1046
+ setScroll(0);
1047
+ return;
1048
+ }
913
1049
  if (
914
1050
  (route.view === "post" && route.tab === "replies") ||
915
1051
  route.view === "thread"
@@ -978,25 +1114,43 @@ function App({ initialConfig, flags }) {
978
1114
  });
979
1115
  }
980
1116
  function deleteSelected() {
1117
+ if (route.view === "drafts" && selected) {
1118
+ askConfirm(
1119
+ "Delete saved draft?",
1120
+ "Only this local draft will be removed.",
1121
+ () =>
1122
+ task(async () => {
1123
+ await queueDraft(() => drafts.remove(selected.id));
1124
+ invalidate("Draft deleted.");
1125
+ }),
1126
+ );
1127
+ return;
1128
+ }
981
1129
  const target = editable();
982
- if (
983
- !target?.feedback ||
984
- target.row.user_id !== user.id ||
985
- target.row.deleted_at
986
- ) {
987
- setError("Select your own line feedback or feedback reply to delete.");
1130
+ if (!target || target.row.user_id !== user.id || target.row.deleted_at) {
1131
+ setError(
1132
+ "Select your own reply or code feedback in the Replies / Code feedback tab to delete.",
1133
+ );
988
1134
  return;
989
1135
  }
990
1136
  askConfirm(
991
- "Delete your feedback?",
992
- "Your text is removed. Other members’ replies are preserved.",
1137
+ target.feedback ? "Delete your feedback?" : "Delete your reply?",
1138
+ "Your text is removed. Other members’ replies are preserved." +
1139
+ (!target.feedback && current.post?.accepted_reply_id === target.row.id
1140
+ ? " Its accepted-solution mark is cleared."
1141
+ : ""),
993
1142
  () =>
994
1143
  task(async () => {
995
1144
  await api(target.url, {
996
1145
  method: "DELETE",
997
1146
  body: { revision: target.row.revision },
998
1147
  });
999
- invalidate("Feedback deleted; the discussion is preserved.");
1148
+ setReading(null);
1149
+ invalidate(
1150
+ target.feedback
1151
+ ? "Feedback deleted; the discussion is preserved."
1152
+ : "Reply deleted; other members’ replies are preserved.",
1153
+ );
1000
1154
  }),
1001
1155
  );
1002
1156
  }
@@ -1074,8 +1228,13 @@ function App({ initialConfig, flags }) {
1074
1228
  if (form) {
1075
1229
  askConfirm(
1076
1230
  "Leave this draft?",
1077
- "The unsent draft lives only in this terminal session.",
1078
- () => exit(),
1231
+ "Save this draft locally and exit? Resume it from Local drafts in this project.",
1232
+ () =>
1233
+ task(async () => {
1234
+ clearTimeout(draftTimer.current);
1235
+ if (drafts) await queueDraft(() => drafts.save(form));
1236
+ exit();
1237
+ }),
1079
1238
  );
1080
1239
  } else exit();
1081
1240
  }
@@ -1090,6 +1249,7 @@ function App({ initialConfig, flags }) {
1090
1249
  );
1091
1250
  }
1092
1251
  useInput((input, keyEvent) => {
1252
+ if (picker) return;
1093
1253
  if (keyEvent.ctrl && input === "c") {
1094
1254
  if (dialog) {
1095
1255
  exit();
@@ -1155,9 +1315,11 @@ function App({ initialConfig, flags }) {
1155
1315
  else if (input === "r") {
1156
1316
  setReading(null);
1157
1317
  reply();
1158
- } else if (input === "e") {
1318
+ } else if (input === "e" || input === "E") {
1159
1319
  setReading(null);
1160
1320
  editSelected();
1321
+ } else if (input === "d" || input === "D") {
1322
+ deleteSelected();
1161
1323
  }
1162
1324
  return;
1163
1325
  }
@@ -1183,15 +1345,26 @@ function App({ initialConfig, flags }) {
1183
1345
  return;
1184
1346
  }
1185
1347
  if (form) {
1348
+ if (
1349
+ keyEvent.ctrl &&
1350
+ ["o", "g"].includes(input) &&
1351
+ form.kind === "publication" &&
1352
+ form.values.kind === "review"
1353
+ ) {
1354
+ setPicker(input === "g" ? "changes" : "files");
1355
+ return;
1356
+ }
1186
1357
  if (keyEvent.escape) {
1187
1358
  askConfirm(
1188
1359
  "Discard this draft?",
1189
1360
  "Nothing has been published. Stay to keep editing.",
1190
- () => {
1191
- setForm(null);
1192
- setFocus("content");
1193
- setError("");
1194
- },
1361
+ () =>
1362
+ task(async () => {
1363
+ await forgetDraft(form);
1364
+ setForm(null);
1365
+ setFocus("content");
1366
+ setError("");
1367
+ }),
1195
1368
  );
1196
1369
  return;
1197
1370
  }
@@ -1236,6 +1409,10 @@ function App({ initialConfig, flags }) {
1236
1409
  return;
1237
1410
  }
1238
1411
  if (keyEvent.return && !active.multiline) {
1412
+ if (active.key === "file" && !form.values.file) {
1413
+ setPicker("files");
1414
+ return;
1415
+ }
1239
1416
  changeField(field + 1);
1240
1417
  return;
1241
1418
  }
@@ -1248,6 +1425,7 @@ function App({ initialConfig, flags }) {
1248
1425
  );
1249
1426
  setForm((old) => ({
1250
1427
  ...old,
1428
+ ...(active.key === "file" ? { source: null } : {}),
1251
1429
  values: { ...old.values, [active.key]: updated.value },
1252
1430
  }));
1253
1431
  setCursor(updated.cursor);
@@ -1406,7 +1584,7 @@ function App({ initialConfig, flags }) {
1406
1584
  editSelected();
1407
1585
  return;
1408
1586
  }
1409
- if (input === "d") {
1587
+ if (input === "d" || input === "D") {
1410
1588
  deleteSelected();
1411
1589
  return;
1412
1590
  }
@@ -1466,6 +1644,34 @@ function App({ initialConfig, flags }) {
1466
1644
  h(Text, null, "Enlarge this terminal to at least 60 × 18."),
1467
1645
  h(Text, null, "Q or Ctrl+C exits."),
1468
1646
  );
1647
+ else if (picker)
1648
+ center = h(WorkspacePicker, {
1649
+ root,
1650
+ width,
1651
+ height: bodyHeight,
1652
+ initial: picker,
1653
+ onCancel: () => setPicker(null),
1654
+ onSelect: (source) => {
1655
+ setForm((old) => ({
1656
+ ...old,
1657
+ source,
1658
+ values: {
1659
+ ...old.values,
1660
+ file: source.path,
1661
+ lines:
1662
+ source.kind === "file" && source.start
1663
+ ? source.start + ":" + source.end
1664
+ : "",
1665
+ },
1666
+ }));
1667
+ setPicker(null);
1668
+ setNotice(
1669
+ source.kind === "diff"
1670
+ ? "Git changes selected. Preview before publishing."
1671
+ : "File selected. Preview before publishing.",
1672
+ );
1673
+ },
1674
+ });
1469
1675
  else if (phase !== "ready") {
1470
1676
  const lines = [
1471
1677
  "A place for curious developers.",
@@ -1743,7 +1949,8 @@ function App({ initialConfig, flags }) {
1743
1949
  )
1744
1950
  : h(TextLine, {
1745
1951
  text: form
1746
- ? "Tab next field · arrows choose · Ctrl+S preview"
1952
+ ? "Tab next field · Ctrl+S preview" +
1953
+ (draftSaved ? " · Draft saved locally" : "")
1747
1954
  : preview
1748
1955
  ? "Scroll to review · ← → choose · Enter confirms"
1749
1956
  : current?.subtitle || " ",
@@ -1810,19 +2017,21 @@ function App({ initialConfig, flags }) {
1810
2017
  compact && focus === "nav" ? null : pane,
1811
2018
  );
1812
2019
  }
1813
- const hint = reading
1814
- ? "↑↓ scroll · R reply · E edit · Esc close"
1815
- : preview
1816
- ? "← → choose · Enter confirm · Esc edit"
1817
- : form
1818
- ? "Tab field · Enter newline · Ctrl+S preview · Esc back"
1819
- : phase !== "ready"
1820
- ? "Enter connect / open browser · Ctrl+C exit"
1821
- : route.view === "feedback"
1822
- ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
1823
- : route.view === "post"
1824
- ? "←→ tabs · ↑↓ scroll/select · R reply · C code remark · Space full text · Tab navigate"
1825
- : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2020
+ const hint = picker
2021
+ ? "Project files stay local until publication is confirmed"
2022
+ : reading
2023
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2024
+ : preview
2025
+ ? "← → choose · Enter confirm · Esc edit"
2026
+ : form
2027
+ ? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview · Esc discard"
2028
+ : phase !== "ready"
2029
+ ? "Enter connect / open browser · Ctrl+C exit"
2030
+ : route.view === "feedback"
2031
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2032
+ : route.view === "post"
2033
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2034
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
1826
2035
  return h(
1827
2036
  Box,
1828
2037
  {
@@ -1892,7 +2101,8 @@ export async function startTui(flags = {}) {
1892
2101
  let config = await loadConfig();
1893
2102
  if (flags.host && config && originOf(flags.host) !== config.origin)
1894
2103
  config = null;
1895
- const app = render(h(App, { initialConfig: config, flags }), {
2104
+ const root = await workspaceRoot(flags.project || process.cwd());
2105
+ const app = render(h(App, { initialConfig: config, flags, root }), {
1896
2106
  alternateScreen: true,
1897
2107
  exitOnCtrlC: false,
1898
2108
  patchConsole: true,