kernelpm 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/README.md +72 -0
- package/dist/cli.js +243 -0
- package/dist/client.js +137 -0
- package/dist/control.js +184 -0
- package/dist/daemon.js +189 -0
- package/dist/decisions.js +56 -0
- package/dist/files.js +226 -0
- package/dist/opencodeDriver.js +611 -0
- package/dist/protocol.js +12 -0
- package/dist/runtime.js +153 -0
- package/dist/session.js +102 -0
- package/dist/store.js +145 -0
- package/package.json +27 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.Daemon = exports.DAEMON_VERSION = void 0;
|
|
37
|
+
exports.controlSocketPath = controlSocketPath;
|
|
38
|
+
exports.pidPath = pidPath;
|
|
39
|
+
exports.ensureToken = ensureToken;
|
|
40
|
+
/**
|
|
41
|
+
* The daemon: a registry of live sessions plus the fan-out to attached clients.
|
|
42
|
+
* It owns the durable store, recovers sessions on startup (reattaching to
|
|
43
|
+
* surviving opencode serve processes or resuming their sessions), and exposes
|
|
44
|
+
* the operations the control socket maps the wire protocol onto.
|
|
45
|
+
*/
|
|
46
|
+
const fs = __importStar(require("fs"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const crypto_1 = require("crypto");
|
|
49
|
+
const store_1 = require("./store");
|
|
50
|
+
const session_1 = require("./session");
|
|
51
|
+
exports.DAEMON_VERSION = '0.1.0';
|
|
52
|
+
/** Default model when a session doesn't specify one (GLM Coding Plan). */
|
|
53
|
+
const DEFAULT_MODEL = 'zai-coding-plan/glm-5.2';
|
|
54
|
+
function controlSocketPath() {
|
|
55
|
+
return path.join((0, store_1.kernelpmHome)(), 'control.sock');
|
|
56
|
+
}
|
|
57
|
+
function pidPath() {
|
|
58
|
+
return path.join((0, store_1.kernelpmHome)(), 'daemon.pid');
|
|
59
|
+
}
|
|
60
|
+
async function ensureToken(home) {
|
|
61
|
+
const p = path.join(home, 'token');
|
|
62
|
+
try {
|
|
63
|
+
const existing = (await fs.promises.readFile(p, 'utf8')).trim();
|
|
64
|
+
if (existing)
|
|
65
|
+
return existing;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* generate below */
|
|
69
|
+
}
|
|
70
|
+
const token = (0, crypto_1.randomUUID)();
|
|
71
|
+
await fs.promises.writeFile(p, token, { encoding: 'utf8', mode: 0o600 });
|
|
72
|
+
return token;
|
|
73
|
+
}
|
|
74
|
+
class Daemon {
|
|
75
|
+
token;
|
|
76
|
+
store = new store_1.Store();
|
|
77
|
+
sessions = new Map();
|
|
78
|
+
subs = new Map(); // sessionId -> subscribers
|
|
79
|
+
clients = new Set();
|
|
80
|
+
constructor(token) {
|
|
81
|
+
this.token = token;
|
|
82
|
+
}
|
|
83
|
+
async init() {
|
|
84
|
+
await this.store.init();
|
|
85
|
+
for (const meta of this.store.list()) {
|
|
86
|
+
if (meta.status === 'exited')
|
|
87
|
+
continue;
|
|
88
|
+
const session = this.spawn(meta);
|
|
89
|
+
try {
|
|
90
|
+
await session.start(true); // reattach if alive, resume if not
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error(`[kernelpm] could not recover session ${meta.id}:`, err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
list() {
|
|
98
|
+
return this.store.list();
|
|
99
|
+
}
|
|
100
|
+
get(id) {
|
|
101
|
+
return this.store.get(id);
|
|
102
|
+
}
|
|
103
|
+
async create(cwd, title, model) {
|
|
104
|
+
const id = (0, crypto_1.randomUUID)();
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const meta = {
|
|
107
|
+
id,
|
|
108
|
+
title: title?.trim() || defaultTitle(cwd),
|
|
109
|
+
cwd,
|
|
110
|
+
status: 'starting',
|
|
111
|
+
lastSeq: 0,
|
|
112
|
+
pendingDecision: null,
|
|
113
|
+
createdAt: now,
|
|
114
|
+
updatedAt: now,
|
|
115
|
+
};
|
|
116
|
+
await this.store.upsert(meta);
|
|
117
|
+
const session = this.spawn(meta, model);
|
|
118
|
+
await session.start(false);
|
|
119
|
+
return this.store.get(id) ?? meta;
|
|
120
|
+
}
|
|
121
|
+
send(id, text) {
|
|
122
|
+
return this.require(id).sendMessage(text);
|
|
123
|
+
}
|
|
124
|
+
answer(id, decisionId, optionId) {
|
|
125
|
+
return this.require(id).answer(decisionId, optionId);
|
|
126
|
+
}
|
|
127
|
+
kill(id) {
|
|
128
|
+
return this.require(id).kill();
|
|
129
|
+
}
|
|
130
|
+
restart(id) {
|
|
131
|
+
return this.require(id).restart();
|
|
132
|
+
}
|
|
133
|
+
/* ------------------------------ client fan-out ----------------------------- */
|
|
134
|
+
addClient(c) {
|
|
135
|
+
this.clients.add(c);
|
|
136
|
+
}
|
|
137
|
+
dropClient(c) {
|
|
138
|
+
this.clients.delete(c);
|
|
139
|
+
for (const set of this.subs.values())
|
|
140
|
+
set.delete(c);
|
|
141
|
+
}
|
|
142
|
+
/** Subscribe a client and replay everything it missed since `sinceSeq`. */
|
|
143
|
+
async attachClient(c, id, rid, sinceSeq = 0) {
|
|
144
|
+
const meta = this.store.get(id);
|
|
145
|
+
if (!meta) {
|
|
146
|
+
c.send({ t: 'error', rid, message: 'no such session' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let set = this.subs.get(id);
|
|
150
|
+
if (!set)
|
|
151
|
+
this.subs.set(id, (set = new Set()));
|
|
152
|
+
set.add(c); // subscribe first so no live event is missed; clients dedupe by seq
|
|
153
|
+
c.send({ t: 'attached', rid, id, lastSeq: meta.lastSeq });
|
|
154
|
+
for (const event of await this.store.readEvents(id, sinceSeq)) {
|
|
155
|
+
c.send({ t: 'event', id, event });
|
|
156
|
+
}
|
|
157
|
+
c.send({ t: 'status', id, meta });
|
|
158
|
+
if (meta.pendingDecision)
|
|
159
|
+
c.send({ t: 'decision', id, decision: meta.pendingDecision });
|
|
160
|
+
}
|
|
161
|
+
detachClient(c, id) {
|
|
162
|
+
this.subs.get(id)?.delete(c);
|
|
163
|
+
}
|
|
164
|
+
broadcast(id, msg) {
|
|
165
|
+
const set = this.subs.get(id);
|
|
166
|
+
if (set)
|
|
167
|
+
for (const c of set)
|
|
168
|
+
c.send(msg);
|
|
169
|
+
}
|
|
170
|
+
/* --------------------------------- internals ------------------------------- */
|
|
171
|
+
spawn(meta, model) {
|
|
172
|
+
const session = new session_1.Session(this.store, meta, (msg) => this.broadcast(meta.id, msg), {
|
|
173
|
+
model: model ?? DEFAULT_MODEL,
|
|
174
|
+
});
|
|
175
|
+
this.sessions.set(meta.id, session);
|
|
176
|
+
return session;
|
|
177
|
+
}
|
|
178
|
+
require(id) {
|
|
179
|
+
const s = this.sessions.get(id);
|
|
180
|
+
if (!s)
|
|
181
|
+
throw new Error('no such session');
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
exports.Daemon = Daemon;
|
|
186
|
+
function defaultTitle(cwd) {
|
|
187
|
+
const base = path.basename(cwd.replace(/[\\/]+$/, ''));
|
|
188
|
+
return base || cwd;
|
|
189
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseDecision = parseDecision;
|
|
4
|
+
/**
|
|
5
|
+
* Turning a finished assistant turn into a tappable option set.
|
|
6
|
+
*
|
|
7
|
+
* When Claude ends a turn by asking the user to pick between numbered choices,
|
|
8
|
+
* we surface those as a `Decision` so the app can render chips. This is a
|
|
9
|
+
* heuristic over free text and deliberately conservative: it only fires when
|
|
10
|
+
* the message both contains a sequential `1. 2. 3.`-style list AND reads like a
|
|
11
|
+
* question (an interrogative cue). Anything it misses still renders as a normal
|
|
12
|
+
* assistant message the user can reply to by typing.
|
|
13
|
+
*/
|
|
14
|
+
const crypto_1 = require("crypto");
|
|
15
|
+
const OPTION_RE = /^\s*(\d+)[.)]\s+(.+?)\s*$/;
|
|
16
|
+
const CUE_RE = /\b(which|choose|select|option|options|prefer|pick|proceed|do you want|would you like|shall i)\b|\?/i;
|
|
17
|
+
function parseDecision(text) {
|
|
18
|
+
if (!text || !text.trim())
|
|
19
|
+
return null;
|
|
20
|
+
const lines = text.split('\n');
|
|
21
|
+
const items = [];
|
|
22
|
+
let firstIdx = -1;
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const m = OPTION_RE.exec(lines[i]);
|
|
25
|
+
if (m) {
|
|
26
|
+
if (firstIdx < 0)
|
|
27
|
+
firstIdx = i;
|
|
28
|
+
items.push({ n: Number(m[1]), label: m[2] });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (items.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
// Must be a clean 1..n sequence — avoids matching scattered numbers.
|
|
34
|
+
if (!items.every((it, idx) => it.n === idx + 1))
|
|
35
|
+
return null;
|
|
36
|
+
if (!CUE_RE.test(text))
|
|
37
|
+
return null;
|
|
38
|
+
const prompt = lastNonEmpty(lines.slice(0, firstIdx)) || 'Choose an option';
|
|
39
|
+
const options = items.map((it) => ({
|
|
40
|
+
id: String(it.n),
|
|
41
|
+
label: truncate(it.label, 120),
|
|
42
|
+
send: String(it.n),
|
|
43
|
+
}));
|
|
44
|
+
return { id: (0, crypto_1.randomUUID)(), prompt: truncate(prompt, 240), options, createdAt: Date.now() };
|
|
45
|
+
}
|
|
46
|
+
function lastNonEmpty(lines) {
|
|
47
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
48
|
+
const t = lines[i].trim();
|
|
49
|
+
if (t)
|
|
50
|
+
return t;
|
|
51
|
+
}
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
function truncate(s, n) {
|
|
55
|
+
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
56
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveInside = resolveInside;
|
|
37
|
+
exports.buildTree = buildTree;
|
|
38
|
+
exports.readProjectFile = readProjectFile;
|
|
39
|
+
/**
|
|
40
|
+
* Read-only project filesystem access for the app's file explorer / reader.
|
|
41
|
+
*
|
|
42
|
+
* Everything is scoped to a session's working directory. Where the cwd is a git
|
|
43
|
+
* worktree we lean on git for both the file set (tracked + untracked, honouring
|
|
44
|
+
* .gitignore) and per-file status / diffs; otherwise we fall back to a plain,
|
|
45
|
+
* bounded directory walk. No writes ever happen here.
|
|
46
|
+
*/
|
|
47
|
+
const fs = __importStar(require("fs"));
|
|
48
|
+
const path = __importStar(require("path"));
|
|
49
|
+
const child_process_1 = require("child_process");
|
|
50
|
+
/** Cap the tree so a huge (or non-git) directory can't blow up the payload. */
|
|
51
|
+
const MAX_FILES = 5000;
|
|
52
|
+
/** Cap a single file read (bytes). */
|
|
53
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
54
|
+
function run(cmd, args, cwd) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
(0, child_process_1.execFile)(cmd, args, { cwd, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
57
|
+
const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0;
|
|
58
|
+
resolve({ code, stdout: stdout ?? '', stderr: stderr ?? '' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function fileType(name) {
|
|
63
|
+
return /\.(ts|tsx)$/.test(name) ? 'ts' : 'generic';
|
|
64
|
+
}
|
|
65
|
+
/** Resolve a project-relative path and refuse anything that escapes the cwd. */
|
|
66
|
+
function resolveInside(cwd, rel) {
|
|
67
|
+
const base = path.resolve(cwd);
|
|
68
|
+
const abs = path.resolve(base, rel);
|
|
69
|
+
if (abs !== base && !abs.startsWith(base + path.sep)) {
|
|
70
|
+
throw new Error('path escapes the project directory');
|
|
71
|
+
}
|
|
72
|
+
return abs;
|
|
73
|
+
}
|
|
74
|
+
/** Map of project-relative path -> git status (modified | untracked). */
|
|
75
|
+
async function gitStatus(cwd) {
|
|
76
|
+
const r = await run('git', ['status', '--porcelain', '-z'], cwd);
|
|
77
|
+
if (r.code !== 0)
|
|
78
|
+
return null; // not a git repo (or git missing)
|
|
79
|
+
const map = new Map();
|
|
80
|
+
const parts = r.stdout.split('\0').filter(Boolean);
|
|
81
|
+
for (let i = 0; i < parts.length; i++) {
|
|
82
|
+
const entry = parts[i];
|
|
83
|
+
const xy = entry.slice(0, 2);
|
|
84
|
+
let p = entry.slice(3);
|
|
85
|
+
// Renames/copies emit "old\0new"; the new path is the following token.
|
|
86
|
+
if (xy[0] === 'R' || xy[0] === 'C')
|
|
87
|
+
p = parts[++i] ?? p;
|
|
88
|
+
if (!p)
|
|
89
|
+
continue;
|
|
90
|
+
map.set(p, xy === '??' ? 'untracked' : 'modified');
|
|
91
|
+
}
|
|
92
|
+
return map;
|
|
93
|
+
}
|
|
94
|
+
/** The set of files to show: git-tracked + untracked (gitignore-aware). */
|
|
95
|
+
async function gitFiles(cwd) {
|
|
96
|
+
const r = await run('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd);
|
|
97
|
+
if (r.code !== 0)
|
|
98
|
+
return null;
|
|
99
|
+
return r.stdout.split('\0').filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
/** Fallback when cwd isn't a git repo: a bounded walk skipping heavy dirs. */
|
|
102
|
+
async function walkFiles(cwd) {
|
|
103
|
+
const skip = new Set(['.git', 'node_modules', '.expo', 'dist', 'build', '.next', '.cache']);
|
|
104
|
+
const out = [];
|
|
105
|
+
const walk = async (dir, rel) => {
|
|
106
|
+
if (out.length >= MAX_FILES)
|
|
107
|
+
return;
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
if (out.length >= MAX_FILES)
|
|
117
|
+
return;
|
|
118
|
+
if (e.name.startsWith('.git') || skip.has(e.name))
|
|
119
|
+
continue;
|
|
120
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
121
|
+
if (e.isDirectory())
|
|
122
|
+
await walk(path.join(dir, e.name), childRel);
|
|
123
|
+
else if (e.isFile())
|
|
124
|
+
out.push(childRel);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
await walk(path.resolve(cwd), '');
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/** Sort folders before files, alphabetically within each group. */
|
|
131
|
+
function sortNodes(nodes) {
|
|
132
|
+
nodes.sort((a, b) => {
|
|
133
|
+
const af = a.type === 'folder';
|
|
134
|
+
const bf = b.type === 'folder';
|
|
135
|
+
return af === bf ? a.name.localeCompare(b.name) : af ? -1 : 1;
|
|
136
|
+
});
|
|
137
|
+
for (const n of nodes)
|
|
138
|
+
if (n.type === 'folder')
|
|
139
|
+
sortNodes(n.children);
|
|
140
|
+
}
|
|
141
|
+
/** Nest a flat list of relative file paths into a git-aware tree. */
|
|
142
|
+
function nest(files, status) {
|
|
143
|
+
const root = [];
|
|
144
|
+
const folders = new Map();
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
const parts = file.split('/');
|
|
147
|
+
let children = root;
|
|
148
|
+
let prefix = '';
|
|
149
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
150
|
+
prefix = prefix ? `${prefix}/${parts[i]}` : parts[i];
|
|
151
|
+
let folder = folders.get(prefix);
|
|
152
|
+
if (!folder) {
|
|
153
|
+
folder = { name: parts[i], type: 'folder', path: prefix, children: [] };
|
|
154
|
+
folders.set(prefix, folder);
|
|
155
|
+
children.push(folder);
|
|
156
|
+
}
|
|
157
|
+
children = folder.children;
|
|
158
|
+
}
|
|
159
|
+
const name = parts[parts.length - 1];
|
|
160
|
+
children.push({ name, type: fileType(name), path: file, git: status?.get(file) ?? 'clean' });
|
|
161
|
+
}
|
|
162
|
+
sortNodes(root);
|
|
163
|
+
return root;
|
|
164
|
+
}
|
|
165
|
+
/** Build the git-aware file tree for a project working directory. */
|
|
166
|
+
async function buildTree(cwd) {
|
|
167
|
+
const status = await gitStatus(cwd);
|
|
168
|
+
let files = await gitFiles(cwd);
|
|
169
|
+
if (!files)
|
|
170
|
+
files = await walkFiles(cwd);
|
|
171
|
+
if (files.length > MAX_FILES)
|
|
172
|
+
files = files.slice(0, MAX_FILES);
|
|
173
|
+
return nest(files, status);
|
|
174
|
+
}
|
|
175
|
+
/** Parse `git diff` output for one file into renderable diff lines. */
|
|
176
|
+
function parseDiff(text) {
|
|
177
|
+
const out = [];
|
|
178
|
+
let oldNo = 0;
|
|
179
|
+
let newNo = 0;
|
|
180
|
+
let inHunk = false;
|
|
181
|
+
for (const line of text.split('\n')) {
|
|
182
|
+
if (line.startsWith('@@')) {
|
|
183
|
+
const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
184
|
+
if (m) {
|
|
185
|
+
oldNo = Number(m[1]);
|
|
186
|
+
newNo = Number(m[2]);
|
|
187
|
+
inHunk = true;
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!inHunk)
|
|
192
|
+
continue; // skip the diff/index/+++/--- header block
|
|
193
|
+
if (line.startsWith('\\'))
|
|
194
|
+
continue; // ""
|
|
195
|
+
const tag = line[0];
|
|
196
|
+
const code = line.slice(1);
|
|
197
|
+
if (tag === '+')
|
|
198
|
+
out.push({ type: 'add', newNo: newNo++, code });
|
|
199
|
+
else if (tag === '-')
|
|
200
|
+
out.push({ type: 'del', oldNo: oldNo++, code });
|
|
201
|
+
else
|
|
202
|
+
out.push({ type: 'ctx', oldNo: oldNo++, newNo: newNo++, code });
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
/** Read one project file: a diff when modified, otherwise its plain contents. */
|
|
207
|
+
async function readProjectFile(cwd, rel) {
|
|
208
|
+
const abs = resolveInside(cwd, rel);
|
|
209
|
+
const status = await gitStatus(cwd);
|
|
210
|
+
const git = status?.get(rel) ?? 'clean';
|
|
211
|
+
if (git === 'modified') {
|
|
212
|
+
let d = (await run('git', ['diff', '--', rel], cwd)).stdout;
|
|
213
|
+
if (!d.trim())
|
|
214
|
+
d = (await run('git', ['diff', '--cached', '--', rel], cwd)).stdout;
|
|
215
|
+
const diff = parseDiff(d);
|
|
216
|
+
if (diff.length)
|
|
217
|
+
return { git, diff };
|
|
218
|
+
// staged-but-identical or binary: fall through to showing contents
|
|
219
|
+
}
|
|
220
|
+
const stat = await fs.promises.stat(abs);
|
|
221
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
222
|
+
return { git, lines: [`— archivo demasiado grande para mostrar (${Math.round(stat.size / 1024)} KB) —`] };
|
|
223
|
+
}
|
|
224
|
+
const text = await fs.promises.readFile(abs, 'utf8');
|
|
225
|
+
return { git, lines: text.split('\n') };
|
|
226
|
+
}
|