reslop 0.2.5 → 0.2.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/LICENSE +1 -1
  3. package/README.md +11 -11
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +72 -19
  6. package/lib/api.js +1 -1
  7. package/lib/capabilities.js +8 -8
  8. package/lib/cli.js +61 -75
  9. package/lib/clipboard.js +39 -47
  10. package/lib/dashboard/git.js +128 -0
  11. package/lib/dashboard/model.js +357 -0
  12. package/lib/dashboard/npm.js +290 -0
  13. package/lib/dashboard/tree.js +278 -0
  14. package/lib/deps-diff.js +197 -402
  15. package/lib/deps.js +113 -240
  16. package/lib/detect.js +8 -18
  17. package/lib/diff/align.js +41 -113
  18. package/lib/diff/diff.js +12 -43
  19. package/lib/diff/display.js +25 -36
  20. package/lib/diff/edit.js +6 -20
  21. package/lib/diff/inline.js +76 -144
  22. package/lib/diff/items.js +33 -47
  23. package/lib/diff/parse.js +28 -56
  24. package/lib/diff/patch.js +12 -33
  25. package/lib/diff/unit.js +79 -178
  26. package/lib/editor.js +52 -69
  27. package/lib/files.js +79 -91
  28. package/lib/find.js +61 -0
  29. package/lib/git-branches.js +254 -416
  30. package/lib/git-deps.js +148 -272
  31. package/lib/git-reword-todo.js +5 -13
  32. package/lib/git-worktree.js +161 -214
  33. package/lib/git.js +21 -49
  34. package/lib/github.js +67 -181
  35. package/lib/gitlab.js +40 -90
  36. package/lib/highlight/bash.js +55 -177
  37. package/lib/highlight/core.js +155 -141
  38. package/lib/highlight/css.js +25 -58
  39. package/lib/highlight/csv.js +19 -37
  40. package/lib/highlight/dart.js +72 -213
  41. package/lib/highlight/dot.js +14 -14
  42. package/lib/highlight/html.js +42 -63
  43. package/lib/highlight/js.js +177 -506
  44. package/lib/highlight/json.js +17 -37
  45. package/lib/highlight/log.js +19 -25
  46. package/lib/highlight/txt.js +7 -10
  47. package/lib/highlight.js +17 -46
  48. package/lib/keys.js +75 -83
  49. package/lib/manifest.js +67 -126
  50. package/lib/npm-commands.js +120 -169
  51. package/lib/remote.js +140 -190
  52. package/lib/render/branches.js +63 -61
  53. package/lib/render/chrome.js +412 -347
  54. package/lib/render/commits.js +160 -361
  55. package/lib/render/dash-activity.js +212 -0
  56. package/lib/render/dash-blocks.js +496 -0
  57. package/lib/render/dash-table.js +295 -0
  58. package/lib/render/dashboard.js +62 -0
  59. package/lib/render/diff.js +123 -258
  60. package/lib/render/files.js +60 -86
  61. package/lib/render/frame.js +82 -117
  62. package/lib/render/notes.js +150 -210
  63. package/lib/render/npm.js +213 -177
  64. package/lib/render/packages.js +93 -0
  65. package/lib/render/present.js +4 -11
  66. package/lib/render/primitives.js +77 -172
  67. package/lib/render/render.js +5 -14
  68. package/lib/render/report.js +43 -0
  69. package/lib/render/repos.js +226 -0
  70. package/lib/render/search.js +25 -0
  71. package/lib/render/tiles.js +355 -0
  72. package/lib/render/unit.js +15 -31
  73. package/lib/report-model.js +110 -33
  74. package/lib/report-parse.js +56 -37
  75. package/lib/report-render.js +102 -59
  76. package/lib/report-run.js +87 -77
  77. package/lib/report-test.js +221 -99
  78. package/lib/report-tools.js +96 -154
  79. package/lib/review-merge.js +33 -41
  80. package/lib/review.js +69 -129
  81. package/lib/runs.js +225 -0
  82. package/lib/select.js +53 -91
  83. package/lib/session/accessors.js +14 -21
  84. package/lib/session/actions.js +189 -65
  85. package/lib/session/branches.js +164 -222
  86. package/lib/session/change-actions.js +49 -119
  87. package/lib/session/commit.js +298 -352
  88. package/lib/session/compose.js +572 -715
  89. package/lib/session/dashboard.js +530 -0
  90. package/lib/session/dispatch.js +197 -171
  91. package/lib/session/files-pane.js +92 -45
  92. package/lib/session/find.js +94 -0
  93. package/lib/session/items.js +6 -17
  94. package/lib/session/lifecycle.js +420 -466
  95. package/lib/session/load.js +1 -14
  96. package/lib/session/navigation.js +4 -2
  97. package/lib/session/npm.js +403 -420
  98. package/lib/session/ops.js +5 -13
  99. package/lib/session/packages.js +143 -0
  100. package/lib/session/pointer.js +30 -14
  101. package/lib/session/progress.js +39 -30
  102. package/lib/session/review.js +1 -3
  103. package/lib/session/tasks.js +277 -236
  104. package/lib/session/templates.js +51 -59
  105. package/lib/session/terminal.js +85 -132
  106. package/lib/session/view.js +59 -80
  107. package/lib/session/watch.js +201 -212
  108. package/lib/session/workspace.js +455 -0
  109. package/lib/session.js +95 -161
  110. package/lib/update.js +44 -111
  111. package/lib/utilities.js +46 -88
  112. package/lib/workspace.js +166 -0
  113. package/lib/wrap.js +29 -55
  114. package/package.json +1 -1
@@ -11,7 +11,8 @@ const { listedCommits } = commit;
11
11
  const editor = require('../editor.js');
12
12
  const { Editor } = editor;
13
13
  const files = require('../files.js');
14
- const { itemPath, isTaskItem, listPath, isReadOnlyOrigin } = files;
14
+ const { itemPath, isTaskItem, isTasksEntry, listPath } = files;
15
+ const { isReadOnlyOrigin } = files;
15
16
  const diff = require('../diff/diff.js');
16
17
  const { blockAddText } = diff;
17
18
  const items = require('./items.js');
@@ -19,151 +20,29 @@ const { itemFeedKey, canEditCode } = items;
19
20
  const review = require('../review.js');
20
21
  const { setFeedback, setCode, checkLabel, rememberTemplate } = review;
21
22
  const templates = require('./templates.js');
22
- const { createTemplatePick } = templates;
23
- const todos = require('./tasks.js');
24
- const { createTasksCompose } = todos;
23
+ const { TemplatePick } = templates;
24
+ const tasks = require('./tasks.js');
25
+ const { TaskPage } = tasks;
25
26
  const unit = require('./unit.js');
26
27
  const clipboard = require('../clipboard.js');
27
28
 
28
29
  const COMPOSE_EDITS = ['backspace', 'delete', 'ctrl-z', 'ctrl-y'];
30
+ const COMPOSE_CLIP = ['ctrl-x', 'ctrl-v'];
29
31
  const COMPOSE_LEAVE = ['next', 'prev', 'tasks'];
30
32
 
