claude-code-session-manager 0.10.2 → 0.11.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/dist/assets/TiptapBody-CAJSNRPs.js +189 -0
- package/dist/assets/{cssMode-DyodRfD-.js → cssMode-o7rZCrm4.js} +1 -1
- package/dist/assets/{freemarker2-D1H1ixRK.js → freemarker2-CgmCS5Wh.js} +1 -1
- package/dist/assets/{handlebars-wnlxpTlt.js → handlebars-BcPLqhPv.js} +1 -1
- package/dist/assets/{html-Dv_oA_OQ.js → html-CC9xWnC3.js} +1 -1
- package/dist/assets/{htmlMode-DGXsu2-V.js → htmlMode-DEgCqH7k.js} +1 -1
- package/dist/assets/{index-oiSqLrkZ.js → index-C7ljEoqc.js} +1223 -1192
- package/dist/assets/{index-CcRP2nIC.css → index-CH3K1pkS.css} +1 -1
- package/dist/assets/{javascript-CxejmYhM.js → javascript-CjwqkQrn.js} +1 -1
- package/dist/assets/{jsonMode-ztPfF7kI.js → jsonMode-BYTLu76d.js} +4 -4
- package/dist/assets/{liquid-DvtfrYeo.js → liquid-wbQUuJwT.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-mIBTKOZq.js → lspLanguageFeatures-BJGMI7Xu.js} +1 -1
- package/dist/assets/{mdx-DTebMWEJ.js → mdx-DcDstgPF.js} +1 -1
- package/dist/assets/{python-zea5QgfT.js → python-B96yyM_5.js} +1 -1
- package/dist/assets/{razor-DODk3om_.js → razor-C7aRIxIE.js} +1 -1
- package/dist/assets/{tsMode-BQGo_Gc8.js → tsMode-B3UYlGaL.js} +1 -1
- package/dist/assets/{typescript-Cfo1NBg6.js → typescript-CV587TvC.js} +1 -1
- package/dist/assets/{xml-D1RKIHcE.js → xml-PWUJecBf.js} +1 -1
- package/dist/assets/{yaml-B8MoJlND.js → yaml-D8bBNHE4.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -1
- package/src/main/agentMemory.cjs +267 -0
- package/src/main/docEditor.cjs +92 -0
- package/src/main/files.cjs +346 -0
- package/src/main/git.cjs +333 -0
- package/src/main/historyAggregator.cjs +70 -0
- package/src/main/index.cjs +66 -0
- package/src/main/ipcSchemas.cjs +75 -0
- package/src/main/projectSkills.cjs +124 -0
- package/src/main/scheduler.cjs +155 -11
- package/src/main/superagent.cjs +202 -0
- package/src/main/transcripts.cjs +8 -1
- package/src/preload/api.d.ts +215 -0
- package/src/preload/index.cjs +54 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files IPC — file-tree-sidebar backend.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Unleashed's files IPC but routes every path through config.cjs's
|
|
5
|
+
* `validatePath` (allowedRoots = home dir), and uses `shell.trashItem` for
|
|
6
|
+
* delete so renames/deletes are recoverable from the OS trash.
|
|
7
|
+
*
|
|
8
|
+
* Notes:
|
|
9
|
+
* - Reads are constrained to anywhere inside the home dir.
|
|
10
|
+
* - Writes (create/rename/delete) likewise stay inside home but additionally
|
|
11
|
+
* reject anything that would land on `.credentials.json`.
|
|
12
|
+
* - All listings sort directories first, then alphabetically.
|
|
13
|
+
* - The renderer is expected to pass absolute paths only. Tilde is expanded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { ipcMain, shell } = require('electron');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const fsp = require('node:fs/promises');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
|
|
22
|
+
const { z } = require('zod');
|
|
23
|
+
|
|
24
|
+
function expandHome(p) {
|
|
25
|
+
if (!p) return p;
|
|
26
|
+
if (p === '~') return os.homedir();
|
|
27
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve to realpath; on ENOENT resolve the parent then re-append basename so
|
|
33
|
+
* we can still validate create destinations. Mirrors config.cjs.
|
|
34
|
+
*/
|
|
35
|
+
function realResolve(abs) {
|
|
36
|
+
const lex = path.resolve(expandHome(abs));
|
|
37
|
+
try {
|
|
38
|
+
return fs.realpathSync(lex);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e.code === 'ENOENT') {
|
|
41
|
+
const parent = path.dirname(lex);
|
|
42
|
+
try {
|
|
43
|
+
return path.join(fs.realpathSync(parent), path.basename(lex));
|
|
44
|
+
} catch {
|
|
45
|
+
return lex;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HOME = os.homedir();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates that the path is under the home directory. Returns the realpath
|
|
56
|
+
* or throws. Files IPC is intentionally home-scoped — broader than
|
|
57
|
+
* config.cjs's write boundaries, since the user may browse any project under
|
|
58
|
+
* ~ — but never escapes the home tree.
|
|
59
|
+
*/
|
|
60
|
+
function validateHomePath(abs) {
|
|
61
|
+
const real = realResolve(abs);
|
|
62
|
+
let realHome;
|
|
63
|
+
try { realHome = fs.realpathSync(HOME); } catch { realHome = HOME; }
|
|
64
|
+
if (real === realHome || real.startsWith(realHome + path.sep)) return real;
|
|
65
|
+
throw new Error(`Path outside home directory: ${real}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reject .credentials.json writes regardless of where they sit. */
|
|
69
|
+
function rejectCredentials(real) {
|
|
70
|
+
if (path.basename(real) === '.credentials.json') {
|
|
71
|
+
throw new Error('Write to .credentials.json denied');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Invalid characters for file/folder names (cross-platform).
|
|
76
|
+
const INVALID_NAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/;
|
|
77
|
+
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
78
|
+
|
|
79
|
+
function validateName(name) {
|
|
80
|
+
if (!name || name.trim().length === 0) return 'Name cannot be empty';
|
|
81
|
+
if (name !== name.trim()) return 'Name cannot start or end with whitespace';
|
|
82
|
+
if (name === '.' || name === '..') return 'Name cannot be "." or ".."';
|
|
83
|
+
if (INVALID_NAME_CHARS.test(name)) return 'Name contains invalid characters';
|
|
84
|
+
if (RESERVED_NAMES.test(name.split('.')[0])) return 'Name is a reserved system name';
|
|
85
|
+
if (name.length > 255) return 'Name is too long (max 255 characters)';
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function listDir(dirPath, showHidden) {
|
|
90
|
+
let resolved;
|
|
91
|
+
try { resolved = validateHomePath(dirPath); }
|
|
92
|
+
catch (e) { return { ok: false, entries: [], error: e.message }; }
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const entries = await fsp.readdir(resolved, { withFileTypes: true });
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!showHidden && entry.name.startsWith('.')) continue;
|
|
99
|
+
const full = path.join(resolved, entry.name);
|
|
100
|
+
let size = 0;
|
|
101
|
+
let mtimeMs = 0;
|
|
102
|
+
try {
|
|
103
|
+
const st = await fsp.stat(full);
|
|
104
|
+
size = st.size;
|
|
105
|
+
mtimeMs = st.mtimeMs;
|
|
106
|
+
} catch { /* skip unreadable */ continue; }
|
|
107
|
+
out.push({
|
|
108
|
+
name: entry.name,
|
|
109
|
+
path: full,
|
|
110
|
+
isDirectory: entry.isDirectory(),
|
|
111
|
+
isFile: entry.isFile(),
|
|
112
|
+
size,
|
|
113
|
+
mtimeMs,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
out.sort((a, b) => {
|
|
117
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
118
|
+
return a.name.localeCompare(b.name);
|
|
119
|
+
});
|
|
120
|
+
return { ok: true, entries: out, error: null };
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { ok: false, entries: [], error: e.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readFile(filePath) {
|
|
127
|
+
let resolved;
|
|
128
|
+
try { resolved = validateHomePath(filePath); }
|
|
129
|
+
catch (e) { return { ok: false, text: '', error: e.message, size: 0 }; }
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const st = await fsp.stat(resolved);
|
|
133
|
+
if (st.isDirectory()) return { ok: false, text: '', error: 'Path is a directory', size: 0 };
|
|
134
|
+
// 5 MB cap — preview pane shouldn't try to load huge logs.
|
|
135
|
+
if (st.size > 5 * 1024 * 1024) {
|
|
136
|
+
return { ok: false, text: '', error: 'File too large to preview (> 5 MB)', size: st.size };
|
|
137
|
+
}
|
|
138
|
+
const text = await fsp.readFile(resolved, 'utf8');
|
|
139
|
+
return { ok: true, text, error: null, size: st.size };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
return { ok: false, text: '', error: e.message, size: 0 };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function writeFile(filePath, content) {
|
|
146
|
+
let resolved;
|
|
147
|
+
try {
|
|
148
|
+
resolved = validateHomePath(filePath);
|
|
149
|
+
rejectCredentials(resolved);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: false, error: e.message };
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const dir = path.dirname(resolved);
|
|
155
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
156
|
+
const tmp = `${resolved}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
157
|
+
await fsp.writeFile(tmp, content, 'utf8');
|
|
158
|
+
await fsp.rename(tmp, resolved);
|
|
159
|
+
return { ok: true, error: null };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { ok: false, error: e.message };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function createEntry(parentPath, name, kind) {
|
|
166
|
+
const nameError = validateName(name);
|
|
167
|
+
if (nameError) return { ok: false, error: nameError };
|
|
168
|
+
|
|
169
|
+
let parent;
|
|
170
|
+
try { parent = validateHomePath(parentPath); }
|
|
171
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
172
|
+
|
|
173
|
+
const target = path.join(parent, name);
|
|
174
|
+
// Re-validate target — name passed validateName but join could still
|
|
175
|
+
// produce something outside parent (defense in depth).
|
|
176
|
+
try { validateHomePath(target); }
|
|
177
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
178
|
+
try { rejectCredentials(target); }
|
|
179
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const parentStat = await fsp.stat(parent);
|
|
183
|
+
if (!parentStat.isDirectory()) {
|
|
184
|
+
return { ok: false, error: 'Parent path is not a directory' };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await fsp.access(target);
|
|
188
|
+
return { ok: false, error: 'A file or folder with that name already exists' };
|
|
189
|
+
} catch { /* expected — doesn't exist */ }
|
|
190
|
+
|
|
191
|
+
if (kind === 'folder') {
|
|
192
|
+
await fsp.mkdir(target, { recursive: false });
|
|
193
|
+
} else {
|
|
194
|
+
// 'wx' is exclusive — fails if it races with another writer.
|
|
195
|
+
await fsp.writeFile(target, '', { encoding: 'utf8', flag: 'wx' });
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, path: target, error: null };
|
|
198
|
+
} catch (e) {
|
|
199
|
+
if (e.code === 'EEXIST') return { ok: false, error: 'A file or folder with that name already exists' };
|
|
200
|
+
return { ok: false, error: e.message };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function renameEntry(oldPath, newName) {
|
|
205
|
+
const nameError = validateName(newName);
|
|
206
|
+
if (nameError) return { ok: false, error: nameError };
|
|
207
|
+
|
|
208
|
+
let resolvedOld;
|
|
209
|
+
try { resolvedOld = validateHomePath(oldPath); }
|
|
210
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
211
|
+
|
|
212
|
+
const newPath = path.join(path.dirname(resolvedOld), newName);
|
|
213
|
+
try { validateHomePath(newPath); }
|
|
214
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
215
|
+
try { rejectCredentials(newPath); }
|
|
216
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await fsp.access(resolvedOld);
|
|
220
|
+
try {
|
|
221
|
+
await fsp.access(newPath);
|
|
222
|
+
return { ok: false, error: 'A file or folder with that name already exists' };
|
|
223
|
+
} catch { /* good */ }
|
|
224
|
+
await fsp.rename(resolvedOld, newPath);
|
|
225
|
+
return { ok: true, newPath, error: null };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return { ok: false, error: e.message };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const CRITICAL_PATHS = new Set([HOME, '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
|
|
232
|
+
|
|
233
|
+
async function deleteEntry(filePath) {
|
|
234
|
+
let resolved;
|
|
235
|
+
try { resolved = validateHomePath(filePath); }
|
|
236
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
237
|
+
|
|
238
|
+
if (CRITICAL_PATHS.has(resolved)) {
|
|
239
|
+
return { ok: false, error: 'Cannot delete system-critical paths' };
|
|
240
|
+
}
|
|
241
|
+
try { rejectCredentials(resolved); }
|
|
242
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// Prefer trash so deletes are recoverable. Fall back to hard delete only
|
|
246
|
+
// if the platform doesn't support it (very old Linux desktops).
|
|
247
|
+
try {
|
|
248
|
+
await shell.trashItem(resolved);
|
|
249
|
+
return { ok: true, error: null };
|
|
250
|
+
} catch {
|
|
251
|
+
const st = await fsp.lstat(resolved);
|
|
252
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
253
|
+
await fsp.rm(resolved, { recursive: true });
|
|
254
|
+
} else {
|
|
255
|
+
await fsp.unlink(resolved);
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, error: null };
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return { ok: false, error: e.message };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function openExternal(filePath) {
|
|
265
|
+
let resolved;
|
|
266
|
+
try { resolved = validateHomePath(filePath); }
|
|
267
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
268
|
+
try {
|
|
269
|
+
await fsp.access(resolved);
|
|
270
|
+
const err = await shell.openPath(resolved);
|
|
271
|
+
if (err) return { ok: false, error: err };
|
|
272
|
+
return { ok: true };
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return { ok: false, error: e.message };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function showInFinder(filePath) {
|
|
279
|
+
let resolved;
|
|
280
|
+
try { resolved = validateHomePath(filePath); }
|
|
281
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
282
|
+
try {
|
|
283
|
+
await fsp.access(resolved);
|
|
284
|
+
shell.showItemInFolder(resolved);
|
|
285
|
+
return { ok: true };
|
|
286
|
+
} catch (e) {
|
|
287
|
+
return { ok: false, error: e.message };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ──────────────────────────────────────────── schemas
|
|
292
|
+
const filesPath = z.object({ path: z.string().min(1).max(4096) });
|
|
293
|
+
const filesList = z.object({ path: z.string().min(1).max(4096), showHidden: z.boolean().optional() });
|
|
294
|
+
const filesWrite = z.object({ path: z.string().min(1).max(4096), content: z.string() });
|
|
295
|
+
const filesCreate = z.object({
|
|
296
|
+
parentPath: z.string().min(1).max(4096),
|
|
297
|
+
name: z.string().min(1).max(255),
|
|
298
|
+
kind: z.enum(['file', 'folder']),
|
|
299
|
+
});
|
|
300
|
+
const filesRename = z.object({ path: z.string().min(1).max(4096), newName: z.string().min(1).max(255) });
|
|
301
|
+
|
|
302
|
+
function registerFilesHandlers() {
|
|
303
|
+
ipcMain.handle('files:list', (_e, payload) => {
|
|
304
|
+
const { path: p, showHidden } = filesList.parse(payload);
|
|
305
|
+
return listDir(p, !!showHidden);
|
|
306
|
+
});
|
|
307
|
+
ipcMain.handle('files:read', (_e, payload) => {
|
|
308
|
+
const { path: p } = filesPath.parse(payload);
|
|
309
|
+
return readFile(p);
|
|
310
|
+
});
|
|
311
|
+
ipcMain.handle('files:write', (_e, payload) => {
|
|
312
|
+
const { path: p, content } = filesWrite.parse(payload);
|
|
313
|
+
return writeFile(p, content);
|
|
314
|
+
});
|
|
315
|
+
ipcMain.handle('files:create', (_e, payload) => {
|
|
316
|
+
const { parentPath, name, kind } = filesCreate.parse(payload);
|
|
317
|
+
return createEntry(parentPath, name, kind);
|
|
318
|
+
});
|
|
319
|
+
ipcMain.handle('files:rename', (_e, payload) => {
|
|
320
|
+
const { path: p, newName } = filesRename.parse(payload);
|
|
321
|
+
return renameEntry(p, newName);
|
|
322
|
+
});
|
|
323
|
+
ipcMain.handle('files:delete', (_e, payload) => {
|
|
324
|
+
const { path: p } = filesPath.parse(payload);
|
|
325
|
+
return deleteEntry(p);
|
|
326
|
+
});
|
|
327
|
+
ipcMain.handle('files:open-external', (_e, payload) => {
|
|
328
|
+
const { path: p } = filesPath.parse(payload);
|
|
329
|
+
return openExternal(p);
|
|
330
|
+
});
|
|
331
|
+
ipcMain.handle('files:show-in-finder', (_e, payload) => {
|
|
332
|
+
const { path: p } = filesPath.parse(payload);
|
|
333
|
+
return showInFinder(p);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
registerFilesHandlers,
|
|
339
|
+
// exported for tests
|
|
340
|
+
listDir,
|
|
341
|
+
readFile,
|
|
342
|
+
writeFile,
|
|
343
|
+
createEntry,
|
|
344
|
+
renameEntry,
|
|
345
|
+
deleteEntry,
|
|
346
|
+
};
|
package/src/main/git.cjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git IPC handlers — richer status data than the StatusBar-only `app:git-branch`.
|
|
3
|
+
*
|
|
4
|
+
* Ported (in spirit) from ClaudeCodeUnleashed's src/main/ipc/git.ts. Built
|
|
5
|
+
* fresh for our conventions: argv-array spawn (never shell:true), validatePath
|
|
6
|
+
* gating, zod-validated IPC payloads, 5s timeout ceiling, 5s per-cwd cache.
|
|
7
|
+
*
|
|
8
|
+
* Channels:
|
|
9
|
+
* git:status → { branch, ahead, behind, uncommittedCount, files[] } | null
|
|
10
|
+
* git:file-status → { [absolutePath]: 'modified' | 'added' | 'deleted' |
|
|
11
|
+
* 'renamed' | 'untracked' | 'staged' | 'conflict' }
|
|
12
|
+
*
|
|
13
|
+
* "Not a git repo" / spawn errors / timeouts all surface as `null` (status)
|
|
14
|
+
* or `{}` (file-status) so the renderer can render unconditionally.
|
|
15
|
+
*
|
|
16
|
+
* Future readers: this file is for OBSERVATION only. No mutation commands
|
|
17
|
+
* (commit/push/pull/add) — those belong to a separate, much-more-restricted
|
|
18
|
+
* surface if they ever land. The whole point of the cache is so the planned
|
|
19
|
+
* file-tree sidebar can poll cheaply without spawning git for every render.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { schemas } = require('./ipcSchemas.cjs');
|
|
25
|
+
const { validatePath } = require('./config.cjs');
|
|
26
|
+
|
|
27
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
28
|
+
const CACHE_TTL_MS = 5_000;
|
|
29
|
+
const MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB — same cap Unleashed uses
|
|
30
|
+
|
|
31
|
+
// Per-cwd in-memory caches. Keyed by the realpath-validated cwd so symlinks
|
|
32
|
+
// to the same repo dedupe. Each entry: { value, expiresAt }.
|
|
33
|
+
const statusCache = new Map();
|
|
34
|
+
const fileStatusCache = new Map();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run `git <args>` in cwd with a hard 5s SIGKILL ceiling. Resolves
|
|
38
|
+
* { ok:true, stdout } on exit 0, { ok:false, code, stderr } otherwise.
|
|
39
|
+
* Never throws — IPC handlers want a discriminated result, not an exception.
|
|
40
|
+
*
|
|
41
|
+
* argv-array spawn — never `shell: true`. cwd MUST already be validatePath-checked.
|
|
42
|
+
*/
|
|
43
|
+
function runGit(args, cwd) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let proc;
|
|
46
|
+
try {
|
|
47
|
+
proc = spawn('git', args, {
|
|
48
|
+
cwd,
|
|
49
|
+
windowsHide: true,
|
|
50
|
+
// Inherit env minus anything that could change git behaviour; default
|
|
51
|
+
// is fine here — we explicitly do NOT want `cleanChildEnv` because
|
|
52
|
+
// git relies on $HOME/$PATH/$GIT_* to find config + ssh credentials.
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
resolve({ ok: false, code: -1, stderr: `spawn failed: ${err?.message ?? String(err)}` });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stdoutChunks = [];
|
|
60
|
+
const stderrChunks = [];
|
|
61
|
+
let stdoutLen = 0;
|
|
62
|
+
let stderrLen = 0;
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
|
|
65
|
+
const killTimer = setTimeout(() => {
|
|
66
|
+
timedOut = true;
|
|
67
|
+
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
|
|
68
|
+
}, GIT_TIMEOUT_MS);
|
|
69
|
+
|
|
70
|
+
proc.stdout.on('data', (b) => {
|
|
71
|
+
if (stdoutLen < MAX_BUFFER) {
|
|
72
|
+
stdoutChunks.push(b);
|
|
73
|
+
stdoutLen += b.length;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
proc.stderr.on('data', (b) => {
|
|
77
|
+
if (stderrLen < MAX_BUFFER) {
|
|
78
|
+
stderrChunks.push(b);
|
|
79
|
+
stderrLen += b.length;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
proc.on('error', (err) => {
|
|
84
|
+
clearTimeout(killTimer);
|
|
85
|
+
resolve({ ok: false, code: -1, stderr: `spawn error: ${err?.message ?? String(err)}` });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
proc.on('close', (code) => {
|
|
89
|
+
clearTimeout(killTimer);
|
|
90
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
91
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
92
|
+
if (timedOut) {
|
|
93
|
+
resolve({ ok: false, code: -1, stderr: `timeout after ${GIT_TIMEOUT_MS}ms\n${stderr}` });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (code === 0) {
|
|
97
|
+
resolve({ ok: true, stdout, stderr });
|
|
98
|
+
} else {
|
|
99
|
+
resolve({ ok: false, code: typeof code === 'number' ? code : -1, stderr });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Unquote a git path. With porcelain v1 *without* -z, git quotes paths
|
|
107
|
+
* containing special chars (spaces, unicode, control bytes). We use -z so
|
|
108
|
+
* paths come through raw NUL-separated, but kept as a defensive shim in
|
|
109
|
+
* case a future caller drops -z.
|
|
110
|
+
*/
|
|
111
|
+
function unquoteGitPath(p) {
|
|
112
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
113
|
+
return p.slice(1, -1)
|
|
114
|
+
.replace(/\\n/g, '\n')
|
|
115
|
+
.replace(/\\t/g, '\t')
|
|
116
|
+
.replace(/\\"/g, '"')
|
|
117
|
+
.replace(/\\\\/g, '\\');
|
|
118
|
+
}
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Map porcelain XY pair to one of our enum values. Mirrors Unleashed's
|
|
124
|
+
* git-file-status handler, with the same conflict-detection rules.
|
|
125
|
+
*
|
|
126
|
+
* ' M' → modified '??' → untracked
|
|
127
|
+
* 'A ' → added 'AM' → added (index) — staged-then-modified
|
|
128
|
+
* ' D' → deleted 'D ' → deleted
|
|
129
|
+
* 'R ' → renamed 'C ' → added (copy treated as new)
|
|
130
|
+
* 'U.' / '.U' / 'DD' / 'AA' → conflict
|
|
131
|
+
* any other staged change → staged
|
|
132
|
+
*/
|
|
133
|
+
function mapStatus(indexChar, workChar) {
|
|
134
|
+
if (indexChar === '?' && workChar === '?') return 'untracked';
|
|
135
|
+
if (
|
|
136
|
+
indexChar === 'U' || workChar === 'U' ||
|
|
137
|
+
(indexChar === 'D' && workChar === 'D') ||
|
|
138
|
+
(indexChar === 'A' && workChar === 'A')
|
|
139
|
+
) return 'conflict';
|
|
140
|
+
if (indexChar === 'A') return 'added';
|
|
141
|
+
if (indexChar === 'D' || workChar === 'D') return 'deleted';
|
|
142
|
+
if (indexChar === 'R') return 'renamed';
|
|
143
|
+
if (indexChar === 'C') return 'added';
|
|
144
|
+
if (indexChar !== ' ' && indexChar !== '?') return 'staged';
|
|
145
|
+
if (workChar === 'M') return 'modified';
|
|
146
|
+
return 'modified';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse `git status --porcelain=v1 -b -u -z` output.
|
|
151
|
+
*
|
|
152
|
+
* First entry (when -b is set) is the branch header:
|
|
153
|
+
* "## main...origin/main [ahead 1, behind 2]"
|
|
154
|
+
* "## HEAD (no branch)" — detached
|
|
155
|
+
* "## No commits yet on main"
|
|
156
|
+
*
|
|
157
|
+
* Remaining entries are NUL-separated "XY <path>" records. Renamed/copied
|
|
158
|
+
* records consume TWO entries (new path, then original path).
|
|
159
|
+
*
|
|
160
|
+
* Returns { branch, ahead, behind, files }.
|
|
161
|
+
*/
|
|
162
|
+
function parsePorcelain(stdout, cwd) {
|
|
163
|
+
const entries = stdout.split('\0').filter((e) => e.length > 0);
|
|
164
|
+
const out = {
|
|
165
|
+
branch: 'HEAD',
|
|
166
|
+
ahead: 0,
|
|
167
|
+
behind: 0,
|
|
168
|
+
files: [],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let i = 0;
|
|
172
|
+
// Branch header
|
|
173
|
+
if (entries.length > 0 && entries[0].startsWith('## ')) {
|
|
174
|
+
const header = entries[0].slice(3);
|
|
175
|
+
if (header.startsWith('No commits yet on ')) {
|
|
176
|
+
out.branch = header.slice('No commits yet on '.length).split(' ')[0];
|
|
177
|
+
} else if (header.startsWith('HEAD (no branch)')) {
|
|
178
|
+
out.branch = 'HEAD';
|
|
179
|
+
} else {
|
|
180
|
+
// "main" or "main...origin/main [ahead 1, behind 2]"
|
|
181
|
+
const m = /^([^.\s]+)(?:\.\.\.([^\s]+))?(?:\s+\[([^\]]+)\])?/.exec(header);
|
|
182
|
+
if (m) {
|
|
183
|
+
out.branch = m[1];
|
|
184
|
+
const bracket = m[3];
|
|
185
|
+
if (bracket) {
|
|
186
|
+
const am = /ahead (\d+)/.exec(bracket);
|
|
187
|
+
const bm = /behind (\d+)/.exec(bracket);
|
|
188
|
+
if (am) out.ahead = parseInt(am[1], 10) || 0;
|
|
189
|
+
if (bm) out.behind = parseInt(bm[1], 10) || 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
i = 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
while (i < entries.length) {
|
|
197
|
+
const entry = entries[i];
|
|
198
|
+
if (entry.length < 3) { i++; continue; }
|
|
199
|
+
const indexChar = entry[0];
|
|
200
|
+
const workChar = entry[1];
|
|
201
|
+
// Format with -z: "XY <path>" — note SINGLE space between XY and path.
|
|
202
|
+
let filePath = entry.substring(3);
|
|
203
|
+
|
|
204
|
+
// Renamed/copied → next entry is the original path; skip it.
|
|
205
|
+
if (indexChar === 'R' || indexChar === 'C') {
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
filePath = unquoteGitPath(filePath);
|
|
210
|
+
const absolutePath = path.resolve(cwd, filePath);
|
|
211
|
+
const status = mapStatus(indexChar, workChar);
|
|
212
|
+
|
|
213
|
+
out.files.push({
|
|
214
|
+
path: absolutePath,
|
|
215
|
+
relativePath: filePath,
|
|
216
|
+
status,
|
|
217
|
+
indexStatus: indexChar,
|
|
218
|
+
workTreeStatus: workChar,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* git:status(cwd) — full status. Returns null if not a git repo / git fails.
|
|
229
|
+
*
|
|
230
|
+
* Cached per realpath-cwd for 5s so the renderer can poll cheaply (the
|
|
231
|
+
* planned file-tree sidebar will tick this every couple seconds).
|
|
232
|
+
*/
|
|
233
|
+
async function getStatus(cwd) {
|
|
234
|
+
let realCwd;
|
|
235
|
+
try {
|
|
236
|
+
realCwd = validatePath(cwd);
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
const cached = statusCache.get(realCwd);
|
|
243
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
244
|
+
|
|
245
|
+
// Single porcelain call gets us branch + ahead/behind + files in one shot.
|
|
246
|
+
const result = await runGit(
|
|
247
|
+
['status', '--porcelain=v1', '-b', '-u', '-z'],
|
|
248
|
+
realCwd,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (!result.ok) {
|
|
252
|
+
// "not a git repository" or any other failure → null
|
|
253
|
+
statusCache.set(realCwd, { value: null, expiresAt: now + CACHE_TTL_MS });
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const parsed = parsePorcelain(result.stdout, realCwd);
|
|
258
|
+
const value = {
|
|
259
|
+
branch: parsed.branch,
|
|
260
|
+
ahead: parsed.ahead,
|
|
261
|
+
behind: parsed.behind,
|
|
262
|
+
uncommittedCount: parsed.files.length,
|
|
263
|
+
files: parsed.files,
|
|
264
|
+
};
|
|
265
|
+
statusCache.set(realCwd, { value, expiresAt: now + CACHE_TTL_MS });
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* git:file-status(cwd) — `{ absPath: status }` map. Returns `{}` if not a
|
|
271
|
+
* git repo / git fails. Same 5s per-cwd cache.
|
|
272
|
+
*/
|
|
273
|
+
async function getFileStatus(cwd) {
|
|
274
|
+
let realCwd;
|
|
275
|
+
try {
|
|
276
|
+
realCwd = validatePath(cwd);
|
|
277
|
+
} catch {
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const cached = fileStatusCache.get(realCwd);
|
|
283
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
284
|
+
|
|
285
|
+
const result = await runGit(
|
|
286
|
+
['status', '--porcelain=v1', '-u', '-z'],
|
|
287
|
+
realCwd,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (!result.ok) {
|
|
291
|
+
fileStatusCache.set(realCwd, { value: {}, expiresAt: now + CACHE_TTL_MS });
|
|
292
|
+
return {};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// No -b here, so parsePorcelain's branch-header branch will simply not fire.
|
|
296
|
+
const parsed = parsePorcelain(result.stdout, realCwd);
|
|
297
|
+
const map = {};
|
|
298
|
+
for (const f of parsed.files) {
|
|
299
|
+
map[f.path] = f.status;
|
|
300
|
+
}
|
|
301
|
+
fileStatusCache.set(realCwd, { value: map, expiresAt: now + CACHE_TTL_MS });
|
|
302
|
+
return map;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Drop cached entries — useful for tests / forced refresh. Not wired to IPC. */
|
|
306
|
+
function clearCache() {
|
|
307
|
+
statusCache.clear();
|
|
308
|
+
fileStatusCache.clear();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function register(ipcMain) {
|
|
312
|
+
ipcMain.handle('git:status', async (_e, payload) => {
|
|
313
|
+
const parsed = schemas.gitStatus.safeParse(payload);
|
|
314
|
+
if (!parsed.success) return null;
|
|
315
|
+
return getStatus(parsed.data.cwd);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
ipcMain.handle('git:file-status', async (_e, payload) => {
|
|
319
|
+
const parsed = schemas.gitFileStatus.safeParse(payload);
|
|
320
|
+
if (!parsed.success) return {};
|
|
321
|
+
return getFileStatus(parsed.data.cwd);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = {
|
|
326
|
+
register,
|
|
327
|
+
// Exposed for unit-test / sanity-check use.
|
|
328
|
+
getStatus,
|
|
329
|
+
getFileStatus,
|
|
330
|
+
parsePorcelain,
|
|
331
|
+
mapStatus,
|
|
332
|
+
clearCache,
|
|
333
|
+
};
|