reslop 0.1.6 → 0.1.7

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/session.js CHANGED
@@ -10,22 +10,24 @@ const wrap = require('./wrap.js');
10
10
  const { extractText, isRange } = require('./select.js');
11
11
  const { copyText } = require('./clipboard.js');
12
12
  const { watchResize } = require('./sys.js');
13
- const { fileEntries, itemPath } = require('./files.js');
13
+ const files = require('./files.js');
14
+ const { fileEntries, itemPath, TODO_FILE } = files;
15
+ const { isTodoItem, isTodosEntry, REPO_TODOS_LABEL, makeTodoItem } = files;
14
16
  const { Editor } = require('./editor.js');
15
17
  const { refreshIndexPatches, blockAddText } = require('./diff.js');
16
18
  const update = require('./update.js');
17
19
  const { checkUpdate, installUpdate, markSkipped } = update;
18
20
 
19
21
  const { renderFrame, presentRows, presentCursor } = render;
20
- const { noteInnerWidth, todoInnerWidth, codeInnerWidth } = render;
22
+ const { noteInnerWidth, codeInnerWidth } = render;
21
23
  const { actionFromKey, hitAction, decodeChunk } = keys;
22
24
  const { disabledActions } = keys;
23
- const { cursorInWrap, wrapDoc, wrapMove } = wrap;
25
+ const { cursorInWrap, wrapDoc } = wrap;
24
26
  const { AUTOSAVE_MS, listReviewNames, loadTemplates } = review;
25
27
  const { resolveReviewPath, loadReview, createStore } = review;
26
28
  const { noteCounts, hasNotes, setFeedback, setCode, addTodo } = review;
27
29
  const { applyImportedNotes } = review;
28
- const { setTodoText, removeTodo, flushReview, mergeTodos } = review;
30
+ const { setTodoText, removeTodo, flushReview } = review;
29
31
  const { feedbackKey, checkLabel, rankedTemplates } = review;
30
32
  const { prefixTemplates, TEMPLATE_SHOW, rememberTemplate } = review;
31
33
 
