reslop 0.1.4 → 0.1.6

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/render.js CHANGED
@@ -10,8 +10,8 @@ const { itemPath } = require('./files.js');
10
10
  const wrap = require('./wrap.js');
11
11
 
12
12
  const { tokenize, overlayTokens } = highlight;
13
- const { ACTIONS, ACTION_IDS, actionLetter, buttonWord } = keys;
14
- const { FILES_DISABLED } = keys;
13
+ const { ACTION_IDS, BRANCH_ACTIONS, actionLetter, buttonWord } = keys;
14
+ const { disabledActions } = keys;
15
15
  const { wrapPlain, wrapMultiline, cursorInWrap } = wrap;
16
16
  const { THEME, CODE_FG, codeFg, paint, visibleWidth } = ansi;
17
17
  const { truncateVisible, fg, RESET, ESC, EL, seq } = ansi;
@@ -113,32 +113,117 @@ const FILE_COL_ALIGN = {
113
113
  m: 'end',
114
114
  };
115
115
 
116
+ const BRANCH_COL_KEYS = ['sha', 'ahead', 'behind', 'gone', 'date'];
117
+
118
+ const BRANCH_COL_ALIGN = {
119
+ sha: 'end',
120
+ ahead: 'start',
121
+ behind: 'start',
122
+ gone: 'end',
123
+ date: 'end',
124
+ };
125
+
126
+ const BRANCH_COL_FG = {
127
+ sha: THEME.mutedFg,
128
+ ahead: THEME.addLineFg,
129
+ behind: THEME.delLineFg,
130
+ gone: THEME.delLineFg,
131
+ date: THEME.mutedFg,
132
+ };
133
+
134
+ const BRANCH_NAME_MAX = 24;
135
+
136
+ const BUSY_BAR = ['▰▰▱▱▱▱', '▱▰▰▱▱▱', '▱▱▰▰▱▱', '▱▱▱▰▰▱', '▱▱▱▱▰▰', '▰▱▱▱▱▰'];
137
+ const BUSY_PROGRESS = [
138
+ 'pulling',
139
+ 'pushing',
140
+ 'loading',
141
+ 'checking npm',
142
+ 'updating reslop',
143
+ 'npm i',
144
+ 'npm uninstall',
145
+ 'npm audit fix',
146
+ 'checking out',
147
+ 'creating branch',
148
+ 'committing',
149
+ 'force pushing',
150
+ ];
151
+
152
+ const formatBusyStatus = (status, frame = 0) => {
153
+ const kind = `${status ?? ''}`;
154
+ if (!BUSY_PROGRESS.includes(kind)) return kind;
155
+ const i = Math.abs(frame) % BUSY_BAR.length;
156
+ return `${kind} ${BUSY_BAR[i]}`;
157
+ };
158
+
116
159
  const INFO_STATUS = [
117
160
  'copied',
118
161
  'staged',
119
162
  'unstaged',
120
163
  'not staged',
121
- 'reverted',
164
+ 'dropped',
122
165
  'already staged',
123
166
  'read only',
124
- 'last block',
125
- 'first block',
126
- 'first file',
127
- 'last file',
128
167
  'nothing to review',
168
+ 'nothing to commit',
129
169
  'unified',
130
170
  'mixed',
131
171
  'side-by-side',
132
172
  'saved',
173
+ 'reloaded',
133
174
  'not a diff block',
175
+ 'committed',
176
+ 'amended',
177
+ 'fixup',
178
+ 'updated',
179
+ 'updating reslop',
180
+ 'loading',
181
+ 'checking npm',
182
+ 'npm i',
183
+ 'npm uninstall',
184
+ 'npm audit fix',
185
+ 'pulling',
186
+ 'pushing',
187
+ 'pulled',
188
+ 'pushed',
189
+ 'force pushing',
190
+ 'force pushed',
191
+ 'checking out',
192
+ 'creating branch',
193
+ 'committing',
194
+ 'checked out',
195
+ 'created',
196
+ 'empty branch name',
134
197
  ];
198
+ const INFO_PREFIX = ['checked out ', 'created '];
135
199
 
136
- const QUIT_FINISH = 'f';
137
- const QUIT_CONTINUE = 'c';
138
- const QUIT_FINISH_LABEL = 'finish as ready';
139
- const QUIT_CONTINUE_LABEL = 'continue next time';
140
- const QUIT_PROMPT = ` ${QUIT_FINISH_LABEL} ${QUIT_CONTINUE_LABEL}`;
200
+ const QUIT_CHOICES = [
201
+ { letter: 'f', label: 'finish as ready', lead: ' ' },
202
+ { letter: 'c', label: 'continue next time', lead: ' ' },
203
+ { letter: 'd', label: 'discard', lead: ' ' },
204
+ ];
205
+ const QUIT_PROMPT = QUIT_CHOICES.map(
206
+ (choice) => `${choice.lead}${choice.label}`,
207
+ ).join('');
208
+ const COMMIT_CHOICES = [
209
+ { letter: 'c', label: 'commit', lead: ' ' },
210
+ { letter: 'a', label: 'amend', lead: ' ' },
211
+ { letter: 'f', label: 'fixup', lead: ' ' },
212
+ ];
213
+ const COMMIT_PROMPT = COMMIT_CHOICES.map(
214
+ (choice) => `${choice.lead}${choice.label}`,
215
+ ).join('');
216
+ const UPDATE_CHOICES = [
217
+ { letter: 'y', label: 'y', lead: ' ' },
218
+ { letter: 'n', label: 'n', lead: ' ' },
219
+ ];
220
+ const updatePrompt = (from, to) => ` update reslop ${from} → ${to}? y n`;
221
+ const PUSH_CHOICES = [{ letter: 'f', label: 'force push', lead: ' ' }];
222
+ const PUSH_REJECT_PREFIX = ' Need force-push?';
223
+ const PUSH_PROMPT = `${PUSH_REJECT_PREFIX} force push`;
141
224
 
