reslop 0.2.0 → 0.2.3

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 (43) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +13 -2
  3. package/lib/ansi.js +263 -6
  4. package/lib/api.js +2 -0
  5. package/lib/capabilities.js +6 -0
  6. package/lib/cli.js +13 -2
  7. package/lib/deps.js +43 -3
  8. package/lib/git-branches.js +228 -17
  9. package/lib/git-reword-todo.js +20 -0
  10. package/lib/git.js +20 -3
  11. package/lib/keys.js +10 -4
  12. package/lib/npm-commands.js +513 -0
  13. package/lib/render/branches.js +23 -58
  14. package/lib/render/chrome.js +156 -99
  15. package/lib/render/commits.js +643 -31
  16. package/lib/render/diff.js +4 -4
  17. package/lib/render/frame.js +51 -5
  18. package/lib/render/notes.js +22 -5
  19. package/lib/render/npm.js +228 -0
  20. package/lib/render/primitives.js +48 -34
  21. package/lib/render/render.js +3 -3
  22. package/lib/report-model.js +127 -0
  23. package/lib/report-parse.js +94 -0
  24. package/lib/report-render.js +87 -0
  25. package/lib/report-run.js +164 -0
  26. package/lib/report-test.js +506 -0
  27. package/lib/report-tools.js +311 -0
  28. package/lib/review-merge.js +270 -0
  29. package/lib/review.js +38 -6
  30. package/lib/session/actions.js +81 -5
  31. package/lib/session/commit.js +187 -19
  32. package/lib/session/compose.js +96 -32
  33. package/lib/session/dispatch.js +36 -2
  34. package/lib/session/files-pane.js +36 -6
  35. package/lib/session/lifecycle.js +116 -16
  36. package/lib/session/navigation.js +1 -0
  37. package/lib/session/npm.js +335 -0
  38. package/lib/session/pointer.js +12 -1
  39. package/lib/session/todos.js +29 -1
  40. package/lib/session/view.js +14 -3
  41. package/lib/session/watch.js +120 -8
  42. package/lib/session.js +11 -2
  43. package/package.json +7 -7
