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/CHANGELOG.md +11 -3
- package/LICENSE +1 -1
- package/README.md +83 -3
- package/bin/reslop.js +23 -0
- package/lib/ansi.js +231 -0
- package/lib/api.js +32 -0
- package/lib/cli.js +181 -0
- package/lib/clipboard.js +42 -0
- package/lib/detect.js +84 -0
- package/lib/diff.js +906 -0
- package/lib/editor.js +148 -0
- package/lib/files.js +47 -0
- package/lib/git.js +261 -0
- package/lib/highlight.js +1234 -0
- package/lib/keys.js +208 -0
- package/lib/render.js +964 -0
- package/lib/review.js +687 -0
- package/lib/select.js +218 -0
- package/lib/session.js +1416 -0
- package/lib/wrap.js +118 -0
- package/package.json +33 -22
- package/.editorconfig +0 -12
- package/.gitattributes +0 -1
- package/.prettierignore +0 -2
- package/AUTHORS +0 -1
- package/CONTRIBUTING.md +0 -41
- package/SECURITY.md +0 -16
- package/eslint.config.js +0 -5
- package/main.js +0 -3
- package/prettier.config.js +0 -10
- package/tsconfig.json +0 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased][unreleased]
|
|
4
4
|
|
|
5
|
-
## [0.0
|
|
5
|
+
## [0.1.0][] - 2026-09-09
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Interactive terminal git diff with intra-line highlighting
|
|
10
|
+
- Stage, unstage, revert, and skip contiguous diff blocks
|
|
11
|
+
- Feedback and todos in `.review/` markdown for an agent to execute
|
|
12
|
+
- Unified, mixed, and side-by-side layouts
|
|
13
|
+
- Review of a worktree, a path, or a given commit (read-only)
|
|
14
|
+
|
|
15
|
+
[unreleased]: https://github.com/tshemsedinov/metadiff/compare/v0.1.0...HEAD
|
|
16
|
+
[0.1.0]: https://github.com/tshemsedinov/metadiff/releases/tag/v0.1.0
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,7 +1,87 @@
|
|
|
1
1
|
# reslop
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Review diff and plan changes
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Prepare specs for the agent.
|
|
6
|
+
- Leave feedback, add todos.
|
|
7
|
+
- Generate a plan the agent executes.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Walk uncommitted git changes, or a given commit. Each contiguous block of diff lines can be staged, unstaged, reverted; add review and todos. Commit review is read-only.
|
|
10
|
+
|
|
11
|
+
Intra-line highlighting paints the words that actually changed in a
|
|
12
|
+
stronger red/green. A close edit inside one word still marks only
|
|
13
|
+
those characters.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Global:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm i -g reslop
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Local:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx reslop
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
reslop
|
|
33
|
+
reslop -n
|
|
34
|
+
reslop path/to/folder
|
|
35
|
+
reslop path/to/file
|
|
36
|
+
reslop 7ac260c
|
|
37
|
+
reslop HEAD~1 path/to/file
|
|
38
|
+
reslop --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Starts on the diff. No arguments, or a folder, reviews that scope.
|
|
42
|
+
A file argument opens that file's diff. A git revision that is not
|
|
43
|
+
also an existing path opens that commit's patch. Esc opens the file
|
|
44
|
+
list; Esc again quits.
|
|
45
|
+
|
|
46
|
+
Specs go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
|
|
47
|
+
|
|
48
|
+
| Status | Meaning |
|
|
49
|
+
| ------- | ------------------------------------------- |
|
|
50
|
+
| editing | Still writing the review in reslop |
|
|
51
|
+
| ready | Ready for AI to work through the checkboxes |
|
|
52
|
+
| partial | AI started; some items remain |
|
|
53
|
+
| done | All items marked `[x]` |
|
|
54
|
+
|
|
55
|
+
`reslop` resumes the latest file when its status is `editing`. Any other
|
|
56
|
+
status starts a new file. `-n` / `--new` always starts a new file. Quit asks
|
|
57
|
+
`f` (finish as `ready`, for the agent to execute) or `c` (keep `editing` and
|
|
58
|
+
continue next time). The agent should execute `ready` and `partial` reviews.
|
|
59
|
+
Files in `.review/` are omitted from the diff list.
|
|
60
|
+
|
|
61
|
+
A `##` heading names the file. Todos are checkboxes under it. Feedback is a
|
|
62
|
+
checkbox with ` - path:old:new:block` on the same line.
|
|
63
|
+
|
|
64
|
+
## Keys and buttons
|
|
65
|
+
|
|
66
|
+
| Key | Action |
|
|
67
|
+
| ----------- | --------------------------------------- |
|
|
68
|
+
| `a` | Stage this block (`git add`) |
|
|
69
|
+
| `u` | Unstage this block (keep worktree) |
|
|
70
|
+
| `r` | Restore this block to the last commit |
|
|
71
|
+
| `s` | Skip (leave unstaged, drop from review) |
|
|
72
|
+
| `←` | Previous remaining block |
|
|
73
|
+
| `→` | Next remaining block |
|
|
74
|
+
| `l` | File list (git status of this scope) |
|
|
75
|
+
| `Esc` | File list, then quit |
|
|
76
|
+
| `m` | Cycle unified / mixed / side-by-side |
|
|
77
|
+
| `f` | Feedback on this diff block |
|
|
78
|
+
| `t` | New todo for this file |
|
|
79
|
+
| `⌫` / `Del` | Remove the selected todo |
|
|
80
|
+
| `⏎` | Open the selected file |
|
|
81
|
+
| `q` | Quit |
|
|
82
|
+
| `?` | Help |
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
Copyright (c) 2026 Timur Shemsedinov.
|
|
87
|
+
This is [MIT](./LICENSE) licensed software.
|
package/bin/reslop.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { run, errorMessage } = require('../lib/cli.js');
|
|
5
|
+
const { LEAVE_TERM } = require('../lib/session.js');
|
|
6
|
+
|
|
7
|
+
const fail = (reason) => {
|
|
8
|
+
try {
|
|
9
|
+
process.stdout.write(LEAVE_TERM);
|
|
10
|
+
} catch {}
|
|
11
|
+
process.stderr.write(`reslop: ${errorMessage(reason)}\n`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
process.on('uncaughtException', (error) => fail(error));
|
|
16
|
+
process.on('unhandledRejection', (reason) => fail(reason));
|
|
17
|
+
|
|
18
|
+
run(process).then(
|
|
19
|
+
(code) => {
|
|
20
|
+
process.exit(code);
|
|
21
|
+
},
|
|
22
|
+
(error) => fail(error),
|
|
23
|
+
);
|
package/lib/ansi.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
4
|
+
|
|
5
|
+
const ESC = String.fromCodePoint(0x1b);
|
|
6
|
+
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
|
|
7
|
+
const RESET = `${ESC}[0m`;
|
|
8
|
+
const BOLD = `${ESC}[1m`;
|
|
9
|
+
const EL = `${ESC}[K`;
|
|
10
|
+
|
|
11
|
+
const WIDE_CP = [
|
|
12
|
+
0x1100, 0x115f, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec, 0x23f0,
|
|
13
|
+
0x23f0, 0x23f3, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615, 0x2630, 0x2637,
|
|
14
|
+
0x2648, 0x2653, 0x267f, 0x267f, 0x268a, 0x268f, 0x2693, 0x2693, 0x26a1,
|
|
15
|
+
0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26ce,
|
|
16
|
+
0x26d4, 0x26d4, 0x26ea, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa,
|
|
17
|
+
0x26fa, 0x26fd, 0x26fd, 0x2705, 0x2705, 0x270a, 0x270b, 0x2728, 0x2728,
|
|
18
|
+
0x274c, 0x274c, 0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795,
|
|
19
|
+
0x2797, 0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50,
|
|
20
|
+
0x2b55, 0x2b55, 0x2e80, 0x2e99, 0x2e9b, 0x2ef3, 0x2f00, 0x2fd5, 0x2ff0,
|
|
21
|
+
0x303e, 0x3041, 0x3096, 0x3099, 0x30ff, 0x3105, 0x312f, 0x3131, 0x318e,
|
|
22
|
+
0x3190, 0x31e5, 0x31ef, 0x321e, 0x3220, 0x3247, 0x3250, 0xa48c, 0xa490,
|
|
23
|
+
0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19,
|
|
24
|
+
0xfe30, 0xfe52, 0xfe54, 0xfe66, 0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0,
|
|
25
|
+
0xffe6, 0x16fe0, 0x16fe4, 0x16ff0, 0x16ff1, 0x17000, 0x187f7, 0x18800,
|
|
26
|
+
0x18cd5, 0x18cff, 0x18d08, 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd,
|
|
27
|
+
0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132, 0x1b150, 0x1b152, 0x1b155,
|
|
28
|
+
0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1d300, 0x1d356, 0x1d360,
|
|
29
|
+
0x1d376, 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191,
|
|
30
|
+
0x1f19a, 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250,
|
|
31
|
+
0x1f251, 0x1f260, 0x1f265, 0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337,
|
|
32
|
+
0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0,
|
|
33
|
+
0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442,
|
|
34
|
+
0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a,
|
|
35
|
+
0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680,
|
|
36
|
+
0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d7, 0x1f6dc,
|
|
37
|
+
0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f7f0,
|
|
38
|
+
0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70,
|
|
39
|
+
0x1fa7c, 0x1fa80, 0x1fa89, 0x1fa8f, 0x1fac6, 0x1face, 0x1fadc, 0x1fadf,
|
|
40
|
+
0x1fae9, 0x1faf0, 0x1faf8, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const THEME = {
|
|
44
|
+
delLineFg: [0xcc, 0x7a, 0x7a],
|
|
45
|
+
delLineBg: [0x3a, 0x1a, 0x1a],
|
|
46
|
+
delCharFg: [0xff, 0xff, 0xff],
|
|
47
|
+
delCharBg: [0x75, 0x22, 0x22],
|
|
48
|
+
addLineFg: [0x7a, 0xcc, 0x8a],
|
|
49
|
+
addLineBg: [0x14, 0x28, 0x14],
|
|
50
|
+
addCharFg: [0xff, 0xff, 0xff],
|
|
51
|
+
addCharBg: [0x1a, 0x4c, 0x26],
|
|
52
|
+
ctxFg: [0x8a, 0x8a, 0x8a],
|
|
53
|
+
ctxBg: [0x12, 0x12, 0x18],
|
|
54
|
+
splitGutterFg: [0x18, 0x18, 0x24],
|
|
55
|
+
splitGutterBg: [0x18, 0x18, 0x24],
|
|
56
|
+
headerFg: [0x3a, 0x3a, 0x3a],
|
|
57
|
+
headerBg: [0xe6, 0xe6, 0xee],
|
|
58
|
+
headerChromeFg: [0x5a, 0x5a, 0x5a],
|
|
59
|
+
headerRepoFg: [0x3a, 0x3a, 0x3a],
|
|
60
|
+
headerDirFg: [0x6e, 0x6e, 0x6e],
|
|
61
|
+
headerSlashFg: [0x8a, 0x8a, 0x8a],
|
|
62
|
+
headerFileFg: [0x4e, 0x4e, 0x4e],
|
|
63
|
+
headerMetaFg: [0x5a, 0x5a, 0x5a],
|
|
64
|
+
chromeFg: [0xd0, 0xd0, 0xe0],
|
|
65
|
+
chromeBg: [0x18, 0x18, 0x24],
|
|
66
|
+
noteBg: [0x24, 0x24, 0x36],
|
|
67
|
+
buttonFg: [0x6a, 0x6a, 0x7e],
|
|
68
|
+
buttonBg: [0x2a, 0x2a, 0x40],
|
|
69
|
+
buttonHotFg: [0xff, 0xff, 0xff],
|
|
70
|
+
checkFg: [0xf4, 0xf4, 0xfc],
|
|
71
|
+
checkBg: [0x52, 0x52, 0x74],
|
|
72
|
+
checkDoneFg: [0xf0, 0xff, 0xf4],
|
|
73
|
+
checkDoneBg: [0x22, 0x58, 0x34],
|
|
74
|
+
errorFg: [0xff, 0x80, 0x80],
|
|
75
|
+
warnFg: [0xe8, 0xc8, 0x4a],
|
|
76
|
+
mutedFg: [0x7a, 0x7a, 0x8a],
|
|
77
|
+
stagedDelLineFg: [0xa0, 0xa0, 0xa8],
|
|
78
|
+
stagedDelLineBg: [0x20, 0x20, 0x26],
|
|
79
|
+
stagedDelCharFg: [0xf0, 0xf0, 0xf4],
|
|
80
|
+
stagedDelCharBg: [0x44, 0x44, 0x4e],
|
|
81
|
+
stagedAddLineFg: [0xb0, 0xb0, 0xb8],
|
|
82
|
+
stagedAddLineBg: [0x26, 0x26, 0x2e],
|
|
83
|
+
stagedAddCharFg: [0xff, 0xff, 0xff],
|
|
84
|
+
stagedAddCharBg: [0x4e, 0x4e, 0x58],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const CODE_FG = {
|
|
88
|
+
keyword: [197, 134, 192],
|
|
89
|
+
control: [197, 134, 192],
|
|
90
|
+
storage: [86, 156, 214],
|
|
91
|
+
literal: [86, 156, 214],
|
|
92
|
+
string: [206, 145, 120],
|
|
93
|
+
template: [206, 145, 120],
|
|
94
|
+
escape: [209, 105, 105],
|
|
95
|
+
interpolation: [156, 220, 254],
|
|
96
|
+
number: [181, 206, 168],
|
|
97
|
+
comment: [106, 153, 85],
|
|
98
|
+
function: [220, 220, 170],
|
|
99
|
+
method: [220, 220, 170],
|
|
100
|
+
className: [78, 201, 176],
|
|
101
|
+
type: [78, 201, 176],
|
|
102
|
+
interface: [78, 201, 176],
|
|
103
|
+
property: [156, 220, 254],
|
|
104
|
+
variable: [156, 220, 254],
|
|
105
|
+
parameter: [156, 220, 254],
|
|
106
|
+
constant: [79, 193, 255],
|
|
107
|
+
operator: [212, 212, 212],
|
|
108
|
+
punct: [212, 212, 212],
|
|
109
|
+
regex: [209, 105, 105],
|
|
110
|
+
decorator: [220, 220, 170],
|
|
111
|
+
tag: [86, 156, 214],
|
|
112
|
+
attr: [156, 220, 254],
|
|
113
|
+
plain: [212, 212, 212],
|
|
114
|
+
punctDim: [128, 128, 128],
|
|
115
|
+
logDate: [210, 210, 220],
|
|
116
|
+
logError: [230, 90, 90],
|
|
117
|
+
logWarn: [230, 180, 80],
|
|
118
|
+
logInfo: [100, 170, 230],
|
|
119
|
+
logDebug: [140, 160, 180],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const LOG_COL_FG = [
|
|
123
|
+
[210, 210, 220],
|
|
124
|
+
[120, 180, 220],
|
|
125
|
+
[180, 160, 220],
|
|
126
|
+
[140, 200, 160],
|
|
127
|
+
[220, 180, 120],
|
|
128
|
+
[220, 140, 160],
|
|
129
|
+
[160, 200, 200],
|
|
130
|
+
[200, 170, 140],
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const codeFg = (style) => {
|
|
134
|
+
if (typeof style !== 'string') return CODE_FG.plain;
|
|
135
|
+
if (Object.hasOwn(CODE_FG, style)) return CODE_FG[style];
|
|
136
|
+
if (style.startsWith('logCol')) {
|
|
137
|
+
const i = parseInt(style.slice(6), 10);
|
|
138
|
+
return LOG_COL_FG[i % LOG_COL_FG.length];
|
|
139
|
+
}
|
|
140
|
+
return CODE_FG.plain;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const fg = (rgb) => `${ESC}[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
|
|
144
|
+
|
|
145
|
+
const bg = (rgb) => `${ESC}[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
|
|
146
|
+
|
|
147
|
+
const seq = (fgRgb, bgRgb) => `${fg(fgRgb)}${bg(bgRgb)}`;
|
|
148
|
+
|
|
149
|
+
const paint = (text, fgRgb, bgRgb, color, bold) => {
|
|
150
|
+
if (!color || text === '') return text;
|
|
151
|
+
const mark = bold ? BOLD : '';
|
|
152
|
+
return `${mark}${seq(fgRgb, bgRgb)}${text}${RESET}`;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const stripAnsi = (text) => text.replace(ANSI_RE, '');
|
|
156
|
+
|
|
157
|
+
const graphemes = (text) => {
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (const { segment } of SEGMENTER.segment(text)) parts.push(segment);
|
|
160
|
+
return parts;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const isWide = (cp) => {
|
|
164
|
+
let lo = 0;
|
|
165
|
+
let hi = WIDE_CP.length >> 1;
|
|
166
|
+
while (lo < hi) {
|
|
167
|
+
const mid = (lo + hi) >> 1;
|
|
168
|
+
const start = WIDE_CP[mid << 1];
|
|
169
|
+
const end = WIDE_CP[(mid << 1) + 1];
|
|
170
|
+
if (cp < start) hi = mid;
|
|
171
|
+
else if (cp > end) lo = mid + 1;
|
|
172
|
+
else return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const graphemeWidth = (ch, col) => {
|
|
178
|
+
if (ch === '\t') return 8 - (col % 8);
|
|
179
|
+
if (ch === '\n' || ch === '\r') return 0;
|
|
180
|
+
const cp = ch.codePointAt(0);
|
|
181
|
+
if (cp <= 0x1f || cp === 0x7f) return 0;
|
|
182
|
+
if (cp === 0x200d) return 0;
|
|
183
|
+
if (cp >= 0xfe00 && cp <= 0xfe0f) return 0;
|
|
184
|
+
if (isWide(cp)) return 2;
|
|
185
|
+
return 1;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const visibleWidth = (text) => {
|
|
189
|
+
const plain = stripAnsi(text);
|
|
190
|
+
let width = 0;
|
|
191
|
+
for (const ch of graphemes(plain)) {
|
|
192
|
+
width += graphemeWidth(ch, width);
|
|
193
|
+
}
|
|
194
|
+
return width;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const truncateVisible = (text, width) => {
|
|
198
|
+
if (width <= 0) return '';
|
|
199
|
+
if (visibleWidth(text) <= width) return text;
|
|
200
|
+
const plain = stripAnsi(text);
|
|
201
|
+
const parts = graphemes(plain);
|
|
202
|
+
let used = 0;
|
|
203
|
+
let out = '';
|
|
204
|
+
for (const ch of parts) {
|
|
205
|
+
const w = graphemeWidth(ch, used);
|
|
206
|
+
if (used + w > width) break;
|
|
207
|
+
out += ch;
|
|
208
|
+
used += w;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
ESC,
|
|
215
|
+
RESET,
|
|
216
|
+
BOLD,
|
|
217
|
+
EL,
|
|
218
|
+
THEME,
|
|
219
|
+
CODE_FG,
|
|
220
|
+
LOG_COL_FG,
|
|
221
|
+
codeFg,
|
|
222
|
+
fg,
|
|
223
|
+
bg,
|
|
224
|
+
seq,
|
|
225
|
+
paint,
|
|
226
|
+
stripAnsi,
|
|
227
|
+
graphemes,
|
|
228
|
+
graphemeWidth,
|
|
229
|
+
visibleWidth,
|
|
230
|
+
truncateVisible,
|
|
231
|
+
};
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ansi = require('./ansi.js');
|
|
4
|
+
const diff = require('./diff.js');
|
|
5
|
+
const git = require('./git.js');
|
|
6
|
+
const keys = require('./keys.js');
|
|
7
|
+
const render = require('./render.js');
|
|
8
|
+
const highlight = require('./highlight.js');
|
|
9
|
+
const detect = require('./detect.js');
|
|
10
|
+
const { Session } = require('./session.js');
|
|
11
|
+
const cli = require('./cli.js');
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
THEME: ansi.THEME,
|
|
15
|
+
CODE_FG: ansi.CODE_FG,
|
|
16
|
+
ACTIONS: keys.ACTIONS,
|
|
17
|
+
diffChars: diff.diffChars,
|
|
18
|
+
parseDiff: diff.parseDiff,
|
|
19
|
+
splitHunk: diff.splitHunk,
|
|
20
|
+
displayLines: diff.displayLines,
|
|
21
|
+
formatPatch: diff.formatPatch,
|
|
22
|
+
itemsFromFiles: diff.itemsFromFiles,
|
|
23
|
+
createGitRepo: git.createGitRepo,
|
|
24
|
+
decodeChunk: keys.decodeChunk,
|
|
25
|
+
hitAction: keys.hitAction,
|
|
26
|
+
renderFrame: render.renderFrame,
|
|
27
|
+
tokenize: highlight.tokenize,
|
|
28
|
+
overlayTokens: highlight.overlayTokens,
|
|
29
|
+
detectLang: detect.detectLang,
|
|
30
|
+
run: cli.run,
|
|
31
|
+
Session,
|
|
32
|
+
};
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { createGitRepo } = require('./git.js');
|
|
7
|
+
const { Session } = require('./session.js');
|
|
8
|
+
const { ACTIONS, actionHot } = require('./keys.js');
|
|
9
|
+
|
|
10
|
+
const FLAGS = Object.assign(Object.create(null), {
|
|
11
|
+
'-h': 'help',
|
|
12
|
+
'--help': 'help',
|
|
13
|
+
'-v': 'version',
|
|
14
|
+
'--version': 'version',
|
|
15
|
+
'-n': 'newReview',
|
|
16
|
+
'--new': 'newReview',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const HEX_REV = /^[0-9a-f]{4,40}$/i;
|
|
20
|
+
const HEAD_REV = /^HEAD([~^].*)?$/i;
|
|
21
|
+
const RANGE_REV = /[~^]/;
|
|
22
|
+
|
|
23
|
+
const errorMessage = (reason) => {
|
|
24
|
+
if (reason instanceof Error) return reason.message;
|
|
25
|
+
if (reason === null || reason === undefined) return '';
|
|
26
|
+
if (typeof reason === 'string') return reason;
|
|
27
|
+
return `${reason}`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const fail = (stderr, reason) => {
|
|
31
|
+
stderr.write(`reslop: ${errorMessage(reason)}\n`);
|
|
32
|
+
return 1;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const helpText = () => {
|
|
36
|
+
const lines = [
|
|
37
|
+
'Usage: reslop [-n] [commit] [path...]',
|
|
38
|
+
'',
|
|
39
|
+
'Review uncommitted git changes, or a given commit (read only).',
|
|
40
|
+
'Resumes the latest .review file when its status is editing.',
|
|
41
|
+
'Finish marks the review ready for the agent to execute.',
|
|
42
|
+
'Use -n / --new to start a new review file.',
|
|
43
|
+
'',
|
|
44
|
+
'Keys:',
|
|
45
|
+
];
|
|
46
|
+
for (const action of ACTIONS) {
|
|
47
|
+
lines.push(` ${actionHot(action)} ${action.label}`);
|
|
48
|
+
}
|
|
49
|
+
lines.push(' ? Help');
|
|
50
|
+
lines.push('');
|
|
51
|
+
return `${lines.join('\n')}\n`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const parseArgv = (argv) => {
|
|
55
|
+
const paths = [];
|
|
56
|
+
const flags = { help: false, version: false, newReview: false };
|
|
57
|
+
for (const arg of argv) {
|
|
58
|
+
const flag = FLAGS[arg];
|
|
59
|
+
if (flag) {
|
|
60
|
+
flags[flag] = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg.startsWith('-')) {
|
|
64
|
+
throw new Error(`unknown option ${arg}`);
|
|
65
|
+
}
|
|
66
|
+
paths.push(arg);
|
|
67
|
+
}
|
|
68
|
+
const help = flags.help;
|
|
69
|
+
const version = flags.version;
|
|
70
|
+
const newReview = flags.newReview;
|
|
71
|
+
return { paths, help, version, newReview };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const looksLikeRev = (spec) => {
|
|
75
|
+
if (!spec) return false;
|
|
76
|
+
if (HEX_REV.test(spec)) return true;
|
|
77
|
+
if (HEAD_REV.test(spec)) return true;
|
|
78
|
+
return RANGE_REV.test(spec);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const pathExists = (cwd, rel, statSync) => {
|
|
82
|
+
try {
|
|
83
|
+
statSync(path.resolve(cwd, rel));
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const resolveScope = (cwd, paths, repo, statSync = fs.statSync) => {
|
|
91
|
+
if (!paths.length) return { rev: null, paths };
|
|
92
|
+
const first = paths[0];
|
|
93
|
+
if (pathExists(cwd, first, statSync)) {
|
|
94
|
+
return { rev: null, paths };
|
|
95
|
+
}
|
|
96
|
+
const resolved = repo.resolveRev ? repo.resolveRev(cwd, first) : null;
|
|
97
|
+
if (resolved) return { rev: resolved, paths: paths.slice(1) };
|
|
98
|
+
if (looksLikeRev(first)) {
|
|
99
|
+
throw new Error(`bad revision ${first}`);
|
|
100
|
+
}
|
|
101
|
+
return { rev: null, paths };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const writeHelp = (stdout) => {
|
|
105
|
+
stdout.write(helpText());
|
|
106
|
+
return 0;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const writeVersion = (stdout, deps) => {
|
|
110
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
111
|
+
const pkg = deps.pkg ?? JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
112
|
+
stdout.write(`${pkg.version}\n`);
|
|
113
|
+
return 0;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const loadSession = (proc, deps, args) => {
|
|
117
|
+
const stdout = proc.stdout;
|
|
118
|
+
const env = proc.env ?? {};
|
|
119
|
+
const repo = deps.repo ?? createGitRepo();
|
|
120
|
+
const cwd = deps.cwd ?? proc.cwd();
|
|
121
|
+
const statSync = deps.statSync ?? fs.statSync;
|
|
122
|
+
const scope = resolveScope(cwd, args.paths, repo, statSync);
|
|
123
|
+
const paths = scope.paths;
|
|
124
|
+
const rev = scope.rev;
|
|
125
|
+
const stdin = proc.stdin;
|
|
126
|
+
const color = stdout.isTTY === true && !env.NO_COLOR;
|
|
127
|
+
const getSize = deps.getSize;
|
|
128
|
+
const startPane = 'diff';
|
|
129
|
+
const newReview = args.newReview;
|
|
130
|
+
const session = new Session({
|
|
131
|
+
repo,
|
|
132
|
+
cwd,
|
|
133
|
+
paths,
|
|
134
|
+
rev,
|
|
135
|
+
stdin,
|
|
136
|
+
stdout,
|
|
137
|
+
color,
|
|
138
|
+
getSize,
|
|
139
|
+
startPane,
|
|
140
|
+
newReview,
|
|
141
|
+
});
|
|
142
|
+
session.load();
|
|
143
|
+
return session;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const run = async (proc, deps = {}) => {
|
|
147
|
+
const stdout = proc.stdout;
|
|
148
|
+
const stderr = proc.stderr;
|
|
149
|
+
let args;
|
|
150
|
+
try {
|
|
151
|
+
args = parseArgv(proc.argv.slice(2));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return fail(stderr, error);
|
|
154
|
+
}
|
|
155
|
+
if (args.help) return writeHelp(stdout);
|
|
156
|
+
if (args.version) return writeVersion(stdout, deps);
|
|
157
|
+
let session;
|
|
158
|
+
try {
|
|
159
|
+
session = loadSession(proc, deps, args);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return fail(stderr, error);
|
|
162
|
+
}
|
|
163
|
+
if (!session.items.length) {
|
|
164
|
+
stdout.write('nothing to review\n');
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
if (!proc.stdin.isTTY || !stdout.isTTY) {
|
|
168
|
+
stderr.write('reslop: interactive terminal required\n');
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
return session.startUi();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
run,
|
|
176
|
+
parseArgv,
|
|
177
|
+
helpText,
|
|
178
|
+
resolveScope,
|
|
179
|
+
looksLikeRev,
|
|
180
|
+
errorMessage,
|
|
181
|
+
};
|
package/lib/clipboard.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const { ESC } = require('./ansi.js');
|
|
6
|
+
|
|
7
|
+
const CLIP_TIMEOUT_MS = 1000;
|
|
8
|
+
const CLIP_TOOLS = [
|
|
9
|
+
{ cmd: 'wl-copy', args: [] },
|
|
10
|
+
{ cmd: 'xclip', args: ['-selection', 'clipboard'] },
|
|
11
|
+
{ cmd: 'xsel', args: ['--clipboard', '--input'] },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const osc52 = (text) => {
|
|
15
|
+
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
|
16
|
+
return `${ESC}]52;c;${b64}\x07`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const runClip = (cmd, args, text) => {
|
|
20
|
+
const result = spawnSync(cmd, args, {
|
|
21
|
+
input: text,
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
timeout: CLIP_TIMEOUT_MS,
|
|
24
|
+
});
|
|
25
|
+
if (result.error) return false;
|
|
26
|
+
return result.status === 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const copyText = (text, stdout) => {
|
|
30
|
+
if (!text) return false;
|
|
31
|
+
let ok = false;
|
|
32
|
+
if (stdout && stdout.write) {
|
|
33
|
+
stdout.write(osc52(text));
|
|
34
|
+
ok = true;
|
|
35
|
+
}
|
|
36
|
+
for (const tool of CLIP_TOOLS) {
|
|
37
|
+
if (runClip(tool.cmd, tool.args, text)) return true;
|
|
38
|
+
}
|
|
39
|
+
return ok;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
module.exports = { osc52, copyText, runClip };
|