225
+ const NOTE_COMPOSE = ['feedback', 'commit'];
226
+ const LIST_PANES = ['files', 'branches'];
142
227
  const GUTTER_W = 2;
143
228
  const BUTTON_GAP = ' ';
144
229
  const LOGO = '👁️';
@@ -340,6 +425,21 @@ const headerTarget = (view) => {
340
425
  if (!entry) return { path: '', origin: '', n, total };
341
426
  return { path: entry.path, origin: entry.status, n, total };
342
427
  }
428
+ if (pane === 'branches') {
429
+ const compose = view.compose;
430
+ const branches = view.branches ?? [];
431
+ if (compose && compose.kind === 'branch') {
432
+ const total = branches.length + 1;
433
+ return { path: compose.text || '', origin: 'new', n: total, total };
434
+ }
435
+ const cursor = view.branchCursor ?? 0;
436
+ const entry = branches[cursor];
437
+ const total = branches.length;
438
+ const n = total ? cursor + 1 : 0;
439
+ if (!entry) return { path: '', origin: 'branch', n, total };
440
+ const origin = entry.current ? 'current' : 'branch';
441
+ return { path: entry.name, origin, n, total };
442
+ }
343
443
  if (!view.item) return { path: '', origin: '', n: 0, total: view.total ?? 0 };
