reslop 0.1.8 → 0.2.0

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 (95) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +27 -38
  3. package/bin/reslop.js +2 -2
  4. package/lib/ansi.js +6 -2
  5. package/lib/api.js +2 -2
  6. package/lib/capabilities.js +10 -14
  7. package/lib/cli.js +28 -47
  8. package/lib/clipboard.js +1 -1
  9. package/lib/deps-diff.js +712 -0
  10. package/lib/deps.js +556 -32
  11. package/lib/detect.js +1 -7
  12. package/lib/diff/align.js +7 -10
  13. package/lib/diff/diff.js +53 -0
  14. package/lib/diff/display.js +5 -4
  15. package/lib/diff/inline.js +26 -21
  16. package/lib/diff/items.js +6 -4
  17. package/lib/diff/patch.js +1 -3
  18. package/lib/diff/unit.js +392 -0
  19. package/lib/files.js +34 -8
  20. package/lib/{git/branches.js → git-branches.js} +103 -10
  21. package/lib/{git/dependencies.js → git-deps.js} +111 -38
  22. package/lib/git.js +592 -65
  23. package/lib/github.js +28 -197
  24. package/lib/gitlab.js +24 -195
  25. package/lib/highlight/bash.js +1 -3
  26. package/lib/highlight/css.js +1 -3
  27. package/lib/highlight/js.js +11 -14
  28. package/lib/highlight.js +6 -9
  29. package/lib/keys.js +22 -9
  30. package/lib/manifest.js +330 -0
  31. package/lib/remote.js +374 -0
  32. package/lib/render/branches.js +34 -66
  33. package/lib/render/chrome.js +57 -22
  34. package/lib/render/commits.js +85 -0
  35. package/lib/render/diff.js +16 -16
  36. package/lib/render/files.js +29 -37
  37. package/lib/render/frame.js +29 -13
  38. package/lib/render/notes.js +4 -3
  39. package/lib/render/primitives.js +46 -1
  40. package/lib/{render.js → render/render.js} +6 -7
  41. package/lib/render/unit.js +41 -0
  42. package/lib/review.js +750 -14
  43. package/lib/select.js +5 -10
  44. package/lib/session/accessors.js +9 -1
  45. package/lib/session/actions.js +93 -36
  46. package/lib/session/branches.js +130 -144
  47. package/lib/session/change-actions.js +206 -124
  48. package/lib/session/commit.js +217 -62
  49. package/lib/session/compose.js +158 -13
  50. package/lib/session/dispatch.js +109 -55
  51. package/lib/session/files-pane.js +145 -142
  52. package/lib/session/items.js +131 -34
  53. package/lib/session/lifecycle.js +82 -42
  54. package/lib/session/load.js +19 -33
  55. package/lib/session/navigation.js +15 -3
  56. package/lib/session/ops.js +28 -41
  57. package/lib/session/pointer.js +58 -54
  58. package/lib/session/review.js +23 -37
  59. package/lib/session/terminal.js +6 -12
  60. package/lib/session/todos.js +8 -12
  61. package/lib/session/unit.js +208 -0
  62. package/lib/session/update.js +33 -46
  63. package/lib/session/view.js +82 -11
  64. package/lib/session/watch.js +11 -19
  65. package/lib/session.js +29 -226
  66. package/lib/source.js +3 -3
  67. package/lib/update.js +40 -75
  68. package/lib/{git/proc.js → utilities.js} +75 -11
  69. package/lib/wrap.js +1 -3
  70. package/package.json +2 -2
  71. package/lib/deps/changes.js +0 -331
  72. package/lib/deps/items.js +0 -131
  73. package/lib/deps/manifest.js +0 -145
  74. package/lib/deps/merge.js +0 -120
  75. package/lib/deps/proposals.js +0 -164
  76. package/lib/deps/reports.js +0 -180
  77. package/lib/deps/rows.js +0 -177
  78. package/lib/deps/scan.js +0 -138
  79. package/lib/deps/usage.js +0 -68
  80. package/lib/deps/versions.js +0 -119
  81. package/lib/diff.js +0 -33
  82. package/lib/git/changes.js +0 -164
  83. package/lib/git/read.js +0 -253
  84. package/lib/git/run.js +0 -100
  85. package/lib/npm/commands.js +0 -100
  86. package/lib/remote/changes.js +0 -37
  87. package/lib/remote/headers.js +0 -36
  88. package/lib/remote/http.js +0 -122
  89. package/lib/remote/notes.js +0 -94
  90. package/lib/review/format.js +0 -434
  91. package/lib/review/storage.js +0 -150
  92. package/lib/review/store.js +0 -178
  93. package/lib/review/templates.js +0 -49
  94. package/lib/session/origins.js +0 -7
  95. package/lib/sys.js +0 -84
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { isReadOnlyOrigin } = require('./origins.js');
3
+ const { stateView } = require('./accessors.js');
4
+
5
+ const { isReadOnlyOrigin } = require('../files.js');
4
6
  const { pickRepoWrite } = require('./ops.js');