@@ -0,0 +1,335 @@
1
+ 'use strict';
2
+
3
+ const commands = require('../npm-commands.js');
4
+ const { logViewRows } = require('../render/npm.js');
5
+
6
+ const { listCommands, reduceOutput, writeLog, parseScriptLine } = commands;
7
+ const { scriptLine, saveScript, removeScript, reorderScript } = commands;
8
+ const { startNpm } = commands;
9
+
10
+ const selected = (api) => {
11
+ const cursor = api.ui.nav.npmCursor;
12
+ return api.state.commands[cursor];
13
+ };
14
+
15
+ const clampCursor = (api) => {
16
+ const last = api.state.commands.length - 1;
17
+ const nav = api.ui.nav;
18
+ if (nav.npmCursor > last) nav.npmCursor = Math.max(0, last);
19
+ if (nav.npmCursor < 0) nav.npmCursor = 0;
20
+ };
21
+
22
+ const refresh = (api) => {
23
+ api.state.commands = listCommands(api.ui.top);
24
+ clampCursor(api);
25
+ };
26
+
27
+ const canEdit = (ui) => {
28
+ if (ui.capabilities && ui.capabilities.changes === false) return false;
29
+ if (ui.readOnly) return false;
30
+ return true;
31
+ };
32
+
33
+ const open = (api) => {
34
+ if (api.ui.mode === 'compose') return;
35
+ refresh(api);
36
+ api.state.viewing = false;
37
+ api.state.output = '';
38
+ api.ui.nav.pane = 'npm';
39
+ api.ui.status = '';
40
+ api.ui.nav.clearSelection();
41
+ };
42
+
43
+ const scrollLog = (api, delta) => {
44
+ const lines = api.state.output.split('\n');
45
+ let count = lines.at(-1) === '' ? lines.length - 1 : lines.length;
46
+ if (api.state.running) count += 1;
47
+ const frame = api.ui.lastFrame;
48
+ const bodyH = frame && frame.bodyH ? frame.bodyH : 1;
49
+ const max = Math.max(0, count - logViewRows(bodyH));
50
+ let start = api.state.followEnd ? max : api.state.logScroll;
51
+ start = Math.min(max, Math.max(0, start + delta));
52
+ api.state.followEnd = start === max;
53
+ api.state.logScroll = start;
54
+ };
55
+
56
+ const move = (api, delta) => {
57
+ if (api.state.viewing) return void scrollLog(api, delta);
58
+ if (!api.state.commands.length) return;
59
+ const nav = api.ui.nav;
60
+ const last = api.state.commands.length - 1;
61
+ const next = Math.min(last, Math.max(0, nav.npmCursor + delta));
62
+ if (next === nav.npmCursor) return;
63
+ nav.npmCursor = next;
64
+ api.ui.status = '';
65
+ };
66
+
67
+ const stopRun = (api) => {
68
+ api.state.running = false;
69
+ api.state.child = null;
70
+ if (api.ui.progress) api.ui.progress.stop('npm');
71
+ };
72
+
73
+ const showOutput = (api, text) => {
74
+ if (!api.state.viewing || !api.state.running) return;
75
+ if (api.state.stopping) return;
76
+ api.state.output = text;
77
+ api.ui.paint();
78
+ };
79
+
80
+ const withNotice = (text, notice) => {
81
+ const body = `${text ?? ''}`;
82
+ if (body.endsWith(`${notice}\n`)) return body;
83
+ if (!body) return `${notice}\n`;
84
+ if (body.endsWith('\n')) return `${body}${notice}\n`;
85
+ return `${body}\n${notice}\n`;
86
+ };
87
+
88
+ const finishRun = (api, entry, result) => {
89
+ const stopped = api.state.stopping === true;
90
+ const reduced = stopped
91
+ ? withNotice(api.state.output, 'terminated')
92
+ : reduceOutput(result.text, api.ui.top, result.status);
93
+ api.state.output = reduced;
94
+ api.state.stopping = false;
95
+ stopRun(api);
96
+ try {
97
+ writeLog(api.ui.top, entry.name, reduced);
98
+ } catch (error) {
99
+ api.ui.status = error.message;
100
+ api.ui.paint();
101
+ return;
102
+ }
103
+ if (stopped && api.state.viewing) api.ui.status = 'terminated';
104
+ api.ui.paint();
105
+ };
106
+
107
+ const startRun = (api, entry) => {
108
+ api.state.output = '';
109
+ api.state.viewing = true;
110
+ api.state.followEnd = true;
111
+ api.state.logScroll = 0;
112
+ api.state.running = true;
113
+ api.state.stopping = false;
114
+ api.ui.status = '';
115
+ if (api.ui.progress) api.ui.progress.start('npm');
116
+ const runner = api.ui.repo && api.ui.repo.runNpmCommand;
117
+ const start = typeof runner === 'function' ? runner : startNpm;
118
+ api.state.child = start(
119
+ api.ui.top,
120
+ entry,
121
+ (text) => showOutput(api, reduceOutput(text, api.ui.top, '')),
122
+ (result) => finishRun(api, entry, result),
123
+ );
124
+ api.ui.paint();
125
+ };
126
+
127
+ const runSelected = (api) => {
128
+ if (api.state.viewing || api.state.child) return;
129
+ const entry = selected(api);
130
+ if (!entry) return;
131
+ startRun(api, entry);
132
+ };
133
+
134
+ const rerun = (api) => {
135
+ if (!api.state.viewing || api.state.running) return;
136
+ const entry = selected(api);
137
+ if (!entry) return;
138
+ startRun(api, entry);
139
+ };
140
+
141
+ const closeView = (api) => {
142
+ if (api.state.running) {
143
+ api.state.stopping = true;
144
+ if (api.state.child) api.state.child.kill();
145
+ if (!api.state.running) return;
146
+ api.state.output = withNotice(api.state.output, 'terminated');
147
+ stopRun(api);
148
+ api.ui.status = 'terminated';
149
+ api.ui.paint();
150
+ return;
151
+ }
152
+ api.state.viewing = false;
153
+ api.ui.status = '';
154
+ api.ui.paint();
155
+ };
156
+
157
+ const editSelected = (api) => {
158
+ if (api.state.viewing) return;
159
+ if (!canEdit(api.ui)) {
160
+ api.ui.status = 'read only';
161
+ return;
162
+ }
163
+ const entry = selected(api);
164
+ if (!entry || entry.kind !== 'script') {
165
+ api.ui.status = 'not a script';
166
+ return;
167
+ }
168
+ api.state.editName = entry.name;
169
+ api.ui.composer.openCompose('npm', scriptLine(entry));
170
+ };
171
+
172
+ const newCommand = (api) => {
173
+ if (api.state.viewing) return;
174
+ if (!canEdit(api.ui)) {
175
+ api.ui.status = 'read only';
176
+ return;
177
+ }
178
+ api.state.editName = '';
179
+ api.ui.composer.openCompose('npm', '');
180
+ };
181
+
182
+ const finishEdit = (api) => {
183
+ const editor = api.ui.composer.state.editor;
184
+ const text = editor ? editor.text : '';
185
+ const parsed = parseScriptLine(text);
186
+ if (!parsed) {
187
+ api.ui.status = 'name: command';
188
+ return;
189
+ }
190
+ try {
191
+ api.ui.ignoreWatch();
192
+ saveScript(api.ui.top, api.state.editName, parsed);
193
+ } catch (error) {
194
+ api.ui.status = error.message;
195
+ return;
196
+ }
197
+ api.ui.composer.closeCompose();
198
+ refresh(api);
199
+ const at = api.state.commands.findIndex(
200
+ (entry) => entry.name === parsed.name,
201
+ );
202
+ if (at >= 0) api.ui.nav.npmCursor = at;
203
+ api.ui.status = 'saved';
204
+ };
205
+
206
+ const askDrop = (api) => {
207
+ if (api.state.viewing) return;
208
+ if (!canEdit(api.ui)) {
209
+ api.ui.status = 'read only';
210
+ return;
211
+ }
212
+ const entry = selected(api);
213
+ if (!entry || entry.kind !== 'script') {
214
+ api.ui.status = 'not a script';
215
+ return;
216
+ }
217
+ api.state.dropName = entry.name;
218
+ api.ui.mode = 'confirmDrop';
219
+ api.ui.status = '';
220
+ };
221
+
222
+ const confirmDrop = (api) => {
223
+ const name = api.state.dropName;
224
+ api.state.dropName = '';
225
+ api.ui.mode = 'review';
226
+ if (!name) return;
227
+ try {
228
+ api.ui.ignoreWatch();
229
+ removeScript(api.ui.top, name);
230
+ } catch (error) {
231
+ api.ui.status = error.message;
232
+ return;
233
+ }
234
+ refresh(api);
235
+ api.ui.status = `dropped ${name}`;
236
+ };
237
+
238
+ const cancelDrop = (api) => {
239
+ api.state.dropName = '';
240
+ api.ui.mode = 'review';
241
+ api.ui.status = '';
242
+ };
243
+
244
+ const reorder = (api, delta) => {
245
+ if (api.state.viewing) return;
246
+ if (!canEdit(api.ui)) {
247
+ api.ui.status = 'read only';
248
+ return;
249
+ }
250
+ const entry = selected(api);
251
+ if (!entry || entry.kind !== 'script') {
252
+ api.ui.status = 'not a script';
253
+ return;
254
+ }
255
+ try {
256
+ api.ui.ignoreWatch();
257
+ if (!reorderScript(api.ui.top, entry.name, delta)) return;
258
+ } catch (error) {
259
+ api.ui.status = error.message;
260
+ return;
261
+ }
262
+ refresh(api);
263
+ const at = api.state.commands.findIndex((item) => item.name === entry.name);
264
+ if (at >= 0) api.ui.nav.npmCursor = at;
265
+ api.ui.status = '';
266
+ };
267
+
268
+ const createNpmController = (ui) => {
269
+ const state = {
270
+ commands: [],
271
+ viewing: false,
272
+ output: '',
273
+ followEnd: true,
274
+ logScroll: 0,
275
+ editName: '',
276
+ dropName: '',
277
+ running: false,
278
+ stopping: false,
279
+ child: null,
280
+ };
281
+ const api = { ui, state };
282
+ return {
283
+ state,
284
+ get commands() {
285
+ return state.commands;
286
+ },
287
+ get viewing() {
288
+ return state.viewing;
289
+ },
290
+ get output() {
291
+ return state.output;
292
+ },
293
+ get dropName() {
294
+ return state.dropName;
295
+ },
296
+ get followEnd() {
297
+ return state.followEnd;
298
+ },
299
+ get logScroll() {
300
+ return state.logScroll;
301
+ },
302
+ get editName() {
303
+ return state.editName;
304
+ },
305
+ get running() {
306
+ return state.running;
307
+ },
308
+ reset() {
309
+ if (state.child) state.child.kill();
310
+ stopRun(api);
311
+ state.commands = [];
312
+ state.viewing = false;
313
+ state.output = '';
314
+ state.followEnd = true;
315
+ state.logScroll = 0;
316
+ state.editName = '';
317
+ state.dropName = '';
318
+ state.stopping = false;
319
+ },
320
+ open: () => open(api),
321
+ move: (delta) => move(api, delta),
322
+ run: () => runSelected(api),
323
+ rerun: () => rerun(api),
324
+ closeView: () => closeView(api),
325
+ edit: () => editSelected(api),
326
+ create: () => newCommand(api),
327
+ finishEdit: () => finishEdit(api),
328
+ askDrop: () => askDrop(api),
329
+ confirmDrop: () => confirmDrop(api),
330
+ cancelDrop: () => cancelDrop(api),
331
+ reorder: (delta) => reorder(api, delta),
332
+ };
333
+ };
334
+
335
+ module.exports = { createNpmController };
@@ -99,13 +99,24 @@ const RELEASE_TARGETS = [
99
99
  match: (ui) => ui.pane === 'commits',
100
100
  hits: 'fileHits',
101
101
  onHit: (ui, hit) => {
102
+ if (ui.mode === 'compose') return;
102
103
  ui.nav.commitCursor = hit.cursor;
103
104
  },
104
105
  },