344
444
  return {
345
445
  path: itemPath(view.item),
@@ -414,17 +514,21 @@ const paintHeader = (view, width, color) => {
414
514
  return `${out}${RESET}`;
415
515
  };
416
516
 
417
- const bodyLines = (item, layout) => {
517
+ const bodyLines = (item, layout, overlay) => {
418
518
  if (item.file.isBinary) {
419
519
  const text = '(binary)';
420
520
  return [{ type: 'ctx', text, spans: [{ text, changed: false }] }];
421
521
  }
422
522
  if (!item.hunk) return [];
423
523
  const radius = item.dep ? Number.MAX_SAFE_INTEGER : undefined;
424
- return displayLines(item.hunk, item.blockId, layout, radius);
524
+ return displayLines(item.hunk, item.blockId, layout, radius, overlay);
425
525
  };
426
526
 
427
- const sourceEmpty = (line) => !line || !(line.text ?? '').trim();
527
+ const sourceEmpty = (line) => {
528
+ if (!line) return true;
529
+ if (line.editStart !== undefined) return false;
530
+ return !(line.text ?? '').trim();
531
+ };
428
532
 
429
533
  const displayRowEmpty = (row, split) => {
430
534
  if (!split) return sourceEmpty(row);
@@ -470,29 +574,37 @@ const paintMarkedWord = (label, letter, color, mark) => {
470
574
  return out;
471
575
  };
472
576
 
473
- const layoutButtons = (width, compact = false, disabled = []) => {
577
+ const layoutButtons = (width, compact = false, hidden = [], extra = []) => {
578
+ const ids = [];
579
+ for (const id of ACTION_IDS) {
580
+ if (id === 'quit') {
581
+ for (const add of extra) {
582
+ if (!hidden.includes(add)) ids.push(add);
583
+ }
584
+ }
585
+ if (hidden.includes(id)) continue;
586
+ ids.push(id);
587
+ }
474
588
  const build = (short) => {
475
589
  const hits = [];
476
590
  const parts = [];
477
591
  let x = 0;
478
- for (const id of ACTION_IDS) {
479
- const action = { id, ...ACTIONS[id] };
480
- const letter = actionLetter(action);
481
- const word = buttonWord(action);
592
+ for (const id of ids) {
593
+ const letter = actionLetter(id);
594
+ const word = buttonWord(id);
482
595
  const label = short ? letter : word;
483
596
  const piece = `${BUTTON_GAP}${label}`;
484
597
  const w = visibleWidth(piece);
485
598
  if (x + w > width && parts.length) break;
486
- const isDisabled = disabled.includes(id);
487
- if (!isDisabled) hits.push({ id, x0: x, x1: x + w });
488
- parts.push({ action, piece, letter, label, disabled: isDisabled });
599
+ hits.push({ id, x0: x, x1: x + w });
600
+ parts.push({ action: { id }, piece, letter, label });
489
601
  x += w;
490
602
  }
491
603
  return { hits, parts, width: x };
492
604
  };
493
605
  if (compact) return build(true);
494
606
  const words = build(false);
495
- if (words.parts.length === ACTION_IDS.length) return words;
607
+ if (words.parts.length === ids.length) return words;
496
608
  return build(true);
497
609
  };
498
610
 
@@ -501,11 +613,7 @@ const paintButtons = (layout, width, color) => {
501
613
  let used = 0;
502
614
  for (const part of layout.parts) {
503
615
  out += paint(BUTTON_GAP, THEME.buttonFg, THEME.buttonBg, color);
504
- if (part.disabled) {
505
- out += paint(part.label, THEME.buttonFg, THEME.buttonBg, color);
506
- } else {
507
- out += paintMarkedWord(part.label, part.letter, color, BUTTON_MARK);
508
- }
616
+ out += paintMarkedWord(part.label, part.letter, color, BUTTON_MARK);
509
617
  used += visibleWidth(part.piece);
510
618
  }
511
619
  if (used < width) {
@@ -514,6 +622,30 @@ const paintButtons = (layout, width, color) => {
514
622
  return out;
515
623
  };
516
624
 
625
+ const branchLabel = (name) => {
626
+ const branch = `${name ?? ''}`.trim();
627
+ if (!branch) return '';
628
+ return `[${branch}]`;
629
+ };
630
+
631
+ const splitBracketName = (label) => {
632
+ const text = `${label ?? ''}`;
633
+ const open = text.startsWith('[') ? '[' : '';
634
+ const rest = open ? text.slice(1) : text;
635
+ const close = rest.endsWith(']') ? ']' : '';
636
+ const name = close ? rest.slice(0, -1) : rest;
637
+ return { open, name, close };
638
+ };
639
+
640
+ const paintBracketName = (label, nameFg, wrapFg, bgRgb, color) => {
641
+ const { open, name, close } = splitBracketName(label);
642
+ return (
643
+ paint(open, wrapFg, bgRgb, color, true) +
644
+ paint(name, nameFg, bgRgb, color) +
645
+ paint(close, wrapFg, bgRgb, color, true)
646
+ );
647
+ };
648
+
517
649
  const remoteCounts = (kind, { counts, notes, sourceLabel }) => {
518
650
  const n = counts.pr ?? 0;
519
651
  const label = sourceLabel || kind;
@@ -527,12 +659,14 @@ const COUNTS_KIND = {
527
659
  const n = counts.commit ?? 0;
528
660
  return ` commit ${revShort} ${n} ${notes}`;
529
661
  },
530
- worktree: ({ counts, notes }) => {
662
+ worktree: ({ counts, notes, branch }) => {
531
663
  const staged = counts.staged ?? 0;
532
664
  const unstaged = counts.unstaged ?? 0;
533
665
  const untracked = counts.untracked ?? 0;
534
666
  const gitCounts = `unstaged ${unstaged} untracked ${untracked}`;
535
667
  const git = `staged ${staged} ${gitCounts}`;
668
+ const mark = branchLabel(branch);
669
+ if (mark) return ` ${mark} ${git} ${notes}`;
536
670
  return ` ${git} ${notes}`;
537
671
  },
538
672
  };
@@ -543,37 +677,92 @@ const countsKind = (sourceKind, revShort) => {
543
677
  return 'worktree';
544
678
  };
545
679
 
546
- const countsLine = (counts, revShort, sourceKind, sourceLabel) => {
680
+ const countsLine = (counts, revShort, sourceKind, sourceLabel, branch) => {
547
681
  const feedback = counts.feedback ?? 0;
548
682
  const todo = counts.todo ?? 0;
549
- const notes = `feedback ${feedback} todo ${todo}`;
683
+ const code = counts.code ?? 0;
684
+ const notes = `feedback ${feedback} todo ${todo} code ${code}`;
550
685
  const kind = countsKind(sourceKind, revShort);
551
- return COUNTS_KIND[kind]({ counts, notes, sourceLabel, revShort });
686
+ return COUNTS_KIND[kind]({ counts, notes, sourceLabel, revShort, branch });
552
687
  };
553
688
 
554
- const paintQuitPrompt = (width, color) => {
689
+ const paintChoicePrompt = (choices, width, color, prefix = '') => {
555
690
  const bgRgb = THEME.chromeBg;
556
- let body = QUIT_PROMPT;
557
- if (color) {
558
- const finish = ` ${QUIT_FINISH_LABEL}`;
559
- const rest = ` ${QUIT_CONTINUE_LABEL}`;
560
- const paintQuit = (label, letter) =>
561
- paintMarkedWord(label, letter, color, QUIT_MARK);
562
- body = paintQuit(finish, QUIT_FINISH) + paintQuit(rest, QUIT_CONTINUE);
691
+ const labels = choices
692
+ .map((choice) => `${choice.lead}${choice.label}`)
693
+ .join('');
694
+ const body = `${prefix}${labels}`;
695
+ if (!color) {
696
+ const pad = Math.max(0, width - visibleWidth(body));
697
+ return body + ' '.repeat(pad);
698
+ }
699
+ let out = '';
700
+ if (prefix) out += paint(prefix, THEME.mutedFg, bgRgb, color);
701
+ for (const choice of choices) {
702
+ const label = `${choice.lead}${choice.label}`;
703
+ out += paintMarkedWord(label, choice.letter, color, QUIT_MARK);
563
704
  }
564
705
  const pad = Math.max(0, width - visibleWidth(body));
565
- return body + fill(pad, THEME.mutedFg, bgRgb, color);
706
+ return out + fill(pad, THEME.mutedFg, bgRgb, color);
707
+ };
708
+
709
+ const isInfoStatus = (status) => {
710
+ if (INFO_STATUS.includes(status)) return true;
711
+ for (const prefix of INFO_PREFIX) {
712
+ if (status.startsWith(prefix)) return true;
713
+ }
714
+ return false;
715
+ };
716
+
717
+ const paintStatusLeft = (text, color) => {
718
+ const bgRgb = THEME.chromeBg;
719
+ const muted = THEME.mutedFg;
720
+ if (!color) return text;
721
+ const open = text.indexOf('[');
722
+ const close = text.indexOf(']');
723
+ if (open < 0 || close <= open) return paint(text, muted, bgRgb, color);
724
+ const before = text.slice(0, open);
725
+ const name = text.slice(open + 1, close);
726
+ const after = text.slice(close + 1);
727
+ const label = `[${name}]`;
728
+ return (
729
+ paint(before, muted, bgRgb, color) +
730
+ paintBracketName(label, THEME.buttonHotFg, muted, bgRgb, color) +
731
+ paint(after, muted, bgRgb, color)
732
+ );
566
733
  };
567
734
 
568
735
  const paintStatusLine = (view, status, width, color) => {
569
736
  const { mode, counts, revShort, sourceKind, sourceLabel } = view;
570
- if (mode === 'confirmQuit') return paintQuitPrompt(width, color);
571
- const left = countsLine(counts ?? {}, revShort, sourceKind, sourceLabel);
737
+ if (mode === 'confirmQuit') {
738
+ return paintChoicePrompt(QUIT_CHOICES, width, color);
739
+ }
740
+ if (mode === 'confirmCommit') {
741
+ return paintChoicePrompt(COMMIT_CHOICES, width, color);
742
+ }
743
+ if (mode === 'confirmUpdate') {
744
+ const from = view.updateFrom ?? '';
745
+ const to = view.updateTo ?? '';
746
+ const prefix = ` update reslop ${from} → ${to}?`;
747
+ return paintChoicePrompt(UPDATE_CHOICES, width, color, prefix);
748
+ }
749
+ if (mode === 'confirmPush') {
750
+ return paintChoicePrompt(PUSH_CHOICES, width, color, PUSH_REJECT_PREFIX);
751
+ }
752
+ const branch = view.branch ?? '';
753
+ const left = countsLine(
754
+ counts ?? {},
755
+ revShort,
756
+ sourceKind,
757
+ sourceLabel,
758
+ branch,
759
+ );
760
+ const shown = formatBusyStatus(status, view.progressFrame ?? 0);
572
761
  let right = '';
573
762
  let msgFg = THEME.chromeFg;
574
- if (status) {
575
- right = ` ${status} `;
576
- if (!INFO_STATUS.includes(status)) msgFg = THEME.errorFg;
763
+ if (shown) {
764
+ right = ` ${shown} `;
765
+ if (!isInfoStatus(status)) msgFg = THEME.errorFg;
577
766
  }
578
767
  const rightW = visibleWidth(right);
579
768
  const room = Math.max(0, width - rightW);
@@ -582,12 +771,39 @@ const paintStatusLine = (view, status, width, color) => {
582
771
  const gap = ' '.repeat(pad);
583
772
  const paintedRight = paint(right, msgFg, THEME.chromeBg, color);
584
773
  return (
585
- paint(clipped, THEME.mutedFg, THEME.chromeBg, color) +
774
+ paintStatusLeft(clipped, color) +
586
775
  paint(gap, THEME.mutedFg, THEME.chromeBg, color) +
587
776
  paintedRight
588
777
  );
589
778
  };
590
779
 
780
+ const statusBranchHit = (view, width, y) => {
781
+ const mode = view.mode;
782
+ if (mode === 'confirmQuit') return null;
783
+ if (mode === 'confirmCommit') return null;
784
+ if (mode === 'confirmUpdate') return null;
785
+ if (mode === 'confirmPush') return null;
786
+ const branch = view.branch ?? '';
787
+ if (!branchLabel(branch)) return null;
788
+ if (countsKind(view.sourceKind, view.revShort) !== 'worktree') return null;
789
+ const left = countsLine(
790
+ view.counts ?? {},
791
+ view.revShort,
792
+ view.sourceKind,
793
+ view.sourceLabel,
794
+ branch,
795
+ );
796
+ const status = view.status ?? '';
797
+ const shown = formatBusyStatus(status, view.progressFrame ?? 0);
798
+ const right = shown ? ` ${shown} ` : '';
799
+ const room = Math.max(0, width - visibleWidth(right));
800
+ const clipped = truncateVisible(left, room);
801
+ const open = clipped.indexOf('[');
802
+ const close = clipped.indexOf(']');
803
+ if (open < 0 || close <= open) return null;
804
+ return { id: 'branch', y, x0: open, x1: close + 1 };
805
+ };
806
+
591
807
  const signedCount = (sign, n) => `${sign}${n ?? 0}`;
592
808
 
593
809
  const fileRowFields = (entry) => {
@@ -680,6 +896,12 @@ const paintFileRow = (entry, width, color, selected, cols) => {
680
896
 
681
897
  const todoInnerWidth = (width) => Math.max(1, width - 2);
682
898
 
899
+ const codeInnerWidth = (width, layout = 'unified') => {
900
+ const lineW = layout === 'side' ? splitWidths(width).rightW : width;
901
+ const gutterW = Math.min(GUTTER_W, Math.max(0, lineW));
902
+ return Math.max(1, lineW - gutterW);
903
+ };
904
+
683
905
  const checkLeadLen = (text) => {
684
906
  const match = CHECK_LEAD.exec(text);
685
907
  return match ? match[0].length : 0;
@@ -792,36 +1014,219 @@ const paintIdleNote = (view, width, color) => {
792
1014
 
793
1015
  const paintNotePanel = (view, width, color, cap) => {
794
1016
  const empty = { rows: [], cursor: null, templateHits: [] };
795
- if ((view.pane ?? 'diff') === 'files') return empty;
796
1017
  const compose = view.compose;
797
- if (compose && compose.kind === 'feedback') {
1018
+ if (compose && NOTE_COMPOSE.includes(compose.kind)) {
798
1019
  return paintComposeNote(view, width, color, cap);
799
1020
  }
1021
+ const pane = view.pane ?? 'diff';
1022
+ if (LIST_PANES.includes(pane)) return empty;
800
1023
  return paintIdleNote(view, width, color);
801
1024
  };
802
1025
 
803
- const paintBodyFiles = (view, width, color, bodyH, headerLines) => {
804
- const files = view.files ?? [];
805
- const cols = fileColumns(files);
806
- const cursor = view.fileCursor ?? 0;
1026
+ const paintCursorList = (
1027
+ items,
1028
+ cursor,
1029
+ width,
1030
+ color,
1031
+ bodyH,
1032
+ headerLines,
1033
+ paintRow,
1034
+ tail,
1035
+ ) => {
807
1036
  const pad = bodyH >= FILE_LIST_PAD * 2 + 1 ? FILE_LIST_PAD : 0;
808
1037
  const listH = bodyH - pad * 2;
809
- const start = cursor >= listH ? cursor - listH + 1 : 0;
1038
+ const extra = tail ? 1 : 0;
1039
+ const last = items.length + extra - 1;
1040
+ const focus = extra ? last : cursor;
1041
+ const start = focus >= listH ? focus - listH + 1 : 0;
810
1042
  const blank = paintBodyFill(width, color, 'files');
811
1043
  const body = [];
812
1044
  const fileHits = [];
1045
+ let textCursor = null;
813
1046
  if (pad) body.push(blank);
814
1047
  for (let i = 0; i < listH; i++) {
815
1048
  const idx = start + i;
816
- const entry = files[idx];
1049
+ if (tail && idx === items.length) {
1050
+ body.push(tail.row);
1051
+ if (tail.cursor) {
1052
+ textCursor = { x: tail.cursor.x, row: body.length - 1 };
1053
+ }
1054
+ continue;
1055
+ }
1056
+ const entry = items[idx];
817
1057
  if (!entry) break;
818
- const selected = idx === cursor;
819
- body.push(paintFileRow(entry, width, color, selected, cols));
1058
+ const selected = extra ? false : idx === cursor;
1059
+ body.push(paintRow(entry, width, color, selected));
820
1060
  const y = headerLines + pad + i + 1;
821
1061
  fileHits.push({ y, cursor: idx });
822
1062
  }
823
1063
  if (pad) body.push(blank);
824
- return { body, fileHits };
1064
+ return { body, fileHits, cursor: textCursor };
1065
+ };
1066
+
1067
+ const paintBodyFiles = (view, width, color, bodyH, headerLines) => {
1068
+ const files = view.files ?? [];
1069
+ const cols = fileColumns(files);
1070
+ const cursor = view.fileCursor ?? 0;
1071
+ const paintRow = (entry, rowWidth, rowColor, selected) =>
1072
+ paintFileRow(entry, rowWidth, rowColor, selected, cols);
1073
+ return paintCursorList(
1074
+ files,
1075
+ cursor,
1076
+ width,
1077
+ color,
1078
+ bodyH,
1079
+ headerLines,
1080
+ paintRow,
1081
+ );
1082
+ };
1083
+
1084
+ const branchRowFg = (entry, selected) => {
1085
+ if (selected) return THEME.chromeFg;
1086
+ return THEME.mutedFg;
1087
+ };
1088
+
1089
+ const branchRowFields = (entry) => {
1090
+ const sha = entry.sha ?? '';
1091
+ const date = entry.date ?? '';
1092
+ const gone = entry.gone ? 'gone' : '';
1093
+ const ahead = entry.ahead ? `⇡${entry.ahead}` : '';
1094
+ const behind = entry.behind ? `⇣${entry.behind}` : '';
1095
+ return { sha, ahead, behind, gone, date };
1096
+ };
1097
+
1098
+ const branchColumns = (branches) => {
1099
+ const widths = { name: 0 };
1100
+ for (const key of BRANCH_COL_KEYS) widths[key] = 0;
1101
+ for (const entry of branches) {
1102
+ const nameW = visibleWidth(branchLabel(entry.name));
1103
+ if (nameW > widths.name) widths.name = nameW;
1104
+ const fields = branchRowFields(entry);
1105
+ for (const key of BRANCH_COL_KEYS) {
1106
+ const w = visibleWidth(fields[key]);
1107
+ if (w > widths[key]) widths[key] = w;
1108
+ }
1109
+ }
1110
+ widths.name = Math.min(BRANCH_NAME_MAX, widths.name);
1111
+ return widths;
1112
+ };
1113
+
1114
+ const branchRowParts = (entry, cols) => {
1115
+ const fields = branchRowFields(entry);
1116
+ const parts = {};
1117
+ for (const key of BRANCH_COL_KEYS) {
1118
+ const width = cols?.[key] ?? visibleWidth(fields[key]);
1119
+ const align = BRANCH_COL_ALIGN[key];
1120
+ parts[key] = padVisible(fields[key], width, align);
1121
+ }
1122
+ return parts;
1123
+ };
1124
+
1125
+ const joinBranchSuffix = (parts, cols) => {
1126
+ const bits = [];
1127
+ for (const key of BRANCH_COL_KEYS) {
1128
+ if (!cols[key]) continue;
1129
+ bits.push(parts[key]);
1130
+ }
1131
+ if (!bits.length) return '';
1132
+ return ` ${bits.join(' ')}`;
1133
+ };
1134
+
1135
+ const paintBranchSuffix = (parts, cols, fgRgb, bgRgb, color) => {
1136
+ const gap = paint(' ', fgRgb, bgRgb, color);
1137
+ const bits = [];
1138
+ for (const key of BRANCH_COL_KEYS) {
1139
+ if (!cols[key]) continue;
1140
+ const tone = BRANCH_COL_FG[key] ?? fgRgb;
1141
+ bits.push(paint(parts[key], tone, bgRgb, color));
1142
+ }
1143
+ if (!bits.length) return '';
1144
+ return gap + bits.join(gap);
1145
+ };
1146
+
1147
+ const paintBranchRow = (entry, width, color, selected, cols) => {
1148
+ const inner = Math.max(1, width - 1);
1149
+ const mark = selected ? FILE_MARK : ' ';
1150
+ const prefix = ` ${mark} `;
1151
+ const parts = branchRowParts(entry, cols);
1152
+ const suffix = joinBranchSuffix(parts, cols);
1153
+ const rest = Math.max(1, inner - visibleWidth(prefix + suffix));
1154
+ const nameW = Math.min(cols.name, rest);
1155
+ const label = truncateVisible(branchLabel(entry.name), nameW);
1156
+ const namePad = Math.max(0, nameW - visibleWidth(label));
1157
+ const canSubject = rest >= nameW + 3;
1158
+ const gap = canSubject ? ' ' : '';
1159
+ const subjectW = canSubject ? rest - nameW - visibleWidth(gap) : 0;
1160
+ const subject = truncateVisible(entry.subject ?? '', subjectW);
1161
+ const mid = `${label}${' '.repeat(namePad)}${gap}${subject}`;
1162
+ const pad = Math.max(0, rest - visibleWidth(mid));
1163
+ const after = `${' '.repeat(namePad)}${gap}${subject}${' '.repeat(pad)}`;
1164
+ const left = `${prefix}${label}${after}`;
1165
+ const fgRgb = branchRowFg(entry, selected);
1166
+ const bgRgb = selected ? THEME.buttonBg : THEME.ctxBg;
1167
+ const nameFg = entry.isDefault ? THEME.headerFg : THEME.buttonHotFg;
1168
+ const nameBg = entry.isDefault ? THEME.buttonHotFg : bgRgb;
1169
+ const edge = fill(1, fgRgb, bgRgb, color);
1170
+ if (!color) return `${left}${suffix}${edge}`;
1171
+ let out = `${seq(fgRgb, bgRgb)}${EL}`;
1172
+ out += paint(prefix, fgRgb, bgRgb, color);
1173
+ out += paintBracketName(label, nameFg, THEME.mutedFg, nameBg, color);
1174
+ out += paint(after, fgRgb, bgRgb, color);
1175
+ out += paintBranchSuffix(parts, cols, fgRgb, bgRgb, color);
1176
+ return out + edge;
1177
+ };
1178
+
1179
+ const paintBranchEdit = (compose, width, color) => {
1180
+ const inner = Math.max(1, width - 1);
1181
+ const prefix = ` ${FILE_MARK} `;
1182
+ const prefixW = visibleWidth(prefix);
1183
+ const rest = Math.max(1, inner - prefixW);
1184
+ const text = compose.text ?? '';
1185
+ const label = `[${text}]`;
1186
+ const clipped = truncateVisible(label, rest);
1187
+ const pad = Math.max(0, rest - visibleWidth(clipped));
1188
+ const left = `${prefix}${clipped}${' '.repeat(pad)}`;
1189
+ const fgRgb = THEME.chromeFg;
1190
+ const bgRgb = THEME.buttonBg;
1191
+ const edge = fill(1, fgRgb, bgRgb, color);
1192
+ let row = `${left}${edge}`;
1193
+ if (color) {
1194
+ const gap = ' '.repeat(pad);
1195
+ row = `${seq(fgRgb, bgRgb)}${EL}`;
1196
+ row += paint(prefix, fgRgb, bgRgb, color);
1197
+ row += paintBracketName(
1198
+ clipped,
1199
+ THEME.buttonHotFg,
1200
+ THEME.mutedFg,
1201
+ bgRgb,
1202
+ color,
1203
+ );
1204
+ row += paint(gap, fgRgb, bgRgb, color);
1205
+ row += edge;
1206
+ }
1207
+ const col = Math.min((compose.cursor ?? 0) + 1, visibleWidth(clipped));
1208
+ return { row, cursor: { x: prefixW + col + 1 } };
1209
+ };
1210
+
1211
+ const paintBodyBranches = (view, width, color, bodyH, headerLines) => {
1212
+ const branches = view.branches ?? [];
1213
+ const cols = branchColumns(branches);
1214
+ const cursor = view.branchCursor ?? 0;
1215
+ const compose = view.compose;
1216
+ const editing = compose && compose.kind === 'branch';
1217
+ const tail = editing ? paintBranchEdit(compose, width, color) : null;
1218
+ const paintRow = (entry, rowWidth, rowColor, selected) =>
1219
+ paintBranchRow(entry, rowWidth, rowColor, selected, cols);
1220
+ return paintCursorList(
1221
+ branches,
1222
+ cursor,
1223
+ width,
1224
+ color,
1225
+ bodyH,
1226
+ headerLines,
1227
+ paintRow,
1228
+ tail,
1229
+ );
825
1230
  };
826
1231
 
827
1232
  const paintTodoCaption = (width, color) =>
@@ -851,34 +1256,93 @@ const wrapSoftLine = (line, width) => {
851
1256
  const gutterW = Math.min(GUTTER_W, Math.max(0, width));
852
1257
  const budget = Math.max(1, width - gutterW);
853
1258
  const rows = wrapPlain(line.text ?? '', budget);
854
- return rows.map((text) => {
855
- const spans = [{ text, changed: false }];
856
- return { ...line, text, spans };
857
- });
1259
+ const changed = line.wrap === true;
1260
+ const pieces = [];
1261
+ let used = 0;
1262
+ for (let i = 0; i < rows.length; i++) {
1263
+ const text = rows[i];
1264
+ const last = i === rows.length - 1;
1265
+ const spans = [{ text, changed }];
1266
+ const next = { ...line, text, spans, editLast: last };
1267
+ if (line.editStart !== undefined) {
1268
+ next.editStart = line.editStart + used;
1269
+ if (last) next.editEnd = line.editEnd;
1270
+ else next.editEnd = next.editStart + text.length;
1271
+ }
1272
+ used += text.length;
1273
+ pieces.push(next);
1274
+ }
1275
+ return pieces;
1276
+ };
1277
+
1278
+ const shouldWrap = (line) => {
1279
+ if (!line) return false;
1280
+ if (SOFT_WRAP.includes(line.type)) return true;
1281
+ return line.wrap === true;
858
1282
  };
859
1283
 
860
1284
  const expandSoftRows = (lines, width, split) => {
861
- const wrapWidth = split ? splitWidths(width).leftW : width;
1285
+ const { leftW, rightW } = splitWidths(width);
862
1286
  const out = [];
863
1287
  for (const row of lines) {
864
- const line = split ? row.left || row.right : row;
865
- if (!line || !SOFT_WRAP.includes(line.type)) {
866
- out.push(row);
1288
+ if (!split) {
1289
+ if (!shouldWrap(row)) {
1290
+ out.push(row);
1291
+ continue;
1292
+ }
1293
+ for (const part of wrapSoftLine(row, width)) out.push(part);
867
1294
  continue;
868
1295
  }
869
- const parts = wrapSoftLine(line, wrapWidth);
870
- for (const part of parts) {
871
- if (split) out.push({ left: part, right: part });
872
- else out.push(part);
1296
+ const right = row.right;
1297
+ if (shouldWrap(right)) {
1298
+ const parts = wrapSoftLine(right, rightW);
1299
+ out.push({ left: row.left, right: parts[0] });
1300
+ for (let i = 1; i < parts.length; i++) {
1301
+ out.push({ left: null, right: parts[i] });
1302
+ }
1303
+ continue;
873
1304
  }
1305
+ const left = row.left;
1306
+ if (shouldWrap(left)) {
1307
+ const parts = wrapSoftLine(left, leftW);
1308
+ for (const part of parts) out.push({ left: part, right: part });
1309
+ continue;
1310
+ }
1311
+ out.push(row);
874
1312
  }
875
1313
  return out;
876
1314
  };
877
1315
 
878
- const paintBodyDiff = (item, layout, width, color) => {
1316
+ const onCodePiece = (line, cursor) => {
1317
+ if (!line || line.editStart === undefined) return false;
1318
+ if (cursor < line.editStart) return false;
1319
+ if (line.editLast) return cursor <= line.editEnd;
1320
+ return cursor < line.editEnd;
1321
+ };
1322
+
1323
+ const codeCursorInRows = (rows, split, width, cursor) => {
1324
+ if (cursor === undefined || cursor === null) return null;
1325
+ const { leftW, rightW } = splitWidths(width);
1326
+ for (let i = 0; i < rows.length; i++) {
1327
+ const line = split ? rows[i].right : rows[i];
1328
+ if (!onCodePiece(line, cursor)) continue;
1329
+ const lineW = split ? rightW : width;
1330
+ const gutterW = Math.min(GUTTER_W, Math.max(0, lineW));
1331
+ const take = Math.min(cursor - line.editStart, line.text.length);
1332
+ const col = visibleWidth(line.text.slice(0, take));
1333
+ const x0 = split ? leftW + 1 : 0;
1334
+ return { x: x0 + gutterW + col + 1, row: i };
1335
+ }
1336
+ return null;
1337
+ };
1338
+
1339
+ const paintBodyDiff = (view, width, color) => {
1340
+ const item = view.item;
1341
+ const layout = view.layout ?? 'unified';
1342
+ const overlay = view.codeOverlay;
879
1343
  const lang = detectLang(itemPath(item));
880
1344
  const splitBody = layout === 'side' && !item.file.isBinary;
881
- const raw = dropLeadingEmpty(bodyLines(item, layout), splitBody);
1345
+ const raw = dropLeadingEmpty(bodyLines(item, layout, overlay), splitBody);
882
1346
  const lines = expandSoftRows(raw, width, splitBody);
883
1347
  const body = [];
884
1348
  if (splitBody) {
@@ -890,11 +1354,14 @@ const paintBodyDiff = (item, layout, width, color) => {
890
1354
  body.push(paintDiffLine(line, width, color, item.origin, lang));
891
1355
  }
892
1356
  }
893
- return { body, splitBody };
1357
+ const edit = overlay ? overlay.cursor : null;
1358
+ const cursor = codeCursorInRows(lines, splitBody, width, edit);
1359
+ return { body, splitBody, cursor };
894
1360
  };
895
1361
 
896
1362
  const bodyKind = (view) => {
897
- if ((view.pane ?? 'diff') === 'files') return 'files';
1363
+ const pane = view.pane ?? 'diff';
1364
+ if (pane === 'files' || pane === 'branches') return pane;
898
1365
  if (!view.item) return 'empty';
899
1366
  if (view.item.origin === 'todo') return 'todo';
900
1367
  return 'diff';
@@ -905,13 +1372,17 @@ const BODY_PAINT = {
905
1372
  ...paintBodyFiles(view, width, color, bodyH, headerLines),
906
1373
  todoOwners: [],
907
1374
  }),
1375
+ branches: (view, width, color, bodyH, headerLines) => ({
1376
+ ...paintBodyBranches(view, width, color, bodyH, headerLines),
1377
+ todoOwners: [],
1378
+ }),
908
1379
  empty: () => ({ body: [], fileHits: [], todoOwners: [] }),
909
1380
  todo: (view, width, color) => ({
910
1381
  ...paintBodyTodo(view, width, color),
911
1382
  fileHits: [],
912
1383
  }),
913
1384
  diff: (view, width, color) => ({
914
- ...paintBodyDiff(view.item, view.layout ?? 'unified', width, color),
1385
+ ...paintBodyDiff(view, width, color),
915
1386
  fileHits: [],
916
1387
  todoOwners: [],
917
1388
  }),
@@ -954,7 +1425,7 @@ const windowBody = (view, width, color, bodyH, headerLines, paintedBody) => {
954
1425
  const todoOwners = paintedBody.todoOwners;
955
1426
  const splitBody = paintedBody.splitBody === true;
956
1427
  const pane = view.pane ?? 'diff';
957
- const listPane = pane === 'files';
1428
+ const listPane = LIST_PANES.includes(pane);
958
1429
  const fillKind = bodyFillKind(listPane, splitBody);
959
1430
  let shift = 0;
960
1431
  if (!listPane && body.length > 0) {
@@ -1006,7 +1477,15 @@ const stackFrame = (view, width, color, rows, layout, windowed, notes) => {
1006
1477
  return overlayRows(rows, view.selection);
1007
1478
  };
1008
1479
 
1009
- const frameResult = (text, painted, layout, windowed, hits, bodyH) => {
1480
+ const frameResult = (
1481
+ text,
1482
+ painted,
1483
+ layout,
1484
+ windowed,
1485
+ hits,
1486
+ bodyH,
1487
+ statusHits,
1488
+ ) => {
1010
1489
  const fileHits = windowed.fileHits;
1011
1490
  const todoHits = windowed.todoHits;
1012
1491
  const templateHits = hits.templateHits;
@@ -1020,6 +1499,7 @@ const frameResult = (text, painted, layout, windowed, hits, bodyH) => {
1020
1499
  fileHits,
1021
1500
  todoHits,
1022
1501
  templateHits,
1502
+ statusHits,
1023
1503
  footerY,
1024
1504
  height: painted.length,
1025
1505
  scroll,
@@ -1028,14 +1508,18 @@ const frameResult = (text, painted, layout, windowed, hits, bodyH) => {
1028
1508
  };
1029
1509
  };
1030
1510
 
1511
+ const hiddenActions = (view) => {
1512
+ const pane = view.pane ?? 'diff';
1513
+ return disabledActions(pane, view.item);
1514
+ };
1515
+
1031
1516
  const renderFrame = (view, options = {}) => {
1032
1517
  const width = Math.max(20, options.width ?? 80);
1033
1518
  const height = Math.max(6, options.height ?? 24);
1034
1519
  const color = options.color ?? true;
1035
1520
  const rows = [paintHeader(view, width, color)];
1036
- const pane = view.pane ?? 'diff';
1037
- const disabled = pane === 'files' ? FILES_DISABLED : [];
1038
- const layout = layoutButtons(width, false, disabled);
1521
+ const extra = view.pane === 'branches' ? Object.keys(BRANCH_ACTIONS) : [];
1522
+ const layout = layoutButtons(width, false, hiddenActions(view), extra);
1039
1523
  const notes = frameNotes(view, width, height, color);
1040
1524
  const headerLines = rows.length;
1041
1525
  const bodyH = notes.bodyH;
@@ -1056,7 +1540,10 @@ const renderFrame = (view, options = {}) => {
1056
1540
  notes.noteCursor,
1057
1541
  bodyH,
1058
1542
  );
1059
- return frameResult(text, painted, layout, windowed, hits, bodyH);
1543
+ const statusY = painted.length - 1;
1544
+ const branchHit = statusBranchHit(view, width, statusY);
1545
+ const statusHits = branchHit ? [branchHit] : [];
1546
+ return frameResult(text, painted, layout, windowed, hits, bodyH, statusHits);
1060
1547
  };
1061
1548
 
1062
1549
  const presentCursor = (cursor) => {
@@ -1080,9 +1567,10 @@ const presentRows = (rows, options = {}) => {
1080
1567
  };
1081
1568
 
1082
1569
  module.exports = {
1083
- QUIT_FINISH,
1084
- QUIT_CONTINUE,
1085
1570
  QUIT_PROMPT,
1571
+ COMMIT_PROMPT,
1572
+ PUSH_PROMPT,
1573
+ updatePrompt,
1086
1574
  DEL_CHAR_BG,
1087
1575
  ADD_CHAR_BG,
1088
1576
  paintDiffLine,
@@ -1090,6 +1578,8 @@ module.exports = {
1090
1578
  layoutButtons,
1091
1579
  todoInnerWidth,
1092
1580
  noteInnerWidth,
1581
+ codeInnerWidth,
1582
+ formatBusyStatus,
1093
1583
  renderFrame,
1094
1584
  presentCursor,
1095
1585
  presentRows,