claude-code-session-manager 0.10.5 → 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.
Files changed (31) hide show
  1. package/dist/assets/{TiptapBody-BroECZ_z.js → TiptapBody-CAJSNRPs.js} +1 -1
  2. package/dist/assets/{cssMode-Crq-Rykh.js → cssMode-o7rZCrm4.js} +1 -1
  3. package/dist/assets/{freemarker2-B6CC21Ql.js → freemarker2-CgmCS5Wh.js} +1 -1
  4. package/dist/assets/{handlebars-BLgR-12n.js → handlebars-BcPLqhPv.js} +1 -1
  5. package/dist/assets/{html-CiQkt_KY.js → html-CC9xWnC3.js} +1 -1
  6. package/dist/assets/{htmlMode-Cy8mc91p.js → htmlMode-DEgCqH7k.js} +1 -1
  7. package/dist/assets/{index-DU-o-LEm.js → index-C7ljEoqc.js} +1161 -1130
  8. package/dist/assets/{index-DW-tvyin.css → index-CH3K1pkS.css} +1 -1
  9. package/dist/assets/{javascript-CHNCN8qj.js → javascript-CjwqkQrn.js} +1 -1
  10. package/dist/assets/{jsonMode-BSN7mvBT.js → jsonMode-BYTLu76d.js} +1 -1
  11. package/dist/assets/{liquid-B0kmZauA.js → liquid-wbQUuJwT.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DI_RToRa.js → lspLanguageFeatures-BJGMI7Xu.js} +1 -1
  13. package/dist/assets/{mdx-BSF-fsyJ.js → mdx-DcDstgPF.js} +1 -1
  14. package/dist/assets/{python-DUl3Fmgk.js → python-B96yyM_5.js} +1 -1
  15. package/dist/assets/{razor-Df7WxBjo.js → razor-C7aRIxIE.js} +1 -1
  16. package/dist/assets/{tsMode-qccVs0_G.js → tsMode-B3UYlGaL.js} +1 -1
  17. package/dist/assets/{typescript-BEwM5qbq.js → typescript-CV587TvC.js} +1 -1
  18. package/dist/assets/{xml-CCtx-_Kw.js → xml-PWUJecBf.js} +1 -1
  19. package/dist/assets/{yaml-B66nOkCW.js → yaml-D8bBNHE4.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/agentMemory.cjs +267 -0
  23. package/src/main/files.cjs +346 -0
  24. package/src/main/git.cjs +333 -0
  25. package/src/main/historyAggregator.cjs +70 -0
  26. package/src/main/index.cjs +12 -0
  27. package/src/main/ipcSchemas.cjs +62 -0
  28. package/src/main/projectSkills.cjs +124 -0
  29. package/src/main/superagent.cjs +202 -0
  30. package/src/preload/api.d.ts +203 -0
  31. package/src/preload/index.cjs +47 -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
+ };
@@ -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
+ };