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