31
33
  const KIND = {
32
- feedback: {
33
- enter: 'save',
34
- tab: 'template',
35
- escape: 'save',
36
- move: 'note',
37
- command: null,
38
- view: 'note',
39
- },
40
- code: {
41
- enter: 'newline',
42
- tab: 'insert-tab',
43
- escape: 'save',
44
- move: 'code',
45
- command: null,
46
- view: 'code',
47
- },
48
- file: {
49
- enter: 'newline',
50
- tab: 'insert-tab',
51
- escape: 'save',
52
- move: 'code',
53
- command: null,
54
- view: 'code',
55
- },
56
- tasks: {
57
- enter: 'edit-next',
58
- tab: 'template',
59
- escape: 'save',
60
- move: 'tasks',
61
- command: null,
62
- view: 'tasks',
63
- },
64
- commit: {
65
- enter: 'save',
66
- tab: 'template',
67
- escape: 'cancel',
68
- move: 'note',
69
- command: 'commit',
70
- view: 'note',
71
- },
72
- branch: {
73
- enter: 'save',
74
- tab: 'template',
75
- escape: 'cancel',
76
- move: 'note',
77
- command: 'branch',
78
- view: 'note',
79
- },
80
- npm: {
81
- enter: 'save',
82
- tab: 'insert-tab',
83
- escape: 'cancel',
84
- move: 'note',
85
- command: 'npm',
86
- view: 'note',
87
- },
88
- };
89
-
90
- const initialState = () => ({
91
- editor: null,
92
- composeKind: null,
93
- commitKind: null,
94
- commitBody: '',
95
- composeTaskId: null,
96
- composeBaseline: '',
97
- templateIndex: -1,
98
- templateFocus: false,
99
- fileDirty: false,
100
- fileEditRows: null,
101
- fileOpened: '',
102
- });
103
-
104
- const reset = (state) => {
105
- Object.assign(state, initialState());
106
- };
107
-
108
- const viewSize = (api) => api.ui.lastSize ?? api.ui.getSize();
109
-
110
- const syncCodeScroll = (api) => {
111
- const kind = api.state.composeKind;
112
- if (kind !== 'code' && kind !== 'file') return;
113
- const ed = api.state.editor;
114
- if (!ed) return;
115
- const size = viewSize(api);
116
- const inner = codeInnerWidth(size.width, api.ui.layout);
117
- ed.reveal(inner);
34
+ feedback: { enter: 'save', tab: 'template', escape: 'save', move: 'note' },
35
+ code: { enter: 'newline', tab: 'insert-tab', escape: 'save', move: 'code' },
36
+ file: { enter: 'newline', tab: 'insert-tab', escape: 'save', move: 'code' },
37
+ tasks: { enter: 'edit-next', tab: 'template', escape: 'save', move: 'tasks' },
38
+ commit: { enter: 'save', tab: 'template', escape: 'cancel', move: 'note' },
39
+ branch: { enter: 'save', tab: 'template', escape: 'cancel', move: 'note' },
40
+ npm: { enter: 'save', tab: 'insert-tab', escape: 'cancel', move: 'note' },
41
+ package: { enter: 'save', tab: 'insert-tab', escape: 'cancel', move: 'note' },
118
42
  };
119
43
 
120
- const followFileEdit = (api) => {
121
- if (api.state.composeKind !== 'file') return;
122
- const text = api.state.editor ? api.state.editor.text : null;
123
- const rows = unit.linesFor(
124
- api.ui,
125
- api.nav.reviewPath,
126
- text,
127
- api.state.fileEditRows,
128
- );
129
- unit.followFileCursor(api.nav, api.state.editor, api.ui.lastFrame, rows);
130
- };
131
-
132
- const afterClose = (api) => {
133
- api.ui.clampIndex();
134
- api.ui.syncReviewPath();
135
- api.ui.syncFileCursor();
136
- };
137
-
138
- const openCompose = (api, kind, text, todoId) => {
139
- const { state, ui, nav } = api;
140
- ui.mode = 'compose';
141
- state.composeKind = kind;
142
- state.composeTaskId = todoId ?? null;
143
- state.composeBaseline = text;
144
- state.fileOpened = kind === 'file' ? text : '';
145
- state.editor = new Editor(text);
146
- state.templateIndex = -1;
147
- state.templateFocus = false;
148
- ui.status = '';
149
- nav.clearSelection();
150
- };
151
-
152
- const closeCompose = (api) => {
153
- const { state, ui } = api;
154
- ui.mode = 'review';
155
- state.editor = null;
156
- state.composeKind = null;
157
- state.commitKind = null;
158
- state.commitBody = '';
159
- state.composeTaskId = null;
160
- state.composeBaseline = '';
161
- state.fileOpened = '';
162
- state.templateIndex = -1;
163
- state.templateFocus = false;
164
- state.fileEditRows = null;
165
- afterClose(api);
166
- };
44
+ const COMMAND_KINDS = ['commit', 'branch', 'npm', 'package'];
45
+ const NOTE_VIEWS = ['feedback', 'commit', 'branch', 'npm', 'package'];
167
46
 
168
47
  const itemNote = (item, text) => {
169
48
  const hunk = item.hunk;
@@ -178,641 +57,619 @@ const itemNote = (item, text) => {
178
57
  };
179
58
  };
180
59
 