@@ -72,7 +74,7 @@ const npmBusyKind = (item) => {
72
74
  const itemKey = (item) => {
73
75
  if (!item || !item.file) return '';
74
76
  const rel = itemPath(item);
75
- if (item.origin === 'todo') return `todo:${rel}`;
77
+ if (isTodoItem(item)) return `todo:${TODO_FILE}`;
76
78
  const origin = item.origin ?? '';
77
79
  const depId = depChangeKey(item);
78
80
  if (depId) return `${origin}:${rel}:dep:${depId}`;
@@ -87,7 +89,7 @@ const itemKey = (item) => {
87
89
  };
88
90
 
89
91
  const itemFeedKey = (item) => {
90
- if (!item || !item.file || item.origin === 'todo') return '';
92
+ if (!item || !item.file || isTodoItem(item)) return '';
91
93
  const hunk = item.hunk;
92
94
  const depId = depChangeKey(item);
93
95
  const file = depId ? `${itemPath(item)}#${depId}` : itemPath(item);
@@ -98,7 +100,7 @@ const itemFeedKey = (item) => {
98
100
  };
99
101
 
100
102
  const canEditCode = (item) => {
101
- if (!item || item.origin === 'todo') return false;
103
+ if (!item || isTodoItem(item)) return false;
102
104
  if (!item.hunk || !item.file) return false;
103
105
  if (item.file.isBinary) return false;
104
106
  return true;
@@ -115,7 +117,6 @@ const sameCursor = (a, b) => {
115
117
  const QUIT_CONFIRM = {
116
118
  f: (session) => session.finishQuit('ready'),
117
119
  c: (session) => session.finishQuit('editing'),
118
- d: (session) => session.discardQuit(),
119
120
  'ctrl-c': (session) => session.finishQuit('ready'),
120
121
  escape: (session) => session.cancelQuit(),
121
122
  };
@@ -133,6 +134,12 @@ const UPDATE_CONFIRM = {
133
134
  escape: (session) => session.declineUpdate(),
134
135
  };
135
136
 
137
+ const DROP_CONFIRM = {
138
+ y: (session) => session.confirmDropBranch(),
139
+ n: (session) => session.cancelDropBranch(),
140
+ escape: (session) => session.cancelDropBranch(),
141
+ };
142
+
136
143
  const PUSH_CONFIRM = {
137
144
  f: (session) => session.onForcePush(),
138
145
  escape: (session) => session.cancelPush(),
@@ -142,6 +149,7 @@ const CONFIRM_MODE = [
142
149
  'confirmQuit',
143
150
  'confirmCommit',
144
151
  'confirmUpdate',
152
+ 'confirmDrop',
145
153
  'confirmPush',
146
154
  ];
147
155
 
@@ -186,6 +194,8 @@ const HANDLERS = {
186
194
  pull: (session) => session.onPull(),
187
195
  push: (session) => session.onPush(),
188
196
  newBranch: (session) => session.onNewBranch(),
197
+ rebase: (session) => session.onRebaseBranch(),
198
+ drop: (session) => session.onDropBranch(),
189
199
  open: (session) => session.onOpenFile(),
190
200
  removeTodo: (session) => session.removeFocusedTodo(),
191
201
  quit: (session) => session.onQuit(),
@@ -201,11 +211,38 @@ const KEY_MODE = {
201
211
  confirmQuit: (session, key) => session.onConfirmQuit(key),
202
212
  confirmCommit: (session, key) => session.onConfirmCommit(key),
203
213
  confirmUpdate: (session, key) => session.onConfirmUpdate(key),
214
+ confirmDrop: (session, key) => session.onConfirmDrop(key),
204
215
  confirmPush: (session, key) => session.onConfirmPush(key),
205
216
  compose: (session, key) => session.handleComposeKey(key),
206
217
  };
207
218
 
208
219
  const COMPOSE_LEAVE = ['next', 'prev', 'todo'];
220
+ const TODO_TYPE_SKIP = [
221
+ 'enter',
222
+ 'escape',
223
+ 'tab',
224
+ 'backspace',
225
+ 'delete',
226
+ 'left',
227
+ 'right',
228
+ 'up',
229
+ 'down',
230
+ 'home',
231
+ 'end',
232
+ 'j',
233
+ 'k',
234
+ 'n',
235
+ 'p',
236
+ 'q',
237
+ ];
238
+
239
+ const isTodoTypeKey = (key) => {
240
+ if (!key) return false;
241
+ if (TODO_TYPE_SKIP.includes(key)) return false;
242
+ if (key.startsWith('ctrl-')) return false;
243
+ if (key.startsWith('alt-')) return false;
244
+ return key.charCodeAt(0) >= 32;
245
+ };
209
246
 
210
247
  const MOUSE_KIND = {
211
248
  wheelUp: (session) => session.onScroll(-1),
@@ -274,7 +311,6 @@ class Session {
274
311
  this.readFileSync = options.readFileSync ?? fs.readFileSync;
275
312
  this.writeFileSync = options.writeFileSync ?? fs.writeFileSync;
276
313
  this.mkdirSync = options.mkdirSync ?? fs.mkdirSync;
277
- this.unlinkSync = options.unlinkSync ?? fs.unlinkSync;
278
314
  this.now = options.now ?? systemNow;
279
315
  this.forceNewReview = options.newReview === true;
280
316
  this.readOnly = options.readOnly === true;
@@ -296,6 +332,7 @@ class Session {
296
332
  this.branch = '';
297
333
  this.branches = [];
298
334
  this.branchCursor = 0;
335
+ this.dropName = '';
299
336
  this.scroll = 0;
300
337
  this.status = '';
301
338
  this.layout = 'unified';
@@ -319,7 +356,8 @@ class Session {
319
356
  this.composeTodoId = null;
320
357
  this.composeBaseline = '';
321
358
  this.todoFocus = 0;
322
- this.todoDraftFile = null;
359
+ this.todoOpen = false;
360
+ this.todoItem = makeTodoItem();
323
361
  this.templateIndex = -1;
324
362
  this.templateFocus = false;
325
363
  this.saveTimer = null;
@@ -378,7 +416,8 @@ class Session {
378
416
  }
379
417
 
380
418
  applyLoaded(loaded) {
381
- const here = itemKey(this.items[this.index]);
419
+ const wasTodo = this.todoOpen === true;
420
+ const here = itemKey(this.current());
382
421
  this.top = loaded.top ?? this.cwd;
383
422
  const items = loaded.items;
384
423
  this.items = items.filter((item) => !this.dismissed.has(itemKey(item)));
@@ -401,8 +440,8 @@ class Session {
401
440
  applyImportedNotes(this.notes, imported);
402
441
  this.importedRemoteNotes = true;
403
442
  }
404
- this.injectTodos();
405
- if (here) {
443
+ this.todoOpen = wasTodo;
444
+ if (here && !wasTodo) {
406
445
  const idx = this.items.findIndex((item) => itemKey(item) === here);
407
446
  if (idx >= 0) this.index = idx;
408
447
  }
@@ -430,6 +469,7 @@ class Session {
430
469
 
431
470
  finishIfEmpty(kind) {
432
471
  if (this.items.length) return;
472
+ if (this.notes && hasNotes(this.notes)) return;
433
473
  if (kind === 'keep') return;
434
474
  if (this.notes && hasNotes(this.notes)) this.flushReview();
435
475
  this.done = true;
@@ -625,10 +665,24 @@ class Session {
625
665
  this.notes = createStore(resolved.reviewPath, templates);
626
666
  }
627
667
 
628
- injectTodos() {
629
- const todos = this.notes ? this.notes.todos : [];
630
- const extra = this.todoDraftFile ? [this.todoDraftFile] : [];
631
- this.items = mergeTodos(this.items, todos, extra);
668
+ repoNameOf() {
669
+ return this.repoName || path.basename(this.top || this.cwd || '');
670
+ }
671
+
672
+ todosEntry() {
673
+ const notes = noteCounts(this.notes);
674
+ return {
675
+ path: REPO_TODOS_LABEL,
676
+ kind: 'todos',
677
+ status: 'todos',
678
+ remaining: notes.todo,
679
+ staged: notes.todoDone,
680
+ firstIndex: 0,
681
+ openIndex: 0,
682
+ added: 0,
683
+ removed: 0,
684
+ unstaged: 0,
685
+ };
632
686
  }
633
687
 
634
688
  clampIndex() {
@@ -644,10 +698,10 @@ class Session {
644
698
 
645
699
  fileList() {
646
700
  const entries = fileEntries(this.items);
647
- if (this.sourceLabel) {
648
- return relabelStatus(entries, 'pr', this.sourceLabel);
649
- }
650
- return relabelStatus(entries, 'commit', this.revShort);
701
+ const labeled = this.sourceLabel
702
+ ? relabelStatus(entries, 'pr', this.sourceLabel)
703
+ : relabelStatus(entries, 'commit', this.revShort);
704
+ return [this.todosEntry(), ...labeled];
651
705
  }
652
706
 
653
707
  syncReviewPath() {
@@ -673,8 +727,12 @@ class Session {
673
727
  this.fileCursor = 0;
674
728
  return;
675
729
  }
676
- const here = this.reviewPath;
677
- const idx = files.findIndex((entry) => entry.path === here);
730
+ const item = this.current();
731
+ const idx = isTodoItem(item)
732
+ ? files.findIndex((entry) => isTodosEntry(entry))
733
+ : files.findIndex(
734
+ (entry) => !isTodosEntry(entry) && entry.path === this.reviewPath,
735
+ );
678
736
  if (idx >= 0) {
679
737
  this.fileCursor = idx;
680
738
  return;
@@ -716,7 +774,7 @@ class Session {
716
774
  const templates = this.shownTemplates();
717
775
  const change = this.change;
718
776
  const sourceKind = (change && SOURCE_KIND[change.source]) || '';
719
- const repoName = this.repoName || path.basename(this.top || this.cwd || '');
777
+ const repoName = this.repoNameOf();
720
778
  return {
721
779
  item: this.current(),
722
780
  index: this.index,
@@ -749,6 +807,7 @@ class Session {
749
807
  templateIndex: this.clampedTemplateIndex(templates),
750
808
  updateFrom: this.updateFrom,
751
809
  updateTo: this.updateTo,
810
+ dropName: this.dropName,
752
811
  };
753
812
  }
754
813
 
@@ -793,7 +852,10 @@ class Session {
793
852
  if (action === 'next') return void this.onBranchMove(1);
794
853
  if (action === 'prev') return void this.onBranchMove(-1);
795
854
  }
796
- const hidden = disabledActions(this.pane, this.current());
855
+ let target = this.current();
856
+ if (this.pane === 'files') target = this.fileCursorEntry();
857
+ if (this.pane === 'branches') target = this.branches[this.branchCursor];
858
+ const hidden = disabledActions(this.pane, target);
797
859
  if (hidden.includes(action)) return;
798
860
  const handler = HANDLERS[action];
799
861
  if (!handler) return;
@@ -801,6 +863,7 @@ class Session {
801
863
  }
802
864
 
803
865
  current() {
866
+ if (this.todoOpen) return this.todoItem;
804
867
  return this.items[this.index] ?? null;
805
868
  }
806
869
 
@@ -920,7 +983,7 @@ class Session {
920
983
  activeDiffItem() {
921
984
  const item = this.current();
922
985
  if (!item) return null;
923
- if (item.origin === 'todo') {
986
+ if (isTodoItem(item)) {
924
987
  this.status = 'not a diff block';
925
988
  return null;
926
989
  }
@@ -934,11 +997,11 @@ class Session {
934
997
 
935
998
  fileActionItems() {
936
999
  const entry = this.fileCursorEntry();
937
- if (!entry) return [];
1000
+ if (!entry || isTodosEntry(entry)) return [];
938
1001
  const rel = entry.path;
939
1002
  const items = [];
940
1003
  for (const item of this.items) {
941
- if (item.origin === 'todo') continue;
1004
+ if (isTodoItem(item)) continue;
942
1005
  if (itemPath(item) !== rel) continue;
943
1006
  items.push(item);
944
1007
  }
@@ -991,6 +1054,10 @@ class Session {
991
1054
 
992
1055
  onFileAdd() {
993
1056
  const items = this.fileActionItems();
1057
+ if (!items.length) {
1058
+ this.status = 'not a diff block';
1059
+ return;
1060
+ }
994
1061
  if (!this.guardFileGit(items)) return;
995
1062
  const rel = itemPath(items[0]);
996
1063
  const targets = items.filter((item) => item.origin !== 'staged');
@@ -1029,6 +1096,10 @@ class Session {
1029
1096
 
1030
1097
  onFileUnstage() {
1031
1098
  const items = this.fileActionItems();
1099
+ if (!items.length) {
1100
+ this.status = 'not a diff block';
1101
+ return;
1102
+ }
1032
1103
  if (!this.guardFileGit(items)) return;
1033
1104
  const rel = itemPath(items[0]);
1034
1105
  const targets = items.filter((item) => item.origin === 'staged');
@@ -1051,6 +1122,10 @@ class Session {
1051
1122
 
1052
1123
  onFileRevert() {
1053
1124
  const items = this.fileActionItems();
1125
+ if (!items.length) {
1126
+ this.status = 'not a diff block';
1127
+ return;
1128
+ }
1054
1129
  if (!this.guardFileGit(items)) return;
1055
1130
  const rel = itemPath(items[0]);
1056
1131
  for (const item of items) {
@@ -1145,10 +1220,23 @@ class Session {
1145
1220
  }
1146
1221
 
1147
1222
  onNext() {
1223
+ if (this.todoOpen) {
1224
+ this.closeTodoPage();
1225
+ if (this.items.length) this.index = 0;
1226
+ this.scroll = 0;
1227
+ this.syncReviewPath();
1228
+ this.syncFileCursor();
1229
+ return;
1230
+ }
1148
1231
  this.moveRemaining(1);
1149
1232
  }
1150
1233
 
1151
1234
  onPrev() {
1235
+ if (this.todoOpen) return;
1236
+ if (this.index <= 0) {
1237
+ this.openTodoPage();
1238
+ return;
1239
+ }
1152
1240
  this.moveRemaining(-1);
1153
1241
  }
1154
1242
 
@@ -1176,23 +1264,6 @@ class Session {
1176
1264
  this.exitCode = 0;
1177
1265
  }
1178
1266
 
1179
- removeReviewFile() {
1180
- const file = this.notes && this.notes.reviewPath;
1181
- if (!file) return;
1182
- try {
1183
- this.unlinkSync(file);
1184
- } catch {
1185
- // ignore missing review file
1186
- }
1187
- }
1188
-
1189
- discardQuit() {
1190
- if (this.notes) this.notes.dirty = false;
1191
- if (!this.didResumeReview) this.removeReviewFile();
1192
- this.done = true;
1193
- this.exitCode = 0;
1194
- }
1195
-
1196
1267
  cancelQuit() {
1197
1268
  this.mode = 'review';
1198
1269
  this.status = '';
@@ -1426,6 +1497,83 @@ class Session {
1426
1497
  if (!this.status) this.status = done;
1427
1498
  }
1428
1499
 
1500
+ refreshBranchList() {
1501
+ if (!this.repo.listBranches) return;
1502
+ try {
1503
+ this.branches = this.repo.listBranches(this.top);
1504
+ } catch (error) {
1505
+ this.status = error.message;
1506
+ return;
1507
+ }
1508
+ const last = this.branches.length - 1;
1509
+ if (this.branchCursor > last) this.branchCursor = Math.max(0, last);
1510
+ }
1511
+
1512
+ onRebaseBranch() {
1513
+ if (this.pane !== 'branches') return;
1514
+ if (!this.canCommit()) return;
1515
+ const entry = this.branches[this.branchCursor];
1516
+ if (!entry || entry.current) return;
1517
+ const onto = entry.name;
1518
+ const done = `rebased onto ${onto}`;
1519
+ if (this.uiOpen && this.repo.rebaseAsync) {
1520
+ const write = (top) => this.repo.rebaseAsync(top, onto);
1521
+ const after = () => {
1522
+ this.refreshBranchList();
1523
+ this.refreshFromRepo({ keepEmpty: true, doneStatus: done });
1524
+ };
1525
+ return void this.runGitBusy('rebasing', done, write, after);
1526
+ }
1527
+ const ok = this.runRepo(done, (top) => this.repo.rebase(top, onto));
1528
+ if (!ok) return;
1529
+ this.refreshBranchList();
1530
+ this.refreshFromRepo({ keepEmpty: true });
1531
+ if (!this.status) this.status = done;
1532
+ }
1533
+
1534
+ onDropBranch() {
1535
+ if (this.pane !== 'branches') return;
1536
+ if (!this.canCommit()) return;
1537
+ const entry = this.branches[this.branchCursor];
1538
+ if (!entry || entry.current) return;
1539
+ this.dropName = entry.name;
1540
+ this.mode = 'confirmDrop';
1541
+ this.status = '';
1542
+ }
1543
+
1544
+ onConfirmDrop(key) {
1545
+ const dropKey = key.length === 1 ? key.toLowerCase() : key;
1546
+ const handler = DROP_CONFIRM[dropKey];
1547
+ if (handler) handler(this);
1548
+ }
1549
+
1550
+ cancelDropBranch() {
1551
+ this.mode = 'review';
1552
+ this.dropName = '';
1553
+ this.status = '';
1554
+ }
1555
+
1556
+ confirmDropBranch() {
1557
+ const name = this.dropName;
1558
+ this.mode = 'review';
1559
+ this.dropName = '';
1560
+ if (!name) return;
1561
+ const done = `dropped ${name}`;
1562
+ if (this.uiOpen && this.repo.dropAsync) {
1563
+ const write = (top) => this.repo.dropAsync(top, name);
1564
+ const after = () => {
1565
+ this.refreshBranchList();
1566
+ this.refreshFromRepo({ keepEmpty: true, doneStatus: done });
1567
+ };
1568
+ return void this.runGitBusy('dropping', done, write, after);
1569
+ }
1570
+ const ok = this.runRepo(done, (top) => this.repo.drop(top, name));
1571
+ if (!ok) return;
1572
+ this.refreshBranchList();
1573
+ this.refreshFromRepo({ keepEmpty: true });
1574
+ if (!this.status) this.status = done;
1575
+ }
1576
+
1429
1577
  onNewBranch() {
1430
1578
  if (this.pane !== 'branches') return;
1431
1579
  if (!this.canCommit()) return;
@@ -1518,7 +1666,7 @@ class Session {
1518
1666
  return;
1519
1667
  }
1520
1668
  const item = this.current();
1521
- if (!item || item.origin === 'todo') {
1669
+ if (!item || isTodoItem(item)) {
1522
1670
  this.status = 'not a diff block';
1523
1671
  return;
1524
1672
  }
@@ -1527,19 +1675,14 @@ class Session {
1527
1675
  this.openCompose('feedback', text);
1528
1676
  }
1529
1677
 
1530
- todoFile() {
1678
+ onTodo() {
1531
1679
  if (this.pane === 'files') {
1532
1680
  const entry = this.fileCursorEntry();
1533
- return entry ? entry.path : '';
1681
+ if (entry && !isTodosEntry(entry)) {
1682
+ this.index = entry.openIndex ?? entry.firstIndex;
1683
+ }
1534
1684
  }
1535
- const item = this.current();
1536
- return item ? itemPath(item) : '';
1537
- }
1538
-
1539
- onTodo() {
1540
- const file = this.todoFile();
1541
- if (!file) return;
1542
- this.createTodo(file);
1685
+ this.createTodo();
1543
1686
  }
1544
1687
 
1545
1688
  onCode() {
@@ -1558,17 +1701,9 @@ class Session {
1558
1701
  this.openCompose('code', text);
1559
1702
  }
1560
1703
 
1561
- todoPageIndex(file) {
1562
- return this.items.findIndex(
1563
- (item) => item.origin === 'todo' && itemPath(item) === file,
1564
- );
1565
- }
1566
-
1567
- focusTodoPage(file, todoId) {
1568
- const idx = this.todoPageIndex(file);
1569
- if (idx < 0) return false;
1570
- this.index = idx;
1571
- const todos = this.fileTodos(file);
1704
+ focusTodoPage(todoId) {
1705
+ this.openTodoPage();
1706
+ const todos = this.listTodos();
1572
1707
  let focus = 0;
1573
1708
  if (todoId === null) {
1574
1709
  focus = todos.length;
@@ -1590,53 +1725,86 @@ class Session {
1590
1725
  );
1591
1726
  }
1592
1727
 
1593
- startDraftCompose(file) {
1594
- this.todoDraftFile = file;
1595
- this.injectTodos();
1596
- this.focusTodoPage(file, null);
1728
+ openTodoPage() {
1729
+ this.todoOpen = true;
1730
+ this.pane = 'diff';
1731
+ this.scroll = 0;
1732
+ this.clearSelection();
1733
+ this.syncReviewPath();
1734
+ }
1735
+
1736
+ closeTodoPage() {
1737
+ this.todoOpen = false;
1597
1738
  this.syncReviewPath();
1739
+ this.syncFileCursor();
1740
+ }
1741
+
1742
+ startDraftCompose() {
1743
+ this.focusTodoPage(null);
1598
1744
  this.openCompose('todo', '', null);
1599
1745
  }
1600
1746
 
1601
1747
  editFocusedTodo() {
1602
- const item = this.current();
1603
- if (!item || item.origin !== 'todo') return;
1604
- const todos = this.fileTodos(itemPath(item));
1748
+ if (!this.todoOpen) return;
1749
+ const todos = this.listTodos();
1605
1750
  const focus = this.clampedTodoFocus();
1606
1751
  const todo = todos[focus];
1607
1752
  if (!todo) {
1608
- this.startDraftCompose(itemPath(item));
1753
+ this.startDraftCompose();
1609
1754
  return;
1610
1755
  }
1611
1756
  this.openCompose('todo', todo.text, todo.id);
1612
1757
  }
1613
1758
 
1759
+ editNextTodo() {
1760
+ if (!this.todoOpen) return;
1761
+ const last = this.todoRowCount() - 1;
1762
+ const next = this.clampedTodoFocus() + 1;
1763
+ if (next <= last) this.todoFocus = next;
1764
+ this.editFocusedTodo();
1765
+ }
1766
+
1767
+ moveTodoCompose(delta) {
1768
+ if (!this.todoOpen) return;
1769
+ const last = this.todoRowCount() - 1;
1770
+ const next = this.clampedTodoFocus() + delta;
1771
+ if (next < 0 || next > last) return;
1772
+ this.commitCompose();
1773
+ this.flushReview();
1774
+ this.closeCompose();
1775
+ const max = Math.max(0, this.todoRowCount() - 1);
1776
+ this.todoFocus = Math.min(next, max);
1777
+ this.editFocusedTodo();
1778
+ }
1779
+
1780
+ typeIntoTodo(key) {
1781
+ if (!this.todoOpen) return false;
1782
+ if (this.pane !== 'diff') return false;
1783
+ if (this.mode !== 'review') return false;
1784
+ if (!isTodoTypeKey(key)) return false;
1785
+ this.editFocusedTodo();
1786
+ this.editor.insert(key);
1787
+ this.resetBlink();
1788
+ return true;
1789
+ }
1790
+
1614
1791
  removeFocusedTodo() {
1615
- if (this.pane !== 'diff') return;
1616
- const item = this.current();
1617
- if (!item || item.origin !== 'todo') return;
1618
- const file = itemPath(item);
1619
- const todos = this.fileTodos(file);
1792
+ if (this.pane !== 'diff' || !this.todoOpen) return;
1793
+ const todos = this.listTodos();
1620
1794
  const focus = this.clampedTodoFocus();
1621
1795
  const todo = todos[focus];
1622
1796
  if (!todo) return;
1623
1797
  removeTodo(this.notes, todo.id);
1624
- this.injectTodos();
1625
- const left = this.fileTodos(file);
1626
- if (!left.length) {
1627
- this.clampIndex();
1628
- } else if (this.todoFocus >= left.length) {
1629
- this.todoFocus = left.length - 1;
1798
+ const left = this.listTodos();
1799
+ if (this.todoFocus >= left.length) {
1800
+ this.todoFocus = Math.max(0, left.length - 1);
1630
1801
  }
1631
- this.syncReviewPath();
1632
- this.syncFileCursor();
1633
1802
  this.flushReview(true);
1634
1803
  }
1635
1804
 
1636
1805
  onTodoHit(cursor) {
1637
- const item = this.current();
1638
- if (!item || item.origin !== 'todo') return;
1639
- const todos = this.fileTodos(itemPath(item));
1806
+ if (!this.todoOpen) return;
1807
+ const todos = this.listTodos();
1640
1808
  const draft = cursor === todos.length;
1641
1809
  const target = todos[cursor];
1642
1810
  if (!draft && !target) return;
@@ -1650,12 +1818,8 @@ class Session {
1650
1818
  this.editFocusedTodo();
1651
1819
  }
1652
1820
 
1653
- createTodo(file) {
1654
- if (!file) return;
1655
- this.pane = 'diff';
1656
- this.scroll = 0;
1657
- this.clearSelection();
1658
- this.startDraftCompose(file);
1821
+ createTodo() {
1822
+ this.startDraftCompose();
1659
1823
  }
1660
1824
 
1661
1825
  openCompose(kind, text, todoId) {
@@ -1676,7 +1840,7 @@ class Session {
1676
1840
  const text = this.editor.text;
1677
1841
  if (this.composeKind === 'feedback') {
1678
1842
  const item = this.current();
1679
- if (!item || item.origin === 'todo') return;
1843
+ if (!item || isTodoItem(item)) return;
1680
1844
  const hunk = item.hunk;
1681
1845
  setFeedback(this.notes, itemFeedKey(item), {
1682
1846
  file: itemPath(item),
@@ -1719,24 +1883,13 @@ class Session {
1719
1883
  const id = this.composeTodoId;
1720
1884
  const todos = this.notes.todos;
1721
1885
  const prev = id === null ? null : todos.find((entry) => entry.id === id);
1722
- const current = this.current();
1723
- const file = prev ? prev.file : itemPath(current) || this.todoDraftFile;
1724
1886
  if (prev) {
1725
1887
  setTodoText(this.notes, id, text);
1726
- } else if (text.trim() && file) {
1727
- const todo = addTodo(this.notes, file, text);
1728
- this.composeTodoId = todo.id;
1729
- }
1730
- this.injectTodos();
1731
- if (this.focusTodoPage(file, this.composeTodoId)) return;
1732
- const hunkIdx = this.items.findIndex(
1733
- (item) => item.origin !== 'todo' && itemPath(item) === file,
1734
- );
1735
- if (hunkIdx >= 0) {
1736
- this.index = hunkIdx;
1737
- } else if (this.index >= this.items.length) {
1738
- this.index = Math.max(0, this.items.length - 1);
1888
+ return;
1739
1889
  }
1890
+ if (!text.trim()) return;
1891
+ const todo = addTodo(this.notes, TODO_FILE, text);
1892
+ this.composeTodoId = todo.id;
1740
1893
  }
1741
1894
 
1742
1895
  closeCompose() {
@@ -1748,8 +1901,6 @@ class Session {
1748
1901
  this.composeBaseline = '';
1749
1902
  this.templateIndex = -1;
1750
1903
  this.templateFocus = false;
1751
- this.todoDraftFile = null;
1752
- this.injectTodos();
1753
1904
  this.clampIndex();
1754
1905
  this.syncReviewPath();
1755
1906
  this.syncFileCursor();
@@ -1846,23 +1997,17 @@ class Session {
1846
1997
  this.applyTemplate();
1847
1998
  }
1848
1999
 
1849
- fileTodos(rel) {
1850
- if (!this.notes || !rel) return [];
1851
- const todos = [];
1852
- for (const todo of this.notes.todos) {
1853
- if (todo.file !== rel) continue;
1854
- todos.push(todo);
1855
- }
1856
- return todos;
2000
+ listTodos() {
2001
+ if (!this.notes) return [];
2002
+ return this.notes.todos;
1857
2003
  }
1858
2004
 
1859
2005
  todoTexts() {
1860
- const item = this.current();
1861
- if (!item || item.origin !== 'todo') return [];
2006
+ if (!this.todoOpen) return [];
1862
2007
  const texts = [];
1863
2008
  const editingTodo = this.mode === 'compose' && this.composeKind === 'todo';
1864
2009
  const editingId = editingTodo ? this.composeTodoId : null;
1865
- for (const todo of this.fileTodos(itemPath(item))) {
2010
+ for (const todo of this.listTodos()) {
1866
2011
  const live = editingId === todo.id && this.editor;
1867
2012
  const text = live ? this.editor.text : todo.text;
1868
2013
  texts.push(checkLabel(text, todo.done));
@@ -1873,9 +2018,8 @@ class Session {
1873
2018
  }
1874
2019
 
1875
2020
  focusedTodo() {
1876
- const item = this.current();
1877
- if (!item || item.origin !== 'todo') return null;
1878
- const todos = this.fileTodos(itemPath(item));
2021
+ if (!this.todoOpen) return null;
2022
+ const todos = this.listTodos();
1879
2023
  return todos[this.clampedTodoFocus()] ?? null;
1880
2024
  }
1881
2025
 
@@ -1887,14 +2031,12 @@ class Session {
1887
2031
  }
1888
2032
 
1889
2033
  todoRowCount() {
1890
- const item = this.current();
1891
- if (!item || item.origin !== 'todo') return 0;
1892
- return this.fileTodos(itemPath(item)).length + 1;
2034
+ if (!this.todoOpen) return 0;
2035
+ return this.listTodos().length + 1;
1893
2036
  }
1894
2037
 
1895
2038
  moveTodoFocus(delta) {
1896
- const item = this.current();
1897
- if (!item || item.origin !== 'todo') return;
2039
+ if (!this.todoOpen) return;
1898
2040
  const n = this.todoRowCount();
1899
2041
  if (!n) return;
1900
2042
  const next = this.clampedTodoFocus() + delta;
@@ -1907,7 +2049,7 @@ class Session {
1907
2049
  if (!this.notes) return '';
1908
2050
  if (this.mode === 'compose') return '';
1909
2051
  const item = this.current();
1910
- if (!item || item.origin === 'todo') return '';
2052
+ if (!item || isTodoItem(item)) return '';
1911
2053
  const note = this.notes.feedback.get(itemFeedKey(item));
1912
2054
  if (!note || !note.text.trim()) return '';
1913
2055
  return checkLabel(note.text, note.done);
@@ -1926,7 +2068,8 @@ class Session {
1926
2068
  }
1927
2069
  }
1928
2070
 
1929
- saveCompose() {
2071
+ saveCompose(options) {
2072
+ const opts = options ?? {};
1930
2073
  if (this.composeKind === 'commit') {
1931
2074
  this.finishGitCommit();
1932
2075
  return;
@@ -1935,6 +2078,7 @@ class Session {
1935
2078
  this.finishCreateBranch();
1936
2079
  return;
1937
2080
  }
2081
+ const editNext = opts.editNext === true;
1938
2082
  this.commitCompose();
1939
2083
  if (this.composeKind === 'feedback') {
1940
2084
  const next = this.editor ? this.editor.text : '';
@@ -1943,6 +2087,7 @@ class Session {
1943
2087
  this.flushReview();
1944
2088
  this.closeCompose();
1945
2089
  this.status = 'saved';
2090
+ if (editNext) this.editNextTodo();
1946
2091
  }
1947
2092
 
1948
2093
  autosave() {
@@ -1954,26 +2099,10 @@ class Session {
1954
2099
  if (this.notes && this.notes.dirty) this.flushReview();
1955
2100
  }
1956
2101
 
1957
- moveTodoLine(delta, inner) {
1958
- const todo = this.focusedTodo();
1959
- const done = todo ? todo.done === true : false;
1960
- const label = checkLabel(this.editor.text, done);
1961
- const prefix = checkLabel('', done).length;
1962
- const col = this.editor.wantCol;
1963
- const at = prefix + this.editor.cursor;
1964
- const moved = wrapMove(label, at, inner, delta, col);
1965
- const next = moved.cursor - prefix;
1966
- const max = this.editor.text.length;
1967
- this.editor.wantCol = moved.col;
1968
- if (next < 0) this.editor.cursor = 0;
1969
- else if (next > max) this.editor.cursor = max;
1970
- else this.editor.cursor = next;
1971
- }
1972
-
1973
2102
  moveComposeLine(delta) {
1974
2103
  const size = this.lastSize ?? this.getSize();
1975
2104
  if (this.composeKind === 'todo') {
1976
- this.moveTodoLine(delta, todoInnerWidth(size.width));
2105
+ this.moveTodoCompose(delta);
1977
2106
  return;
1978
2107
  }
1979
2108
  if (this.composeKind === 'code') {
@@ -2035,6 +2164,10 @@ class Session {
2035
2164
  this.resetBlink();
2036
2165
  return;
2037
2166
  }
2167
+ if (this.composeKind === 'todo') {
2168
+ this.saveCompose({ editNext: true });
2169
+ return;
2170
+ }
2038
2171
  this.saveCompose();
2039
2172
  return;
2040
2173
  }
@@ -2067,6 +2200,7 @@ class Session {
2067
2200
  this.pane = 'files';
2068
2201
  this.clearSelection();
2069
2202
  this.followReviewPath();
2203
+ this.todoOpen = false;
2070
2204
  }
2071
2205
 
2072
2206
  onEscape() {
@@ -2086,6 +2220,7 @@ class Session {
2086
2220
  if (!this.reviewPath) return;
2087
2221
  this.pane = 'diff';
2088
2222
  this.clearSelection();
2223
+ if (this.reviewPath === TODO_FILE) this.openTodoPage();
2089
2224
  return;
2090
2225
  }
2091
2226
  this.showFiles();
@@ -2094,13 +2229,18 @@ class Session {
2094
2229
  onOpenFile() {
2095
2230
  if (this.pane === 'branches') return void this.onCheckoutBranch();
2096
2231
  if (this.pane !== 'files') {
2097
- const item = this.current();
2098
- if (item && item.origin === 'todo') this.editFocusedTodo();
2232
+ if (this.todoOpen) this.editFocusedTodo();
2099
2233
  return;
2100
2234
  }
2101
2235
  const files = this.fileList();
2102
2236
  const entry = files[this.fileCursor];
2103
2237
  if (!entry) return;
2238
+ if (isTodosEntry(entry)) {
2239
+ this.openTodoPage();
2240
+ this.status = '';
2241
+ return;
2242
+ }
2243
+ this.todoOpen = false;
2104
2244
  this.index = entry.openIndex ?? entry.firstIndex;
2105
2245
  this.pane = 'diff';
2106
2246
  this.scroll = 0;
@@ -2122,8 +2262,12 @@ class Session {
2122
2262
  if (next === this.fileCursor) return;
2123
2263
  this.fileCursor = next;
2124
2264
  const entry = files[next];
2125
- this.reviewPath = entry.path;
2126
- this.index = entry.firstIndex;
2265
+ if (isTodosEntry(entry)) {
2266
+ this.reviewPath = TODO_FILE;
2267
+ } else {
2268
+ this.reviewPath = entry.path;
2269
+ this.index = entry.firstIndex;
2270
+ }
2127
2271
  this.status = '';
2128
2272
  }
2129
2273
 
@@ -2146,8 +2290,7 @@ class Session {
2146
2290
  this.onBranchMove(delta);
2147
2291
  return;
2148
2292
  }
2149
- const item = this.current();
2150
- if (item && item.origin === 'todo' && this.todoRowCount() > 1) {
2293
+ if (this.todoOpen && this.todoRowCount() > 1) {
2151
2294
  this.moveTodoFocus(delta);
2152
2295
  return;
2153
2296
  }
@@ -2254,6 +2397,7 @@ class Session {
2254
2397
  this.onEscape();
2255
2398
  return;
2256
2399
  }
2400
+ if (this.typeIntoTodo(event.key)) return;
2257
2401
  const action = actionFromKey(event.key, this.pane);
2258
2402
  if (action) this.dispatch(action);
2259
2403
  return;
@@ -2291,7 +2435,9 @@ class Session {
2291
2435
  }
2292
2436
 
2293
2437
  async startUi() {
2294
- if (this.didLoad && !this.items.length) {
2438
+ const empty = !this.items.length;
2439
+ const notes = this.notes && hasNotes(this.notes);
2440
+ if (this.didLoad && empty && !notes) {
2295
2441
  this.emptyReview = true;
2296
2442
  return 0;
2297
2443
  }