reslop 0.3.1 → 0.3.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.
- package/CHANGELOG.md +28 -1
- package/README.md +30 -4
- package/lib/agents/acp.js +309 -0
- package/lib/agents/agents.js +274 -438
- package/lib/agents/allow.js +141 -52
- package/lib/agents/allow.json +16 -14
- package/lib/agents/models.js +250 -383
- package/lib/agents/registry.js +437 -0
- package/lib/agents/run.js +234 -0
- package/lib/agents/script.js +93 -0
- package/lib/agents/transcript.js +143 -0
- package/lib/cli.js +2 -1
- package/lib/common/process.js +26 -1
- package/lib/dashboard/git.js +11 -1
- package/lib/dashboard/reports.js +80 -0
- package/lib/dashboard/watch.js +43 -8
- package/lib/dashboard/workspace.js +19 -20
- package/lib/dashboard/worktrees.js +145 -0
- package/lib/git/dir.js +50 -0
- package/lib/git/linked.js +152 -0
- package/lib/git/local.js +179 -0
- package/lib/git/status.js +126 -0
- package/lib/git/worktree.js +113 -36
- package/lib/input/actions.js +83 -5
- package/lib/input/keys.js +30 -1
- package/lib/input/line-edit.js +5 -1
- package/lib/render/agents.js +84 -34
- package/lib/render/branches.js +55 -27
- package/lib/render/field.js +19 -5
- package/lib/render/footer.js +14 -6
- package/lib/render/frame.js +16 -2
- package/lib/render/menu.js +103 -6
- package/lib/render/name-edit.js +31 -0
- package/lib/render/repos.js +52 -8
- package/lib/render/status.js +22 -4
- package/lib/report/run.js +16 -23
- package/lib/review/agent-instructions.md +2 -2
- package/lib/runs/process.js +3 -3
- package/lib/runs/runs.js +27 -4
- package/lib/session/agents/agents.js +220 -205
- package/lib/session/agents/away.js +48 -0
- package/lib/session/agents/job.js +23 -86
- package/lib/session/agents/jobs.js +14 -27
- package/lib/session/agents/options.js +45 -101
- package/lib/session/agents/plans.js +53 -0
- package/lib/session/agents/view.js +34 -0
- package/lib/session/branches.js +18 -1
- package/lib/session/compose.js +13 -5
- package/lib/session/dispatch.js +43 -4
- package/lib/session/editor-pointer.js +98 -0
- package/lib/session/files-pane.js +3 -0
- package/lib/session/lifecycle.js +67 -10
- package/lib/session/navigation.js +5 -0
- package/lib/session/npm/history.js +2 -1
- package/lib/session/npm/npm.js +89 -12
- package/lib/session/ops.js +3 -0
- package/lib/session/pointer.js +304 -46
- package/lib/session/session.js +15 -2
- package/lib/session/terminal.js +2 -2
- package/lib/session/text-menu.js +50 -0
- package/lib/session/view.js +19 -2
- package/lib/session/workspace.js +179 -38
- package/lib/session/worktrees.js +280 -0
- package/lib/term/themes/dark.json +4 -0
- package/lib/term/themes/light.json +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
// Where the install scripts of agent CLIs put their command
|
|
9
|
+
const localBin = (env = process.env) => {
|
|
10
|
+
const home = env.HOME || env.USERPROFILE;
|
|
11
|
+
return home ? path.join(home, '.local', 'bin') : '';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const httpsUrl = (href) => {
|
|
15
|
+
if (!href) throw new Error('no install script for this platform');
|
|
16
|
+
const url = new URL(href);
|
|
17
|
+
if (url.protocol !== 'https:') {
|
|
18
|
+
throw new Error('install script must use https');
|
|
19
|
+
}
|
|
20
|
+
return url.href;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// The script a vendor publishes for this platform
|
|
24
|
+
const scriptUrl = (script, platform = process.platform) =>
|
|
25
|
+
httpsUrl(platform === 'win32' ? script.win : script.unix);
|
|
26
|
+
|
|
27
|
+
const shellOf = (file, platform) => {
|
|
28
|
+
if (platform !== 'win32') return ['bash', [file]];
|
|
29
|
+
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file];
|
|
30
|
+
return ['powershell.exe', args];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The script finds the directory it installs to on PATH and does not fail
|
|
34
|
+
const scriptEnv = (env) => {
|
|
35
|
+
const bin = localBin(env);
|
|
36
|
+
const key = Object.keys(env).find((name) => name.toUpperCase() === 'PATH');
|
|
37
|
+
const name = key ?? 'PATH';
|
|
38
|
+
const dirs = `${env[name] ?? ''}`.split(path.delimiter).filter(Boolean);
|
|
39
|
+
if (!bin || dirs.includes(bin)) return { ...env };
|
|
40
|
+
return { ...env, [name]: [bin, ...dirs].join(path.delimiter) };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const runFile = (cmd, args, env, spawnImpl) =>
|
|
44
|
+
new Promise((resolve, reject) => {
|
|
45
|
+
const stdio = ['ignore', 'inherit', 'inherit'];
|
|
46
|
+
const child = spawnImpl(cmd, args, { env, stdio, windowsHide: true });
|
|
47
|
+
child.on('error', reject);
|
|
48
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const runScript = async (url, options = {}) => {
|
|
52
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
53
|
+
if (typeof fetchImpl !== 'function') {
|
|
54
|
+
throw new Error('fetch is not available');
|
|
55
|
+
}
|
|
56
|
+
const env = options.env ?? process.env;
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
const log = options.log ?? (() => {});
|
|
59
|
+
const href = httpsUrl(url);
|
|
60
|
+
log(`Downloading ${href}…`);
|
|
61
|
+
const response = await fetchImpl(href);
|
|
62
|
+
if (!response || !response.ok) {
|
|
63
|
+
const status = response ? ` (${response.status})` : '';
|
|
64
|
+
throw new Error(`download failed${status}`);
|
|
65
|
+
}
|
|
66
|
+
const text = await response.text();
|
|
67
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'reslop-'));
|
|
68
|
+
try {
|
|
69
|
+
const name = platform === 'win32' ? 'install.ps1' : 'install.sh';
|
|
70
|
+
const file = path.join(dir, name);
|
|
71
|
+
await fs.promises.writeFile(file, text, { mode: 0o600 });
|
|
72
|
+
const [cmd, args] = shellOf(file, platform);
|
|
73
|
+
const spawnImpl = options.spawn ?? spawn;
|
|
74
|
+
return await runFile(cmd, args, scriptEnv(env), spawnImpl);
|
|
75
|
+
} finally {
|
|
76
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const main = async () => {
|
|
81
|
+
try {
|
|
82
|
+
process.exitCode = await runScript(process.argv[2], {
|
|
83
|
+
log: (line) => process.stdout.write(`${line}\n`),
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
process.stderr.write(`${error.message}\n`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (require.main === module) void main();
|
|
92
|
+
|
|
93
|
+
module.exports = { localBin, scriptUrl, scriptEnv, runScript };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isHashObject } = require('metautil');
|
|
4
|
+
|
|
5
|
+
const TITLE_MAX = 200;
|
|
6
|
+
const TOOL_MARK = { completed: '✔', failed: '✖' };
|
|
7
|
+
const RUNNING_MARK = '▸';
|
|
8
|
+
const THOUGHT_MARK = '│ ';
|
|
9
|
+
const WRITE_KINDS = ['edit', 'delete', 'move'];
|
|
10
|
+
const CHUNK_KIND = new Map([
|
|
11
|
+
['agent_message_chunk', 'text'],
|
|
12
|
+
['agent_thought_chunk', 'thought'],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const oneLine = (text) => {
|
|
16
|
+
const line = `${text ?? ''}`.replace(/\s+/g, ' ').trim();
|
|
17
|
+
return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const chunkText = (content) => {
|
|
21
|
+
if (!isHashObject(content)) return '';
|
|
22
|
+
return content.type === 'text' ? `${content.text ?? ''}` : '';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const quoted = (text) =>
|
|
26
|
+
text
|
|
27
|
+
.replace(/\n+$/, '')
|
|
28
|
+
.split('\n')
|
|
29
|
+
.map((line) => `${THOUGHT_MARK}${line}`)
|
|
30
|
+
.join('\n');
|
|
31
|
+
|
|
32
|
+
const rootPrefix = (root) => {
|
|
33
|
+
const slash = `${root ?? ''}`.replaceAll('\\', '/');
|
|
34
|
+
const trimmed = slash.replace(/\/+$/, '');
|
|
35
|
+
return trimmed ? `${trimmed}/` : '';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const FACE = {
|
|
39
|
+
text: (block) => block.text.replace(/\n+$/, ''),
|
|
40
|
+
thought: (block) => quoted(block.text),
|
|
41
|
+
note: (block) => `△ ${block.text}`,
|
|
42
|
+
tool: (block) => {
|
|
43
|
+
const title = block.title || block.tool || 'tool';
|
|
44
|
+
if (block.denied) return `${TOOL_MARK.failed} denied: ${title}`;
|
|
45
|
+
return `${TOOL_MARK[block.status] ?? RUNNING_MARK} ${title}`;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// The session updates of a run as the text its log shows
|
|
50
|
+
class Transcript {
|
|
51
|
+
constructor(root = '') {
|
|
52
|
+
// Agents name files by full path: inside the repository it is noise
|
|
53
|
+
this.prefix = rootPrefix(root);
|
|
54
|
+
this.blocks = [];
|
|
55
|
+
this.tools = new Map();
|
|
56
|
+
this.files = new Set();
|
|
57
|
+
this.tokens = null;
|
|
58
|
+
this.cache = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
add(block) {
|
|
62
|
+
this.blocks.push(block);
|
|
63
|
+
this.cache = null;
|
|
64
|
+
return block;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
note(text) {
|
|
68
|
+
const line = oneLine(text);
|
|
69
|
+
if (line) this.add({ kind: 'note', text: line });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fail(error) {
|
|
73
|
+
const text = `${(error && error.message) || error || 'failed'}`.trim();
|
|
74
|
+
this.add({ kind: 'text', text: `✖ ${text}` });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
write(kind, text) {
|
|
78
|
+
if (!text) return;
|
|
79
|
+
const last = this.blocks.at(-1);
|
|
80
|
+
if (last && last.kind === kind) last.text += text;
|
|
81
|
+
else this.add({ kind, text });
|
|
82
|
+
this.cache = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
title(text) {
|
|
86
|
+
const line = `${text ?? ''}`.replaceAll('\\', '/');
|
|
87
|
+
const stripped = this.prefix ? line.replaceAll(this.prefix, '') : line;
|
|
88
|
+
return oneLine(stripped);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
deny(call) {
|
|
92
|
+
this.tool(call).denied = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
tool(update) {
|
|
96
|
+
const id = `${update.toolCallId ?? ''}`;
|
|
97
|
+
const known = this.tools.get(id);
|
|
98
|
+
const block = known ?? this.add({ kind: 'tool', title: '', status: '' });
|
|
99
|
+
if (!known) this.tools.set(id, block);
|
|
100
|
+
const { title, status, kind } = update;
|
|
101
|
+
if (typeof title === 'string') block.title = this.title(title);
|
|
102
|
+
if (typeof status === 'string') block.status = status;
|
|
103
|
+
if (typeof kind === 'string') block.tool = kind;
|
|
104
|
+
this.cache = null;
|
|
105
|
+
if (!WRITE_KINDS.includes(block.tool)) return block;
|
|
106
|
+
const listed = Array.isArray(update.locations) ? update.locations : [];
|
|
107
|
+
for (const location of listed) {
|
|
108
|
+
const file = isHashObject(location) ? location.path : '';
|
|
109
|
+
if (file) this.files.add(file);
|
|
110
|
+
}
|
|
111
|
+
return block;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
update(update) {
|
|
115
|
+
if (!isHashObject(update)) return;
|
|
116
|
+
const type = update.sessionUpdate;
|
|
117
|
+
const kind = CHUNK_KIND.get(type);
|
|
118
|
+
if (kind) return void this.write(kind, chunkText(update.content));
|
|
119
|
+
if (type === 'tool_call' || type === 'tool_call_update') {
|
|
120
|
+
return void this.tool(update);
|
|
121
|
+
}
|
|
122
|
+
if (type !== 'usage_update') return;
|
|
123
|
+
if (Number.isFinite(update.used)) this.tokens = update.used;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
text() {
|
|
127
|
+
if (this.cache !== null) return this.cache;
|
|
128
|
+
let out = '';
|
|
129
|
+
let prev = '';
|
|
130
|
+
for (const block of this.blocks) {
|
|
131
|
+
const face = FACE[block.kind](block);
|
|
132
|
+
if (!face) continue;
|
|
133
|
+
const joined = prev === 'tool' && block.kind === 'tool';
|
|
134
|
+
if (out) out += joined ? '\n' : '\n\n';
|
|
135
|
+
out += face;
|
|
136
|
+
prev = block.kind;
|
|
137
|
+
}
|
|
138
|
+
this.cache = out ? `${out}\n` : '';
|
|
139
|
+
return this.cache;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { Transcript };
|
package/lib/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
} = require('./source/capabilities.js');
|
|
17
17
|
const { runCapture } = require('./report/run.js');
|
|
18
18
|
const { workspaceFromArgs } = require('./dashboard/workspace.js');
|
|
19
|
+
const { expandWorkspace } = require('./dashboard/worktrees.js');
|
|
19
20
|
|
|
20
21
|
const HEX_REV = /^[0-9a-f]{4,40}$/i;
|
|
21
22
|
const HEAD_REV = /^HEAD([~^].*)?$/i;
|
|
@@ -72,7 +73,7 @@ const localOptions = async (deps, args, selected, cwd) => {
|
|
|
72
73
|
rev: null,
|
|
73
74
|
capabilities,
|
|
74
75
|
startPane: 'repos',
|
|
75
|
-
workspace: found,
|
|
76
|
+
workspace: await expandWorkspace(found),
|
|
76
77
|
repoName: path.basename(found.root),
|
|
77
78
|
};
|
|
78
79
|
}
|
package/lib/common/process.js
CHANGED
|
@@ -6,6 +6,7 @@ const IS_WIN = process.platform === 'win32';
|
|
|
6
6
|
const INSTALL_MS = 120000;
|
|
7
7
|
const DEFAULT_ENCODING = 'utf8';
|
|
8
8
|
const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
9
|
+
const CMD_SAFE = /^[\w./:=@+-]+$/;
|
|
9
10
|
|
|
10
11
|
const npmBin = () => (IS_WIN ? 'npm.cmd' : 'npm');
|
|
11
12
|
|
|
@@ -14,6 +15,28 @@ const spawnBase = (extra = {}) => ({ windowsHide: true, ...extra });
|
|
|
14
15
|
const npmOpts = (extra = {}, platform = process.platform) =>
|
|
15
16
|
spawnBase({ shell: platform === 'win32', ...extra });
|
|
16
17
|
|
|
18
|
+
const quoteCmdArg = (arg) => {
|
|
19
|
+
const text = `${arg}`;
|
|
20
|
+
if (text && CMD_SAFE.test(text)) return text;
|
|
21
|
+
const escaped = text.replaceAll('"', '""').replaceAll('%', '"%"');
|
|
22
|
+
return `"${escaped}"`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const winCommand = (program, args) => {
|
|
26
|
+
const list = args ?? [];
|
|
27
|
+
const parts = [program, ...list].map(quoteCmdArg);
|
|
28
|
+
return `"${parts.join(' ')}"`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// cmd.exe quoting, not Node's shell:true join, which splits a quoted -e script
|
|
32
|
+
const spawnCmd = (cmd, args, options = {}) => {
|
|
33
|
+
if (!IS_WIN || options.shell !== true) return spawn(cmd, args, options);
|
|
34
|
+
const shell = process.env.comspec || 'cmd.exe';
|
|
35
|
+
const line = winCommand(cmd, args);
|
|
36
|
+
const opts = { ...options, windowsVerbatimArguments: true, shell: false };
|
|
37
|
+
return spawn(shell, ['/d', '/s', '/c', line], opts);
|
|
38
|
+
};
|
|
39
|
+
|
|
17
40
|
const codeError = (message, code) =>
|
|
18
41
|
Object.assign(new Error(message), { code });
|
|
19
42
|
|
|
@@ -98,7 +121,7 @@ const runProc = (cmd, args, options = {}) =>
|
|
|
98
121
|
if (options.shell) spawnOpts.shell = true;
|
|
99
122
|
let child = null;
|
|
100
123
|
try {
|
|
101
|
-
child =
|
|
124
|
+
child = spawnCmd(cmd, args, spawnBase(spawnOpts));
|
|
102
125
|
} catch (error) {
|
|
103
126
|
resolve(procResult({ error }));
|
|
104
127
|
}
|
|
@@ -112,6 +135,8 @@ module.exports = {
|
|
|
112
135
|
npmBin,
|
|
113
136
|
spawnBase,
|
|
114
137
|
npmOpts,
|
|
138
|
+
winCommand,
|
|
139
|
+
spawnCmd,
|
|
115
140
|
abortError,
|
|
116
141
|
isAbort,
|
|
117
142
|
runProc,
|
package/lib/dashboard/git.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require('node:path');
|
|
|
5
5
|
const { readText } = require('../common/utilities.js');
|
|
6
6
|
|
|
7
7
|
const { runGit } = require('../git/worktree.js');
|
|
8
|
+
const { commitCount, gitDirPath } = require('../git/local.js');
|
|
8
9
|
|
|
9
10
|
const UNIT = '\x1f';
|
|
10
11
|
const RECENT_COUNT = 8;
|
|
@@ -97,9 +98,18 @@ const rebaseState = (gitDir) => {
|
|
|
97
98
|
return null;
|
|
98
99
|
};
|
|
99
100
|
|
|
101
|
+
const probeText = (top, name) => {
|
|
102
|
+
if (name === 'dir') return gitDirPath(top);
|
|
103
|
+
if (name === 'total') {
|
|
104
|
+
const counted = commitCount(top);
|
|
105
|
+
if (counted !== '') return counted;
|
|
106
|
+
}
|
|
107
|
+
return gitText(top, PROBES[name]);
|
|
108
|
+
};
|
|
109
|
+
|
|
100
110
|
const readGitSummary = async (top) => {
|
|
101
111
|
const names = Object.keys(PROBES);
|
|
102
|
-
const read = (name) =>
|
|
112
|
+
const read = (name) => probeText(top, name);
|
|
103
113
|
const texts = await Promise.all(names.map(read));
|
|
104
114
|
const probes = {};
|
|
105
115
|
for (let i = 0; i < names.length; i++) probes[names[i]] = texts[i];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const { MANIFEST, LOCKFILE } = require('../deps/deps.js');
|
|
8
|
+
const { collectReport } = require('../git/deps.js');
|
|
9
|
+
|
|
10
|
+
const REPORT_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
const MODULES_LOCK = path.join('node_modules', '.package-lock.json');
|
|
12
|
+
|
|
13
|
+
const answers = new Map();
|
|
14
|
+
|
|
15
|
+
const hashOf = (file) => {
|
|
16
|
+
try {
|
|
17
|
+
const bytes = fs.readFileSync(file);
|
|
18
|
+
return crypto.createHash('sha1').update(bytes).digest('hex');
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const stampOf = (file) => {
|
|
25
|
+
try {
|
|
26
|
+
const stat = fs.statSync(file);
|
|
27
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// `npm audit` reads the lockfile only, so equal lockfiles have equal answers
|
|
34
|
+
// in every worktree of a repository
|
|
35
|
+
const auditKey = (dir) => {
|
|
36
|
+
const hash = hashOf(path.join(dir, LOCKFILE));
|
|
37
|
+
return hash ? `audit:${hash}` : '';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// `npm outdated` reads what is installed, so it is kept for the directory
|
|
41
|
+
const outdatedKey = (dir) => {
|
|
42
|
+
const files = [MANIFEST, LOCKFILE, MODULES_LOCK];
|
|
43
|
+
const stamps = files.map((file) => stampOf(path.join(dir, file)));
|
|
44
|
+
return `outdated:${path.resolve(dir)}:${stamps.join(':')}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// The answer is asked of npm once while the files stay as they are, however
|
|
48
|
+
// many times the tiles are read: npm goes to the network
|
|
49
|
+
const remembered = (key, collect, now = Date.now()) => {
|
|
50
|
+
if (!key) return collect();
|
|
51
|
+
const hit = answers.get(key);
|
|
52
|
+
if (hit && now - hit.at < REPORT_TTL_MS) return hit.promise;
|
|
53
|
+
const promise = collect();
|
|
54
|
+
answers.set(key, { at: now, promise });
|
|
55
|
+
promise.catch(() => answers.delete(key));
|
|
56
|
+
return promise;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const sizeOf = async (collect, dir, kind) => {
|
|
60
|
+
const report = await collect(dir, kind);
|
|
61
|
+
return report ? report.size : 0;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const reportCounts = async (dir, collect = collectReport) => {
|
|
65
|
+
const [audit, outdated] = await Promise.all([
|
|
66
|
+
remembered(auditKey(dir), () => sizeOf(collect, dir, 'audit')),
|
|
67
|
+
remembered(outdatedKey(dir), () => sizeOf(collect, dir, 'outdated')),
|
|
68
|
+
]);
|
|
69
|
+
return { audit, outdated };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const forgetReports = () => answers.clear();
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
auditKey,
|
|
76
|
+
outdatedKey,
|
|
77
|
+
remembered,
|
|
78
|
+
reportCounts,
|
|
79
|
+
forgetReports,
|
|
80
|
+
};
|
package/lib/dashboard/watch.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('node:path');
|
|
|
6
6
|
const runs = require('../runs/runs.js');
|
|
7
7
|
const { isRunsRel } = runs;
|
|
8
8
|
const { REVIEW_DIR, posixRel } = require('../common/files.js');
|
|
9
|
+
const { resolveGitDirs } = require('../git/dir.js');
|
|
9
10
|
|
|
10
11
|
const DEBOUNCE_MS = 200;
|
|
11
12
|
const UNKNOWN_PATH = '.';
|
|
@@ -42,7 +43,13 @@ const ignoredName = (name) => {
|
|
|
42
43
|
return suffix || backup || store;
|
|
43
44
|
};
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// Linked worktrees keep their HEAD and index in `.git/worktrees/<name>`
|
|
47
|
+
const ignoredLinkedRel = ([, , , leaf = '']) =>
|
|
48
|
+
leaf !== '' && !GIT_WATCH_NAMES.includes(leaf) && leaf !== 'logs';
|
|
49
|
+
|
|
50
|
+
const ignoredGitRel = (parts) => {
|
|
51
|
+
const [, name = '', sub] = parts;
|
|
52
|
+
if (name === 'worktrees') return ignoredLinkedRel(parts);
|
|
46
53
|
if (name === 'logs' && sub === 'HEAD') return false;
|
|
47
54
|
return name !== '' && !GIT_WATCH_NAMES.includes(name);
|
|
48
55
|
};
|
|
@@ -53,7 +60,8 @@ const ignoredRel = (rel) => {
|
|
|
53
60
|
if (norm === MODULES_STAMP) return false;
|
|
54
61
|
const parts = norm.split('/');
|
|
55
62
|
if (parts.some((part) => SKIP_DIRS.includes(part))) return true;
|
|
56
|
-
|
|
63
|
+
const gitAt = parts.indexOf('.git');
|
|
64
|
+
if (gitAt >= 0) return ignoredGitRel(parts.slice(gitAt));
|
|
57
65
|
return ignoredName(parts[parts.length - 1]);
|
|
58
66
|
};
|
|
59
67
|
|
|
@@ -150,15 +158,35 @@ class DiskWatcher {
|
|
|
150
158
|
this.closed = false;
|
|
151
159
|
const scopes = options.paths?.length ? options.paths : ['.'];
|
|
152
160
|
for (const spec of scopes) this.watchScope(spec);
|
|
153
|
-
|
|
154
|
-
this.watchOne(gitDir);
|
|
155
|
-
this.watchOne(path.join(gitDir, 'refs'));
|
|
156
|
-
this.watchOne(path.join(gitDir, 'logs'));
|
|
161
|
+
this.watchGit();
|
|
157
162
|
this.watchOne(path.resolve(this.root, runs.logDir('.')));
|
|
158
163
|
this.watchOne(path.resolve(this.root, runs.runsDir('.')));
|
|
159
164
|
this.watchOne(path.resolve(this.root, REVIEW_DIR));
|
|
160
165
|
}
|
|
161
166
|
|
|
167
|
+
// `.git` of a linked worktree is a file, its state lives in other places
|
|
168
|
+
watchGit() {
|
|
169
|
+
const dirs = resolveGitDirs(this.root);
|
|
170
|
+
this.gitBases = dirs && dirs.linked ? [dirs.gitDir, dirs.commonDir] : [];
|
|
171
|
+
const { gitDir, commonDir } = dirs ?? {};
|
|
172
|
+
const dot = path.resolve(this.root, '.git');
|
|
173
|
+
for (const base of new Set([gitDir ?? dot, commonDir ?? dot])) {
|
|
174
|
+
this.watchOne(base);
|
|
175
|
+
this.watchOne(path.join(base, 'refs'));
|
|
176
|
+
this.watchOne(path.join(base, 'logs'));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Events of the git directories are reported as paths of `.git`
|
|
181
|
+
relOf(full) {
|
|
182
|
+
for (const base of this.gitBases) {
|
|
183
|
+
const inside = path.relative(base, full);
|
|
184
|
+
if (inside.startsWith('..') || path.isAbsolute(inside)) continue;
|
|
185
|
+
return posixRel(path.join('.git', inside));
|
|
186
|
+
}
|
|
187
|
+
return posixRel(path.relative(this.root, full));
|
|
188
|
+
}
|
|
189
|
+
|
|
162
190
|
schedule(key, fire) {
|
|
163
191
|
if (this.closed || !fire) return;
|
|
164
192
|
this.clearTimer(key);
|
|
@@ -212,11 +240,17 @@ class DiskWatcher {
|
|
|
212
240
|
|
|
213
241
|
onEvent(dir, eventType, filename) {
|
|
214
242
|
if (this.closed) return;
|
|
243
|
+
// Windows keeps reporting a directory after it was deleted, forever
|
|
244
|
+
if (!hasFile(dir)) {
|
|
245
|
+
const rel = this.relOf(dir) || UNKNOWN_PATH;
|
|
246
|
+
this.close();
|
|
247
|
+
if (this.onPaths) this.onPaths([rel]);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
215
250
|
const name = filename ? `${filename}` : '';
|
|
216
251
|
let rel = '';
|
|
217
252
|
if (name) {
|
|
218
|
-
|
|
219
|
-
rel = posixRel(path.relative(this.root, full));
|
|
253
|
+
rel = this.relOf(path.join(dir, name));
|
|
220
254
|
}
|
|
221
255
|
const wanted = this.files.has(rel);
|
|
222
256
|
if (isRunsRel(rel)) {
|
|
@@ -253,6 +287,7 @@ class DiskWatcher {
|
|
|
253
287
|
const watcher = fs.watch(target, options, (eventType, filename) => {
|
|
254
288
|
this.onEvent(target, eventType, filename);
|
|
255
289
|
});
|
|
290
|
+
watcher.unref();
|
|
256
291
|
watcher.on('error', () => {});
|
|
257
292
|
this.watched.add(target);
|
|
258
293
|
this.watchers.push(watcher);
|
|
@@ -6,7 +6,8 @@ const path = require('node:path');
|
|
|
6
6
|
const { REVIEW_DIR } = require('../common/files.js');
|
|
7
7
|
const review = require('../review/review.js');
|
|
8
8
|
const { listReviewNames, latestReviewName, loadReview, noteCounts } = review;
|
|
9
|
-
const {
|
|
9
|
+
const { diffCounts } = require('../git/worktree.js');
|
|
10
|
+
const { branchOf, commitCount, worktreeRoot } = require('../git/local.js');
|
|
10
11
|
const { gitText, countOf } = require('./git.js');
|
|
11
12
|
const { runName } = require('./model.js');
|
|
12
13
|
const { readRuns } = require('../runs/runs.js');
|
|
@@ -87,29 +88,26 @@ const sumNumstat = (text) => {
|
|
|
87
88
|
return { added, removed };
|
|
88
89
|
};
|
|
89
90
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
const readDiffStat = async (dir) => {
|
|
92
|
+
try {
|
|
93
|
+
return await diffCounts(dir);
|
|
94
|
+
} catch {
|
|
95
|
+
return emptyDiff();
|
|
96
|
+
}
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
countUntrackedAdded(dir),
|
|
102
|
-
]);
|
|
103
|
-
return {
|
|
104
|
-
stagedAdded: staged.added,
|
|
105
|
-
unstagedAdded: open.added + untracked,
|
|
106
|
-
stagedRemoved: staged.removed,
|
|
107
|
-
unstagedRemoved: open.removed,
|
|
108
|
-
};
|
|
99
|
+
const countCommits = async (dir) => {
|
|
100
|
+
const counted = commitCount(dir);
|
|
101
|
+
if (counted !== '') return countOf(counted);
|
|
102
|
+
return countOf(await gitText(dir, ['rev-list', '--count', 'HEAD']));
|
|
109
103
|
};
|
|
110
104
|
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
// Empty when HEAD is detached
|
|
106
|
+
const currentBranchName = async (dir) => {
|
|
107
|
+
const root = worktreeRoot(dir);
|
|
108
|
+
if (root) return branchOf(root);
|
|
109
|
+
return gitText(dir, ['symbolic-ref', '--short', '-q', 'HEAD']);
|
|
110
|
+
};
|
|
113
111
|
|
|
114
112
|
const taskStats = (dir) => {
|
|
115
113
|
const latest = latestReviewName(listReviewNames(dir));
|
|
@@ -146,6 +144,7 @@ module.exports = {
|
|
|
146
144
|
sumNumstat,
|
|
147
145
|
readDiffStat,
|
|
148
146
|
countCommits,
|
|
147
|
+
currentBranchName,
|
|
149
148
|
taskStats,
|
|
150
149
|
pickRun,
|
|
151
150
|
};
|