106
+ {
107
+ match: (ui) => ui.pane === 'npm',
108
+ hits: 'fileHits',
109
+ onHit: (ui, hit) => {
110
+ ui.nav.npmCursor = hit.cursor;
111
+ },
112
+ },
105
113
  {
106
114
  match: (ui) => ui.pane === 'diff',
107
115
  hits: 'todoHits',
108
- onHit: (ui, hit) => ui.composer.onTodoHit(hit.cursor),
116
+ onHit: (ui, hit) => {
117
+ if (hit.check) return void ui.composer.onTodoCheck(hit.cursor);
118
+ ui.composer.onTodoHit(hit.cursor);
119
+ },
109
120
  },
110
121
  {
111
122
  match: (ui) => ui.mode === 'compose',
@@ -3,7 +3,7 @@
3
3
  const files = require('../files.js');
4
4
  const { isTodosEntry, TODO_FILE } = files;
5
5
  const review = require('../review.js');
6
- const { addTodo, setTodoText, removeTodo, checkLabel } = review;
6
+ const { addTodo, setTodoText, setTodoDone, removeTodo, checkLabel } = review;
7
7
 
8
8
  const TODO_TYPE_SKIP = [
9
9
  'enter',
@@ -101,6 +101,7 @@ const closeTodoPage = (api) => {
101
101
  api.nav.todoOpen = false;
102
102
  api.ui.syncReviewPath();
103
103
  api.ui.syncFileCursor();
104
+ api.ui.lifecycle.catchUpWatch();
104
105
  };
105
106
 
106
107
  const focusTodoPage = (api, todoId) => {
@@ -189,6 +190,17 @@ const removeFocusedTodo = (api) => {
189
190
  api.ui.flushReview(true);
190
191
  };
191
192
 
193
+ const toggleFocusedTodo = (api) => {
194
+ const nav = api.nav;
195
+ if (!nav.todoOpen || nav.pane !== 'diff') return false;
196
+ if (api.ui.mode !== 'review') return false;
197
+ const todo = listTodos(api)[clampedTodoFocus(api)];
198
+ if (!todo) return false;
199
+ setTodoDone(api.review.store, todo.id, !todo.done);
200
+ api.ui.flushReview(true);
201
+ return true;
202
+ };
203
+
192
204
  const onTodoHit = (api, cursor) => {
193
205
  if (!api.nav.todoOpen) return;
194
206
  const todos = listTodos(api);
@@ -206,6 +218,20 @@ const onTodoHit = (api, cursor) => {
206
218
  editFocusedTodo(api);
207
219
  };
208
220
 
221
+ const onTodoCheck = (api, cursor) => {
222
+ if (!api.nav.todoOpen) return;
223
+ const todo = listTodos(api)[cursor];
224
+ if (!todo) return void onTodoHit(api, cursor);
225
+ const { state } = api;
226
+ if (state.composeKind === 'todo' && state.composeTodoId !== todo.id) {
227
+ api.commit();
228
+ api.close();
229
+ }
230
+ setTodoDone(api.review.store, todo.id, !todo.done);
231
+ api.nav.todoFocus = cursor;
232
+ api.ui.flushReview(true);
233
+ };
234
+
209
235
  const createTodo = (api) => {
210
236
  startDraftCompose(api);
211
237
  };
@@ -253,6 +279,8 @@ const createTodoCompose = (api) => ({
253
279
  typeIntoTodo: (key) => typeIntoTodo(api, key),
254
280
  removeFocusedTodo: () => removeFocusedTodo(api),
255
281
  onTodoHit: (cursor) => onTodoHit(api, cursor),
282
+ onTodoCheck: (cursor) => onTodoCheck(api, cursor),
283
+ toggleFocusedTodo: () => toggleFocusedTodo(api),
256
284
  onTodo: () => onTodo(api),
257
285
  todoEditView: () => todoEditView(api),
258
286
  commitTodo: (text) => commitTodo(api, text),
@@ -3,6 +3,7 @@
3
3
  const path = require('node:path');
4
4
 
5
5
  const files = require('../files.js');
6
+ const { listedCommits } = require('./commit.js');
6
7
  const { fileEntries, REPO_TODOS_LABEL } = files;
7
8
  const review = require('../review.js');
8
9
  const { noteCounts } = review;
@@ -143,7 +144,7 @@ const unitBodyLines = (api) => {
143
144
  };
144
145
 
145
146
  const view = (api) => {
146
- const { composer, nav, ui, gitBranches, commits, updater } = api;
147
+ const { composer, nav, ui, gitBranches, commits, npm, updater } = api;
147
148
  const templates = composer.shownTemplates();
148
149
  const change = ui.change;
149
150
  const sourceKind = (change && change.source) || '';
@@ -169,9 +170,19 @@ const view = (api) => {
169
170
  branch: ui.branch,
170
171
  branches: gitBranches.branches,
171
172
  branchCursor: nav.branchCursor,
172
- commits: commits.commits,
173
+ commits: listedCommits(commits.commits),
173
174
  commitCursor: nav.commitCursor,
175
+ commitView: commits.commitView,
176
+ npmCommands: npm.commands,
177
+ npmCursor: nav.npmCursor,
178
+ npmView: npm.viewing,
179
+ npmOutput: npm.output,
180
+ npmFollow: npm.followEnd,
181
+ npmScroll: npm.logScroll,
182
+ npmEditName: npm.editName,
183
+ npmRunning: npm.running,
174
184
  repoName: repoNameOf(api),
185
+ rev: ui.rev || '',
175
186
  revShort: ui.revShort || '',
176
187
  sourceLabel: ui.sourceLabel || '',
177
188
  sourceKind,
@@ -186,7 +197,7 @@ const view = (api) => {
186
197
  templateIndex: composer.clampedTemplateIndex(templates),
187
198
  updateFrom: updater.from,
188
199
  updateTo: updater.to,
189
- dropName: gitBranches.dropName || commits.dropName,
200
+ dropName: gitBranches.dropName || commits.dropName || npm.dropName,
190
201
  };
191
202
  };
192
203
 
@@ -72,26 +72,127 @@ const closeWatcher = (watcher) => {
72
72
  }
73
73
  };
74
74
 
75
- const schedule = (state) => {
76
- if (state.closed) return;
77
- if (state.timer !== null) clearTimeout(state.timer);
78
- state.timer = setTimeout(() => {
79
- state.timer = null;
75
+ const reviewMarkdown = (rel) => {
76
+ const norm = `${rel}`.replaceAll('\\', '/');
77
+ if (!norm.startsWith('.review/')) return false;
78
+ const rest = norm.slice('.review/'.length);
79
+ if (!rest || rest.includes('/')) return false;
80
+ return rest.endsWith('.md');
81
+ };
82
+
83
+ const isReviewDir = (root, dir) => {
84
+ const rel = path.relative(root, dir).replaceAll('\\', '/');
85
+ return rel === '.review' || rel.startsWith('.review/');
86
+ };
87
+
88
+ const hasFile = (full) => {
89
+ try {
90
+ fs.accessSync(full);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ };
96
+
97
+ const isBareReviewName = (root, dir, rel) => {
98
+ if (!rel || rel.includes('/') || !rel.endsWith('.md')) return false;
99
+ const base = path.basename(rel);
100
+ if (hasFile(path.join(dir, base))) return false;
101
+ return hasFile(path.join(root, '.review', base));
102
+ };
103
+
104
+ const isReviewEvent = (root, dir, name) => {
105
+ if (isReviewDir(root, dir)) return true;
106
+ if (!name) return false;
107
+ const rel = path.relative(root, path.join(dir, name));
108
+ const norm = rel.replaceAll('\\', '/');
109
+ if (reviewMarkdown(norm)) return true;
110
+ return isBareReviewName(root, dir, norm);
111
+ };
112
+
113
+ const isOwnDirEvent = (dir, name) => {
114
+ if (!name) return false;
115
+ const base = path.basename(dir);
116
+ const slash = `${name}`.replaceAll('\\', '/');
117
+ const bare = slash.startsWith('./') ? slash.slice(2) : slash;
118
+ const trimmed = bare.endsWith('/') ? bare.slice(0, -1) : bare;
119
+ if (trimmed === base) {
120
+ try {
121
+ return !fs.statSync(path.join(dir, trimmed)).isFile();
122
+ } catch {
123
+ return true;
124
+ }
125
+ }
126
+ const full = path.resolve(dir, name);
127
+ if (full === path.resolve(dir)) return true;
128
+ if (path.basename(full) !== base) return false;
129
+ try {
130
+ return fs.realpathSync(full) === fs.realpathSync(dir);
131
+ } catch {
132
+ return false;
133
+ }
134
+ };
135
+
136
+ const schedule = (state, timerKey, fire) => {
137
+ if (state.closed || !fire) return;
138
+ if (state[timerKey] !== null) clearTimeout(state[timerKey]);
139
+ state[timerKey] = setTimeout(() => {
140
+ state[timerKey] = null;
80
141
  if (state.closed) return;
81
- state.onChange();
142
+ fire();
82
143
  }, state.debounceMs);
83
- if (typeof state.timer.unref === 'function') state.timer.unref();
144
+ const timer = state[timerKey];
145
+ if (timer && typeof timer.unref === 'function') timer.unref();
146
+ };
147
+
148
+ const REVIEW_HOLD_MS = 200;
149
+
150
+ const reviewBurst = (state) => {
151
+ const seen = state.reviewSeenAt;
152
+ if (!seen) return false;
153
+ const hold = Math.max(state.debounceMs * 2, REVIEW_HOLD_MS);
154
+ return Date.now() - seen < hold;
155
+ };
156
+
157
+ const scheduleChange = (state) => {
158
+ schedule(state, 'timer', () => {
159
+ if (reviewBurst(state)) return;
160
+ if (state.onChange) state.onChange();
161
+ });
162
+ };
163
+
164
+ const scheduleUnnamed = (state) => {
165
+ if (reviewBurst(state)) return;
166
+ schedule(state, 'looseTimer', () => {
167
+ if (reviewBurst(state)) return;
168
+ scheduleChange(state);
169
+ });
170
+ };
171
+
172
+ const dropChange = (state) => {
173
+ if (state.timer !== null) clearTimeout(state.timer);
174
+ if (state.looseTimer !== null) clearTimeout(state.looseTimer);
175
+ state.timer = null;
176
+ state.looseTimer = null;
84
177
  };
85
178
 
86
179
  const handleWatchEvent = (state, dir, eventType, filename) => {
87
180
  if (state.closed) return;
88
181
  const name = filename ? `${filename}` : '';
89
182
  const rel = name ? path.relative(state.root, path.join(dir, name)) : '';
183
+ if (isReviewEvent(state.root, dir, name)) {
184
+ state.reviewSeenAt = Date.now();
185
+ dropChange(state);
186
+ return void schedule(state, 'reviewTimer', state.onReview);
187
+ }
90
188
  if (rel && ignoredRel(rel)) return;
189
+ if (reviewBurst(state)) return;
190
+ if (!name) return void scheduleUnnamed(state);
191
+ if (isOwnDirEvent(dir, name)) return;
91
192
  if (eventType === 'rename' && name) {
92
193
  state.watchNewDir(path.join(dir, name));
93
194
  }
94
- schedule(state);
195
+ scheduleChange(state);
95
196
  };
96
197
 
97
198
  const watchOne = (state, target, extra = {}) => {
@@ -141,6 +242,7 @@ const maybeWatchNewDir = (state, full) => {
141
242
  return;
142
243
  }
143
244
  if (!stat.isDirectory()) return;
245
+ if (path.basename(full) === '.review') return void watchOne(state, full);
144
246
  if (skipDirName(path.basename(full))) return;
145
247
  watchTree(state, full);
146
248
  };
@@ -166,7 +268,11 @@ const watchGit = (state) => {
166
268
  const stopState = (state) => {
167
269
  state.closed = true;
168
270
  if (state.timer !== null) clearTimeout(state.timer);
271
+ if (state.reviewTimer !== null) clearTimeout(state.reviewTimer);
272
+ if (state.looseTimer !== null) clearTimeout(state.looseTimer);
169
273
  state.timer = null;
274
+ state.reviewTimer = null;
275
+ state.looseTimer = null;
170
276
  while (state.watchers.length) closeWatcher(state.watchers.pop());
171
277
  state.watched.clear();
172
278
  };
@@ -189,13 +295,18 @@ const createDiskWatcher = (options) => {
189
295
  dirWatchOpts: recursive ? { recursive: true } : {},
190
296
  watchers: [],
191
297
  watched: new Set(),
298
+ onReview: options.onReview,
192
299
  timer: null,
300
+ reviewTimer: null,
301
+ looseTimer: null,
302
+ reviewSeenAt: 0,
193
303
  closed: false,
194
304
  watchNewDir: (full) => maybeWatchNewDir(state, full),
195
305
  };
196
306
  const scopes = options.paths && options.paths.length ? options.paths : ['.'];
197
307
  for (const spec of scopes) watchScope(state, spec);
198
308
  watchGit(state);
309
+ watchOne(state, path.resolve(state.root, '.review'));
199
310
  return {
200
311
  close: () => stopState(state),
201
312
  watchFiles: (rels) => watchFiles(state, rels),
@@ -207,5 +318,6 @@ module.exports = {
207
318
  POLL_MS,
208
319
  canWatchRecursive,
209
320
  ignoredRel,
321
+ isOwnDirEvent,
210
322
  createDiskWatcher,
211
323
  };