reslop 0.0.1 → 0.1.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.
package/lib/editor.js ADDED
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ const { graphemes } = require('./ansi.js');
4
+ const { wrapMove } = require('./wrap.js');
5
+
6
+ const UNDO_CAP = 100;
7
+
8
+ const graphemeIndex = (text, cursor) => {
9
+ const parts = graphemes(text);
10
+ let pos = 0;
11
+ let i = 0;
12
+ while (i < parts.length && pos + parts[i].length <= cursor) {
13
+ pos += parts[i].length;
14
+ i += 1;
15
+ }
16
+ return { parts, i, pos };
17
+ };
18
+
19
+ const offsetAt = (parts, count) => {
20
+ let pos = 0;
21
+ for (let i = 0; i < count; i++) pos += parts[i].length;
22
+ return pos;
23
+ };
24
+
25
+ class Editor {
26
+ constructor(text = '') {
27
+ this.text = text;
28
+ this.cursor = text.length;
29
+ this.undo = [];
30
+ this.redo = [];
31
+ this.wantCol = null;
32
+ }
33
+
34
+ snapshot() {
35
+ return { text: this.text, cursor: this.cursor };
36
+ }
37
+
38
+ restore(snap) {
39
+ this.text = snap.text;
40
+ this.cursor = snap.cursor;
41
+ }
42
+
43
+ edit(nextText, nextCursor) {
44
+ this.undo.push(this.snapshot());
45
+ if (this.undo.length > UNDO_CAP) {
46
+ this.undo = this.undo.slice(this.undo.length - UNDO_CAP);
47
+ }
48
+ this.redo = [];
49
+ this.text = nextText;
50
+ this.cursor = nextCursor;
51
+ }
52
+
53
+ insert(chunk) {
54
+ this.wantCol = null;
55
+ const before = this.text.slice(0, this.cursor);
56
+ const after = this.text.slice(this.cursor);
57
+ this.edit(before + chunk + after, this.cursor + chunk.length);
58
+ }
59
+
60
+ replace(text) {
61
+ this.wantCol = null;
62
+ this.edit(text, text.length);
63
+ }
64
+
65
+ backspace() {
66
+ this.wantCol = null;
67
+ const { parts, i } = graphemeIndex(this.text, this.cursor);
68
+ if (i === 0) return;
69
+ const drop = parts[i - 1];
70
+ const nextParts = [...parts.slice(0, i - 1), ...parts.slice(i)];
71
+ this.edit(nextParts.join(''), this.cursor - drop.length);
72
+ }
73
+
74
+ delete() {
75
+ this.wantCol = null;
76
+ const { parts, i } = graphemeIndex(this.text, this.cursor);
77
+ if (i >= parts.length) return;
78
+ const nextParts = [...parts.slice(0, i), ...parts.slice(i + 1)];
79
+ this.edit(nextParts.join(''), this.cursor);
80
+ }
81
+
82
+ move(delta) {
83
+ this.wantCol = null;
84
+ const { parts, i } = graphemeIndex(this.text, this.cursor);
85
+ const next = i + delta;
86
+ if (next < 0 || next > parts.length) return;
87
+ this.cursor = offsetAt(parts, next);
88
+ }
89
+
90
+ linePos() {
91
+ const before = this.text.slice(0, this.cursor);
92
+ const at = before.lastIndexOf('\n');
93
+ const line = before.split('\n').length - 1;
94
+ const col = at < 0 ? before.length : before.length - at - 1;
95
+ return { line, col };
96
+ }
97
+
98
+ moveLine(delta, width) {
99
+ if (width !== undefined) {
100
+ const col = this.wantCol;
101
+ const moved = wrapMove(this.text, this.cursor, width, delta, col);
102
+ this.wantCol = moved.col;
103
+ this.cursor = moved.cursor;
104
+ return;
105
+ }
106
+ const rows = this.text.split('\n');
107
+ const { line, col } = this.linePos();
108
+ const next = line + delta;
109
+ if (next < 0 || next >= rows.length) return;
110
+ const row = rows[next];
111
+ const c = Math.min(col, row.length);
112
+ let cursor = 0;
113
+ for (let i = 0; i < next; i++) cursor += rows[i].length + 1;
114
+ this.cursor = cursor + c;
115
+ }
116
+
117
+ home() {
118
+ this.wantCol = null;
119
+ const { col } = this.linePos();
120
+ this.cursor -= col;
121
+ }
122
+
123
+ end() {
124
+ this.wantCol = null;
125
+ const { line, col } = this.linePos();
126
+ const rows = this.text.split('\n');
127
+ const row = rows[line] ?? '';
128
+ this.cursor += row.length - col;
129
+ }
130
+
131
+ undoEdit() {
132
+ if (!this.undo.length) return;
133
+ this.wantCol = null;
134
+ const prev = this.undo.pop();
135
+ this.redo.push(this.snapshot());
136
+ this.restore(prev);
137
+ }
138
+
139
+ redoEdit() {
140
+ if (!this.redo.length) return;
141
+ this.wantCol = null;
142
+ const next = this.redo.pop();
143
+ this.undo.push(this.snapshot());
144
+ this.restore(next);
145
+ }
146
+ }
147
+
148
+ module.exports = { UNDO_CAP, Editor };
package/lib/files.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const ORIGIN_ORDER = ['staged', 'unstaged', 'untracked', 'commit'];
4
+
5
+ const itemPath = (item) => {
6
+ if (!item || !item.file) return '';
7
+ return item.file.newPath || item.file.oldPath || '';
8
+ };
9
+
10
+ const fileStatus = (origins) => {
11
+ const present = new Set(origins);
12
+ const seen = ORIGIN_ORDER.filter((origin) => present.has(origin));
13
+ const key = seen.join('+');
14
+ if (!key) {
15
+ if (present.has('todo')) return 'todo';
16
+ return '';
17
+ }
18
+ if (key === 'staged+unstaged') return 'partial';
19
+ return key;
20
+ };
21
+
22
+ const fileEntries = (items, kept) => {
23
+ const groups = new Map();
24
+ for (let i = 0; i < items.length; i++) {
25
+ if (!kept.has(i)) continue;
26
+ const item = items[i];
27
+ const rel = itemPath(item);
28
+ let group = groups.get(rel);
29
+ if (!group) {
30
+ group = { path: rel, origins: [], firstIndex: i, remaining: 0 };
31
+ groups.set(rel, group);
32
+ }
33
+ group.origins.push(item.origin);
34
+ group.remaining += 1;
35
+ }
36
+ const entries = [];
37
+ for (const group of groups.values()) {
38
+ const path = group.path;
39
+ const status = fileStatus(group.origins);
40
+ const firstIndex = group.firstIndex;
41
+ const remaining = group.remaining;
42
+ entries.push({ path, status, firstIndex, remaining });
43
+ }
44
+ return entries;
45
+ };
46
+
47
+ module.exports = { ORIGIN_ORDER, itemPath, fileStatus, fileEntries };
package/lib/git.js ADDED
@@ -0,0 +1,261 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const diff = require('./diff.js');
8
+ const { parseDiff, synthesizeNewFile, itemsFromFiles } = diff;
9
+ const { itemPath } = require('./files.js');
10
+ const { REVIEW_DIR } = require('./review.js');
11
+
12
+ const GIT_CONFIG = ['-c', 'core.quotepath=false'];
13
+ const DIFF_OPTS = ['--no-color', '--no-ext-diff', '--no-renames', '-U3'];
14
+ const GIT_ENCODING = 'utf8';
15
+ const GIT_MAX_BUFFER = 32 * 1024 * 1024;
16
+ const BINARY_SIZE = 1000000;
17
+
18
+ const oneLine = (text) => text.trim().split('\n')[0] || 'git failed';
19
+
20
+ const requireOk = (result, fallback = 'git failed') => {
21
+ if (result.status === 0) return result;
22
+ const msg = result.stderr || result.stdout || fallback;
23
+ throw new Error(oneLine(msg));
24
+ };
25
+
26
+ const isReviewFile = (rel) => {
27
+ if (!rel) return false;
28
+ const first = rel.split(/[/\\]/)[0];
29
+ return first === REVIEW_DIR;
30
+ };
31
+
32
+ const dropReviewItems = (items) => {
33
+ const kept = [];
34
+ for (const item of items) {
35
+ if (isReviewFile(itemPath(item))) continue;
36
+ kept.push(item);
37
+ }
38
+ return kept;
39
+ };
40
+
41
+ const runGit = (args, cwd, input) => {
42
+ const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
43
+ const options = {
44
+ cwd,
45
+ encoding: GIT_ENCODING,
46
+ input,
47
+ maxBuffer: GIT_MAX_BUFFER,
48
+ env,
49
+ };
50
+ const result = spawnSync('git', args, options);
51
+ if (result.error) {
52
+ if (result.error.code === 'ENOENT') throw new Error('git not found');
53
+ throw result.error;
54
+ }
55
+ return result;
56
+ };
57
+
58
+ const toplevel = (cwd) => {
59
+ const result = runGit(['rev-parse', '--show-toplevel'], cwd);
60
+ if (result.status !== 0) throw new Error('not a git repository');
61
+ return result.stdout.trim();
62
+ };
63
+
64
+ const applyPatch = (cwd, patch, flags) => {
65
+ const args = ['apply', ...flags, '--whitespace=nowarn', '-'];
66
+ const result = runGit(args, cwd, patch);
67
+ requireOk(result, 'apply failed');
68
+ };
69
+
70
+ const isBinaryBuffer = (buf) => {
71
+ if (buf.length > BINARY_SIZE) return true;
72
+ return buf.includes(0);
73
+ };
74
+
75
+ const listUntracked = (top, paths) => {
76
+ const args = ['ls-files', '-o', '--exclude-standard', '-z'];
77
+ if (paths.length) args.push('--', ...paths);
78
+ const result = requireOk(runGit(args, top));
79
+ if (!result.stdout) return [];
80
+ return result.stdout.split('\0').filter(Boolean);
81
+ };
82
+
83
+ const binaryNewFile = (rel) => ({
84
+ oldPath: rel,
85
+ newPath: rel,
86
+ isNew: true,
87
+ isDeleted: false,
88
+ isBinary: true,
89
+ preamble: [`diff --git a/${rel} b/${rel}`, 'new file mode 100644'],
90
+ hunks: [],
91
+ });
92
+
93
+ const untrackedItems = (top, paths) => {
94
+ const files = [];
95
+ for (const rel of listUntracked(top, paths)) {
96
+ if (isReviewFile(rel)) continue;
97
+ const full = path.join(top, rel);
98
+ let buf;
99
+ try {
100
+ buf = fs.readFileSync(full);
101
+ } catch (error) {
102
+ if (error.code === 'ENOENT') continue;
103
+ throw error;
104
+ }
105
+ if (isBinaryBuffer(buf)) {
106
+ files.push(binaryNewFile(rel));
107
+ continue;
108
+ }
109
+ files.push(synthesizeNewFile(rel, buf.toString('utf8')));
110
+ }
111
+ return itemsFromFiles(files, 'untracked');
112
+ };
113
+
114
+ const mergeItems = (groups) => {
115
+ const byPath = new Map();
116
+ for (const group of groups) {
117
+ for (const item of group) {
118
+ const rel = itemPath(item);
119
+ const list = byPath.get(rel);
120
+ if (list) {
121
+ list.push(item);
122
+ continue;
123
+ }
124
+ byPath.set(rel, [item]);
125
+ }
126
+ }
127
+ const items = [];
128
+ for (const list of byPath.values()) {
129
+ for (const item of list) items.push(item);
130
+ }
131
+ return items;
132
+ };
133
+
134
+ const resolveRev = (cwd, spec) => {
135
+ if (!spec) return null;
136
+ const top = toplevel(cwd);
137
+ const args = ['rev-parse', '--verify', `${spec}^{commit}`];
138
+ const result = runGit(args, top);
139
+ if (result.status !== 0) return null;
140
+ return result.stdout.trim();
141
+ };
142
+
143
+ const shortRev = (top, commit) => {
144
+ const result = runGit(['rev-parse', '--short', commit], top);
145
+ if (result.status !== 0) return commit.slice(0, 7);
146
+ return result.stdout.trim();
147
+ };
148
+
149
+ const loadCommit = (top, commit, extra) => {
150
+ const parent = runGit(['rev-parse', '--verify', `${commit}^`], top);
151
+ let args;
152
+ if (parent.status === 0) {
153
+ const from = parent.stdout.trim();
154
+ args = [...GIT_CONFIG, 'diff', ...DIFF_OPTS, from, commit, ...extra];
155
+ } else {
156
+ args = [
157
+ ...GIT_CONFIG,
158
+ 'diff-tree',
159
+ '--no-commit-id',
160
+ '--root',
161
+ '-p',
162
+ ...DIFF_OPTS,
163
+ commit,
164
+ ...extra,
165
+ ];
166
+ }
167
+ const result = requireOk(runGit(args, top));
168
+ return parseDiff(result.stdout);
169
+ };
170
+
171
+ const load = (cwd, paths = [], options = {}) => {
172
+ const top = toplevel(cwd);
173
+ const extra = paths.length ? ['--', ...paths] : [];
174
+ const rev = options.commit;
175
+ if (rev) {
176
+ const files = loadCommit(top, rev, extra);
177
+ const items = dropReviewItems(itemsFromFiles(files, 'commit'));
178
+ const revShort = shortRev(top, rev);
179
+ return { top, items, rev, revShort };
180
+ }
181
+ const stagedArgs = [
182
+ ...GIT_CONFIG,
183
+ 'diff',
184
+ '--cached',
185
+ ...DIFF_OPTS,
186
+ ...extra,
187
+ ];
188
+ const unstagedArgs = [...GIT_CONFIG, 'diff', ...DIFF_OPTS, ...extra];
189
+ const stagedRes = requireOk(runGit(stagedArgs, top));
190
+ const unstagedRes = requireOk(runGit(unstagedArgs, top));
191
+ const staged = itemsFromFiles(parseDiff(stagedRes.stdout), 'staged');
192
+ const unstaged = itemsFromFiles(parseDiff(unstagedRes.stdout), 'unstaged');
193
+ const untracked = untrackedItems(top, paths);
194
+ const items = dropReviewItems(mergeItems([staged, unstaged, untracked]));
195
+ return { top, items };
196
+ };
197
+
198
+ const addItem = (top, item) => {
199
+ if (item.origin === 'staged' || item.origin === 'commit') return;
200
+ if (item.origin === 'untracked' || item.file.isBinary) {
201
+ requireOk(runGit(['add', '--', item.file.newPath], top));
202
+ return;
203
+ }
204
+ applyPatch(top, item.patchAdd, ['--cached']);
205
+ };
206
+
207
+ const unstageItem = (top, item) => {
208
+ if (item.origin !== 'staged') return;
209
+ const rel = itemPath(item);
210
+ if (item.file.isBinary) {
211
+ requireOk(runGit(['restore', '--staged', '--', rel], top));
212
+ return;
213
+ }
214
+ applyPatch(top, item.patchAdd, ['--reverse', '--cached']);
215
+ };
216
+
217
+ const revertItem = (top, item) => {
218
+ if (item.origin === 'commit') return;
219
+ const rel = itemPath(item);
220
+ if (item.origin === 'untracked') {
221
+ fs.unlinkSync(path.join(top, rel));
222
+ return;
223
+ }
224
+ if (item.file.isBinary) {
225
+ const args = ['restore', '-s', 'HEAD', '--worktree'];
226
+ if (item.origin === 'staged') args.push('--staged');
227
+ args.push('--', rel);
228
+ requireOk(runGit(args, top));
229
+ return;
230
+ }
231
+ if (item.origin === 'unstaged') {
232
+ applyPatch(top, item.patchRevert, ['--reverse']);
233
+ return;
234
+ }
235
+ applyPatch(top, item.patchRevert, ['--reverse', '--cached']);
236
+ try {
237
+ applyPatch(top, item.patchRevert, ['--reverse']);
238
+ } catch (error) {
239
+ if (!error.message.includes('does not apply')) throw error;
240
+ }
241
+ };
242
+
243
+ const createGitRepo = () => ({
244
+ load,
245
+ resolveRev,
246
+ add: addItem,
247
+ unstage: unstageItem,
248
+ revert: revertItem,
249
+ });
250
+
251
+ module.exports = {
252
+ runGit,
253
+ toplevel,
254
+ load,
255
+ resolveRev,
256
+ addItem,
257
+ unstageItem,
258
+ revertItem,
259
+ createGitRepo,
260
+ oneLine,
261
+ };