reslop 0.1.8 → 0.1.9

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.
@@ -21,25 +21,127 @@ const npmBusyKind = (item) => {
21
21
  return 'npm i';
22
22
  };
23
23
 
24
- const itemKey = (item) => {
24
+ const blockKey = (item) => {
25
25
  if (!item || !item.file) return '';
26
26
  const rel = itemPath(item);
27
27
  if (isTodoItem(item)) return `todo:${TODO_FILE}`;
28
- const origin = item.origin ?? '';
29
28
  const depId = depChangeKey(item);
30
- if (depId) return `${origin}:${rel}:dep:${depId}`;
29
+ if (depId) return `${rel}:dep:${depId}`;
31
30
  if (!item.hunk) {
32
31
  const block = item.blockId ?? '-';
33
- return `${origin}:${rel}:file:${block}`;
32
+ return `${rel}:file:${block}`;
34
33
  }
35
34
  const oldStart = item.hunk.oldStart;
36
35
  const newStart = item.hunk.newStart;
37
36
  const block = item.blockId ?? '-';
38
- return `${origin}:${rel}:${oldStart}:${newStart}:${block}`;
37
+ return `${rel}:${oldStart}:${newStart}:${block}`;
38
+ };
39
+
40
+ const itemKey = (item) => {
41
+ const key = blockKey(item);
42
+ if (!key || isTodoItem(item)) return key;
43
+ const origin = item.origin ?? '';
44
+ return `${origin}:${key}`;
45
+ };
46
+
47
+ const blockLinesKey = (item) => {
48
+ if (!item || !item.file || isTodoItem(item)) return '';
49
+ if (!item.hunk) return '';
50
+ const parts = [itemPath(item)];
51
+ for (const line of item.hunk.lines) {
52
+ if (line.blockId !== item.blockId) continue;
53
+ if (line.type === 'ctx') continue;
54
+ parts.push(`${line.type}:${line.text}`);
55
+ }
56
+ return parts.join('\0');
57
+ };
58
+
59
+ const pushBucket = (map, key, item) => {
60
+ if (!key) return;
61
+ const list = map.get(key) ?? [];
62
+ list.push(item);
63
+ map.set(key, list);
64
+ };
65
+
66
+ const takeBucket = (map, key, used) => {
67
+ if (!key) return null;
68
+ const list = map.get(key);
69
+ if (!list || !list.length) return null;
70
+ while (list.length) {
71
+ const item = list.shift();
72
+ if (!used.has(item)) return item;
73
+ }
74
+ return null;
75
+ };
76
+
77
+ const pathOf = (item) => itemPath(item) || '';
78
+
79
+ const groupByPath = (items) => {
80
+ const groups = new Map();
81
+ for (const item of items) {
82
+ const rel = pathOf(item);
83
+ const list = groups.get(rel) ?? [];
84
+ list.push(item);
85
+ groups.set(rel, list);
86
+ }
87
+ return groups;
88
+ };
89
+
90
+ const alignGroup = (prev, next) => {
91
+ if (!prev.length) return next;
92
+ if (!next.length) return [];
93
+ const byBlock = new Map();
94
+ const byLines = new Map();
95
+ for (const item of next) {
96
+ pushBucket(byBlock, blockKey(item), item);
97
+ pushBucket(byLines, blockLinesKey(item), item);
98
+ }
99
+ const used = new Set();
100
+ const ordered = [];
101
+ for (const item of prev) {
102
+ const byText = takeBucket(byLines, blockLinesKey(item), used);
103
+ const taken = byText ?? takeBucket(byBlock, blockKey(item), used);
104
+ if (!taken) continue;
105
+ used.add(taken);
106
+ ordered.push(taken);
107
+ }
108
+ for (const item of next) {
109
+ if (used.has(item)) continue;
110
+ ordered.push(item);
111
+ }
112
+ return ordered;
113
+ };
114
+
115
+ const alignLoadedItems = (prev, next) => {
116
+ if (!prev.length || !next.length) return next;
117
+ const nextGroups = groupByPath(next);
118
+ const seen = new Set();
119
+ const ordered = [];
120
+ for (const [rel, list] of groupByPath(prev)) {
121
+ seen.add(rel);
122
+ const aligned = alignGroup(list, nextGroups.get(rel) ?? []);
123
+ for (const item of aligned) ordered.push(item);
124
+ }
125
+ for (const [rel, list] of nextGroups) {
126
+ if (seen.has(rel)) continue;
127
+ for (const item of list) ordered.push(item);
128
+ }
129
+ return ordered;
39
130
  };
40
131
 
41
132
  const restoredIndex = (items, item) => {
42
133
  if (!item || !item.file) return -1;
134
+ const origin = item.origin ?? '';
135
+ const lines = blockLinesKey(item);
136
+ if (lines) {
137
+ let any = -1;
138
+ for (let i = 0; i < items.length; i++) {
139
+ if (blockLinesKey(items[i]) !== lines) continue;
140
+ if (any < 0) any = i;
141
+ if ((items[i].origin ?? '') === origin) return i;
142
+ }
143
+ if (any >= 0) return any;
144
+ }
43
145
  const key = itemKey(item);
44
146
  if (key) {
45
147
  const exact = items.findIndex((entry) => itemKey(entry) === key);
@@ -47,7 +149,6 @@ const restoredIndex = (items, item) => {
47
149
  }
48
150
  const rel = itemPath(item);
49
151
  if (!rel) return -1;
50
- const origin = item.origin ?? '';
51
152
  const start = item.hunk ? item.hunk.newStart : 0;
52
153
  let fileMatch = -1;
53
154
  let best = -1;
@@ -106,8 +207,9 @@ const createItemCollection = () => {
106
207
  const isDismissed = (item) => state.dismissed.has(itemKey(item));
107
208
 
108
209
  const replace = (items) => {
210
+ const aligned = alignLoadedItems(state.items, items);
109
211
  const next = [];
110
- for (const item of items) {
212
+ for (const item of aligned) {
111
213
  if (isDismissed(item)) continue;
112
214
  next.push(item);
113
215
  }
@@ -168,8 +270,11 @@ module.exports = {
168
270
  depChangeKey,
169
271
  needsReload,
170
272
  npmBusyKind,
273
+ blockKey,
171
274
  itemKey,
172
275
  itemFeedKey,
276
+ blockLinesKey,
277
+ alignLoadedItems,
173
278
  restoredIndex,
174
279
  sameBlock,
175
280
  canEditCode,
@@ -6,7 +6,7 @@ const path = require('node:path');
6
6
  const { isAbortError } = require('./load.js');
7
7
  const { createDiskWatcher, POLL_MS } = require('./watch.js');
8
8
  const files = require('../files.js');
9
- const { itemPath } = files;
9
+ const { itemPath, relativeAge } = files;
10
10
  const { MANIFEST, LOCKFILE } = require('../deps/manifest.js');
11
11
  const review = require('../review.js');
12
12
  const { hasNotes } = review;
@@ -103,6 +103,28 @@ const itemRels = (collection) => {
103
103
  return rels;
104
104
  };
105
105
 
106
+ const fileDatesFromDisk = (top, rels) => {
107
+ const dates = new Map();
108
+ if (!top) return dates;
109
+ const now = Date.now();
110
+ for (const rel of rels) {
111
+ try {
112
+ const st = fs.statSync(path.join(top, rel));
113
+ dates.set(rel, relativeAge(st.mtimeMs, now));
114
+ } catch {
115
+ continue;
116
+ }
117
+ }
118
+ return dates;
119
+ };
120
+
121
+ const stampItemDates = (items, dates) => {
122
+ for (const item of items) {
123
+ const date = dates.get(itemPath(item));
124
+ if (date) item.date = date;
125
+ }
126
+ };
127
+
106
128
  const syncItemWatches = (api) => {
107
129
  if (!api.diskWatcher) return;
108
130
  api.diskWatcher.watchFiles(itemRels(api.collection));
@@ -117,6 +139,10 @@ const applyLoaded = (api, loaded) => {
117
139
  const here = ui.current();
118
140
  ui.top = loaded.top ?? api.loadConfig.cwd;
119
141
  collection.replace(loaded.items);
142
+ stampItemDates(
143
+ collection.items,
144
+ fileDatesFromDisk(ui.top, itemRels(collection)),
145
+ );
120
146
  if (loaded.rev) ui.rev = loaded.rev;
121
147
  if (loaded.revShort) {
122
148
  ui.revShort = loaded.revShort;
@@ -340,9 +366,11 @@ const refreshFromRepo = (api, extra = {}) => {
340
366
  };
341
367
 
342
368
  const reloadAfterChange = (api, afterLoad) => {
343
- const here = api.nav.index;
369
+ const hereItem = api.ui.current();
370
+ const hereIndex = api.nav.index;
344
371
  const restore = () => {
345
- api.nav.index = here;
372
+ const idx = api.collection.findRestoredIndex(hereItem);
373
+ api.nav.index = idx >= 0 ? idx : hereIndex;
346
374
  api.ui.clampIndex();
347
375
  api.ui.syncReviewPath();
348
376
  api.ui.syncFileCursor();
@@ -380,10 +408,20 @@ const shouldDeferWatch = (api) => {
380
408
  return false;
381
409
  };
382
410
 
411
+ const WATCH_IGNORE_MS = 500;
412
+
413
+ const ignoreWatch = (api, ms = WATCH_IGNORE_MS) => {
414
+ const extra = watchOpts(api);
415
+ const now = extra.now ?? Date.now;
416
+ api.ignoreWatchUntil = now() + ms;
417
+ };
418
+
383
419
  const onDiskChange = (api) => {
420
+ const extra = watchOpts(api);
421
+ const now = extra.now ?? Date.now;
422
+ if (now() < (api.ignoreWatchUntil ?? 0)) return;
384
423
  if (shouldDeferWatch(api)) {
385
424
  if (api.watchRetry !== null) return;
386
- const extra = watchOpts(api);
387
425
  const setTimeoutFn = extra.setTimeout ?? setTimeout;
388
426
  const delay = extra.debounceMs ?? 200;
389
427
  api.watchRetry = setTimeoutFn(() => {
@@ -456,6 +494,7 @@ const createLifecycle = (api) => {
456
494
  api.diskWatcher = null;
457
495
  api.watchRetry = null;
458
496
  api.watchStamp = '';
497
+ api.ignoreWatchUntil = 0;
459
498
  api.npmExtras = null;
460
499
  return {
461
500
  applyLoaded: (loaded) => applyLoaded(api, loaded),
@@ -469,6 +508,7 @@ const createLifecycle = (api) => {
469
508
  pollCurrentFile: () => pollCurrentFile(api),
470
509
  startWatch: () => startWatch(api),
471
510
  stopWatch: () => stopWatch(api),
511
+ ignoreWatch: (ms) => ignoreWatch(api, ms),
472
512
  clearNpmExtras: () => clearNpmExtras(api),
473
513
  };
474
514
  };
@@ -1,13 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const files = require('../files.js');
4
- const { itemPath, isTodoItem, isTodosEntry, TODO_FILE } = files;
4
+ const { listPath, isTodoItem, isTodosEntry, TODO_FILE } = files;
5
5
 
6
6
  const initialState = (options = {}) => ({
7
7
  pane: options.startPane ?? 'files',
8
8
  index: 0,
9
9
  fileCursor: 0,
10
10
  branchCursor: 0,
11
+ commitCursor: 0,
11
12
  scroll: 0,
12
13
  listScroll: 0,
13
14
  reviewPath: null,
@@ -34,7 +35,7 @@ const current = (nav, items, todoItem) => {
34
35
 
35
36
  const syncReviewPath = (nav, item) => {
36
37
  if (nav.pane !== 'diff') return;
37
- nav.reviewPath = item ? itemPath(item) : null;
38
+ nav.reviewPath = item ? listPath(item) : null;
38
39
  };
39
40
 
40
41
  const clampFileCursor = (nav, length) => {
@@ -97,6 +97,13 @@ const RELEASE_TARGETS = [
97
97
  options.onCheckoutBranch();
98
98
  },
99
99
  },
100
+ {
101
+ match: (options) => options.getPane() === 'commits',
102
+ hits: 'fileHits',
103
+ onHit: (options, hit) => {
104
+ options.nav.commitCursor = hit.cursor;
105
+ },
106
+ },
100
107
  {
101
108
  match: (options) => options.getPane() === 'diff',
102
109
  hits: 'todoHits',
@@ -77,7 +77,7 @@ const counts = (api, notes = noteCounts(api.review.store)) => {
77
77
  const viewStatus = (api) => api.ui.status || api.ui.busy;
78
78
 
79
79
  const view = (api) => {
80
- const { composer, nav, ui, gitBranches, updater } = api;
80
+ const { composer, nav, ui, gitBranches, commits, updater } = api;
81
81
  const templates = composer.shownTemplates();
82
82
  const change = ui.change;
83
83
  const sourceKind = (change && SOURCE_KIND[change.source]) || '';
@@ -100,6 +100,8 @@ const view = (api) => {
100
100
  branch: ui.branch,
101
101
  branches: gitBranches.branches,
102
102
  branchCursor: nav.branchCursor,
103
+ commits: commits.commits,
104
+ commitCursor: nav.commitCursor,
103
105
  repoName: repoNameOf(api),
104
106
  revShort: ui.revShort || '',
105
107
  sourceLabel: ui.sourceLabel || '',
@@ -115,7 +117,7 @@ const view = (api) => {
115
117
  templateIndex: composer.clampedTemplateIndex(templates),
116
118
  updateFrom: updater.from,
117
119
  updateTo: updater.to,
118
- dropName: gitBranches.dropName,
120
+ dropName: gitBranches.dropName || commits.dropName,
119
121
  };
120
122
  };
121
123
 
package/lib/session.js CHANGED
@@ -37,11 +37,17 @@ const PROGRESS_MS = 80;
37
37
 
38
38
  const systemNow = () => new Date();
39
39
 
40
+ const targetHasStaged = (session) => {
41
+ if (session.repo.hasStaged) return session.repo.hasStaged(session.top);
42
+ return session.items.some((item) => item.origin === 'staged');
43
+ };
44
+
40
45
  const NAV_FIELDS = [
41
46
  'pane',
42
47
  'index',
43
48
  'fileCursor',
44
49
  'branchCursor',
50
+ 'commitCursor',
45
51
  'scroll',
46
52
  'listScroll',
47
53
  'reviewPath',
@@ -197,6 +203,7 @@ const assignControllers = (session) => {
197
203
  setStatus,
198
204
  syncReviewPath: () => session.syncReviewPath(),
199
205
  syncFileCursor: () => session.syncFileCursor(),
206
+ ignoreWatch: () => session.lifecycle.ignoreWatch(),
200
207
  });
201
208
  session.commits = createCommitController({
202
209
  repo: () => session.repo,
@@ -212,6 +219,9 @@ const assignControllers = (session) => {
212
219
  setStatus,
213
220
  isDone: () => session.done,
214
221
  pendingExtras: () => session.pendingExtras,
222
+ getMode: () => session.mode,
223
+ getStatus: () => session.status,
224
+ nav: session.nav,
215
225
  });
216
226
  session.gitBranches = createBranchController({
217
227
  repo: () => session.repo,
@@ -286,6 +296,7 @@ const bindViewer = (session) => {
286
296
  collection: session.collection,
287
297
  review: session.review,
288
298
  gitBranches: session.gitBranches,
299
+ commits: session.commits,
289
300
  updater: session.updater,
290
301
  ui: session,
291
302
  });
@@ -304,6 +315,7 @@ const bindFilesPane = (session) => {
304
315
  onQuit: () => session.onQuit(),
305
316
  onCheckoutBranch: session.gitBranches.onCheckoutBranch,
306
317
  onBranchMove: session.gitBranches.onBranchMove,
318
+ onCommitMove: session.commits.onCommitMove,
307
319
  layout: () => session.layout,
308
320
  setLayout: (value) => {
309
321
  session.layout = value;
@@ -344,6 +356,12 @@ const bindDispatch = (session) => {
344
356
  if (session.pane === 'branches') {
345
357
  return session.branches[session.branchCursor];
346
358
  }
359
+ if (session.pane === 'commits') {
360
+ const entry = session.commits.commits[session.commitCursor];
361
+ const staged = targetHasStaged(session);
362
+ if (!entry) return { canCommit: staged };
363
+ return { ...entry, canCommit: staged };
364
+ }
347
365
  return session.current();
348
366
  },
349
367
  composer: session.composer,
@@ -352,6 +370,7 @@ const bindDispatch = (session) => {
352
370
  gitBranches: session.gitBranches,
353
371
  onFileMove: filesPane.onFileMove,
354
372
  onBranchMove: session.gitBranches.onBranchMove,
373
+ onCommitMove: session.commits.onCommitMove,
355
374
  onNext: filesPane.onNext,
356
375
  onPrev: filesPane.onPrev,
357
376
  onOpenFile: filesPane.onOpenFile,
@@ -398,19 +417,23 @@ const bindDispatch = (session) => {
398
417
  editing: () => session.finishQuit('editing'),
399
418
  cancel: () => session.cancelQuit(),
400
419
  },
401
- commit: {
402
- commit: () => session.commits.startGitCommit('commit'),
403
- amend: () => session.commits.startGitCommit('amend'),
404
- fixup: () => session.commits.startGitCommit('fixup'),
405
- cancel: session.commits.cancelCommit,
406
- },
407
420
  update: {
408
421
  accept: session.updater.accept,
409
422
  decline: session.updater.decline,
410
423
  },
411
424
  drop: {
412
- confirm: session.gitBranches.confirmDropBranch,
413
- cancel: session.gitBranches.cancelDropBranch,
425
+ confirm: () => {
426
+ if (session.commits.dropName) {
427
+ return void session.commits.confirmDropCommit();
428
+ }
429
+ session.gitBranches.confirmDropBranch();
430
+ },
431
+ cancel: () => {
432
+ if (session.commits.dropName) {
433
+ return void session.commits.cancelDropCommit();
434
+ }
435
+ session.gitBranches.cancelDropBranch();
436
+ },
414
437
  },
415
438
  push: {
416
439
  force: session.gitBranches.onForcePush,
@@ -444,6 +467,7 @@ class Session {
444
467
  if (this.composer) this.composer.reset();
445
468
  if (this.ops) this.ops.reset();
446
469
  if (this.gitBranches) this.gitBranches.reset();
470
+ if (this.commits) this.commits.reset();
447
471
  this.top = this.cwd;
448
472
  this.branch = '';
449
473
  this.status = '';
@@ -507,7 +531,11 @@ class Session {
507
531
  height: size.height,
508
532
  color: this.color,
509
533
  });
510
- if (this.pane === 'files' || this.pane === 'branches') {
534
+ if (
535
+ this.pane === 'files' ||
536
+ this.pane === 'branches' ||
537
+ this.pane === 'commits'
538
+ ) {
511
539
  this.nav.listScroll = frame.listScroll;
512
540
  }
513
541
  const composing = this.mode === 'compose';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reslop",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Interactive code review and repair planning for AI-assisted changes",
5
5
  "keywords": [
6
6
  "code-review",
@@ -59,7 +59,7 @@
59
59
  "eslint": "^9.39.4",
60
60
  "eslint-config-metarhia": "^9.1.9",
61
61
  "metaskills": "^1.0.5",
62
- "prettier": "^3.9.7"
62
+ "prettier": "^3.9.8"
63
63
  },
64
64
  "dependencies": {
65
65
  "metautil": "^5.5.2"