5
7
 
6
8
  const COMMIT_STATUS = {
@@ -9,114 +11,267 @@ const COMMIT_STATUS = {
9
11
  fixup: 'fixup',
10
12
  };
11
13
 
12
- const canCommit = (options) => {
13
- const caps = options.capabilities();
14
+ const canCommit = (ui) => {
15
+ const caps = ui.capabilities;
14
16
  if (!caps.branches) {
15
- options.setStatus('read only');
17
+ ui.status = 'read only';
16
18
  return false;
17
19
  }
18
- for (const item of options.getItems()) {
20
+ for (const item of ui.items) {
19
21
  if (isReadOnlyOrigin(item.origin)) {
20
- options.setStatus('read only');
22
+ ui.status = 'read only';
21
23
  return false;
22
24
  }
23
25
  }
24
26
  return true;
25
27
  };
26
28
 
27
- const hasStagedChanges = (options) => {
28
- const repo = options.repo();
29
- if (repo.hasStaged) return repo.hasStaged(options.top());
30
- for (const item of options.getItems()) {
29
+ const hasStagedChanges = (ui) => {
30
+ const repo = ui.repo;
31
+ if (repo.hasStaged) return repo.hasStaged(ui.top);
32
+ for (const item of ui.items) {
31
33
  if (item.origin === 'staged') return true;
32
34
  }
33
35
  return false;
34
36
  };
35
37
 
36
- const onCommit = (options) => {
37
- if (!canCommit(options)) return;
38
- options.setMode('confirmCommit');
39
- options.setStatus('');
38
+ const selectedCommit = (api) => {
39
+ const cursor = api.ui.nav.commitCursor;
40
+ return api.state.commits[cursor] ?? null;
41
+ };
42
+
43
+ const clampCommitCursor = (api) => {
44
+ const last = api.state.commits.length - 1;
45
+ const nav = api.ui.nav;
46
+ if (nav.commitCursor > last) nav.commitCursor = Math.max(0, last);
47
+ };
48
+
49
+ const refreshCommitList = (api) => {
50
+ const repo = api.ui.repo;
51
+ if (!repo.listCommits) return;
52
+ try {
53
+ api.state.commits = repo.listCommits(api.ui.top);
54
+ } catch (error) {
55
+ api.ui.status = error.message;
56
+ return;
57
+ }
58
+ clampCommitCursor(api);
40
59
  };
41
60
 
42
- const cancelCommit = (options) => {
43
- options.setMode('review');
44
- options.composer.state.commitKind = null;
45
- options.setStatus('');
61
+ const refreshAfterWrite = (api, extra = {}) => {
62
+ refreshCommitList(api);
63
+ api.ui.refreshFromRepo({ keepEmpty: true, ...extra });
46
64
  };
47
65
 
48
- const startGitCommit = (options, kind) => {
49
- if (kind !== 'amend' && !hasStagedChanges(options)) {
50
- options.setMode('review');
51
- options.composer.state.commitKind = null;
52
- options.setStatus('nothing to commit');
66
+ const showCommits = (api) => {
67
+ if (api.ui.mode === 'compose') return;
68
+ if (!canCommit(api.ui)) return;
69
+ const repo = api.ui.repo;
70
+ if (!repo.listCommits) {
71
+ api.ui.status = 'read only';
53
72
  return;
54
73
  }
55
- options.composer.state.commitKind = kind;
74
+ try {
75
+ api.state.commits = repo.listCommits(api.ui.top);
76
+ } catch (error) {
77
+ api.ui.status = error.message;
78
+ return;
79
+ }
80
+ api.ui.nav.commitCursor = 0;
81
+ api.ui.nav.pane = 'commits';
82
+ api.ui.status = '';
83
+ api.ui.nav.clearSelection();
84
+ };
85
+
86
+ const onCommitMove = (api, delta) => {
87
+ const commits = api.state.commits;
88
+ if (!commits.length) {
89
+ api.ui.status = 'nothing to review';
90
+ return;
91
+ }
92
+ const next = Math.min(
93
+ commits.length - 1,
94
+ Math.max(0, api.ui.nav.commitCursor + delta),
95
+ );
96
+ if (next === api.ui.nav.commitCursor) return;
97
+ api.ui.nav.commitCursor = next;
98
+ api.ui.status = '';
99
+ };
100
+
101
+ const startGitCommit = (api, kind) => {
102
+ const ui = api.ui;
103
+ if (kind !== 'amend' && !hasStagedChanges(ui)) {
104
+ ui.mode = 'review';
105
+ ui.composer.state.commitKind = null;
106
+ ui.status = 'nothing to commit';
107
+ return;
108
+ }
109
+ ui.composer.state.commitKind = kind;
56
110
  let text = '';
57
- if (kind === 'fixup') text = 'HEAD';
58
- if (kind === 'amend' && options.repo().lastMessage) {
59
- text = options.repo().lastMessage(options.top()) ?? '';
111
+ if (kind === 'fixup') {
112
+ const entry = selectedCommit(api);
113
+ text = entry ? entry.shortSha || entry.sha : 'HEAD';
60
114
  }
61
- options.composer.openCompose('commit', text);
115
+ if (kind === 'amend' && ui.repo.lastMessage) {
116
+ text = ui.repo.lastMessage(ui.top) ?? '';
117
+ }
118
+ ui.composer.openCompose('commit', text);
119
+ };
120
+
121
+ const onCommit = (api) => {
122
+ if (api.ui.nav.pane !== 'commits') return void showCommits(api);
123
+ if (!canCommit(api.ui)) return;
124
+ startGitCommit(api, 'commit');
125
+ };
126
+
127
+ const onAmend = (api) => {
128
+ if (api.ui.nav.pane !== 'commits') return;
129
+ if (!canCommit(api.ui)) return;
130
+ startGitCommit(api, 'amend');
131
+ };
132
+
133
+ const onFixup = (api) => {
134
+ if (api.ui.nav.pane !== 'commits') return;
135
+ if (!canCommit(api.ui)) return;
136
+ startGitCommit(api, 'fixup');
137
+ };
138
+
139
+ const cancelCommit = (api) => {
140
+ api.ui.mode = 'review';
141
+ api.ui.composer.state.commitKind = null;
142
+ api.ui.status = '';
62
143
  };
63
144
 
64
- const finishAsyncCommit = (options, kind, message, done, write) => {
65
- const composer = options.composer;
66
- composer.closeCompose();
145
+ const finishAsyncCommit = (api, kind, message, done, write) => {
146
+ const ui = api.ui;
147
+ ui.composer.closeCompose();
67
148
  const run = (top) => write(top, kind, message);
68
- const after = () => {
69
- options.collection.clearDismissed();
70
- options.refreshFromRepo({ doneStatus: done });
71
- };
72
- return void options.runner.runBusy('committing', done, run, after);
149
+ const after = () => refreshAfterWrite(api, { doneStatus: done });
150
+ return void ui.ops.runBusy('committing', done, run, after);
73
151
  };
74
152
 
75
- const finishSyncCommit = (options, kind, message, done) => {
153
+ const finishSyncCommit = (api, kind, message, done) => {
154
+ const ui = api.ui;
76
155
  try {
77
- options.repo().commit(options.top(), kind, message);
156
+ ui.repo.commit(ui.top, kind, message);
78
157
  } catch (error) {
79
- options.setStatus(error.message);
158
+ ui.status = error.message;
80
159
  return;
81
160
  }
82
- options.composer.closeCompose();
83
- options.collection.clearDismissed();
84
- options.refreshFromRepo();
85
- if (!options.isDone() && !options.pendingExtras()) {
86
- options.setStatus(done);
161
+ ui.composer.closeCompose();
162
+ ui.collection.clearDismissed();
163
+ refreshAfterWrite(api);
164
+ if (!ui.done && !ui.pendingExtras) {
165
+ ui.status = done;
87
166
  }
88
167
  };
89
168
 
90
- const finishGitCommit = (options) => {
91
- const kind = options.composer.state.commitKind;
92
- const editor = options.composer.state.editor;
169
+ const finishGitCommit = (api) => {
170
+ const ui = api.ui;
171
+ const kind = ui.composer.state.commitKind;
172
+ const editor = ui.composer.state.editor;
93
173
  const message = editor ? editor.text : '';
94
174
  if (kind !== 'fixup' && !message.trim()) {
95
- options.setStatus('empty commit message');
175
+ ui.status = 'empty commit message';
96
176
  return;
97
177
  }
98
- if (kind !== 'amend' && !hasStagedChanges(options)) {
99
- options.composer.closeCompose();
100
- options.setStatus('nothing to commit');
178
+ if (kind !== 'amend' && !hasStagedChanges(ui)) {
179
+ ui.composer.closeCompose();
180
+ ui.status = 'nothing to commit';
101
181
  return;
102
182
  }
103
183
  const done = COMMIT_STATUS[kind] ?? 'committed';
104
- const uiOpen = options.uiOpen();
105
- const picked = pickRepoWrite(options.repo(), 'commit', uiOpen);
184
+ const uiOpen = ui.uiOpen;
185
+ const picked = pickRepoWrite(ui.repo, 'commit', uiOpen);
106
186
  if (picked.async) {
107
- finishAsyncCommit(options, kind, message, done, picked.write);
187
+ return void finishAsyncCommit(api, kind, message, done, picked.write);
188
+ }
189
+ finishSyncCommit(api, kind, message, done);
190
+ };
191
+
192
+ const onDropCommit = (api) => {
193
+ if (api.ui.nav.pane !== 'commits') return;
194
+ if (!canCommit(api.ui)) return;
195
+ const entry = selectedCommit(api);
196
+ if (!entry) return;
197
+ api.state.dropName = entry.shortSha || entry.sha;
198
+ api.state.dropSha = entry.sha;
199
+ api.ui.mode = 'confirmDrop';
200
+ api.ui.status = '';
201
+ };
202
+
203
+ const cancelDropCommit = (api) => {
204
+ api.ui.mode = 'review';
205
+ api.state.dropName = '';
206
+ api.state.dropSha = '';
207
+ api.ui.status = '';
208
+ };
209
+
210
+ const dropAsync = (api, sha, done, write) => {
211
+ const run = (top) => write(top, sha);
212
+ const after = () => refreshAfterWrite(api, { doneStatus: done });
213
+ return void api.ui.ops.runBusy('dropping', done, run, after);
214
+ };
215
+
216
+ const confirmDropCommit = (api) => {
217
+ const sha = api.state.dropSha;
218
+ const name = api.state.dropName;
219
+ api.ui.mode = 'review';
220
+ api.state.dropName = '';
221
+ api.state.dropSha = '';
222
+ if (!sha) return;
223
+ const done = `dropped ${name}`;
224
+ const repo = api.ui.repo;
225
+ if (!repo.dropCommit) {
226
+ api.ui.status = 'read only';
108
227
  return;
109
228
  }
110
- finishSyncCommit(options, kind, message, done);
229
+ const uiOpen = api.ui.uiOpen;
230
+ const picked = pickRepoWrite(repo, 'dropCommit', uiOpen);
231
+ if (picked.async) {
232
+ return void dropAsync(api, sha, done, picked.write);
233
+ }
234
+ const ok = api.ui.ops.runRepo(done, (top) => {
235
+ repo.dropCommit(top, sha);
236
+ });
237
+ if (!ok) return;
238
+ refreshAfterWrite(api);
239
+ if (!api.ui.status) api.ui.status = done;
240
+ };
241
+
242
+ const reset = (state) => {
243
+ state.commits = [];
244
+ state.dropName = '';
245
+ state.dropSha = '';
111
246
  };
112
247
 
113
- const createCommitController = (options) => ({
114
- canCommit: () => canCommit(options),
115
- onCommit: () => onCommit(options),
116
- cancelCommit: () => cancelCommit(options),
117
- startGitCommit: (kind) => startGitCommit(options, kind),
118
- finishGitCommit: () => finishGitCommit(options),
119
- });
248
+ const createCommitController = (ui) => {
249
+ const state = {
250
+ commits: [],
251
+ dropName: '',
252
+ dropSha: '',
253
+ };
254
+ const api = { ui, state };
255
+ return stateView(
256
+ state,
257
+ {
258
+ reset: () => reset(state),
259
+ canCommit: () => canCommit(ui),
260
+ onCommit: () => onCommit(api),
261
+ onAmend: () => onAmend(api),
262
+ onFixup: () => onFixup(api),
263
+ onCommitMove: (delta) => onCommitMove(api, delta),
264
+ cancelCommit: () => cancelCommit(api),
265
+ startGitCommit: (kind) => startGitCommit(api, kind),
266
+ finishGitCommit: () => finishGitCommit(api),
267
+ refresh: () => refreshCommitList(api),
268
+ onDropCommit: () => onDropCommit(api),
269
+ cancelDropCommit: () => cancelDropCommit(api),
270
+ confirmDropCommit: () => confirmDropCommit(api),
271
+ },
272
+ ['commits', 'dropName'],
273
+ );
274
+ };
120
275
 
121
276
  module.exports = {
122
277
  COMMIT_STATUS,
@@ -2,19 +2,19 @@
2
2
 
3
3
  const wrap = require('../wrap.js');
4
4
  const { cursorInWrap, wrapDoc } = wrap;
5
- const render = require('../render.js');
5
+ const render = require('../render/render.js');
6
6
  const { noteInnerWidth, codeInnerWidth, todoBodyWidth } = render;
7
7
  const { Editor } = require('../editor.js');
8
8
  const files = require('../files.js');
9
- const { itemPath, isTodoItem } = files;
10
- const { blockAddText } = require('../diff.js');
9
+ const { itemPath, isTodoItem, listPath, isReadOnlyOrigin } = files;
10
+ const { blockAddText } = require('../diff/diff.js');
11
11
  const items = require('./items.js');
12
- const { itemFeedKey, canEditCode } = items;
13
- const { isReadOnlyOrigin } = require('./origins.js');
12
+ const { itemFeedKey, canEditCode, blockLinesKey } = items;
14
13
  const review = require('../review.js');
15
14
  const { setFeedback, setCode, checkLabel, rememberTemplate } = review;
16
15
  const { createTemplatePick } = require('./templates.js');
17
16
  const { createTodoCompose } = require('./todos.js');
17
+ const unit = require('./unit.js');
18
18
 
19
19
  const COMPOSE_EDITS = ['backspace', 'delete', 'ctrl-z', 'ctrl-y'];
20
20
  const COMPOSE_LEAVE = ['next', 'prev', 'todo'];
@@ -36,6 +36,14 @@ const KIND = {
36
36
  command: null,
37
37
  view: 'code',
38
38
  },
39
+ file: {
40
+ enter: 'newline',
41
+ tab: 'insert-tab',
42
+ escape: 'save',
43
+ move: 'code',
44
+ command: null,
45
+ view: 'code',
46
+ },
39
47
  todo: {
40
48
  enter: 'edit-next',
41
49
  tab: 'template',
@@ -72,6 +80,7 @@ const initialState = () => ({
72
80
  templateFocus: false,
73
81
  blinkOn: true,
74
82
  fileDirty: false,
83
+ fileEditRows: null,
75
84
  });
76
85
 
77
86
  const reset = (state) => {
@@ -91,6 +100,18 @@ const tickBlink = (api) => {
91
100
 
92
101
  const viewSize = (api) => api.ui.lastSize ?? api.ui.getSize();
93
102
 
103
+ const followFileEdit = (api) => {
104
+ if (api.state.composeKind !== 'file') return;
105
+ const text = api.state.editor ? api.state.editor.text : null;
106
+ const rows = unit.linesFor(
107
+ api.ui,
108
+ api.nav.reviewPath,
109
+ text,
110
+ api.state.fileEditRows,
111
+ );
112
+ unit.followFileCursor(api.nav, api.state.editor, api.ui.lastFrame, rows);
113
+ };
114
+
94
115
  const afterClose = (api) => {
95
116
  api.ui.clampIndex();
96
117
  api.ui.syncReviewPath();
@@ -121,6 +142,7 @@ const closeCompose = (api) => {
121
142
  state.composeBaseline = '';
122
143
  state.templateIndex = -1;
123
144
  state.templateFocus = false;
145
+ state.fileEditRows = null;
124
146
  afterClose(api);
125
147
  };
126
148
 
@@ -167,8 +189,7 @@ const saveCodeNote = (api, item, text) => {
167
189
  const key = itemFeedKey(item);
168
190
  const note = itemNote(item, text);
169
191
  if (text === original) {
170
- setCode(notes, key, { ...note, clear: true });
171
- return;
192
+ return void setCode(notes, key, { ...note, clear: true });
172
193
  }
173
194
  setCode(notes, key, note);
174
195
  };
@@ -198,6 +219,85 @@ const commitCode = (api, text) => {
198
219
  return true;
199
220
  };
200
221
 
222
+ const saveUnitFile = (api, text) => {
223
+ const rel = api.nav.reviewPath;
224
+ const repo = api.ui.repo;
225
+ if (!rel || !repo || typeof repo.writeFile !== 'function') return true;
226
+ const caps = api.ui.capabilities;
227
+ if (!caps || caps.changes !== true) {
228
+ api.ui.status = 'read only';
229
+ return false;
230
+ }
231
+ try {
232
+ repo.writeFile(api.ui.top, rel, text);
233
+ } catch (error) {
234
+ api.ui.status = error.message;
235
+ return false;
236
+ }
237
+ if (typeof api.ui.ignoreWatch === 'function') api.ui.ignoreWatch();
238
+ api.state.composeBaseline = text;
239
+ api.state.fileDirty = true;
240
+ return true;
241
+ };
242
+
243
+ const snapshotPathKeys = (list, rel) => {
244
+ const keys = new Set();
245
+ for (const item of list) {
246
+ if (listPath(item) !== rel) continue;
247
+ keys.add(blockLinesKey(item));
248
+ }
249
+ return keys;
250
+ };
251
+
252
+ const stageNewFileHunks = (api, rel, beforeKeys) => {
253
+ const repo = api.ui.repo;
254
+ if (!rel || !repo || typeof repo.add !== 'function') return 0;
255
+ const caps = api.ui.capabilities;
256
+ if (!caps || caps.changes !== true) return 0;
257
+ const list = api.ui.items ?? [];
258
+ let staged = 0;
259
+ for (const item of list) {
260
+ if (listPath(item) !== rel) continue;
261
+ if (isReadOnlyOrigin(item.origin)) continue;
262
+ if (item.origin === 'staged') continue;
263
+ if (beforeKeys.has(blockLinesKey(item))) continue;
264
+ try {
265
+ repo.add(api.ui.top, item);
266
+ } catch (error) {
267
+ api.ui.status = error.message;
268
+ return staged;
269
+ }
270
+ if (api.ui.collection && api.ui.collection.keepOrigin) {
271
+ api.ui.collection.keepOrigin(item, 'staged');
272
+ }
273
+ if (typeof api.ui.ignoreWatch === 'function') api.ui.ignoreWatch();
274
+ staged += 1;
275
+ }
276
+ return staged;
277
+ };
278
+
279
+ const saveFileCompose = (api) => {
280
+ const rel = api.nav.reviewPath;
281
+ const beforeKeys = snapshotPathKeys(api.ui.items ?? [], rel);
282
+ if (api.state.editor) {
283
+ api.nav.unitLine = api.state.editor.linePos().line;
284
+ }
285
+ const result = saveUnitFile(api, api.state.editor.text);
286
+ if (result === false) return { kind: 'error' };
287
+ const reload = api.state.fileDirty === true;
288
+ api.ui.flushReview();
289
+ closeCompose(api);
290
+ if (!reload || typeof api.ui.reloadAfterChange !== 'function') {
291
+ api.ui.status = 'saved';
292
+ return { kind: 'saved' };
293
+ }
294
+ api.ui.reloadAfterChange(() => {
295
+ const n = stageNewFileHunks(api, rel, beforeKeys);
296
+ api.ui.status = n > 0 ? 'staged' : 'saved';
297
+ });
298
+ return { kind: 'saved' };
299
+ };
300
+
201
301
  const commitCompose = (api) => {
202
302
  const { state } = api;
203
303
  if (!state.composeKind || !state.editor) return true;
@@ -211,6 +311,7 @@ const commitCompose = (api) => {
211
311
  return true;
212
312
  }
213
313
  if (state.composeKind === 'code') return commitCode(api, text);
314
+ if (state.composeKind === 'file') return saveUnitFile(api, text);
214
315
  return true;
215
316
  };
216
317
 
@@ -219,6 +320,7 @@ const saveCompose = (api, extra) => {
219
320
  const spec = KIND[api.state.composeKind];
220
321
  if (spec && spec.command) return { kind: spec.command };
221
322
  const kind = api.state.composeKind;
323
+ if (kind === 'file') return saveFileCompose(api);
222
324
  const baseline = api.state.composeBaseline;
223
325
  const result = commitCompose(api);
224
326
  if (result === false) return { kind: 'error' };
@@ -243,6 +345,10 @@ const autosave = (api) => {
243
345
  const { state } = api;
244
346
  const spec = KIND[state.composeKind];
245
347
  const text = state.editor ? state.editor.text : '';
348
+ if (state.composeKind === 'file' && state.editor) {
349
+ if (text === state.composeBaseline) return;
350
+ return void saveUnitFile(api, text);
351
+ }
246
352
  const editingCode = state.composeKind === 'code';
247
353
  const composing = state.composeKind !== null;
248
354
  const reviewKind = composing && (!spec || !spec.command);
@@ -267,9 +373,10 @@ const composeView = (api) => {
267
373
  };
268
374
 
269
375
  const liveCodeText = (api) => {
376
+ const { state } = api;
377
+ if (state.composeKind === 'file' && state.editor) return state.editor.text;
270
378
  const item = api.ui.current();
271
379
  if (!canEditCode(item)) return null;
272
- const { state } = api;
273
380
  if (state.composeKind === 'code' && state.editor) return state.editor.text;
274
381
  if (writesCodeFile(api, item)) return null;
275
382
  const notes = api.review.store;
@@ -282,7 +389,8 @@ const liveCodeText = (api) => {
282
389
  const codeOverlayView = (api) => {
283
390
  const text = liveCodeText(api);
284
391
  if (text === null) return null;
285
- const editing = api.state.composeKind === 'code';
392
+ const editing =
393
+ api.state.composeKind === 'code' || api.state.composeKind === 'file';
286
394
  const editor = api.state.editor;
287
395
  const cursor = editing && editor ? editor.cursor : null;
288
396
  return { text, keepEmpty: editing, cursor };
@@ -351,14 +459,12 @@ const moveComposeLine = (api, delta) => {
351
459
  if (move === 'todo') {
352
460
  const size = viewSize(api);
353
461
  const inner = todoBodyWidth(size.width);
354
- api.todos.moveTodoCompose(delta, inner);
355
- return;
462
+ return void api.todos.moveTodoCompose(delta, inner);
356
463
  }
357
464
  if (move === 'code') {
358
465
  const size = viewSize(api);
359
466
  const inner = codeInnerWidth(size.width, api.ui.layout);
360
- api.state.editor.moveLine(delta, inner);
361
- return;
467
+ return void api.state.editor.moveLine(delta, inner);
362
468
  }
363
469
  moveNoteLine(api, delta);
364
470
  };
@@ -410,7 +516,26 @@ const onFeedback = (api) => {
410
516
  openCompose(api, 'feedback', text);
411
517
  };
412
518
 
519
+ const onUnitEdit = (api) => {
520
+ const rel = api.nav.reviewPath;
521
+ if (!rel) {
522
+ api.ui.status = 'not a diff block';
523
+ return;
524
+ }
525
+ const caps = api.ui.capabilities;
526
+ if (!caps || caps.changes !== true) {
527
+ api.ui.status = 'read only';
528
+ return;
529
+ }
530
+ const at = unit.editCursor(api.ui);
531
+ openCompose(api, 'file', at.text);
532
+ api.state.fileEditRows = at.rows ?? null;
533
+ api.state.editor.cursor = Math.min(at.cursor, at.text.length);
534
+ followFileEdit(api);
535
+ };
536
+
413
537
  const onCode = (api) => {
538
+ if (api.nav.pane === 'unit') return void onUnitEdit(api);
414
539
  if (api.nav.pane === 'files') {
415
540
  api.ui.status = 'not a diff block';
416
541
  return;
@@ -438,6 +563,7 @@ const enterCompose = (api) => {
438
563
  if (spec && spec.enter === 'newline') {
439
564
  state.editor.insert('\n');
440
565
  resetBlink(api);
566
+ followFileEdit(api);
441
567
  return null;
442
568
  }
443
569
  if (spec && spec.enter === 'edit-next') {
@@ -484,6 +610,7 @@ const handleKey = (api, key) => {
484
610
  const result = special(api);
485
611
  if (COMPOSE_EDITS.includes(key)) api.templates.clearTemplatePick();
486
612
  resetBlink(api);
613
+ followFileEdit(api);
487
614
  return result ?? null;
488
615
  }
489
616
  if (!key.length) return null;
@@ -492,10 +619,22 @@ const handleKey = (api, key) => {
492
619
  api.templates.clearTemplatePick();
493
620
  api.state.editor.insert(key);
494
621
  resetBlink(api);
622
+ followFileEdit(api);
495
623
  }
496
624
  return null;
497
625
  };
498
626
 
627
+ const applyDiskText = (api, text) => {
628
+ if (api.state.composeKind !== 'file' || !api.state.editor) return;
629
+ if (text === api.state.editor.text) return;
630
+ const cursor = api.state.editor.cursor;
631
+ api.state.editor.text = text;
632
+ api.state.editor.cursor = Math.min(cursor, text.length);
633
+ api.state.composeBaseline = text;
634
+ api.state.fileEditRows = unit.linesFor(api.ui, api.nav.reviewPath, null);
635
+ followFileEdit(api);
636
+ };
637
+
499
638
  const composerMethods = (api) => ({
500
639
  reset: () => reset(api.state),
501
640
  openCompose: (kind, text, todoId) => openCompose(api, kind, text, todoId),
@@ -511,6 +650,12 @@ const composerMethods = (api) => ({
511
650
  onFeedback: () => onFeedback(api),
512
651
  onCode: () => onCode(api),
513
652
  handleKey: (key) => handleKey(api, key),
653
+ liveFileText: () => {
654
+ if (api.state.composeKind !== 'file' || !api.state.editor) return null;
655
+ return api.state.editor.text;
656
+ },
657
+ fileEditRows: () => api.state.fileEditRows,
658
+ applyDiskText: (text) => applyDiskText(api, text),
514
659
  });
515
660
 
516
661
  const createComposer = ({ nav, review, ui, restartBlink }) => {