@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/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
  " · " +
@@ -350,7 +364,7 @@ function listRows(route, data, index, width) {
350
364
  ];
351
365
  });
352
366
  }
353
- function App({ initialConfig, flags }) {
367
+ function App({ initialConfig, flags, root }) {
354
368
  const { exit } = useApp(),
355
369
  { columns, rows } = useWindowSize();
356
370
  const [config, setConfig] = useState(initialConfig),
@@ -380,12 +394,24 @@ function App({ initialConfig, flags }) {
380
394
  [yes, setYes] = useState(false),
381
395
  [pending, setPending] = useState(false),
382
396
  [tick, setTick] = useState(0),
383
- [reading, setReading] = useState(null);
397
+ [reading, setReading] = useState(null),
398
+ [picker, setPicker] = useState(null),
399
+ [draftSaved, setDraftSaved] = useState(false);
384
400
  const cache = useRef(new PageCache()),
385
401
  history = useRef([]),
386
402
  busy = useRef(false),
387
403
  alive = useRef(true),
388
- authController = useRef(null);
404
+ authController = useRef(null),
405
+ draftQueue = useRef(Promise.resolve()),
406
+ draftTimer = useRef(null),
407
+ draftGeneration = useRef(0);
408
+ const drafts = useMemo(
409
+ () =>
410
+ config && user
411
+ ? new DraftStore({ origin: config.origin, userId: user.id, root })
412
+ : null,
413
+ [config?.origin, user?.id, root],
414
+ );
389
415
  const api = useMemo(
390
416
  () => (config ? client(config.origin, config.token) : null),
391
417
  [config?.origin, config?.token],
@@ -405,6 +431,7 @@ function App({ initialConfig, flags }) {
405
431
  history.current = [];
406
432
  setData(null);
407
433
  setForm(null);
434
+ setPicker(null);
408
435
  setPreview(null);
409
436
  setDevice(null);
410
437
  setUser(null);
@@ -462,6 +489,79 @@ function App({ initialConfig, flags }) {
462
489
  setRefresh((n) => n + 1);
463
490
  if (message) setNotice(message);
464
491
  }
492
+ function queueDraft(work) {
493
+ const result = draftQueue.current.catch(() => {}).then(work);
494
+ draftQueue.current = result;
495
+ return result;
496
+ }
497
+ async function forgetDraft(value) {
498
+ clearTimeout(draftTimer.current);
499
+ cache.current.clear();
500
+ if (drafts && value)
501
+ await queueDraft(() => drafts.remove(draftIdentity(value)));
502
+ }
503
+ async function restoreDraft(item) {
504
+ await task(async () => {
505
+ const saved = await drafts.read(item.id),
506
+ next = { ...saved.form, draftId: item.id };
507
+ if (next.kind === "publication") {
508
+ const [topics, communities] = await Promise.all([
509
+ api("/topics"),
510
+ api("/communities?joined=1&limit=50"),
511
+ ]);
512
+ next.topics = itemsOf(topics).map((t) => [t.id, t.name]);
513
+ next.audiences = [
514
+ ["", "Public feed · everyone"],
515
+ ...itemsOf(communities).map((c) => [c.id, "Private · " + c.name]),
516
+ ];
517
+ if (
518
+ next.values.community_id &&
519
+ !next.audiences.some(
520
+ (c) => String(c[0]) === String(next.values.community_id),
521
+ )
522
+ ) {
523
+ let space;
524
+ try {
525
+ space = await api(
526
+ "/communities/" + encodeURIComponent(next.values.community_id),
527
+ );
528
+ } catch (e) {
529
+ if (![403, 404].includes(e.status)) throw e;
530
+ }
531
+ if (space?.is_member)
532
+ next.audiences.push([space.id, "Private · " + space.name]);
533
+ else {
534
+ next.unavailableAudience = next.values.community_id;
535
+ next.audiences.push([
536
+ next.values.community_id,
537
+ "Unavailable private audience · choose another",
538
+ ]);
539
+ }
540
+ }
541
+ }
542
+ openForm(next);
543
+ setNotice(
544
+ "Restored local draft. Check the code selection before publishing.",
545
+ );
546
+ });
547
+ }
548
+ useEffect(() => {
549
+ const generation = ++draftGeneration.current;
550
+ if (!drafts || !form) return;
551
+ setDraftSaved(false);
552
+ draftTimer.current = setTimeout(() => {
553
+ queueDraft(() => drafts.save(form))
554
+ .then((id) => {
555
+ if (id && alive.current && generation === draftGeneration.current)
556
+ setDraftSaved(true);
557
+ })
558
+ .catch((e) => {
559
+ if (alive.current)
560
+ setError("Could not save local draft: " + clean(e.message));
561
+ });
562
+ }, 450);
563
+ return () => clearTimeout(draftTimer.current);
564
+ }, [drafts, form]);
465
565
  function askConfirm(title, description, action) {
466
566
  setYes(false);
467
567
  setDialog({ title, description, action });
@@ -505,7 +605,15 @@ function App({ initialConfig, flags }) {
505
605
  setLoading(!cached);
506
606
  setError("");
507
607
  if (!cached)
508
- loadPage(api, route, controller.signal)
608
+ (route.view === "drafts"
609
+ ? drafts.list().then((items) => ({
610
+ items,
611
+ title: "Local drafts",
612
+ subtitle:
613
+ "Private to this account and project · Enter restore · D delete",
614
+ }))
615
+ : loadPage(api, route, controller.signal)
616
+ )
509
617
  .then((result) => {
510
618
  if (!active) return;
511
619
  cache.current.set(key, result);
@@ -605,7 +713,11 @@ function App({ initialConfig, flags }) {
605
713
  }
606
714
  }
607
715
  function openForm(next) {
608
- setForm(next);
716
+ setForm({
717
+ ...next,
718
+ draftId: next.draftId || randomBytes(32).toString("hex"),
719
+ });
720
+ setDraftSaved(false);
609
721
  setField(0);
610
722
  setCursor(graphemes(next.values[fieldsFor(next)[0].key] || "").length);
611
723
  setPreview(null);
@@ -704,6 +816,13 @@ function App({ initialConfig, flags }) {
704
816
  async function prepare() {
705
817
  if (!form) return;
706
818
  await task(async () => {
819
+ if (
820
+ form.unavailableAudience &&
821
+ String(form.values.community_id) === String(form.unavailableAudience)
822
+ )
823
+ throw Error(
824
+ "Choose an available audience explicitly before publishing this draft.",
825
+ );
707
826
  const fields = fieldsFor(form);
708
827
  for (let i = 0; i < fields.length; i++) {
709
828
  const f = fields[i],
@@ -736,36 +855,31 @@ function App({ initialConfig, flags }) {
736
855
  };
737
856
  url = "/posts";
738
857
  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");
858
+ let source = form.source;
859
+ if (source?.kind !== "diff") {
860
+ source = {
861
+ kind: "file",
862
+ path: form.values.file.replace(/^~(?=\/)/, homedir()),
863
+ };
864
+ if (form.values.lines) {
865
+ const match = form.values.lines.match(/^([1-9]\d*):([1-9]\d*)$/);
866
+ if (!match) throw Error("Use START:END for file lines.");
867
+ source.start = Number(match[1]);
868
+ source.end = Number(match[2]);
869
+ }
870
+ }
871
+ const chosen = await reviewSource(root, source);
872
+ payload.code = chosen.code;
873
+ if (chosen.kind === "diff") {
874
+ payload.body +=
875
+ "\n\nReview source: " +
876
+ clean(chosen.label).replace(/\n/g, " ") +
877
+ "\nReview line numbers refer to the published patch, including its headers.";
878
+ if (payload.body.length > 15000)
879
+ throw Error(
880
+ "Shorten the context to leave room for the Git diff source label.",
881
+ );
765
882
  }
766
- if (code.trim().length < 10)
767
- throw Error("The selected snippet needs at least 10 characters.");
768
- payload.code = code;
769
883
  payload.review_focus = form.values.review_focus;
770
884
  }
771
885
  if (payload.kind === "showcase")
@@ -823,10 +937,16 @@ function App({ initialConfig, flags }) {
823
937
  body: preview.payload,
824
938
  });
825
939
  cache.current.clear();
940
+ let localWarning = "";
941
+ try {
942
+ await forgetDraft(form);
943
+ } catch {
944
+ localWarning =
945
+ " Published successfully; the local draft could not be removed. Do not publish it again.";
946
+ }
826
947
  setPreview(null);
827
948
  setForm(null);
828
949
  setScroll(0);
829
- setNotice("Saved. Your web account sees the same update.");
830
950
  if (preview.url === "/posts")
831
951
  navigate({ view: "post", id: response.id, tab: "overview", page: 1 });
832
952
  else if (preview.url === "/communities")
@@ -841,12 +961,17 @@ function App({ initialConfig, flags }) {
841
961
  page: 1,
842
962
  });
843
963
  }
964
+ setNotice("Saved. Your web account sees the same update." + localWarning);
844
965
  });
845
966
  }
846
967
  async function openSelected() {
847
968
  if (route.view === "community" && !current?.community?.is_member) return;
848
969
  const item = selected;
849
970
  if (!item) return;
971
+ if (route.view === "drafts") {
972
+ await restoreDraft(item);
973
+ return;
974
+ }
850
975
  if (route.view === "devices") {
851
976
  askConfirm("Revoke this terminal?", item.name, () =>
852
977
  task(async () => {
@@ -978,6 +1103,18 @@ function App({ initialConfig, flags }) {
978
1103
  });
979
1104
  }
980
1105
  function deleteSelected() {
1106
+ if (route.view === "drafts" && selected) {
1107
+ askConfirm(
1108
+ "Delete saved draft?",
1109
+ "Only this local draft will be removed.",
1110
+ () =>
1111
+ task(async () => {
1112
+ await queueDraft(() => drafts.remove(selected.id));
1113
+ invalidate("Draft deleted.");
1114
+ }),
1115
+ );
1116
+ return;
1117
+ }
981
1118
  const target = editable();
982
1119
  if (
983
1120
  !target?.feedback ||
@@ -1074,8 +1211,13 @@ function App({ initialConfig, flags }) {
1074
1211
  if (form) {
1075
1212
  askConfirm(
1076
1213
  "Leave this draft?",
1077
- "The unsent draft lives only in this terminal session.",
1078
- () => exit(),
1214
+ "Save this draft locally and exit? Resume it from Local drafts in this project.",
1215
+ () =>
1216
+ task(async () => {
1217
+ clearTimeout(draftTimer.current);
1218
+ if (drafts) await queueDraft(() => drafts.save(form));
1219
+ exit();
1220
+ }),
1079
1221
  );
1080
1222
  } else exit();
1081
1223
  }
@@ -1090,6 +1232,7 @@ function App({ initialConfig, flags }) {
1090
1232
  );
1091
1233
  }
1092
1234
  useInput((input, keyEvent) => {
1235
+ if (picker) return;
1093
1236
  if (keyEvent.ctrl && input === "c") {
1094
1237
  if (dialog) {
1095
1238
  exit();
@@ -1183,15 +1326,26 @@ function App({ initialConfig, flags }) {
1183
1326
  return;
1184
1327
  }
1185
1328
  if (form) {
1329
+ if (
1330
+ keyEvent.ctrl &&
1331
+ ["o", "g"].includes(input) &&
1332
+ form.kind === "publication" &&
1333
+ form.values.kind === "review"
1334
+ ) {
1335
+ setPicker(input === "g" ? "changes" : "files");
1336
+ return;
1337
+ }
1186
1338
  if (keyEvent.escape) {
1187
1339
  askConfirm(
1188
1340
  "Discard this draft?",
1189
1341
  "Nothing has been published. Stay to keep editing.",
1190
- () => {
1191
- setForm(null);
1192
- setFocus("content");
1193
- setError("");
1194
- },
1342
+ () =>
1343
+ task(async () => {
1344
+ await forgetDraft(form);
1345
+ setForm(null);
1346
+ setFocus("content");
1347
+ setError("");
1348
+ }),
1195
1349
  );
1196
1350
  return;
1197
1351
  }
@@ -1236,6 +1390,10 @@ function App({ initialConfig, flags }) {
1236
1390
  return;
1237
1391
  }
1238
1392
  if (keyEvent.return && !active.multiline) {
1393
+ if (active.key === "file" && !form.values.file) {
1394
+ setPicker("files");
1395
+ return;
1396
+ }
1239
1397
  changeField(field + 1);
1240
1398
  return;
1241
1399
  }
@@ -1248,6 +1406,7 @@ function App({ initialConfig, flags }) {
1248
1406
  );
1249
1407
  setForm((old) => ({
1250
1408
  ...old,
1409
+ ...(active.key === "file" ? { source: null } : {}),
1251
1410
  values: { ...old.values, [active.key]: updated.value },
1252
1411
  }));
1253
1412
  setCursor(updated.cursor);
@@ -1466,6 +1625,34 @@ function App({ initialConfig, flags }) {
1466
1625
  h(Text, null, "Enlarge this terminal to at least 60 × 18."),
1467
1626
  h(Text, null, "Q or Ctrl+C exits."),
1468
1627
  );
1628
+ else if (picker)
1629
+ center = h(WorkspacePicker, {
1630
+ root,
1631
+ width,
1632
+ height: bodyHeight,
1633
+ initial: picker,
1634
+ onCancel: () => setPicker(null),
1635
+ onSelect: (source) => {
1636
+ setForm((old) => ({
1637
+ ...old,
1638
+ source,
1639
+ values: {
1640
+ ...old.values,
1641
+ file: source.path,
1642
+ lines:
1643
+ source.kind === "file" && source.start
1644
+ ? source.start + ":" + source.end
1645
+ : "",
1646
+ },
1647
+ }));
1648
+ setPicker(null);
1649
+ setNotice(
1650
+ source.kind === "diff"
1651
+ ? "Git changes selected. Preview before publishing."
1652
+ : "File selected. Preview before publishing.",
1653
+ );
1654
+ },
1655
+ });
1469
1656
  else if (phase !== "ready") {
1470
1657
  const lines = [
1471
1658
  "A place for curious developers.",
@@ -1743,7 +1930,8 @@ function App({ initialConfig, flags }) {
1743
1930
  )
1744
1931
  : h(TextLine, {
1745
1932
  text: form
1746
- ? "Tab next field · arrows choose · Ctrl+S preview"
1933
+ ? "Tab next field · Ctrl+S preview" +
1934
+ (draftSaved ? " · Draft saved locally" : "")
1747
1935
  : preview
1748
1936
  ? "Scroll to review · ← → choose · Enter confirms"
1749
1937
  : current?.subtitle || " ",
@@ -1810,19 +1998,21 @@ function App({ initialConfig, flags }) {
1810
1998
  compact && focus === "nav" ? null : pane,
1811
1999
  );
1812
2000
  }
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";
2001
+ const hint = picker
2002
+ ? "Project files stay local until publication is confirmed"
2003
+ : reading
2004
+ ? "↑↓ scroll · R reply · E edit · Esc close"
2005
+ : preview
2006
+ ? "← → choose · Enter confirm · Esc edit"
2007
+ : form
2008
+ ? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview · Esc discard"
2009
+ : phase !== "ready"
2010
+ ? "Enter connect / open browser · Ctrl+C exit"
2011
+ : route.view === "feedback"
2012
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2013
+ : route.view === "post"
2014
+ ? "←→ tabs · ↑↓ scroll/select · R reply · C code remark · Space full text · Tab navigate"
2015
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
1826
2016
  return h(
1827
2017
  Box,
1828
2018
  {
@@ -1892,7 +2082,8 @@ export async function startTui(flags = {}) {
1892
2082
  let config = await loadConfig();
1893
2083
  if (flags.host && config && originOf(flags.host) !== config.origin)
1894
2084
  config = null;
1895
- const app = render(h(App, { initialConfig: config, flags }), {
2085
+ const root = await workspaceRoot(flags.project || process.cwd());
2086
+ const app = render(h(App, { initialConfig: config, flags, root }), {
1896
2087
  alternateScreen: true,
1897
2088
  exitOnCtrlC: false,
1898
2089
  patchConsole: true,