181
- const writesCodeFile = (api, item) => {
182
- if (!canEditCode(item)) return false;
183
- const caps = api.ui.capabilities;
184
- if (!caps || caps.changes !== true) return false;
185
- if (isReadOnlyOrigin(item.origin)) return false;
186
- const repo = api.ui.repo;
187
- return !!(repo && typeof repo.edit === 'function');
188
- };
189
-
190
- const commitFeedback = (api, text) => {
191
- const item = api.ui.current();
192
- if (!item || isTaskItem(item)) return;
193
- const notes = api.review.store;
194
- setFeedback(notes, itemFeedKey(item), itemNote(item, text));
60
+ const onEmptyLastLine = (editor) => {
61
+ if (!editor) return false;
62
+ const text = editor.text;
63
+ const lastBreak = text.lastIndexOf('\n');
64
+ if (editor.cursor < lastBreak + 1) return false;
65
+ return text.slice(lastBreak + 1).trim() === '';
195
66
  };
196
67
 
197
- const dropCodeNote = (api, item, text) => {
198
- const notes = api.review.store;
199
- if (!notes) return;
200
- const key = itemFeedKey(item);
201
- if (!notes.code.has(key)) return;
202
- setCode(notes, key, { ...itemNote(item, text), clear: true });
203
- };
68
+ const filledLines = (text) =>
69
+ text.split('\n').filter((line) => line.trim() !== '').length;
204
70
 
205
- const saveCodeNote = (api, item, text) => {
206
- const original = blockAddText(item.hunk, item.blockId);
207
- const notes = api.review.store;
208
- const key = itemFeedKey(item);
209
- const note = itemNote(item, text);
210
- if (text === original) {
211
- return void setCode(notes, key, { ...note, clear: true });
71
+ const blankTail = (text) => {
72
+ const lines = text.split('\n');
73
+ let count = 0;
74
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
75
+ if (lines[i].trim() !== '') break;
76
+ count += 1;
212
77
  }
213
- setCode(notes, key, note);
78
+ return count;
214
79
  };
215
80
 
216
- const saveCodeFile = (api, item, text) => {
217
- const original = blockAddText(item.hunk, item.blockId);
218
- if (text === original) {
219
- dropCodeNote(api, item, text);
220
- return true;
81
+ const COMPOSE_SPECIAL = {
82
+ backspace: (c) => c.editor.backspace(),
83
+ delete: (c) => c.editor.delete(),
84
+ left: (c) => c.editor.move(-1),
85
+ right: (c) => c.editor.move(1),
86
+ 'shift-left': (c) => c.editor.move(-1, true),
87
+ 'shift-right': (c) => c.editor.move(1, true),
88
+ 'ctrl-left': (c) => c.editor.moveWord(-1),
89
+ 'ctrl-right': (c) => c.editor.moveWord(1),
90
+ 'ctrl-shift-left': (c) => c.editor.moveWord(-1, true),
91
+ 'ctrl-shift-right': (c) => c.editor.moveWord(1, true),
92
+ up: (c) => c.moveLine(-1),
93
+ down: (c) => c.moveLine(1),
94
+ 'shift-up': (c) => c.moveLine(-1, true),
95
+ 'shift-down': (c) => c.moveLine(1, true),
96
+ home: (c) => c.home(),
97
+ end: (c) => c.end(),
98
+ 'shift-home': (c) => c.home(true),
99
+ 'shift-end': (c) => c.end(true),
100
+ pageUp: (c) => c.movePage(-1),
101
+ pageDown: (c) => c.movePage(1),
102
+ 'ctrl-a': (c) => c.editor.home(),
103
+ 'ctrl-e': (c) => c.editor.end(),
104
+ 'ctrl-c': (c) => c.copy(),
105
+ 'ctrl-x': (c) => c.cut(),
106
+ 'ctrl-v': (c) => c.paste(),
107
+ 'ctrl-z': (c) => c.editor.undoEdit(),
108
+ 'ctrl-y': (c) => c.editor.redoEdit(),
109
+ tab: (c) => c.tab(),
110
+ };
111
+
112
+ class Composer {
113
+ constructor(ui) {
114
+ this.ui = ui;
115
+ this.templates = new TemplatePick(this);
116
+ this.tasks = new TaskPage(this);
117
+ this.reset();
118
+ }
119
+
120
+ reset() {
121
+ this.fileDirty = false;
122
+ this.clear();
123
+ }
124
+
125
+ clear() {
126
+ this.editor = null;
127
+ this.composeKind = null;
128
+ this.commitKind = null;
129
+ this.commitBody = '';
130
+ this.composeTaskId = null;
131
+ this.composeBaseline = '';
132
+ this.templateIndex = -1;
133
+ this.templateFocus = false;
134
+ this.fileEditRows = null;
135
+ this.fileOpened = '';
136
+ }
137
+
138
+ get spec() {
139
+ return KIND[this.composeKind] ?? null;
140
+ }
141
+
142
+ get notes() {
143
+ return this.ui.review.store;
144
+ }
145
+
146
+ viewSize() {
147
+ return this.ui.lastSize ?? this.ui.getSize();
148
+ }
149
+
150
+ canChange() {
151
+ const caps = this.ui.capabilities;
152
+ return Boolean(caps && caps.changes === true);
153
+ }
154
+
155
+ syncCodeScroll() {
156
+ const kind = this.composeKind;
157
+ if (kind !== 'code' && kind !== 'file') return;
158
+ if (!this.editor) return;
159
+ const width = this.viewSize().width;
160
+ this.editor.reveal(codeInnerWidth(width, this.ui.layout));
161
+ }
162
+
163
+ followFileEdit() {
164
+ if (this.composeKind !== 'file') return;
165
+ const ui = this.ui;
166
+ const text = this.editor ? this.editor.text : null;
167
+ const rel = ui.nav.reviewPath;
168
+ const rows = unit.linesFor(ui, rel, text, this.fileEditRows);
169
+ unit.followFileCursor(ui.nav, this.editor, ui.lastFrame, rows);
170
+ }
171
+
172
+ openCompose(kind, text, taskId = null) {
173
+ this.ui.mode = 'compose';
174
+ this.composeKind = kind;
175
+ this.composeTaskId = taskId;
176
+ this.composeBaseline = text;
177
+ this.fileOpened = kind === 'file' ? text : '';
178
+ this.editor = new Editor(text);
179
+ this.templateIndex = -1;
180
+ this.templateFocus = false;
181
+ this.ui.status = '';
182
+ this.ui.nav.clearSelection();
183
+ }
184
+
185
+ closeCompose() {
186
+ const ui = this.ui;
187
+ ui.mode = 'review';
188
+ this.clear();
189
+ ui.clampIndex();
190
+ ui.syncReviewPath();
191
+ ui.syncFileCursor();
192
+ }
193
+
194
+ liveFileText() {
195
+ if (this.composeKind !== 'file' || !this.editor) return null;
196
+ return this.editor.text;
221
197
  }
222
- try {
223
- api.ui.repo.edit(api.ui.top, item, text);
224
- } catch (error) {
225
- api.ui.status = error.message;
226
- return false;
198
+
199
+ writesCodeFile(item) {
200
+ if (!canEditCode(item) || !this.canChange()) return false;
201
+ if (isReadOnlyOrigin(item.origin)) return false;
202
+ return Boolean(this.ui.repo.edit);
227
203
  }
228
- dropCodeNote(api, item, text);
229
- api.state.fileDirty = true;
230
- return true;
231
- };
232
204
 
233
- const commitCode = (api, text) => {
234
- const item = api.ui.current();
235
- if (!canEditCode(item)) return true;
236
- if (writesCodeFile(api, item)) return saveCodeFile(api, item, text);
237
- saveCodeNote(api, item, text);
238
- return true;
239
- };
205
+ commitFeedback(text) {
206
+ const item = this.ui.current();
207
+ if (!item || isTaskItem(item)) return;
208
+ setFeedback(this.notes, itemFeedKey(item), itemNote(item, text));
209
+ }
240
210
 
241
- const saveUnitFile = (api, text) => {
242
- const rel = api.nav.reviewPath;
243
- const repo = api.ui.repo;
244
- if (!rel || !repo || typeof repo.writeFile !== 'function') return true;
245
- const caps = api.ui.capabilities;
246
- if (!caps || caps.changes !== true) {
247
- api.ui.status = 'read only';
248
- return false;
211
+ dropCodeNote(item, text) {
212
+ const notes = this.notes;
213
+ if (!notes) return;
214
+ const key = itemFeedKey(item);
215
+ if (!notes.code.has(key)) return;
216
+ setCode(notes, key, { ...itemNote(item, text), clear: true });
249
217
  }
250
- try {
251
- repo.writeFile(api.ui.top, rel, text);
252
- } catch (error) {
253
- api.ui.status = error.message;
254
- return false;
218
+
219
+ commitCode(text) {
220
+ const ui = this.ui;
221
+ const item = ui.current();
222
+ if (!canEditCode(item)) return true;
223
+ const original = blockAddText(item.hunk, item.blockId);
224
+ if (!this.writesCodeFile(item)) {
225
+ const note = itemNote(item, text);
226
+ const clear = text === original;
227
+ setCode(this.notes, itemFeedKey(item), clear ? { ...note, clear } : note);
228
+ return true;
229
+ }
230
+ if (text !== original) {
231
+ try {
232
+ ui.repo.edit(ui.top, item, text);
233
+ } catch (error) {
234
+ ui.status = error.message;
235
+ return false;
236
+ }
237
+ this.fileDirty = true;
238
+ }
239
+ this.dropCodeNote(item, text);
240
+ return true;
255
241
  }
256
- api.ui.ignoreWatch();
257
- api.state.composeBaseline = text;
258
- api.state.fileDirty = true;
259
- return true;
260
- };
261
242
 
262
- const stageEditedFile = (api, rel) => {
263
- if (!rel) return false;
264
- const repo = api.ui.repo;
265
- if (!repo) return false;
266
- const caps = api.ui.capabilities;
267
- if (!caps || caps.changes !== true) return false;
268
- if (typeof repo.stagePath === 'function') {
243
+ saveUnitFile(text) {
244
+ const ui = this.ui;
245
+ const rel = ui.nav.reviewPath;
246
+ if (!rel || !ui.repo.writeFile) return true;
247
+ if (!this.canChange()) {
248
+ ui.status = 'read only';
249
+ return false;
250
+ }
269
251
  try {
270
- repo.stagePath(api.ui.top, rel);
252
+ ui.repo.writeFile(ui.top, rel, text);
271
253
  } catch (error) {
272
- api.ui.status = error.message;
254
+ ui.status = error.message;
273
255
  return false;
274
256
  }
275
- api.ui.ignoreWatch();
257
+ ui.ignoreWatch();
258
+ this.composeBaseline = text;
259
+ this.fileDirty = true;
276
260
  return true;
277
261
  }
278
- if (typeof repo.add !== 'function') return false;
279
- let staged = 0;
280
- for (const item of api.ui.items) {
281
- if (listPath(item) !== rel) continue;
282
- if (isReadOnlyOrigin(item.origin)) continue;
283
- if (item.origin === 'staged') continue;
284
- try {
285
- repo.add(api.ui.top, item);
286
- } catch (error) {
287
- api.ui.status = error.message;
288
- return staged > 0;
262
+
263
+ stageEditedFile(rel) {
264
+ const ui = this.ui;
265
+ const repo = ui.repo;
266
+ if (!rel || !this.canChange()) return false;
267
+ if (repo.stagePath) {
268
+ try {
269
+ repo.stagePath(ui.top, rel);
270
+ } catch (error) {
271
+ ui.status = error.message;
272
+ return false;
273
+ }
274
+ ui.ignoreWatch();
275
+ return true;
276
+ }
277
+ if (!repo.add) return false;
278
+ let staged = 0;
279
+ for (const item of ui.items) {
280
+ if (listPath(item) !== rel) continue;
281
+ if (isReadOnlyOrigin(item.origin) || item.origin === 'staged') continue;
282
+ try {
283
+ repo.add(ui.top, item);
284
+ } catch (error) {
285
+ ui.status = error.message;
286
+ return staged > 0;
287
+ }
288
+ ui.collection.keepOrigin(item, 'staged');
289
+ staged += 1;
289
290
  }
290
- api.ui.collection.keepOrigin(item, 'staged');
291
- staged += 1;
291
+ if (staged) ui.ignoreWatch();
292
+ return staged > 0;
292
293
  }
293
- if (staged) api.ui.ignoreWatch();
294
- return staged > 0;
295
- };
296
294
 
297
- const saveFileCompose = (api) => {
298
- const rel = api.nav.reviewPath;
299
- const opened = api.state.fileOpened;
300
- const text = api.state.editor ? api.state.editor.text : '';
301
- if (api.state.editor) {
302
- api.nav.unitLine = api.state.editor.linePos().line;
303
- }
304
- const result = saveUnitFile(api, text);
305
- if (result === false) return { kind: 'error' };
306
- const changed = text !== opened;
307
- const reload = api.state.fileDirty === true;
308
- if (changed) stageEditedFile(api, rel);
309
- api.ui.flushReview();
310
- closeCompose(api);
311
- const doneStatus = changed ? 'staged' : 'saved';
312
- if (!reload) {
313
- if (!api.ui.status) api.ui.status = doneStatus;
314
- return { kind: 'saved' };
295
+ finishSave(reload, doneStatus) {
296
+ const ui = this.ui;
297
+ ui.flushReview();
298
+ this.closeCompose();
299
+ if (!reload) {
300
+ if (!ui.status) ui.status = doneStatus;
301
+ return;
302
+ }
303
+ ui.reloadAfterChange(() => {
304
+ if (!ui.status) ui.status = doneStatus;
305
+ });
315
306
  }
316
- api.ui.reloadAfterChange(() => {
317
- if (!api.ui.status) api.ui.status = doneStatus;
318
- });
319
- return { kind: 'saved' };
320
- };
321
307
 
322
- const commitCompose = (api) => {
323
- const { state } = api;
324
- if (!state.composeKind || !state.editor) return true;
325
- const text = state.editor.text;
326
- if (state.composeKind === 'feedback') {
327
- if (api.review.store) commitFeedback(api, text);
328
- return true;
308
+ saveFileCompose() {
309
+ const rel = this.ui.nav.reviewPath;
310
+ const opened = this.fileOpened;
311
+ const text = this.editor ? this.editor.text : '';
312
+ if (this.editor) this.ui.nav.unitLine = this.editor.linePos().line;
313
+ if (!this.saveUnitFile(text)) return { kind: 'error' };
314
+ const changed = text !== opened;
315
+ const reload = this.fileDirty === true;
316
+ if (changed) this.stageEditedFile(rel);
317
+ this.finishSave(reload, changed ? 'staged' : 'saved');
318
+ return { kind: 'saved' };
329
319
  }
330
- if (state.composeKind === 'tasks') {
331
- api.tasks.commitTask(text);
320
+
321
+ commitCompose() {
322
+ const kind = this.composeKind;
323
+ if (!kind || !this.editor) return true;
324
+ const text = this.editor.text;
325
+ if (kind === 'feedback') {
326
+ if (this.notes) this.commitFeedback(text);
327
+ return true;
328
+ }
329
+ if (kind === 'tasks') {
330
+ this.tasks.commitTask(text);
331
+ return true;
332
+ }
333
+ if (kind === 'code') return this.commitCode(text);
334
+ if (kind === 'file') return this.saveUnitFile(text);
332
335
  return true;
333
336
  }
334
- if (state.composeKind === 'code') return commitCode(api, text);
335
- if (state.composeKind === 'file') return saveUnitFile(api, text);
336
- return true;
337
- };
338
337
 
339
- const saveCompose = (api, extra) => {
340
- const opts = extra ?? {};
341
- const spec = KIND[api.state.composeKind];
342
- if (spec && spec.command) return { kind: spec.command };
343
- const kind = api.state.composeKind;
344
- if (kind === 'file') return saveFileCompose(api);
345
- const item = api.ui.current();
346
- const shouldStage =
347
- kind === 'code' &&
348
- writesCodeFile(api, item) &&
349
- item &&
350
- item.origin !== 'staged';
351
- const rel = itemPath(item);
352
- const baseline = api.state.composeBaseline;
353
- const result = commitCompose(api);
354
- if (result === false) return { kind: 'error' };
355
- const reload = api.state.fileDirty === true;
356
- if (kind === 'feedback') {
357
- const notes = api.review.store;
358
- const editor = api.state.editor;
359
- const next = editor ? editor.text : '';
360
- if (notes) rememberTemplate(notes, baseline, next);
361
- }
362
- if (shouldStage && reload) stageEditedFile(api, rel);
363
- api.ui.flushReview();
364
- closeCompose(api);
365
- const doneStatus = shouldStage && reload ? 'staged' : 'saved';
366
- if (reload) {
367
- api.ui.reloadAfterChange(() => {
368
- if (!api.ui.status) api.ui.status = doneStatus;
369
- });
370
- } else {
371
- api.ui.status = doneStatus;
338
+ saveCompose(opts = {}) {
339
+ const ui = this.ui;
340
+ const kind = this.composeKind;
341
+ if (COMMAND_KINDS.includes(kind)) return { kind };
342
+ if (kind === 'file') return this.saveFileCompose();
343
+ const item = ui.current();
344
+ const shouldStage =
345
+ kind === 'code' && this.writesCodeFile(item) && item.origin !== 'staged';
346
+ const rel = itemPath(item);
347
+ const baseline = this.composeBaseline;
348
+ if (this.commitCompose() === false) return { kind: 'error' };
349
+ const reload = this.fileDirty === true;
350
+ if (kind === 'feedback' && this.notes) {
351
+ const next = this.editor ? this.editor.text : '';
352
+ rememberTemplate(this.notes, baseline, next);
353
+ }
354
+ const staging = shouldStage && reload;
355
+ if (staging) this.stageEditedFile(rel);
356
+ const doneStatus = staging ? 'staged' : 'saved';
357
+ if (reload) {
358
+ this.finishSave(true, doneStatus);
359
+ } else {
360
+ ui.flushReview();
361
+ this.closeCompose();
362
+ ui.status = doneStatus;
363
+ }
364
+ if (opts.editNext === true) this.tasks.editNextTask();
365
+ return { kind: 'saved' };
372
366
  }
373
- if (opts.editNext === true) api.tasks.editNextTask();
374
- return { kind: 'saved' };
375
- };
376
367
 
377
- const autosave = (api) => {
378
- const { state } = api;
379
- const spec = KIND[state.composeKind];
380
- const text = state.editor ? state.editor.text : '';
381
- if (state.composeKind === 'file' && state.editor) {
382
- if (text === state.composeBaseline) return;
383
- return void saveUnitFile(api, text);
384
- }
385
- const editingCode = state.composeKind === 'code';
386
- const composing = state.composeKind !== null;
387
- const reviewKind = composing && (!spec || !spec.command);
388
- const liveItem = api.ui.current ? api.ui.current() : null;
389
- const fileEdit = editingCode && writesCodeFile(api, liveItem);
390
- if (reviewKind && !fileEdit && (editingCode || text.trim())) {
391
- commitCompose(api);
392
- }
393
- const notes = api.review.store;
394
- if (notes && notes.dirty) api.ui.flushReview();
395
- };
396
-
397
- const composeView = (api) => {
398
- const { state } = api;
399
- if (!state.composeKind || !state.editor) return null;
400
- const spec = KIND[state.composeKind];
401
- if (!spec || spec.view !== 'note') return null;
402
- const kind = state.composeKind;
403
- const text = state.editor.text;
404
- const cursor = state.editor.cursor;
405
- const scrollCol = state.editor.scrollCol;
406
- const anchor = state.editor.anchor;
407
- return {
408
- kind,
409
- text,
410
- cursor,
411
- scrollCol,
412
- anchor,
413
- commitKind: state.commitKind,
414
- };
415
- };
416
-
417
- const liveCodeText = (api) => {
418
- const { state } = api;
419
- if (state.composeKind === 'file' && state.editor) return state.editor.text;
420
- const item = api.ui.current();
421
- if (!canEditCode(item)) return null;
422
- if (state.composeKind === 'code' && state.editor) return state.editor.text;
423
- if (writesCodeFile(api, item)) return null;
424
- const notes = api.review.store;
425
- if (!notes) return null;
426
- const note = notes.code.get(itemFeedKey(item));
427
- if (!note) return null;
428
- return note.text;
429
- };
430
-
431
- const codeOverlayView = (api) => {
432
- const text = liveCodeText(api);
433
- if (text === null) return null;
434
- const editing =
435
- api.state.composeKind === 'code' || api.state.composeKind === 'file';
436
- const editor = api.state.editor;
437
- const cursor = editing && editor ? editor.cursor : null;
438
- const picked = editing && editor ? editor.scrollCol : 0;
439
- const scrollCol = picked ?? 0;
440
- const anchor = editing && editor ? editor.anchor : null;
441
- return { text, keepEmpty: editing, cursor, scrollCol, anchor };
442
- };
443
-
444
- const idleNoteText = (api) => {
445
- const notes = api.review.store;
446
- if (!notes) return '';
447
- if (api.state.composeKind) return '';
448
- const item = api.ui.current();
449
- if (!item || isTaskItem(item)) return '';
450
- const note = notes.feedback.get(itemFeedKey(item));
451
- if (!note || !note.text.trim()) return '';
452
- return checkLabel(note.text, note.done);
453
- };
368
+ autosave() {
369
+ const kind = this.composeKind;
370
+ const text = this.editor ? this.editor.text : '';
371
+ if (kind === 'file' && this.editor) {
372
+ if (text === this.composeBaseline) return;
373
+ return void this.saveUnitFile(text);
374
+ }
375
+ const editingCode = kind === 'code';
376
+ const reviewKind = kind !== null && !COMMAND_KINDS.includes(kind);
377
+ const fileEdit = editingCode && this.writesCodeFile(this.ui.current());
378
+ if (reviewKind && !fileEdit && (editingCode || text.trim())) {
379
+ this.commitCompose();
380
+ }
381
+ if (this.notes && this.notes.dirty) this.ui.flushReview();
382
+ }
383
+
384
+ composeView() {
385
+ const { composeKind: kind, editor } = this;
386
+ if (!editor || !NOTE_VIEWS.includes(kind)) return null;
387
+ const { text, cursor, scrollCol, anchor } = editor;
388
+ return {
389
+ kind,
390
+ text,
391
+ cursor,
392
+ scrollCol,
393
+ anchor,
394
+ commitKind: this.commitKind,
395
+ };
396
+ }
397
+
398
+ liveCodeText() {
399
+ const kind = this.composeKind;
400
+ if (kind === 'file' && this.editor) return this.editor.text;
401
+ const item = this.ui.current();
402
+ if (!canEditCode(item)) return null;
403
+ if (kind === 'code' && this.editor) return this.editor.text;
404
+ if (this.writesCodeFile(item) || !this.notes) return null;
405
+ const note = this.notes.code.get(itemFeedKey(item));
406
+ return note ? note.text : null;
407
+ }
408
+
409
+ codeOverlayView() {
410
+ const text = this.liveCodeText();
411
+ if (text === null) return null;
412
+ const editing = this.composeKind === 'code' || this.composeKind === 'file';
413
+ const editor = editing ? this.editor : null;
414
+ return {
415
+ text,
416
+ keepEmpty: editing,
417
+ cursor: editor?.cursor ?? null,
418
+ scrollCol: editor?.scrollCol ?? 0,
419
+ anchor: editor?.anchor ?? null,
420
+ };
421
+ }
422
+
423
+ idleNoteText() {
424
+ const notes = this.notes;
425
+ if (!notes || this.composeKind) return '';
426
+ const item = this.ui.current();
427
+ if (!item || isTaskItem(item)) return '';
428
+ const note = notes.feedback.get(itemFeedKey(item));
429
+ if (!note || !note.text.trim()) return '';
430
+ return checkLabel(note.text, note.done);
431
+ }
432
+
433
+ tab() {
434
+ if (this.spec && this.spec.tab === 'insert-tab') {
435
+ this.editor.insert('\t');
436
+ return null;
437
+ }
438
+ return this.templates.applyTemplate();
439
+ }
440
+
441
+ moveTemplateLine(delta, shown, width) {
442
+ const pick = this.templates;
443
+ if (this.templateFocus) {
444
+ const next = Math.max(
445
+ 0,
446
+ Math.min(this.templateIndex + delta, shown.length - 1),
447
+ );
448
+ if (next === this.templateIndex) this.templateFocus = false;
449
+ else pick.focusTemplate(next, false);
450
+ return true;
451
+ }
452
+ const pos = cursorInWrap(this.editor.text, this.editor.cursor, width);
453
+ const last = wrapDoc(this.editor.text, width).length - 1;
454
+ if (delta < 0 && pos.row === 0) {
455
+ pick.focusTemplate(shown.length - 1, false);
456
+ return true;
457
+ }
458
+ if (delta > 0 && pos.row === last) {
459
+ pick.focusTemplate(0, false);
460
+ return true;
461
+ }
462
+ return false;
463
+ }
454
464
 
455
- const composeTab = (api) => {
456
- const spec = KIND[api.state.composeKind];
457
- if (spec && spec.tab === 'insert-tab') {
458
- api.state.editor.insert('\t');
459
- return null;
465
+ noteLineWidth() {
466
+ const width = this.viewSize().width;
467
+ const fullCommit =
468
+ this.composeKind === 'commit' && this.ui.commits.commitView === 'full';
469
+ if (!fullCommit) return noteInnerWidth(width);
470
+ const inPlace = this.commitKind === 'amend' || this.commitKind === 'reword';
471
+ const list = listedCommits(this.ui.commits.commits);
472
+ const entry = (inPlace && list[this.ui.nav.commitCursor]) || null;
473
+ return commitMessageWidth(entry, width);
460
474
  }
461
- return api.templates.applyTemplate();
462
- };
463
475
 
464
- const moveTemplateLine = (api, delta, shown, width) => {
465
- const { state } = api;
466
- if (state.templateFocus) {
467
- let next = state.templateIndex + delta;
468
- if (next < 0) next = 0;
469
- if (next >= shown.length) next = shown.length - 1;
470
- if (next === state.templateIndex) {
471
- state.templateFocus = false;
472
- return true;
476
+ moveLine(delta, extend) {
477
+ const move = this.spec ? this.spec.move : 'note';
478
+ if (move === 'tasks') {
479
+ const inner = todoBodyWidth(this.viewSize().width);
480
+ return void this.tasks.moveTaskCompose(delta, inner, extend);
473
481
  }
474
- api.templates.focusTemplate(next, false);
475
- return true;
482
+ if (move === 'code') {
483
+ this.editor.moveLine(delta, undefined, extend);
484
+ return void this.syncCodeScroll();
485
+ }
486
+ const width = this.noteLineWidth();
487
+ const shown = this.templates.shownTemplates();
488
+ const feedback = this.composeKind === 'feedback';
489
+ if (shown.length && feedback && !extend) {
490
+ if (this.moveTemplateLine(delta, shown, width)) return;
491
+ }
492
+ this.editor.moveLine(delta, width, extend);
476
493
  }
477
- const pos = cursorInWrap(state.editor.text, state.editor.cursor, width);
478
- const last = wrapDoc(state.editor.text, width).length - 1;
479
- if (delta < 0 && pos.row === 0) {
480
- api.templates.focusTemplate(shown.length - 1, false);
481
- return true;
494
+
495
+ movePage(dir) {
496
+ const frame = this.ui.lastFrame;
497
+ const fallback = Math.max(1, (this.viewSize().height ?? 24) - 4);
498
+ const step = Math.max(1, frame?.bodyH ?? fallback);
499
+ this.moveLine(step * dir);
482
500
  }
483
- if (delta > 0 && pos.row === last) {
484
- api.templates.focusTemplate(0, false);
501
+
502
+ jumpTemplates(toEnd) {
503
+ if (!this.templateFocus) return false;
504
+ const shown = this.templates.shownTemplates();
505
+ if (!shown.length) return false;
506
+ this.templates.focusTemplate(toEnd ? shown.length - 1 : 0, false);
485
507
  return true;
486
508
  }
487
- return false;
488
- };
489
509
 
490
- const moveNoteLine = (api, delta, extend) => {
491
- const size = viewSize(api);
492
- const width = noteInnerWidth(size.width);
493
- const shown = api.templates.shownTemplates();
494
- const feedback = api.state.composeKind === 'feedback';
495
- if (shown.length && feedback && !extend) {
496
- if (moveTemplateLine(api, delta, shown, width)) return;
510
+ home(extend) {
511
+ if (!extend && this.jumpTemplates(false)) return;
512
+ this.editor.home(extend);
497
513
  }
498
- api.state.editor.moveLine(delta, width, extend);
499
- };
500
-
501
- const fullCommitEdit = (api) =>
502
- api.state.composeKind === 'commit' && api.ui.commits.commitView === 'full';
503
514
 
504
- const editedCommitEntry = (api) => {
505
- const kind = api.state.commitKind;
506
- if (kind !== 'amend' && kind !== 'reword') return null;
507
- const list = listedCommits(api.ui.commits.commits);
508
- return list[api.nav.commitCursor] ?? null;
509
- };
510
-
511
- const moveCommitLine = (api, delta, extend) => {
512
- const size = viewSize(api);
513
- const width = commitMessageWidth(editedCommitEntry(api), size.width);
514
- api.state.editor.moveLine(delta, width, extend);
515
- };
516
-
517
- const moveComposeLine = (api, delta, extend) => {
518
- const spec = KIND[api.state.composeKind];
519
- const move = spec ? spec.move : 'note';
520
- if (move === 'tasks') {
521
- const size = viewSize(api);
522
- const inner = todoBodyWidth(size.width);
523
- return void api.tasks.moveTaskCompose(delta, inner, extend);
524
- }
525
- if (move === 'code') {
526
- api.state.editor.moveLine(delta, undefined, extend);
527
- return void syncCodeScroll(api);
515
+ end(extend) {
516
+ if (!extend && this.jumpTemplates(true)) return;
517
+ this.editor.end(extend);
528
518
  }
529
- if (fullCommitEdit(api)) return void moveCommitLine(api, delta, extend);
530
- moveNoteLine(api, delta, extend);
531
- };
532
-
533
- const composePageStep = (api) => {
534
- const frame = api.ui.lastFrame ?? null;
535
- const size = viewSize(api);
536
- const fallback = Math.max(1, (size.height ?? 24) - 4);
537
- return Math.max(1, frame?.bodyH ?? fallback);
538
- };
539
-
540
- const jumpComposeTemplates = (api, toEnd) => {
541
- if (!api.state.templateFocus) return false;
542
- const shown = api.templates.shownTemplates();
543
- if (!shown.length) return false;
544
- const index = toEnd ? shown.length - 1 : 0;
545
- api.templates.focusTemplate(index, false);
546
- return true;
547
- };
548
-
549
- const composeHome = (api, extend) => {
550
- if (!extend && jumpComposeTemplates(api, false)) return;
551
- api.state.editor.home(extend);
552
- };
553
-
554
- const composeEnd = (api, extend) => {
555
- if (!extend && jumpComposeTemplates(api, true)) return;
556
- api.state.editor.end(extend);
557
- };
558
519
 
559
- const moveComposePage = (api, dir) => {
560
- const step = composePageStep(api) * dir;
561
- moveComposeLine(api, step);
562
- };
563
-
564
- const onFeedback = (api) => {
565
- if (api.nav.pane === 'files') {
566
- api.ui.status = 'not a diff block';
567
- return;
568
- }
569
- const item = api.ui.current();
570
- if (!item || isTaskItem(item)) {
571
- api.ui.status = 'not a diff block';
572
- return;
520
+ onFeedback() {
521
+ const ui = this.ui;
522
+ const item = ui.current();
523
+ if (ui.nav.pane === 'files' || !item || isTaskItem(item)) {
524
+ ui.status = 'not a diff block';
525
+ return;
526
+ }
527
+ const prev = this.notes.feedback.get(itemFeedKey(item));
528
+ this.openCompose('feedback', prev ? prev.text : '');
573
529
  }
574
- const notes = api.review.store;
575
- const prev = notes.feedback.get(itemFeedKey(item));
576
- const text = prev ? prev.text : '';
577
- openCompose(api, 'feedback', text);
578
- };
579
-
580
- const onUnitEdit = (api) => {
581
- const rel = api.nav.reviewPath;
582
- if (!rel) {
583
- api.ui.status = 'not a diff block';
584
- return;
585
- }
586
- const caps = api.ui.capabilities;
587
- if (!caps || caps.changes !== true) {
588
- api.ui.status = 'read only';
589
- return;
590
- }
591
- const at = unit.editCursor(api.ui);
592
- openCompose(api, 'file', at.text);
593
- api.state.fileEditRows = at.rows ?? null;
594
- api.state.editor.cursor = Math.min(at.cursor, at.text.length);
595
- syncCodeScroll(api);
596
- followFileEdit(api);
597
- };
598
530
 
599
- const onCode = (api) => {
600
- if (api.nav.pane === 'unit') return void onUnitEdit(api);
601
- if (api.nav.pane === 'files') {
602
- api.ui.status = 'not a diff block';
603
- return;
604
- }
605
- const item = api.ui.current();
606
- if (!canEditCode(item)) {
607
- api.ui.status = 'not a diff block';
608
- return;
609
- }
610
- const original = blockAddText(item.hunk, item.blockId);
611
- const notes = api.review.store;
612
- const prev = writesCodeFile(api, item)
613
- ? null
614
- : notes.code.get(itemFeedKey(item));
615
- const text = prev ? prev.text : original;
616
- openCompose(api, 'code', text);
617
- syncCodeScroll(api);
618
- };
531
+ onUnitEdit() {
532
+ const ui = this.ui;
533
+ if (!ui.nav.reviewPath) {
534
+ ui.status = 'not a diff block';
535
+ return;
536
+ }
537
+ if (!this.canChange()) {
538
+ ui.status = 'read only';
539
+ return;
540
+ }
541
+ const at = unit.editCursor(ui);
542
+ this.openCompose('file', at.text);
543
+ this.fileEditRows = at.rows ?? null;
544
+ this.editor.cursor = Math.min(at.cursor, at.text.length);
545
+ this.syncCodeScroll();
546
+ this.followFileEdit();
547
+ }
548
+
549
+ onFileEdit() {
550
+ const ui = this.ui;
551
+ const list = ui.fileList();
552
+ const entry = list[ui.nav.fileCursor];
553
+ if (!entry || isTasksEntry(entry)) {
554
+ ui.status = 'not a diff block';
555
+ return;
556
+ }
557
+ unit.openUnitFile(ui, entry);
558
+ this.onUnitEdit();
559
+ }
560
+
561
+ onCode() {
562
+ const ui = this.ui;
563
+ if (ui.nav.pane === 'files') return void this.onFileEdit();
564
+ if (ui.nav.pane === 'unit') return void this.onUnitEdit();
565
+ const item = ui.current();
566
+ if (ui.nav.pane === 'files' || !canEditCode(item)) {
567
+ ui.status = 'not a diff block';
568
+ return;
569
+ }
570
+ const original = blockAddText(item.hunk, item.blockId);
571
+ const writes = this.writesCodeFile(item);
572
+ const prev = writes ? null : this.notes.code.get(itemFeedKey(item));
573
+ this.openCompose('code', prev ? prev.text : original);
574
+ this.syncCodeScroll();
575
+ }
619
576
 
620
- const insertNewline = (api) => {
621
- api.state.editor.insert('\n');
622
- followFileEdit(api);
623
- return null;
624
- };
577
+ insertNewline() {
578
+ this.editor.insert('\n');
579
+ this.followFileEdit();
580
+ return null;
581
+ }
625
582
 
626
- const onEmptyLastLine = (editor) => {
627
- if (!editor) return false;
628
- const text = editor.text;
629
- const lastBreak = text.lastIndexOf('\n');
630
- if (editor.cursor < lastBreak + 1) return false;
631
- return text.slice(lastBreak + 1).trim() === '';
632
- };
583
+ enter() {
584
+ const kind = this.composeKind;
585
+ if (this.templateFocus && kind === 'feedback') {
586
+ return this.templates.selectTemplate(this.templateIndex);
587
+ }
588
+ const spec = this.spec;
589
+ if (spec && spec.enter === 'newline') return this.insertNewline();
590
+ if (kind === 'commit' && this.ui.commits.commitView === 'full') {
591
+ const text = this.editor ? this.editor.text : '';
592
+ const shaped = filledLines(text) >= 2 || blankTail(text) >= 2;
593
+ if (onEmptyLastLine(this.editor) && shaped) return this.saveCompose();
594
+ return this.insertNewline();
595
+ }
596
+ if (spec && spec.enter === 'edit-next') {
597
+ return this.saveCompose({ editNext: true });
598
+ }
599
+ return this.saveCompose();
600
+ }
633
601
 
634
- const filledLines = (text) => {
635
- let count = 0;
636
- for (const line of `${text ?? ''}`.split('\n')) {
637
- if (line.trim() !== '') count += 1;
602
+ leave(key) {
603
+ if (this.spec && this.spec.escape === 'cancel' && key === 'escape') {
604
+ this.closeCompose();
605
+ this.ui.status = '';
606
+ return null;
607
+ }
608
+ return this.saveCompose();
638
609
  }
639
- return count;
640
- };
641
610
 
642
- const blankTail = (text) => {
643
- const lines = `${text ?? ''}`.split('\n');
644
- let count = 0;
645
- for (let i = lines.length - 1; i >= 0; i -= 1) {
646
- if (lines[i].trim() !== '') break;
647
- count += 1;
611
+ copy() {
612
+ const text = this.editor.selectedText();
613
+ if (!text) return;
614
+ const ok = clipboard.copyText(text, this.ui.stdout);
615
+ this.ui.status = ok ? 'copied' : 'copy failed';
648
616
  }
649
- return count;
650
- };
651
617
 
652
- const enterFullCommit = (api) => {
653
- const editor = api.state.editor;
654
- const text = editor ? editor.text : '';
655
- const shaped = filledLines(text) >= 2 || blankTail(text) >= 2;
656
- if (onEmptyLastLine(editor) && shaped) return saveCompose(api);
657
- return insertNewline(api);
658
- };
618
+ cut() {
619
+ if (!this.editor.hasSelect()) return;
620
+ this.copy();
621
+ this.editor.removeSpan();
622
+ }
659
623
 
660
- const enterCompose = (api) => {
661
- const { state } = api;
662
- if (state.templateFocus && state.composeKind === 'feedback') {
663
- return api.templates.selectTemplate(state.templateIndex);
624
+ paste() {
625
+ const text = clipboard.pasteText();
626
+ if (text) this.editor.insert(text);
664
627
  }
665
- const spec = KIND[state.composeKind];
666
- if (spec && spec.enter === 'newline') return insertNewline(api);
667
- if (fullCommitEdit(api)) return enterFullCommit(api);
668
- if (spec && spec.enter === 'edit-next') {
669
- return saveCompose(api, { editNext: true });
628
+
629
+ afterEdit() {
630
+ this.followFileEdit();
631
+ this.syncCodeScroll();
670
632
  }
671
- return saveCompose(api);
672
- };
673
633
 
674
- const leaveCompose = (api, key) => {
675
- const spec = KIND[api.state.composeKind];
676
- if (spec && spec.escape === 'cancel' && key === 'escape') {
677
- closeCompose(api);
678
- api.ui.status = '';
634
+ handleKey(key) {
635
+ if (key === 'enter') {
636
+ const result = this.enter();
637
+ this.syncCodeScroll();
638
+ return result;
639
+ }
640
+ if (key === 'escape' || key === 'ctrl-s') return this.leave(key);
641
+ const special = COMPOSE_SPECIAL[key];
642
+ if (special) {
643
+ const result = special(this);
644
+ const edits = COMPOSE_EDITS.includes(key) || COMPOSE_CLIP.includes(key);
645
+ if (edits) this.templates.clearTemplatePick();
646
+ this.afterEdit();
647
+ return result ?? null;
648
+ }
649
+ if (!key.length || key.startsWith('ctrl-') || key.startsWith('alt-')) {
650
+ return null;
651
+ }
652
+ if (key.length === 1 && key.charCodeAt(0) >= 32) {
653
+ this.templates.clearTemplatePick();
654
+ this.editor.insert(key);
655
+ this.afterEdit();
656
+ }
679
657
  return null;
680
658
  }
681
- return saveCompose(api);
682
- };
683
-
684
- const copyCompose = (api) => {
685
- const text = api.state.editor.selectedText();
686
- if (!text) return;
687
- const ok = clipboard.copyText(text, api.ui.stdout);
688
- api.ui.status = ok ? 'copied' : 'copy failed';
689
- };
690
-
691
- const cutCompose = (api) => {
692
- if (!api.state.editor.hasSelect()) return;
693
- copyCompose(api);
694
- api.state.editor.removeSpan();
695
- };
696
-
697
- const pasteCompose = (api) => {
698
- const text = clipboard.pasteText();
699
- if (!text) return;
700
- api.state.editor.insert(text);
701
- };
702
-
703
- const COMPOSE_CLIP = ['ctrl-x', 'ctrl-v'];
704
659
 
705
- const COMPOSE_SPECIAL = {
706
- backspace: (api) => api.state.editor.backspace(),
707
- delete: (api) => api.state.editor.delete(),
708
- left: (api) => api.state.editor.move(-1),
709
- right: (api) => api.state.editor.move(1),
710
- 'shift-left': (api) => api.state.editor.move(-1, true),
711
- 'shift-right': (api) => api.state.editor.move(1, true),
712
- 'ctrl-left': (api) => api.state.editor.moveWord(-1),
713
- 'ctrl-right': (api) => api.state.editor.moveWord(1),
714
- 'ctrl-shift-left': (api) => api.state.editor.moveWord(-1, true),
715
- 'ctrl-shift-right': (api) => api.state.editor.moveWord(1, true),
716
- up: (api) => moveComposeLine(api, -1),
717
- down: (api) => moveComposeLine(api, 1),
718
- 'shift-up': (api) => moveComposeLine(api, -1, true),
719
- 'shift-down': (api) => moveComposeLine(api, 1, true),
720
- home: (api) => composeHome(api),
721
- end: (api) => composeEnd(api),
722
- 'shift-home': (api) => composeHome(api, true),
723
- 'shift-end': (api) => composeEnd(api, true),
724
- pageUp: (api) => moveComposePage(api, -1),
725
- pageDown: (api) => moveComposePage(api, 1),
726
- 'ctrl-a': (api) => api.state.editor.home(),
727
- 'ctrl-e': (api) => api.state.editor.end(),
728
- 'ctrl-c': (api) => copyCompose(api),
729
- 'ctrl-x': (api) => cutCompose(api),
730
- 'ctrl-v': (api) => pasteCompose(api),
731
- 'ctrl-z': (api) => api.state.editor.undoEdit(),
732
- 'ctrl-y': (api) => api.state.editor.redoEdit(),
733
- tab: (api) => composeTab(api),
734
- };
735
-
736
- const handleKey = (api, key) => {
737
- if (key === 'enter') {
738
- const result = enterCompose(api);
739
- syncCodeScroll(api);
740
- return result;
741
- }
742
- if (key === 'escape' || key === 'ctrl-s') return leaveCompose(api, key);
743
- const special = COMPOSE_SPECIAL[key];
744
- if (special) {
745
- const result = special(api);
746
- const edits = COMPOSE_EDITS.includes(key) || COMPOSE_CLIP.includes(key);
747
- if (edits) api.templates.clearTemplatePick();
748
- followFileEdit(api);
749
- syncCodeScroll(api);
750
- return result ?? null;
751
- }
752
- if (!key.length) return null;
753
- if (key.startsWith('ctrl-') || key.startsWith('alt-')) return null;
754
- if (key.charCodeAt(0) >= 32) {
755
- api.templates.clearTemplatePick();
756
- api.state.editor.insert(key);
757
- followFileEdit(api);
758
- syncCodeScroll(api);
759
- }
760
- return null;
761
- };
762
-
763
- const applyDiskText = (api, text) => {
764
- if (api.state.composeKind !== 'file' || !api.state.editor) return;
765
- if (text === api.state.editor.text) return;
766
- const cursor = api.state.editor.cursor;
767
- api.state.editor.text = text;
768
- api.state.editor.cursor = Math.min(cursor, text.length);
769
- api.state.editor.anchor = null;
770
- api.state.composeBaseline = text;
771
- api.state.fileEditRows = unit.linesFor(api.ui, api.nav.reviewPath, null);
772
- syncCodeScroll(api);
773
- followFileEdit(api);
774
- };
775
-
776
- const composerMethods = (api) => ({
777
- reset: () => reset(api.state),
778
- openCompose: (kind, text, todoId) => openCompose(api, kind, text, todoId),
779
- closeCompose: () => closeCompose(api),
780
- commitCompose: () => commitCompose(api),
781
- saveCompose: (opts) => saveCompose(api, opts),
782
- autosave: () => autosave(api),
783
- composeView: () => composeView(api),
784
- codeOverlayView: () => codeOverlayView(api),
785
- idleNoteText: () => idleNoteText(api),
786
- onFeedback: () => onFeedback(api),
787
- onCode: () => onCode(api),
788
- handleKey: (key) => handleKey(api, key),
789
- liveFileText: () => {
790
- if (api.state.composeKind !== 'file' || !api.state.editor) return null;
791
- return api.state.editor.text;
792
- },
793
- fileEditRows: () => api.state.fileEditRows,
794
- applyDiskText: (text) => applyDiskText(api, text),
795
- });
796
-
797
- const createComposer = ({ nav, review, ui }) => {
798
- const state = initialState();
799
- const api = { nav, review, ui, state };
800
- api.open = (kind, text, todoId) => openCompose(api, kind, text, todoId);
801
- api.close = () => closeCompose(api);
802
- api.commit = () => commitCompose(api);
803
- api.save = (opts) => saveCompose(api, opts);
804
- api.templates = createTemplatePick(api);
805
- api.tasks = createTasksCompose(api);
806
- const composer = {
807
- state,
808
- ...composerMethods(api),
809
- ...api.tasks,
810
- ...api.templates,
811
- };
812
- return composer;
813
- };
660
+ applyDiskText(text) {
661
+ const editor = this.editor;
662
+ if (this.composeKind !== 'file' || !editor) return;
663
+ if (text === editor.text) return;
664
+ const cursor = editor.cursor;
665
+ editor.text = text;
666
+ editor.cursor = Math.min(cursor, text.length);
667
+ editor.anchor = null;
668
+ this.composeBaseline = text;
669
+ this.fileEditRows = unit.linesFor(this.ui, this.ui.nav.reviewPath, null);
670
+ this.syncCodeScroll();
671
+ this.followFileEdit();
672
+ }
673
+ }
814
674
 
815
- module.exports = {
816
- COMPOSE_LEAVE,
817
- createComposer,
818
- };
675
+ module.exports = { COMPOSE_LEAVE, Composer };