reslop 0.1.2 → 0.1.4

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/keys.js CHANGED
@@ -4,8 +4,8 @@ const ACTIONS = {
4
4
  add: { keys: ['a'], label: 'Add' },
5
5
  unstage: { keys: ['u'], label: 'Unstage' },
6
6
  revert: { keys: ['r'], label: 'Revert' },
7
- prev: { keys: ['left', 'p'], hot: '←', label: 'Prev' },
8
- next: { keys: ['right', 'n'], hot: '→', label: 'Next' },
7
+ prev: { keys: ['left', 'p', 'k'], hot: '←', label: 'Prev' },
8
+ next: { keys: ['right', 'n', 'j'], hot: '→', label: 'Next' },
9
9
  layout: { keys: ['m'], label: 'Mode' },
10
10
  files: { keys: ['l'], label: 'Files' },
11
11
  feedback: { keys: ['f'], label: 'Feedback' },
@@ -19,6 +19,12 @@ const FILES_DISABLED = ['layout', 'feedback'];
19
19
  const SCROLL = {
20
20
  up: 'scrollUp',
21
21
  down: 'scrollDown',
22
+ 'ctrl-y': 'scrollUp',
23
+ 'ctrl-e': 'scrollDown',
24
+ 'ctrl-b': 'pageUp',
25
+ 'ctrl-f': 'pageDown',
26
+ 'ctrl-u': 'halfUp',
27
+ 'ctrl-d': 'halfDown',
22
28
  enter: 'open',
23
29
  backspace: 'removeTodo',
24
30
  delete: 'removeTodo',
package/lib/render.js CHANGED
@@ -103,6 +103,16 @@ const FILE_STATUS_FG = {
103
103
  partial: THEME.warnFg,
104
104
  };
105
105
 
106
+ const FILE_COL_KEYS = ['add', 'del', 'status', 'n', 'm'];
107
+
108
+ const FILE_COL_ALIGN = {
109
+ add: 'start',
110
+ del: 'start',
111
+ status: 'start',
112
+ n: 'start',
113
+ m: 'end',
114
+ };
115
+
106
116
  const INFO_STATUS = [
107
117
  'copied',
108
118
  'staged',
@@ -504,12 +514,15 @@ const paintButtons = (layout, width, color) => {
504
514
  return out;
505
515
  };
506
516
 
517
+ const remoteCounts = (kind, { counts, notes, sourceLabel }) => {
518
+ const n = counts.pr ?? 0;
519
+ const label = sourceLabel || kind;
520
+ return ` ${kind} ${label} ${n} ${notes}`;
521
+ };
522
+
507
523
  const COUNTS_KIND = {
508
- pr: ({ counts, notes, sourceLabel }) => {
509
- const n = counts.pr ?? 0;
510
- const label = sourceLabel || 'pr';
511
- return ` pr ${label} ${n} ${notes}`;
512
- },
524
+ pr: (args) => remoteCounts('pr', args),
525
+ mr: (args) => remoteCounts('mr', args),
513
526
  commit: ({ counts, notes, revShort }) => {
514
527
  const n = counts.commit ?? 0;
515
528
  return ` commit ${revShort} ${n} ${notes}`;
@@ -525,7 +538,7 @@ const COUNTS_KIND = {
525
538
  };
526
539
 
527
540
  const countsKind = (sourceKind, revShort) => {
528
- if (sourceKind === 'pr') return 'pr';
541
+ if (sourceKind === 'pr' || sourceKind === 'mr') return sourceKind;
529
542
  if (revShort) return 'commit';
530
543
  return 'worktree';
531
544
  };
@@ -575,23 +588,36 @@ const paintStatusLine = (view, status, width, color) => {
575
588
  );
576
589
  };
577
590
 
591
+ const signedCount = (sign, n) => `${sign}${n ?? 0}`;
592
+
593
+ const fileRowFields = (entry) => {
594
+ const { added, removed, staged, remaining } = entry;
595
+ const add = signedCount('+', added);
596
+ const del = signedCount('-', removed);
597
+ const status = entry.status ?? '';
598
+ const n = `${staged ?? 0}`;
599
+ const m = `${remaining ?? 0}`;
600
+ return { add, del, status, n, m };
601
+ };
602
+
578
603
  const fileColumns = (files) => {
579
- let statusW = 0;
580
- let countW = 0;
604
+ const widths = {};
605
+ for (const key of FILE_COL_KEYS) widths[key] = 0;
581
606
  for (const entry of files) {
582
- const status = entry.status ?? '';
583
- const count = `${entry.remaining ?? 0}`;
584
- const sw = visibleWidth(status);
585
- const cw = visibleWidth(count);
586
- if (sw > statusW) statusW = sw;
587
- if (cw > countW) countW = cw;
607
+ const fields = fileRowFields(entry);
608
+ for (const key of FILE_COL_KEYS) {
609
+ const w = visibleWidth(fields[key]);
610
+ if (w > widths[key]) widths[key] = w;
611
+ }
588
612
  }
589
- return { statusW, countW };
613
+ return widths;
590
614
  };
591
615
 
592
- const padStartVisible = (text, width) => {
616
+ const padVisible = (text, width, align) => {
593
617
  const n = Math.max(0, width - visibleWidth(text));
594
- return `${' '.repeat(n)}${text}`;
618
+ const gap = ' '.repeat(n);
619
+ if (align === 'end') return `${text}${gap}`;
620
+ return `${gap}${text}`;
595
621
  };
596
622
 
597
623
  const fileRowFg = (status, selected) => {
@@ -601,25 +627,55 @@ const fileRowFg = (status, selected) => {
601
627
  return THEME.mutedFg;
602
628
  };
603
629
 
630
+ const fileRowParts = (entry, cols) => {
631
+ const fields = fileRowFields(entry);
632
+ const parts = {};
633
+ for (const key of FILE_COL_KEYS) {
634
+ const width = cols?.[key] ?? visibleWidth(fields[key]);
635
+ const align = FILE_COL_ALIGN[key];
636
+ parts[key] = padVisible(fields[key], width, align);
637
+ }
638
+ return parts;
639
+ };
640
+
641
+ const joinFileSuffix = (parts) => {
642
+ const { add, del, status, n, m } = parts;
643
+ return ` ${add} ${del} ${status} ${n}/${m}`;
644
+ };
645
+
646
+ const paintFileSuffix = (parts, fgRgb, bgRgb, color) => {
647
+ const { add, del, status, n, m } = parts;
648
+ const gap = paint(' ', fgRgb, bgRgb, color);
649
+ const addText = paint(add, THEME.addLineFg, bgRgb, color);
650
+ const delText = paint(del, THEME.delLineFg, bgRgb, color);
651
+ const statusText = paint(status, fgRgb, bgRgb, color);
652
+ const nText = paint(n, fgRgb, bgRgb, color);
653
+ const slash = paint('/', fgRgb, bgRgb, color);
654
+ const mText = paint(m, fgRgb, bgRgb, color);
655
+ const counts = addText + gap + delText;
656
+ const meta = statusText + gap + nText + slash + mText;
657
+ return gap + counts + gap + meta;
658
+ };
659
+
604
660
  const paintFileRow = (entry, width, color, selected, cols) => {
605
661
  const inner = Math.max(1, width - 1);
606
662
  const mark = selected ? FILE_MARK : ' ';
607
663
  const prefix = ` ${mark} `;
608
- const status = entry.status ?? '';
609
- const count = `${entry.remaining ?? 0}`;
610
- const statusW = cols?.statusW ?? visibleWidth(status);
611
- const countW = cols?.countW ?? visibleWidth(count);
612
- const statusCol = padStartVisible(status, statusW);
613
- const countCol = padStartVisible(count, countW);
614
- const suffix = ` ${statusCol} ${countCol}`;
664
+ const parts = fileRowParts(entry, cols);
665
+ const suffix = joinFileSuffix(parts);
615
666
  const pathW = Math.max(1, inner - visibleWidth(prefix + suffix));
616
- const clipped = truncateVisible(entry.path, pathW);
667
+ const { path, status } = entry;
668
+ const clipped = truncateVisible(path, pathW);
617
669
  const pad = Math.max(0, pathW - visibleWidth(clipped));
618
- const line = `${prefix}${clipped}${' '.repeat(pad)}${suffix}`;
670
+ const left = `${prefix}${clipped}${' '.repeat(pad)}`;
619
671
  const fgRgb = fileRowFg(status, selected);
620
672
  const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
621
- const row = paintBar(line, inner, fgRgb, bgRgb, color);
622
- return row + fill(1, fgRgb, bgRgb, color);
673
+ const edge = fill(1, fgRgb, bgRgb, color);
674
+ if (!color) return `${left}${suffix}${edge}`;
675
+ let out = `${seq(fgRgb, bgRgb)}${EL}`;
676
+ out += paint(left, fgRgb, bgRgb, color);
677
+ out += paintFileSuffix(parts, fgRgb, bgRgb, color);
678
+ return out + edge;
623
679
  };
624
680
 
625
681
  const todoInnerWidth = (width) => Math.max(1, width - 2);
@@ -950,7 +1006,7 @@ const stackFrame = (view, width, color, rows, layout, windowed, notes) => {
950
1006
  return overlayRows(rows, view.selection);
951
1007
  };
952
1008
 
953
- const frameResult = (text, painted, layout, windowed, hits) => {
1009
+ const frameResult = (text, painted, layout, windowed, hits, bodyH) => {
954
1010
  const fileHits = windowed.fileHits;
955
1011
  const todoHits = windowed.todoHits;
956
1012
  const templateHits = hits.templateHits;
@@ -968,6 +1024,7 @@ const frameResult = (text, painted, layout, windowed, hits) => {
968
1024
  height: painted.length,
969
1025
  scroll,
970
1026
  cursor,
1027
+ bodyH,
971
1028
  };
972
1029
  };
973
1030
 
@@ -999,7 +1056,7 @@ const renderFrame = (view, options = {}) => {
999
1056
  notes.noteCursor,
1000
1057
  bodyH,
1001
1058
  );
1002
- return frameResult(text, painted, layout, windowed, hits);
1059
+ return frameResult(text, painted, layout, windowed, hits, bodyH);
1003
1060
  };
1004
1061
 
1005
1062
  const presentCursor = (cursor) => {
package/lib/session.js CHANGED
@@ -9,6 +9,7 @@ const review = require('./review.js');
9
9
  const wrap = require('./wrap.js');
10
10
  const { extractText, isRange } = require('./select.js');
11
11
  const { copyText } = require('./clipboard.js');
12
+ const { watchResize } = require('./sys.js');
12
13
  const { fileEntries, itemPath } = require('./files.js');
13
14
  const { Editor } = require('./editor.js');
14
15
  const { refreshIndexPatches } = require('./diff.js');
@@ -29,6 +30,10 @@ const { prefixTemplates, TEMPLATE_SHOW, rememberTemplate } = review;
29
30
 
30
31
  const LAYOUTS = ['unified', 'mixed', 'side'];
31
32
  const READ_ONLY_ORIGINS = ['commit', 'pr'];
33
+ const SOURCE_KIND = {
34
+ 'github-pr': 'pr',
35
+ 'gitlab-mr': 'mr',
36
+ };
32
37
  const ENTER_TERM = '\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[?1002h\x1b[?1006h';
33
38
  const LEAVE_TERM =
34
39
  '\x1b[?1006l\x1b[?1002l\x1b[?7h\x1b[0 q\x1b[?12l\x1b[?25h\x1b[?1049l';
@@ -82,6 +87,8 @@ const itemFeedKey = (item) => {
82
87
  return feedbackKey({ file, oldStart, newStart, blockId });
83
88
  };
84
89
 
90
+ const isAbortError = (error) => !!(error && error.code === 'ABORT');
91
+
85
92
  const sameCursor = (a, b) => {
86
93
  if (!a && !b) return true;
87
94
  if (!a || !b) return false;
@@ -128,6 +135,10 @@ const HANDLERS = {
128
135
  quit: (session) => session.onQuit(),
129
136
  scrollUp: (session) => session.onScroll(-1),
130
137
  scrollDown: (session) => session.onScroll(1),
138
+ pageUp: (session) => session.onScroll(session.scrollStep(-1)),
139
+ pageDown: (session) => session.onScroll(session.scrollStep(1)),
140
+ halfUp: (session) => session.onScroll(session.scrollStep(-0.5)),
141
+ halfDown: (session) => session.onScroll(session.scrollStep(0.5)),
131
142
  };
132
143
 
133
144
  const KEY_MODE = {
@@ -191,6 +202,7 @@ class Session {
191
202
  this.mkdirSync = options.mkdirSync ?? fs.mkdirSync;
192
203
  this.now = options.now ?? systemNow;
193
204
  this.forceNewReview = options.newReview === true;
205
+ this.readOnly = options.readOnly === true;
194
206
  this.audit = options.audit === true;
195
207
  this.outdatedMap = options.outdatedMap;
196
208
  this.auditMap = options.auditMap;
@@ -233,6 +245,16 @@ class Session {
233
245
  this.saveTimer = null;
234
246
  this.blinkTimer = null;
235
247
  this.blinkOn = true;
248
+ this.loadGen = 0;
249
+ this.loadAbort = null;
250
+ this.loadPromise = null;
251
+ this.busy = '';
252
+ this.emptyReview = false;
253
+ this.loadError = null;
254
+ this.didLoad = false;
255
+ this.pendingExtras = false;
256
+ this.uiOpen = false;
257
+ this.finishReadLoop = null;
236
258
  }
237
259
 
238
260
  constructor(options) {
@@ -240,14 +262,35 @@ class Session {
240
262
  this.resetState();
241
263
  }
242
264
 
243
- load() {
244
- const here = itemKey(this.items[this.index]);
265
+ loadOptions(extra = {}) {
245
266
  const commit = this.rev;
246
267
  const audit = this.audit;
247
268
  const outdatedMap = this.outdatedMap;
248
269
  const auditMap = this.auditMap;
249
- const options = { commit, audit, outdatedMap, auditMap };
250
- const loaded = this.repo.load(this.cwd, this.paths, options);
270
+ return { commit, audit, outdatedMap, auditMap, ...extra };
271
+ }
272
+
273
+ shouldDeferExtras() {
274
+ if (!this.audit) return false;
275
+ if (this.outdatedMap !== undefined && this.auditMap !== undefined) {
276
+ return false;
277
+ }
278
+ return true;
279
+ }
280
+
281
+ bumpLoad() {
282
+ this.loadGen += 1;
283
+ if (this.loadAbort) this.loadAbort.abort();
284
+ this.loadAbort = new AbortController();
285
+ }
286
+
287
+ ensureRepo() {
288
+ if (!this.repo.toplevel) return;
289
+ this.top = this.repo.toplevel(this.cwd);
290
+ }
291
+
292
+ applyLoaded(loaded) {
293
+ const here = itemKey(this.items[this.index]);
251
294
  this.top = loaded.top ?? this.cwd;
252
295
  const items = loaded.items;
253
296
  this.items = items.filter((item) => !this.dismissed.has(itemKey(item)));
@@ -277,6 +320,121 @@ class Session {
277
320
  this.clampIndex();
278
321
  this.syncReviewPath();
279
322
  this.syncFileCursor();
323
+ this.didLoad = true;
324
+ this.pendingExtras = loaded.pending === true;
325
+ }
326
+
327
+ load() {
328
+ const loaded = this.repo.load(this.cwd, this.paths, this.loadOptions());
329
+ this.applyLoaded(loaded);
330
+ }
331
+
332
+ paint() {
333
+ if (!this.uiOpen) return;
334
+ this.draw();
335
+ }
336
+
337
+ viewStatus() {
338
+ if (this.status) return this.status;
339
+ return this.busy;
340
+ }
341
+
342
+ finishIfEmpty(kind) {
343
+ if (this.items.length) return;
344
+ if (this.notes && hasNotes(this.notes)) this.flushReview();
345
+ this.done = true;
346
+ this.exitCode = 0;
347
+ this.status = 'nothing to review';
348
+ this.emptyReview = kind === 'open';
349
+ if (this.finishReadLoop) this.finishReadLoop();
350
+ }
351
+
352
+ failOpen(error) {
353
+ if (isAbortError(error)) return;
354
+ this.loadError = error;
355
+ this.done = true;
356
+ this.exitCode = 1;
357
+ this.busy = '';
358
+ if (this.finishReadLoop) this.finishReadLoop();
359
+ }
360
+
361
+ async followExtras(loaded, gen, kind) {
362
+ if (!this.repo.loadExtras) {
363
+ this.busy = '';
364
+ this.pendingExtras = false;
365
+ this.paint();
366
+ this.finishIfEmpty(kind);
367
+ return;
368
+ }
369
+ const signal = this.loadAbort ? this.loadAbort.signal : undefined;
370
+ const extra = this.loadOptions({
371
+ paths: this.paths,
372
+ signal,
373
+ });
374
+ let next;
375
+ try {
376
+ next = await this.repo.loadExtras(loaded, extra);
377
+ } catch (error) {
378
+ if (gen !== this.loadGen) return;
379
+ this.failOpen(error);
380
+ return;
381
+ }
382
+ if (gen !== this.loadGen || this.done) return;
383
+ this.applyLoaded(next);
384
+ this.busy = '';
385
+ this.paint();
386
+ this.finishIfEmpty(kind);
387
+ }
388
+
389
+ openLoad() {
390
+ if (this.loadPromise) return this.loadPromise;
391
+ if (this.didLoad && !this.pendingExtras) {
392
+ this.loadPromise = Promise.resolve();
393
+ return this.loadPromise;
394
+ }
395
+ this.bumpLoad();
396
+ this.loadPromise = this.runOpenLoad();
397
+ return this.loadPromise;
398
+ }
399
+
400
+ async runOpenLoad() {
401
+ const gen = this.loadGen;
402
+ const signal = this.loadAbort.signal;
403
+ const options = this.loadOptions({
404
+ deferExtras: this.shouldDeferExtras(),
405
+ signal,
406
+ });
407
+ this.busy = 'loading';
408
+ this.paint();
409
+ let loaded;
410
+ try {
411
+ if (this.repo.loadAsync) {
412
+ loaded = await this.repo.loadAsync(this.cwd, this.paths, options);
413
+ } else {
414
+ loaded = this.repo.load(this.cwd, this.paths, options);
415
+ }
416
+ } catch (error) {
417
+ if (gen !== this.loadGen) return;
418
+ this.failOpen(error);
419
+ return;
420
+ }
421
+ if (gen !== this.loadGen || this.done) return;
422
+ this.applyLoaded(loaded);
423
+ if (loaded.pending) {
424
+ this.busy = 'checking npm';
425
+ this.paint();
426
+ await this.followExtras(loaded, gen, 'open');
427
+ return;
428
+ }
429
+ this.busy = '';
430
+ this.paint();
431
+ this.finishIfEmpty('open');
432
+ }
433
+
434
+ async loadReady() {
435
+ this.ensureRepo();
436
+ await this.openLoad();
437
+ if (this.loadError) throw this.loadError;
280
438
  }
281
439
 
282
440
  initReview() {
@@ -386,14 +544,14 @@ class Session {
386
544
  view() {
387
545
  const templates = this.shownTemplates();
388
546
  const change = this.change;
389
- const isGithubPr = change && change.source === 'github-pr';
547
+ const sourceKind = (change && SOURCE_KIND[change.source]) || '';
390
548
  const repoName = this.repoName || path.basename(this.top || this.cwd || '');
391
549
  return {
392
550
  item: this.current(),
393
551
  index: this.index,
394
552
  total: this.items.length,
395
553
  scroll: this.scroll,
396
- status: this.status,
554
+ status: this.viewStatus(),
397
555
  layout: this.layout,
398
556
  counts: this.counts(),
399
557
  selection: this.selection,
@@ -404,7 +562,7 @@ class Session {
404
562
  repoName,
405
563
  revShort: this.revShort || '',
406
564
  sourceLabel: this.sourceLabel || '',
407
- sourceKind: isGithubPr ? 'pr' : '',
565
+ sourceKind,
408
566
  mode: this.mode,
409
567
  compose: this.composeView(),
410
568
  noteText: this.idleNoteText(),
@@ -466,20 +624,25 @@ class Session {
466
624
 
467
625
  reloadAfterChange() {
468
626
  const here = this.index;
469
- this.load();
470
- if (!this.items.length) {
471
- if (hasNotes(this.notes)) this.flushReview();
472
- this.done = true;
473
- this.exitCode = 0;
474
- this.status = 'nothing to review';
475
- return;
476
- }
627
+ this.loadPromise = null;
628
+ this.bumpLoad();
629
+ const options = this.loadOptions({
630
+ deferExtras: this.shouldDeferExtras(),
631
+ });
632
+ const loaded = this.repo.load(this.cwd, this.paths, options);
633
+ this.applyLoaded(loaded);
477
634
  this.index = here;
478
635
  this.clampIndex();
479
636
  this.syncReviewPath();
480
637
  this.syncFileCursor();
481
638
  this.scroll = 0;
482
639
  this.clearSelection();
640
+ if (loaded.pending) {
641
+ this.busy = 'checking npm';
642
+ this.followExtras(loaded, this.loadGen, 'reload');
643
+ return;
644
+ }
645
+ this.finishIfEmpty('reload');
483
646
  }
484
647
 
485
648
  clearSelection() {
@@ -535,16 +698,20 @@ class Session {
535
698
  return items;
536
699
  }
537
700
 
538
- guardFileGit(items) {
539
- if (!items.length) return false;
540
- const first = items[0];
541
- if (isReadOnlyOrigin(first.origin)) {
701
+ guardWritable(item) {
702
+ if (!item) return false;
703
+ if (this.readOnly || isReadOnlyOrigin(item.origin)) {
542
704
  this.status = 'read only';
543
705
  return false;
544
706
  }
545
707
  return true;
546
708
  }
547
709
 
710
+ guardFileGit(items) {
711
+ if (!items.length) return false;
712
+ return this.guardWritable(items[0]);
713
+ }
714
+
548
715
  applyFileGit(items, label, write, afterItem) {
549
716
  try {
550
717
  for (const item of items) {
@@ -578,12 +745,13 @@ class Session {
578
745
  onFileAdd() {
579
746
  const items = this.fileActionItems();
580
747
  if (!this.guardFileGit(items)) return;
748
+ const rel = itemPath(items[0]);
581
749
  const targets = items.filter((item) => item.origin !== 'staged');
582
750
  if (!targets.length) {
583
751
  this.status = 'already staged';
752
+ this.selectNextFileAfter(rel);
584
753
  return;
585
754
  }
586
- const rel = itemPath(targets[0]);
587
755
  const proposed = targets.some((item) => needsReload(item));
588
756
  const ok = this.applyFileGit(
589
757
  targets,
@@ -598,12 +766,13 @@ class Session {
598
766
  onFileUnstage() {
599
767
  const items = this.fileActionItems();
600
768
  if (!this.guardFileGit(items)) return;
769
+ const rel = itemPath(items[0]);
601
770
  const targets = items.filter((item) => item.origin === 'staged');
602
771
  if (!targets.length) {
603
772
  this.status = 'not staged';
773
+ this.selectNextFileAfter(rel);
604
774
  return;
605
775
  }
606
- const rel = itemPath(targets[0]);
607
776
  const ok = this.applyFileGit(
608
777
  targets,
609
778
  'unstaged',
@@ -638,11 +807,7 @@ class Session {
638
807
  onAdd() {
639
808
  if (this.pane === 'files') return void this.onFileAdd();
640
809
  const item = this.activeDiffItem();
641
- if (!item) return;
642
- if (isReadOnlyOrigin(item.origin)) {
643
- this.status = 'read only';
644
- return;
645
- }
810
+ if (!this.guardWritable(item)) return;
646
811
  if (item.origin === 'staged') {
647
812
  this.status = 'already staged';
648
813
  return;
@@ -664,11 +829,7 @@ class Session {
664
829
  onUnstage() {
665
830
  if (this.pane === 'files') return void this.onFileUnstage();
666
831
  const item = this.activeDiffItem();
667
- if (!item) return;
668
- if (isReadOnlyOrigin(item.origin)) {
669
- this.status = 'read only';
670
- return;
671
- }
832
+ if (!this.guardWritable(item)) return;
672
833
  if (item.origin !== 'staged') {
673
834
  this.status = 'not staged';
674
835
  return;
@@ -685,11 +846,7 @@ class Session {
685
846
  onRevert() {
686
847
  if (this.pane === 'files') return void this.onFileRevert();
687
848
  const item = this.activeDiffItem();
688
- if (!item) return;
689
- if (isReadOnlyOrigin(item.origin)) {
690
- this.status = 'read only';
691
- return;
692
- }
849
+ if (!this.guardWritable(item)) return;
693
850
  this.applyGit(
694
851
  item,
695
852
  'reverted',
@@ -1267,13 +1424,12 @@ class Session {
1267
1424
  this.status = 'nothing to review';
1268
1425
  return;
1269
1426
  }
1270
- const next = this.fileCursor + delta;
1271
- if (next < 0) {
1272
- this.status = 'first file';
1273
- return;
1274
- }
1275
- if (next >= files.length) {
1276
- this.status = 'last file';
1427
+ const next = Math.min(
1428
+ files.length - 1,
1429
+ Math.max(0, this.fileCursor + delta),
1430
+ );
1431
+ if (next === this.fileCursor) {
1432
+ this.status = next <= 0 ? 'first file' : 'last file';
1277
1433
  return;
1278
1434
  }
1279
1435
  this.fileCursor = next;
@@ -1283,6 +1439,16 @@ class Session {
1283
1439
  this.status = '';
1284
1440
  }
1285
1441
 
1442
+ scrollStep(fraction) {
1443
+ const bodyH = this.lastFrame?.bodyH;
1444
+ const size = this.lastSize ?? this.getSize();
1445
+ const fallback = Math.max(1, (size.height ?? 24) - 4);
1446
+ const height = Math.max(1, bodyH ?? fallback);
1447
+ const n = Math.max(1, Math.floor(height * Math.abs(fraction)));
1448
+ if (fraction < 0) return -n;
1449
+ return n;
1450
+ }
1451
+
1286
1452
  onScroll(delta) {
1287
1453
  if (this.pane === 'files') {
1288
1454
  this.onFileMove(delta);
@@ -1428,12 +1594,20 @@ class Session {
1428
1594
  }
1429
1595
 
1430
1596
  async startUi() {
1597
+ if (this.didLoad && !this.items.length) {
1598
+ this.emptyReview = true;
1599
+ return 0;
1600
+ }
1601
+ this.uiOpen = true;
1431
1602
  this.enterTerm();
1432
1603
  try {
1604
+ this.openLoad();
1433
1605
  this.draw();
1434
1606
  await this.readLoop();
1435
1607
  } finally {
1608
+ this.uiOpen = false;
1436
1609
  this.leaveTerm();
1610
+ if (this.loadAbort) this.loadAbort.abort();
1437
1611
  }
1438
1612
  return this.exitCode;
1439
1613
  }
@@ -1442,7 +1616,10 @@ class Session {
1442
1616
  return new Promise((resolve) => {
1443
1617
  this.abortController = new AbortController();
1444
1618
  const { signal } = this.abortController;
1619
+ let settled = false;
1445
1620
  const finish = () => {
1621
+ if (settled) return;
1622
+ settled = true;
1446
1623
  if (this.saveTimer !== null) {
1447
1624
  this.clearInterval(this.saveTimer);
1448
1625
  this.saveTimer = null;
@@ -1452,8 +1629,14 @@ class Session {
1452
1629
  this.blinkTimer = null;
1453
1630
  }
1454
1631
  this.abortController.abort();
1632
+ this.finishReadLoop = null;
1455
1633
  resolve();
1456
1634
  };
1635
+ this.finishReadLoop = finish;
1636
+ if (this.done) {
1637
+ finish();
1638
+ return;
1639
+ }
1457
1640
  const onData = (chunk) => {
1458
1641
  this.pushInput(chunk.toString('utf8'));
1459
1642
  if (this.done) {
@@ -1468,7 +1651,7 @@ class Session {
1468
1651
  CURSOR_BLINK_MS,
1469
1652
  );
1470
1653
  this.stdin.on('data', (chunk) => onData(chunk), { signal });
1471
- process.on('SIGWINCH', () => this.draw(), { signal });
1654
+ watchResize(this.stdout, process, () => this.draw(), signal);
1472
1655
  });
1473
1656
  }
1474
1657
  }
package/lib/source.js CHANGED
@@ -1,13 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  const { parseGithubPrUrl } = require('./github.js');
4
+ const { parseGitlabMrUrl } = require('./gitlab.js');
4
5
 
5
6
  const selectChangeSource = (paths) => {
6
7
  if (!paths.length) return { kind: 'local', paths };
7
8
  const first = paths[0];
8
9
  const pr = parseGithubPrUrl(first);
9
- if (!pr) return { kind: 'local', paths };
10
- return { kind: 'github-pr', pr, paths: paths.slice(1) };
10
+ if (pr) return { kind: 'github-pr', pr, paths: paths.slice(1) };
11
+ const mr = parseGitlabMrUrl(first);
12
+ if (mr) return { kind: 'gitlab-mr', mr, paths: paths.slice(1) };
13
+ return { kind: 'local', paths };
11
14
  };
12
15
 
13
16
  const nop = () => {};