@xbzbing/dsh-git-panel 0.1.1 → 0.3.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/lib/host/index.js CHANGED
@@ -50,7 +50,7 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
50
50
  // src/host/index.ts
51
51
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
52
52
  import Schema from "@deepseek-ai/schemastery";
53
- import { readFile as readFile3, realpath, rm, stat } from "node:fs/promises";
53
+ import { readFile as readFile3, readdir, realpath, rm, stat } from "node:fs/promises";
54
54
 
55
55
  // src/host/git.ts
56
56
  import { readFile } from "node:fs/promises";
@@ -66,7 +66,7 @@ function createGitRunner(subprocess, timeoutMs, maxBytes) {
66
66
  argv,
67
67
  cwd: opts.cwd,
68
68
  stdio: {
69
- stdin: "ignore",
69
+ stdin: opts.stdinData === void 0 ? "ignore" : { data: opts.stdinData },
70
70
  stdout: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } },
71
71
  stderr: { collect: { maxBytes, spill: { maxBytes: spillMaxBytes } } }
72
72
  },
@@ -77,19 +77,22 @@ function createGitRunner(subprocess, timeoutMs, maxBytes) {
77
77
  try {
78
78
  outcome = await handle.done;
79
79
  } catch (error) {
80
+ const cancelled2 = opts.signal?.aborted === true && !controller.signal.aborted;
80
81
  if (controller.signal.aborted || opts.signal?.aborted === true) {
81
- return { exitCode: null, stdout: "", stderr: "", timedOut: true, stdoutLossy: false };
82
+ return { exitCode: null, stdout: "", stderr: "", timedOut: !cancelled2, cancelled: cancelled2, stdoutLossy: false };
82
83
  }
83
84
  throw error;
84
85
  }
85
86
  const stdout = handle.collected.stdout?.readFrom(0);
86
87
  const stderr = handle.collected.stderr?.readFrom(0);
87
88
  const resolved = await resolveStdout(stdout);
89
+ const cancelled = opts.signal?.aborted === true && !controller.signal.aborted;
88
90
  return {
89
91
  exitCode: outcome.exitCode,
90
92
  stdout: resolved.text,
91
93
  stderr: stderr?.text ?? "",
92
- timedOut: controller.signal.aborted || opts.signal?.aborted === true,
94
+ timedOut: controller.signal.aborted,
95
+ cancelled,
93
96
  stdoutLossy: resolved.lossy
94
97
  };
95
98
  } finally {
@@ -172,12 +175,13 @@ function parseGraphLog(stdout) {
172
175
  if (rec.trim() === "") continue;
173
176
  const parts = rec.split("");
174
177
  if (parts.length < 7) continue;
175
- const [hash, shortHash, parentsRaw, author, dateIso, decoration, subject] = parts;
178
+ const [hash, shortHash, parentsRaw, author, dateIso, decoration] = parts;
179
+ const subject = parts.slice(6).join("");
176
180
  const parents = (parentsRaw ?? "").trim() === "" ? [] : parentsRaw.trim().split(/\s+/);
177
181
  out.push({
178
182
  hash: hash ?? "",
179
183
  shortHash: shortHash ?? "",
180
- subject: subject ?? "",
184
+ subject,
181
185
  author: author ?? "",
182
186
  dateIso: dateIso ?? "",
183
187
  parents,
@@ -226,23 +230,38 @@ function parseBranches(stdout) {
226
230
  }
227
231
  return out;
228
232
  }
233
+ function parseTags(stdout) {
234
+ const out = [];
235
+ for (const line of stdout.split("\n")) {
236
+ if (line.trim() === "") continue;
237
+ const [name, shortHash = ""] = line.split("\0");
238
+ if (name) out.push({ name, shortHash: shortHash === "" ? null : shortHash });
239
+ }
240
+ return out;
241
+ }
229
242
  function parseNameStatus(stdout) {
230
243
  const out = [];
231
244
  const tokens = stdout.split("\0");
232
- for (let i = 0; i < tokens.length; i++) {
245
+ let i = 0;
246
+ while (i < tokens.length) {
233
247
  const tok = tokens[i];
234
- if (tok === void 0 || tok === "") continue;
248
+ if (tok === void 0 || tok === "") {
249
+ i += 1;
250
+ continue;
251
+ }
235
252
  const code = tok[0];
236
- if (code === void 0 || !/[AMDRCTU]/.test(code)) continue;
253
+ if (code === void 0 || !/[AMDRCTU]/.test(code)) break;
237
254
  if (code === "R" || code === "C") {
238
255
  const newPath = tokens[i + 2];
256
+ if (newPath === void 0) break;
257
+ out.push({ path: newPath, status: statusOf(code) });
258
+ i += 3;
259
+ } else {
260
+ const path = tokens[i + 1];
261
+ if (path === void 0) break;
262
+ if (path !== "") out.push({ path, status: statusOf(code) });
239
263
  i += 2;
240
- if (newPath) out.push({ path: newPath, status: statusOf(code) });
241
- continue;
242
264
  }
243
- const path = tokens[i + 1];
244
- i += 1;
245
- if (path) out.push({ path, status: statusOf(code) });
246
265
  }
247
266
  return out;
248
267
  }
@@ -261,29 +280,71 @@ function sumNumstat(stdout) {
261
280
  return { insertions, deletions };
262
281
  }
263
282
 
283
+ // src/host/validate.ts
284
+ import { isAbsolute, normalize } from "node:path";
285
+ function isSafePath(path) {
286
+ if (path === "" || isAbsolute(path)) return false;
287
+ const norm = normalize(path);
288
+ if (norm === ".." || norm.startsWith("../") || norm.startsWith("..\\")) return false;
289
+ return true;
290
+ }
291
+ var REV_META = /[\x00-\x20\x7f~^:?*[\\]/;
292
+ function isSafeRev(input) {
293
+ if (input === "" || input.startsWith("-")) return false;
294
+ if (REV_META.test(input)) return false;
295
+ if (input.includes("..") || input.includes("@{")) return false;
296
+ return true;
297
+ }
298
+ function isSafeBranchName(name) {
299
+ return isSafeRev(name) && !name.startsWith("/");
300
+ }
301
+
264
302
  // src/host/core.ts
265
303
  var DEFAULT_CONFIG = {
266
304
  timeoutMs: 8e3,
267
305
  maxBytes: 4 * 1024 * 1024,
268
306
  maxChanges: 1e3,
269
307
  refreshIntervalMs: 3e4,
270
- showInputPill: true
308
+ showInputPill: true,
309
+ defaultDiffView: "unified"
271
310
  };
272
311
  function normalizeConfig(raw) {
273
312
  const c = raw ?? {};
274
- const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : d;
313
+ const num = (v, d) => typeof v === "number" && Number.isFinite(v) && v >= 1 ? Math.floor(v) : d;
275
314
  return {
276
315
  timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
277
316
  maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
278
317
  maxChanges: num(c.maxChanges, DEFAULT_CONFIG.maxChanges),
279
318
  refreshIntervalMs: num(c.refreshIntervalMs, DEFAULT_CONFIG.refreshIntervalMs),
280
- showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill)
319
+ showInputPill: readBool(c.showInputPill, DEFAULT_CONFIG.showInputPill),
320
+ defaultDiffView: readDiffView(c.defaultDiffView, DEFAULT_CONFIG.defaultDiffView)
281
321
  };
282
322
  }
323
+ function unwrapVolatile(value) {
324
+ return value !== null && typeof value === "object" && "get" in value && typeof value.get === "function" ? value.get() : value;
325
+ }
326
+ function readDiffView(value, fallback) {
327
+ const raw = unwrapVolatile(value);
328
+ return raw === "unified" || raw === "split" ? raw : fallback;
329
+ }
283
330
  function readBool(value, fallback) {
284
- const raw = value !== null && typeof value === "object" && "get" in value && typeof value.get === "function" ? value.get() : value;
331
+ const raw = unwrapVolatile(value);
285
332
  return typeof raw === "boolean" ? raw : fallback;
286
333
  }
334
+ var NEG_CACHE_MS = 15e3;
335
+ async function resolveBrowseRoot(deps, sessionId) {
336
+ const ws = await resolveWorkspace(deps, sessionId);
337
+ if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
338
+ const err = ws.failure.error;
339
+ if (err.code === "not-a-git-repo" && err.cwd !== void 0 && err.cwd !== "") {
340
+ try {
341
+ return { ok: true, root: await deps.fs.realpath(err.cwd), isGitRepo: false };
342
+ } catch {
343
+ return { ok: false, error: { code: "git-error" } };
344
+ }
345
+ }
346
+ return { ok: false, error: mapWorkspaceFailure(ws.failure) };
347
+ }
287
348
  async function resolveWorkspace(deps, sessionId) {
288
349
  let cwd = deps.sessions.liveCwd(sessionId);
289
350
  if (cwd === void 0 || cwd === "") {
@@ -295,16 +356,26 @@ async function resolveWorkspace(deps, sessionId) {
295
356
  }
296
357
  const cached = deps.rootCache?.get(cwd);
297
358
  if (cached !== void 0) return { ok: true, root: cached };
359
+ const negAt = deps.rootNegCache?.get(cwd);
360
+ if (negAt !== void 0) {
361
+ if (Date.now() < negAt) return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
362
+ deps.rootNegCache?.delete(cwd);
363
+ }
298
364
  const top = await runCommand(deps.run, ["git", "rev-parse", "--show-toplevel"], cwd, "toplevel", deps.signal);
299
365
  if ("failure" in top) {
300
366
  return { ok: false, failure: { ok: false, error: mapRunFailure(top.failure) } };
301
367
  }
368
+ if (top.run.cancelled) return { ok: false, failure: { ok: false, error: { code: "cancelled" } } };
302
369
  if (top.run.timedOut) return { ok: false, failure: { ok: false, error: { code: "timeout" } } };
303
370
  if (top.run.exitCode !== 0) {
371
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
304
372
  return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
305
373
  }
306
374
  const raw = top.run.stdout.trim();
307
- if (raw === "") return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
375
+ if (raw === "") {
376
+ deps.rootNegCache?.set(cwd, Date.now() + NEG_CACHE_MS);
377
+ return { ok: false, failure: { ok: false, error: { code: "not-a-git-repo", cwd } } };
378
+ }
308
379
  let root = raw;
309
380
  try {
310
381
  root = await deps.fs.realpath(raw);
@@ -314,9 +385,9 @@ async function resolveWorkspace(deps, sessionId) {
314
385
  deps.rootCache?.set(cwd, root);
315
386
  return { ok: true, root };
316
387
  }
317
- async function runCommand(runner, argv, cwd, _label, signal) {
388
+ async function runCommand(runner, argv, cwd, _label, signal, stdinData) {
318
389
  try {
319
- const run = await runner.run(argv, { cwd, ...signal ? { signal } : {} });
390
+ const run = await runner.run(argv, { cwd, ...signal ? { signal } : {}, ...stdinData !== void 0 ? { stdinData } : {} });
320
391
  return { run };
321
392
  } catch (error) {
322
393
  return { failure: error };
@@ -326,6 +397,11 @@ function mapRunFailure(failure) {
326
397
  const message = failure instanceof Error ? failure.message : String(failure);
327
398
  return { code: "git-unavailable", detail: message };
328
399
  }
400
+ function mapWorkspaceFailure(failure) {
401
+ const error = failure.error;
402
+ const message = "detail" in error ? error.detail : void 0;
403
+ return { code: error.code, ...message !== void 0 ? { message } : {} };
404
+ }
329
405
  async function snapshotForSession(deps, config, sessionId) {
330
406
  const workspace = await resolveWorkspace(deps, sessionId);
331
407
  if (!workspace.ok) {
@@ -336,26 +412,30 @@ async function snapshotForSession(deps, config, sessionId) {
336
412
  return failure;
337
413
  }
338
414
  const root = workspace.root;
339
- const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes] = await Promise.all([
415
+ const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes] = await Promise.all([
340
416
  runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
341
417
  runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
342
418
  runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
343
419
  runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
344
- runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal)
420
+ runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
421
+ runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
422
+ runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal)
345
423
  ]);
346
424
  const branch = "run" in branchRes && branchRes.run.exitCode === 0 ? branchRes.run.stdout.trim() || null : null;
347
425
  const unborn = "run" in headRes && headRes.run.exitCode !== 0;
348
426
  const head = unborn ? null : "run" in headRes ? headRes.run.stdout.trim() || null : null;
349
427
  let allChanges = [];
428
+ let statusLossy = false;
350
429
  if ("run" in statusRes && !statusRes.run.timedOut && statusRes.run.exitCode === 0) {
351
430
  allChanges = parseStatus(statusRes.run.stdout);
431
+ statusLossy = statusRes.run.stdoutLossy;
352
432
  }
353
- const truncated = allChanges.length > config.maxChanges;
354
- const changes = truncated ? allChanges.slice(0, config.maxChanges) : allChanges;
433
+ const truncated = allChanges.length > config.maxChanges || statusLossy;
434
+ const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
355
435
  let staged = 0;
356
436
  let modified = 0;
357
437
  let untracked = 0;
358
- for (const c of changes) {
438
+ for (const c of allChanges) {
359
439
  if (c.status === "untracked") untracked++;
360
440
  else if (c.staged) staged++;
361
441
  else modified++;
@@ -380,12 +460,35 @@ async function snapshotForSession(deps, config, sessionId) {
380
460
  };
381
461
  }
382
462
  }
463
+ const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
464
+ const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
465
+ let untrackedInsertions = 0;
466
+ const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
467
+ const safeUntracked = untrackedPaths.filter(isSafePath).slice(0, 200);
468
+ if (safeUntracked.length > 0) {
469
+ const perFile = await Promise.all(
470
+ safeUntracked.map((p) => runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", p], root, "numstat-untracked", deps.signal))
471
+ );
472
+ for (const r of perFile) if ("run" in r) untrackedInsertions += sumNumstat(r.run.stdout).insertions;
473
+ }
474
+ const lastChangeAt = await maxChangeMtime(deps, root, allChanges);
475
+ const distinct = new Set(allChanges.map((c) => c.path));
476
+ const stats = {
477
+ fileCount: distinct.size,
478
+ staged,
479
+ modified,
480
+ untracked,
481
+ insertions: wt.insertions + stg.insertions + untrackedInsertions,
482
+ deletions: wt.deletions + stg.deletions,
483
+ lastChangeAt,
484
+ headCommittedAt: lastCommit?.dateIso ?? null
485
+ };
383
486
  const snapshot = {
384
487
  root,
385
488
  branch,
386
489
  head,
387
490
  unborn,
388
- dirty: changes.length > 0,
491
+ dirty: allChanges.length > 0,
389
492
  staged,
390
493
  modified,
391
494
  untracked,
@@ -393,9 +496,11 @@ async function snapshotForSession(deps, config, sessionId) {
393
496
  behind,
394
497
  lastCommit,
395
498
  changes,
499
+ stats,
396
500
  truncated,
397
501
  refreshIntervalMs: config.refreshIntervalMs,
398
502
  showInputPill: config.showInputPill,
503
+ defaultDiffView: config.defaultDiffView,
399
504
  checkedAt: Date.now()
400
505
  };
401
506
  return { ok: true, value: snapshot };
@@ -417,13 +522,7 @@ async function maxChangeMtime(deps, root, changes, cap = 200) {
417
522
  }
418
523
 
419
524
  // src/host/actions.ts
420
- import { isAbsolute, normalize } from "node:path";
421
- function isSafePath(path) {
422
- if (path === "" || isAbsolute(path)) return false;
423
- const norm = normalize(path);
424
- if (norm === ".." || norm.startsWith("../") || norm.startsWith("..\\")) return false;
425
- return true;
426
- }
525
+ import { join as join2, sep } from "node:path";
427
526
  function withPaths(prefixes, paths) {
428
527
  if (paths.length === 0) return { error: "invalid-path", message: "no paths given" };
429
528
  for (const path of paths) {
@@ -445,55 +544,66 @@ function planAction(action, unborn) {
445
544
  return withPaths([["git", "restore", "--"]], action.paths);
446
545
  case "commit": {
447
546
  const message = action.message.trim();
448
- const amendFlag = action.amend === true ? ["--amend"] : [];
449
- if (message === "" && action.amend !== true) return { error: "empty-message" };
450
- const msgArgs = message === "" ? [] : ["-m", message];
547
+ const amend = action.amend === true;
548
+ if (message === "" && !amend) return { error: "empty-message" };
549
+ const amendFlag = amend ? ["--amend"] : [];
550
+ const msgArgs = message === "" ? ["--no-edit"] : ["-m", message];
451
551
  if (action.paths === void 0 || action.paths.length === 0) {
452
552
  return { argv: [["git", "commit", ...amendFlag, ...msgArgs]] };
453
553
  }
454
554
  const staged = withPaths([["git", "add", "--"]], action.paths);
455
555
  if ("error" in staged) return staged;
456
556
  const commitCmd = ["git", "commit", ...amendFlag, ...msgArgs, "--", ...action.paths];
457
- for (const path of action.paths) {
458
- if (!isSafePath(path)) return { error: "invalid-path", message: `unsafe path: ${path}` };
459
- }
460
557
  return { argv: [...staged.argv, commitCmd] };
461
558
  }
462
559
  case "branch-checkout":
463
- return { argv: [["git", "checkout", action.name]] };
560
+ if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
561
+ return { argv: [["git", "checkout", "--end-of-options", action.name]] };
464
562
  case "fetch":
465
563
  return { argv: [["git", "fetch", "--all", "--prune"]] };
466
564
  }
467
565
  }
468
566
  async function runAction(deps, config, request) {
469
567
  const workspace = await resolveWorkspace(deps, request.sessionId);
470
- if (!workspace.ok) {
471
- const error = workspace.failure.error;
472
- return { ok: false, error: { code: error.code, message: "detail" in error ? error.detail : void 0 } };
473
- }
568
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
474
569
  const root = workspace.root;
475
570
  const headProbe = await runCommand(deps.run, ["git", "rev-parse", "--verify", "HEAD"], root, "head-probe", deps.signal);
476
571
  const unborn = !("run" in headProbe) || headProbe.run.exitCode !== 0;
572
+ if (request.action.kind === "discard") {
573
+ const removed = await discardUntracked(deps, config, root, request.sessionId, request.action.paths);
574
+ if (removed !== null) {
575
+ if (!removed.ok) return removed.result;
576
+ if (removed.remainingTracked.length === 0) {
577
+ const snapshot2 = await snapshotForSession(deps, config, request.sessionId);
578
+ if (!snapshot2.ok) return { ok: false, error: { code: "git-error", message: "snapshot after action failed" } };
579
+ return { ok: true, snapshot: snapshot2.value, output: "" };
580
+ }
581
+ request = { ...request, action: { kind: "discard", paths: removed.remainingTracked } };
582
+ }
583
+ }
477
584
  const plan = planAction(request.action, unborn);
478
585
  if ("error" in plan) return { ok: false, error: { code: plan.error, ...plan.message ? { message: plan.message } : {} } };
479
586
  let lastOutput = "";
480
- for (const argv of plan.argv) {
587
+ for (let step = 0; step < plan.argv.length; step += 1) {
588
+ const argv = plan.argv[step];
481
589
  const outcome = await runCommand(deps.run, argv, root, "action", deps.signal);
590
+ const where = plan.argv.length > 1 ? ` (step ${step + 1}/${plan.argv.length}: ${argv.join(" ")})` : "";
482
591
  if ("failure" in outcome) {
483
592
  const message = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure);
484
- return { ok: false, error: { code: "git-unavailable", message } };
593
+ return { ok: false, error: { code: "git-unavailable", message: message + where } };
485
594
  }
595
+ if (outcome.run.cancelled) return { ok: false, error: { code: "cancelled" } };
486
596
  if (outcome.run.timedOut) return { ok: false, error: { code: "timeout" } };
487
597
  lastOutput = outcome.run.stdout || outcome.run.stderr;
488
598
  if (outcome.run.exitCode !== 0) {
489
599
  const stderr = outcome.run.stderr;
490
600
  if (/nothing to commit|no changes added/i.test(stderr + lastOutput)) {
491
- return { ok: false, error: { code: "git-error", message: stderr.trim() || "nothing to commit" } };
601
+ return { ok: false, error: { code: "git-error", message: (stderr.trim() || "nothing to commit") + where } };
492
602
  }
493
603
  if (/would be overwritten by checkout|local changes/i.test(stderr)) {
494
- return { ok: false, error: { code: "local-changes-block", message: stderr.trim() } };
604
+ return { ok: false, error: { code: "local-changes-block", message: stderr.trim() + where } };
495
605
  }
496
- return { ok: false, error: { code: "git-error", message: stderr.trim() || `git exited ${outcome.run.exitCode}` } };
606
+ return { ok: false, error: { code: "git-error", message: (stderr.trim() || `git exited ${outcome.run.exitCode}`) + where } };
497
607
  }
498
608
  }
499
609
  const snapshot = await snapshotForSession(deps, config, request.sessionId);
@@ -502,9 +612,39 @@ async function runAction(deps, config, request) {
502
612
  }
503
613
  return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
504
614
  }
615
+ async function discardUntracked(deps, config, root, sessionId, paths) {
616
+ const snap = await snapshotForSession(deps, config, sessionId);
617
+ if (!snap.ok) return null;
618
+ const untrackedSet = new Set(snap.value.changes.filter((c) => c.status === "untracked").map((c) => c.path));
619
+ const untracked = paths.filter((p) => untrackedSet.has(p));
620
+ if (untracked.length === 0) return null;
621
+ const tracked = paths.filter((p) => !untrackedSet.has(p));
622
+ let rootReal;
623
+ try {
624
+ rootReal = await deps.fs.realpath(root);
625
+ } catch {
626
+ return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
627
+ }
628
+ for (const path of untracked) {
629
+ if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
630
+ const target = join2(root, path);
631
+ let targetReal;
632
+ try {
633
+ targetReal = await deps.fs.realpath(target);
634
+ } catch {
635
+ continue;
636
+ }
637
+ if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
638
+ return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
639
+ }
640
+ await deps.fs.remove(target).catch(() => {
641
+ });
642
+ }
643
+ return { ok: true, remainingTracked: tracked };
644
+ }
505
645
 
506
646
  // src/host/queries.ts
507
- import { join as join2 } from "node:path";
647
+ import { join as join3, sep as sep2 } from "node:path";
508
648
 
509
649
  // src/host/types.ts
510
650
  var IMAGE_MIME = {
@@ -532,25 +672,27 @@ function isHexLike(text) {
532
672
  return /^[0-9a-fA-F]{7,40}$/.test(text.trim());
533
673
  }
534
674
  async function runQuery(deps, config, request) {
535
- const workspace = await resolveWorkspace(deps, request.sessionId);
536
- if (!workspace.ok) {
537
- const error = workspace.failure.error;
538
- return {
539
- ok: false,
540
- error: {
541
- code: error.code === "not-a-git-repo" || error.code === "cwd-unavailable" || error.code === "session-not-found" || error.code === "timeout" || error.code === "git-unavailable" ? error.code : "git-error",
542
- ..."detail" in error ? { message: error.detail } : {}
543
- }
544
- };
675
+ const q = request.query;
676
+ if (q.kind === "dir-list" || q.kind === "file-content") {
677
+ const browse = await resolveBrowseRoot(deps, request.sessionId);
678
+ if (!browse.ok) return { ok: false, error: browse.error };
679
+ try {
680
+ return q.kind === "dir-list" ? await queryDirList(deps, browse.root, q, browse.isGitRepo) : await queryFileContent(deps, config, browse.root, q);
681
+ } catch (error) {
682
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : String(error) } };
683
+ }
545
684
  }
685
+ const workspace = await resolveWorkspace(deps, request.sessionId);
686
+ if (!workspace.ok) return { ok: false, error: mapWorkspaceFailure(workspace.failure) };
546
687
  const root = workspace.root;
547
- const q = request.query;
548
688
  try {
549
689
  switch (q.kind) {
550
690
  case "history":
551
691
  return await queryHistory(deps, root, q);
552
692
  case "diff":
553
693
  return await queryDiff(deps, root, q);
694
+ case "file-lines":
695
+ return await queryFileLines(deps, root, q);
554
696
  case "image-diff":
555
697
  return await queryImageDiff(deps, config, root, q);
556
698
  case "show":
@@ -564,7 +706,7 @@ async function runQuery(deps, config, request) {
564
706
  case "last-commit-message":
565
707
  return await queryLastCommitMessage(deps, root);
566
708
  case "worktree-stats":
567
- return await queryWorktreeStats(deps, config, root, request.sessionId);
709
+ return await queryWorktreeStats(deps, config, request.sessionId);
568
710
  }
569
711
  } catch (error) {
570
712
  const message = error instanceof Error ? error.message : String(error);
@@ -572,18 +714,13 @@ async function runQuery(deps, config, request) {
572
714
  }
573
715
  }
574
716
  async function queryHistory(deps, root, q) {
575
- const args = ["git", "log", GRAPH_FORMAT, `--max-count=${q.limit}`, `--skip=${q.skip}`];
717
+ const limit = Number.isFinite(q.limit) ? Math.min(500, Math.max(1, Math.trunc(q.limit))) : 100;
718
+ const skip = Number.isFinite(q.skip) ? Math.max(0, Math.trunc(q.skip)) : 0;
719
+ const args = ["git", "log", GRAPH_FORMAT, `--max-count=${limit}`, `--skip=${skip}`];
576
720
  const search = q.search?.trim() ?? "";
577
721
  const hexJump = search !== "" && isHexLike(search);
578
722
  const countArgs = ["git", "rev-list", "--count"];
579
723
  if (!hexJump) {
580
- if (q.ref !== void 0 && q.ref !== "") {
581
- args.push(q.ref);
582
- countArgs.push(q.ref);
583
- } else {
584
- args.push("--all");
585
- countArgs.push("--all");
586
- }
587
724
  if (search !== "") {
588
725
  args.push("-i", "-E", `--grep=${search}`);
589
726
  countArgs.push("-i", "-E", `--grep=${search}`);
@@ -596,22 +733,35 @@ async function queryHistory(deps, root, q) {
596
733
  args.push(`--since=${q.since}`);
597
734
  countArgs.push(`--since=${q.since}`);
598
735
  }
736
+ if (q.ref !== void 0 && q.ref !== "") {
737
+ if (!isSafeRev(q.ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${q.ref}` } };
738
+ args.push("--end-of-options", q.ref);
739
+ countArgs.push("--end-of-options", q.ref);
740
+ } else {
741
+ args.push("--all");
742
+ countArgs.push("--all");
743
+ }
599
744
  } else {
600
- args.push(search);
745
+ args.push("--end-of-options", search);
601
746
  }
602
747
  const [res, countRes] = await Promise.all([
603
748
  runCommand(deps.run, args, root, "history", deps.signal),
604
749
  hexJump ? Promise.resolve(null) : runCommand(deps.run, countArgs, root, "history-count", deps.signal)
605
750
  ]);
606
751
  if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
752
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
607
753
  if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
608
754
  if (res.run.exitCode !== 0) {
609
- return { ok: true, value: { kind: "history", commits: [], total: 0 } };
755
+ const stderr = res.run.stderr.trim();
756
+ if (hexJump || /unknown revision|bad revision|does not have any commits|ambiguous argument/i.test(stderr)) {
757
+ return { ok: true, value: { kind: "history", commits: [], total: 0 } };
758
+ }
759
+ return { ok: false, error: { code: "git-error", message: stderr || `git exited ${res.run.exitCode}` } };
610
760
  }
611
761
  const commits = parseGraphLog(res.run.stdout);
612
762
  let total = -1;
613
763
  if (hexJump) {
614
- total = commits.length;
764
+ total = -1;
615
765
  } else if (countRes !== null && "run" in countRes && countRes.run.exitCode === 0) {
616
766
  const n = Number(countRes.run.stdout.trim());
617
767
  if (Number.isFinite(n)) total = n;
@@ -626,22 +776,75 @@ async function queryDiff(deps, root, q) {
626
776
  if (q.base === "staged") {
627
777
  args = ["git", "diff", unified, "--cached", "--", q.path];
628
778
  } else if (q.base === "commit") {
629
- args = ["git", "show", unified, `${q.commit}`, "--", q.path];
779
+ if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
780
+ args = ["git", "show", unified, "--end-of-options", q.commit, "--", q.path];
630
781
  } else {
631
782
  args = ["git", "diff", unified, "--", q.path];
632
783
  }
633
784
  const res = await runCommand(deps.run, args, root, "diff", deps.signal);
634
785
  if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
786
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
635
787
  if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
636
788
  let text = res.run.stdout;
637
789
  if (q.base === "worktree" && text.trim() === "") {
638
- const noIndex = await runCommand(deps.run, ["git", "diff", unified, "--no-index", "--", "/dev/null", q.path], root, "diff-untracked", deps.signal);
639
- if ("run" in noIndex) text = noIndex.run.stdout;
790
+ const tracked = await runCommand(deps.run, ["git", "ls-files", "--error-unmatch", "--", q.path], root, "diff-tracked-probe", deps.signal);
791
+ const isUntracked = !("run" in tracked) || tracked.run.exitCode !== 0;
792
+ if (isUntracked) {
793
+ const noIndex = await runCommand(deps.run, ["git", "diff", unified, "--no-index", "--", "/dev/null", q.path], root, "diff-untracked", deps.signal);
794
+ if ("run" in noIndex) text = noIndex.run.stdout;
795
+ }
640
796
  }
641
797
  return { ok: true, value: { kind: "diff", path: q.path, text } };
642
798
  }
799
+ async function queryFileLines(deps, root, q) {
800
+ if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
801
+ const start = Number.isFinite(q.start) ? Math.max(1, Math.floor(q.start)) : 1;
802
+ const end = Number.isFinite(q.end) ? Math.max(start, Math.floor(q.end)) : start;
803
+ let args;
804
+ if (q.base === "commit") {
805
+ if (!isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
806
+ args = ["git", "show", "--end-of-options", `${q.commit}:${q.path}`];
807
+ } else {
808
+ args = ["git", "show", `--end-of-options`, `:${q.path}`];
809
+ if (q.base === "worktree") args = ["git", "cat-file", "-p", `:${q.path}`];
810
+ }
811
+ const res = await runCommand(deps.run, args, root, "file-lines", deps.signal);
812
+ if (!("run" in res)) return { ok: false, error: { code: "git-unavailable" } };
813
+ if (res.run.cancelled) return { ok: false, error: { code: "cancelled" } };
814
+ if (res.run.timedOut) return { ok: false, error: { code: "timeout" } };
815
+ if (res.run.exitCode !== 0) {
816
+ if (q.base === "worktree") {
817
+ const wt = await readWorktreeText(deps, root, q.path);
818
+ if (wt !== null) return sliceLines(q.path, wt, start, end);
819
+ }
820
+ return { ok: false, error: { code: "git-error", message: res.run.stderr.trim() || "no such blob" } };
821
+ }
822
+ return sliceLines(q.path, res.run.stdout, start, end);
823
+ }
824
+ async function readWorktreeText(deps, root, path) {
825
+ try {
826
+ const file = join3(root, path);
827
+ const real = await deps.fs.realpath(file);
828
+ const rootReal = await deps.fs.realpath(root);
829
+ if (real !== rootReal && !real.startsWith(rootReal + sep2)) return null;
830
+ const buf = await deps.fs.readFile(file);
831
+ return buf.toString("utf8");
832
+ } catch {
833
+ return null;
834
+ }
835
+ }
836
+ function sliceLines(path, content, start, end) {
837
+ const all = content.split("\n");
838
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
839
+ const from = Math.min(start, all.length + 1);
840
+ const to = Math.min(end, all.length);
841
+ const lines = from <= to ? all.slice(from - 1, to) : [];
842
+ const eof = to >= all.length;
843
+ return { ok: true, value: { kind: "file-lines", path, start: from, lines, eof } };
844
+ }
643
845
  async function queryImageDiff(deps, config, root, q) {
644
846
  if (!isSafePath(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
847
+ if (q.base === "commit" && !isSafeRev(q.commit)) return { ok: false, error: { code: "invalid-name", message: `unsafe commit: ${q.commit}` } };
645
848
  const mime = imageMimeFor(q.path);
646
849
  if (mime === null) return { ok: true, value: { kind: "image-diff", path: q.path, mime } };
647
850
  const oldSpec = q.base === "staged" ? `HEAD:${q.path}` : q.base === "commit" ? `${q.commit}^1:${q.path}` : `:${q.path}`;
@@ -694,7 +897,7 @@ async function blobSide(deps, gitDir, oid, cap) {
694
897
  if (!("run" in nameRes) || nameRes.run.exitCode !== 0) return void 0;
695
898
  const name = nameRes.run.stdout.trim();
696
899
  if (!/^[A-Za-z0-9._-]+$/.test(name)) return void 0;
697
- const file = join2(gitDir, name);
900
+ const file = join3(gitDir, name);
698
901
  try {
699
902
  const buf = await deps.fs.readFile(file);
700
903
  if (buf.length > cap) return { tooLarge: true };
@@ -708,7 +911,10 @@ async function blobSide(deps, gitDir, oid, cap) {
708
911
  }
709
912
  async function worktreeSide(deps, root, path, cap) {
710
913
  try {
711
- const file = join2(root, path);
914
+ const file = join3(root, path);
915
+ const real = await deps.fs.realpath(file);
916
+ const rootReal = await deps.fs.realpath(root);
917
+ if (real !== rootReal && !real.startsWith(rootReal + sep2)) return void 0;
712
918
  const info = await deps.fs.stat(file);
713
919
  if (info.size > cap) return { tooLarge: true };
714
920
  const buf = await deps.fs.readFile(file);
@@ -718,13 +924,115 @@ async function worktreeSide(deps, root, path, cap) {
718
924
  return void 0;
719
925
  }
720
926
  }
927
+ var DIR_ENTRY_CAP = 2e3;
928
+ async function queryDirList(deps, root, q, isGitRepo) {
929
+ const rel = q.path;
930
+ if (rel !== "" && !isSafePath(rel) || hasGitSegment(rel)) return { ok: false, error: { code: "invalid-path", message: rel } };
931
+ const dir = rel === "" ? root : join3(root, rel);
932
+ const inside = await isInsideRoot(deps, root, dir, true);
933
+ if (!inside) return { ok: false, error: { code: "invalid-path", message: rel } };
934
+ let raw;
935
+ try {
936
+ raw = await deps.fs.readdir(dir);
937
+ } catch (error) {
938
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "readdir failed" } };
939
+ }
940
+ const filtered = raw.filter((e) => e.name !== ".git");
941
+ filtered.sort((a, b) => a.isDirectory !== b.isDirectory ? a.isDirectory ? -1 : 1 : a.name.localeCompare(b.name));
942
+ const truncated = filtered.length > DIR_ENTRY_CAP;
943
+ const slice = truncated ? filtered.slice(0, DIR_ENTRY_CAP) : filtered;
944
+ const ignored = isGitRepo ? await ignoredEntries(deps, root, rel, slice.map((e) => e.name)) : /* @__PURE__ */ new Set();
945
+ const entries = await Promise.all(slice.map(async (e) => {
946
+ const ignoreFlag = ignored.has(e.name) ? { ignored: true } : {};
947
+ if (e.isDirectory) return { name: e.name, dir: true, ...ignoreFlag };
948
+ let size;
949
+ try {
950
+ size = (await deps.fs.stat(join3(dir, e.name))).size;
951
+ } catch {
952
+ size = void 0;
953
+ }
954
+ return { name: e.name, dir: false, ...size !== void 0 ? { size } : {}, ...ignoreFlag };
955
+ }));
956
+ const path = rel === "" ? "" : rel.replace(/\/+$/, "");
957
+ return { ok: true, value: { kind: "dir-list", path, entries, truncated } };
958
+ }
959
+ async function ignoredEntries(deps, root, parent, names) {
960
+ const ignored = /* @__PURE__ */ new Set();
961
+ const paths = names.map((name) => parent === "" ? name : `${parent}/${name}`);
962
+ for (let index = 0; index < paths.length; ) {
963
+ const batch = [];
964
+ let bytes = 0;
965
+ while (index < paths.length && batch.length < 128) {
966
+ const candidate = paths[index];
967
+ const size = Buffer.byteLength(candidate) + 1;
968
+ if (batch.length > 0 && bytes + size > 32 * 1024) break;
969
+ batch.push(candidate);
970
+ bytes += size;
971
+ index++;
972
+ }
973
+ const result = await runCommand(deps.run, ["git", "check-ignore", "-z", "--stdin"], root, "dir-ignore", deps.signal, `${batch.join("\0")}\0`);
974
+ if ("failure" in result || result.run.timedOut || result.run.cancelled || result.run.stdoutLossy || ![0, 1].includes(result.run.exitCode ?? -1)) continue;
975
+ const matches = new Set(result.run.stdout.split("\0"));
976
+ for (const path of batch) if (matches.has(path)) ignored.add(parent === "" ? path : path.slice(parent.length + 1));
977
+ }
978
+ return ignored;
979
+ }
980
+ async function queryFileContent(deps, config, root, q) {
981
+ if (!isSafePath(q.path) || hasGitSegment(q.path)) return { ok: false, error: { code: "invalid-path", message: q.path } };
982
+ const file = join3(root, q.path);
983
+ const inside = await isInsideRoot(deps, root, file, true);
984
+ if (!inside) return { ok: false, error: { code: "invalid-path", message: q.path } };
985
+ const cap = config.maxBytes;
986
+ let info;
987
+ try {
988
+ info = await deps.fs.stat(file);
989
+ } catch (error) {
990
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "stat failed" } };
991
+ }
992
+ const mime = imageMimeFor(q.path);
993
+ if (info.size > cap) return { ok: true, value: { kind: "file-content", path: q.path, variant: mime !== null ? "image" : "text", tooLarge: true } };
994
+ let buf;
995
+ try {
996
+ buf = await deps.fs.readFile(file);
997
+ } catch (error) {
998
+ return { ok: false, error: { code: "git-error", message: error instanceof Error ? error.message : "read failed" } };
999
+ }
1000
+ if (buf.length > cap) return { ok: true, value: { kind: "file-content", path: q.path, variant: mime !== null ? "image" : "text", tooLarge: true } };
1001
+ if (mime !== null) {
1002
+ return { ok: true, value: { kind: "file-content", path: q.path, variant: "image", dataUrl: `data:${mime};base64,${buf.toString("base64")}` } };
1003
+ }
1004
+ if (isBinaryBuffer(buf)) return { ok: true, value: { kind: "file-content", path: q.path, variant: "binary" } };
1005
+ const content = buf.toString("utf8");
1006
+ const lines = content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
1007
+ return { ok: true, value: { kind: "file-content", path: q.path, variant: "text", content, lines } };
1008
+ }
1009
+ function hasGitSegment(path) {
1010
+ return path.split(/[\\/]/).includes(".git");
1011
+ }
1012
+ async function isInsideRoot(deps, root, path, hideGit = false) {
1013
+ try {
1014
+ const real = await deps.fs.realpath(path);
1015
+ const rootReal = await deps.fs.realpath(root);
1016
+ if (real !== rootReal && !real.startsWith(rootReal + sep2)) return false;
1017
+ return !hideGit || !hasGitSegment(real.slice(rootReal.length + 1));
1018
+ } catch {
1019
+ return false;
1020
+ }
1021
+ }
1022
+ function isBinaryBuffer(buf) {
1023
+ const n = Math.min(buf.length, 8192);
1024
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
1025
+ return false;
1026
+ }
721
1027
  async function queryShow(deps, root, ref) {
1028
+ if (!isSafeRev(ref)) return { ok: false, error: { code: "invalid-name", message: `unsafe ref: ${ref}` } };
722
1029
  const metaFormat = "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1f%b";
723
1030
  const [metaRes, statRes] = await Promise.all([
724
- runCommand(deps.run, ["git", "show", "-s", metaFormat, ref], root, "show-meta", deps.signal),
725
- runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", ref], root, "show-stat", deps.signal)
1031
+ runCommand(deps.run, ["git", "show", "-s", metaFormat, "--end-of-options", ref], root, "show-meta", deps.signal),
1032
+ runCommand(deps.run, ["git", "show", "--name-status", "-z", "--format=", "--end-of-options", ref], root, "show-stat", deps.signal)
726
1033
  ]);
727
1034
  if (!("run" in metaRes)) return { ok: false, error: { code: "git-unavailable" } };
1035
+ if (metaRes.run.cancelled) return { ok: false, error: { code: "cancelled" } };
728
1036
  if (metaRes.run.timedOut) return { ok: false, error: { code: "timeout" } };
729
1037
  if (metaRes.run.exitCode !== 0) {
730
1038
  return { ok: false, error: { code: "git-error", message: metaRes.run.stderr.trim() || "unknown ref" } };
@@ -740,7 +1048,7 @@ async function queryShow(deps, root, ref) {
740
1048
  author: parts[3] ?? "",
741
1049
  dateIso: parts[4] ?? ""
742
1050
  };
743
- body = (parts[5] ?? "").trim();
1051
+ body = parts.slice(5).join("").trim();
744
1052
  }
745
1053
  const stats = "run" in statRes && statRes.run.exitCode === 0 ? parseNameStatus(statRes.run.stdout) : [];
746
1054
  return { ok: true, value: { kind: "show", ref, commit, body, stats } };
@@ -765,11 +1073,7 @@ async function queryBranches(deps, root) {
765
1073
  }
766
1074
  async function queryTags(deps, root) {
767
1075
  const res = await runCommand(deps.run, ["git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname:short)", "refs/tags"], root, "tags", deps.signal);
768
- const tags = "run" in res && res.run.exitCode === 0 ? res.run.stdout.split("\n").flatMap((line) => {
769
- if (line.trim() === "") return [];
770
- const [name, shortHash = ""] = line.split("\0");
771
- return name ? [{ name, shortHash: shortHash === "" ? null : shortHash }] : [];
772
- }) : [];
1076
+ const tags = "run" in res && res.run.exitCode === 0 ? parseTags(res.run.stdout) : [];
773
1077
  return { ok: true, value: { kind: "tags", tags } };
774
1078
  }
775
1079
  async function queryAuthors(deps, root) {
@@ -782,48 +1086,18 @@ async function queryLastCommitMessage(deps, root) {
782
1086
  const message = "run" in res && res.run.exitCode === 0 ? res.run.stdout.replace(/\n+$/, "") : "";
783
1087
  return { ok: true, value: { kind: "last-commit-message", message } };
784
1088
  }
785
- async function queryWorktreeStats(deps, config, root, sessionId) {
1089
+ async function queryWorktreeStats(deps, config, sessionId) {
786
1090
  const snapshot = await snapshotForSession(deps, config, sessionId);
787
1091
  if (!snapshot.ok) return { ok: false, error: { code: "git-error", message: "snapshot failed" } };
788
- const snap = snapshot.value;
789
- const distinct = new Set(snap.changes.map((c) => c.path));
790
- const [worktreeNum, stagedNum, headTime] = await Promise.all([
791
- runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
792
- runCommand(deps.run, ["git", "diff", "--numstat", "--cached"], root, "numstat-staged", deps.signal),
793
- runCommand(deps.run, ["git", "log", "-1", "--format=%aI"], root, "head-time", deps.signal)
794
- ]);
795
- const a = "run" in worktreeNum && worktreeNum.run.exitCode === 0 ? sumNumstat(worktreeNum.run.stdout) : { insertions: 0, deletions: 0 };
796
- const b = "run" in stagedNum && stagedNum.run.exitCode === 0 ? sumNumstat(stagedNum.run.stdout) : { insertions: 0, deletions: 0 };
797
- let untrackedInsertions = 0;
798
- const untrackedPaths = snap.changes.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
799
- if (untrackedPaths.length > 0) {
800
- const safe = untrackedPaths.filter(isSafePath).slice(0, 200);
801
- if (safe.length > 0) {
802
- const noIndex = await runCommand(deps.run, ["git", "diff", "--numstat", "--no-index", "--", "/dev/null", ...safe], root, "numstat-untracked", deps.signal);
803
- if ("run" in noIndex) untrackedInsertions = sumNumstat(noIndex.run.stdout).insertions;
804
- }
805
- }
806
- const lastChangeAt = await maxChangeMtime(deps, root, snap.changes);
807
- const headCommittedAt = "run" in headTime && headTime.run.exitCode === 0 ? headTime.run.stdout.trim() || null : null;
808
- const stats = {
809
- fileCount: distinct.size,
810
- staged: snap.staged,
811
- modified: snap.modified,
812
- untracked: snap.untracked,
813
- insertions: a.insertions + b.insertions + untrackedInsertions,
814
- deletions: a.deletions + b.deletions,
815
- lastChangeAt,
816
- headCommittedAt
817
- };
818
- return { ok: true, value: { kind: "worktree-stats", stats } };
1092
+ return { ok: true, value: { kind: "worktree-stats", stats: snapshot.value.stats } };
819
1093
  }
820
1094
 
821
1095
  // src/host/version.ts
822
1096
  import { readFile as readFile2 } from "node:fs/promises";
823
- import { dirname, join as join3 } from "node:path";
1097
+ import { dirname, join as join4 } from "node:path";
824
1098
  import { fileURLToPath } from "node:url";
825
1099
  function manifestPath() {
826
- return join3(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
1100
+ return join4(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
827
1101
  }
828
1102
  function stringField(value) {
829
1103
  return typeof value === "string" && value !== "" ? value : void 0;
@@ -841,9 +1115,10 @@ function compareVersions(a, b) {
841
1115
  const split = (value) => {
842
1116
  const trimmed = value.trim().replace(/^v/i, "");
843
1117
  const [core, ...rest] = trimmed.split("-");
1118
+ const preRaw = rest.join("-");
844
1119
  return {
845
1120
  core: core.split(".").map((part) => Number.parseInt(part, 10)).filter((part) => Number.isFinite(part)),
846
- prerelease: rest.length > 0 && rest.join("-") !== ""
1121
+ pre: preRaw === "" ? [] : preRaw.split(".")
847
1122
  };
848
1123
  };
849
1124
  const left = split(a);
@@ -855,17 +1130,39 @@ function compareVersions(a, b) {
855
1130
  if (l > r) return 1;
856
1131
  if (l < r) return -1;
857
1132
  }
858
- if (left.prerelease === right.prerelease) return 0;
859
- return left.prerelease ? -1 : 1;
1133
+ if (left.pre.length === 0 && right.pre.length === 0) return 0;
1134
+ if (left.pre.length === 0) return 1;
1135
+ if (right.pre.length === 0) return -1;
1136
+ const preLen = Math.max(left.pre.length, right.pre.length);
1137
+ for (let i = 0; i < preLen; i += 1) {
1138
+ const lp = left.pre[i];
1139
+ const rp = right.pre[i];
1140
+ if (lp === void 0) return -1;
1141
+ if (rp === void 0) return 1;
1142
+ const ln = Number.parseInt(lp, 10);
1143
+ const rn = Number.parseInt(rp, 10);
1144
+ const lNum = Number.isFinite(ln) && String(ln) === lp;
1145
+ const rNum = Number.isFinite(rn) && String(rn) === rp;
1146
+ if (lNum && rNum) {
1147
+ if (ln !== rn) return ln > rn ? 1 : -1;
1148
+ } else if (lp !== rp) return lp > rp ? 1 : -1;
1149
+ }
1150
+ return 0;
860
1151
  }
1152
+ var manifestCache;
861
1153
  async function readManifest(path) {
862
1154
  const raw = await readFile2(path, "utf8");
863
1155
  const parsed = JSON.parse(raw);
864
1156
  if (typeof parsed !== "object" || parsed === null) throw new Error("package.json is not an object");
865
1157
  return parsed;
866
1158
  }
1159
+ async function loadManifest(path) {
1160
+ if (path !== void 0) return readManifest(path);
1161
+ if (manifestCache === void 0) manifestCache = await readManifest(manifestPath());
1162
+ return manifestCache;
1163
+ }
867
1164
  async function readVersionInfo(options = {}) {
868
- const manifest = await readManifest(options.manifestPath ?? manifestPath());
1165
+ const manifest = await loadManifest(options.manifestPath);
869
1166
  const current = stringField(manifest.version) ?? "0.0.0";
870
1167
  const repo = parseRepository(manifest.repository);
871
1168
  const repositoryUrl = repo === void 0 ? void 0 : `https://github.com/${repo.owner}/${repo.repo}`;
@@ -876,16 +1173,26 @@ async function readVersionInfo(options = {}) {
876
1173
  checkedRemote: false
877
1174
  };
878
1175
  }
1176
+ function safeReleaseUrl(url) {
1177
+ if (url === void 0) return void 0;
1178
+ return /^https:\/\/github\.com\//i.test(url) ? url : void 0;
1179
+ }
1180
+ var REMOTE_CACHE_MS = 10 * 60 * 1e3;
1181
+ var remoteCache;
879
1182
  async function checkLatestVersion(options = {}) {
1183
+ if (options.manifestPath === void 0 && options.fetchFn === void 0 && remoteCache !== void 0 && Date.now() - remoteCache.at < REMOTE_CACHE_MS) {
1184
+ return remoteCache.info;
1185
+ }
880
1186
  const base = await readVersionInfo({ manifestPath: options.manifestPath });
881
- const repo = parseRepository((await readManifest(options.manifestPath ?? manifestPath())).repository);
1187
+ const repo = parseRepository((await loadManifest(options.manifestPath)).repository);
882
1188
  if (repo === void 0) {
883
1189
  return { ...base, checkedRemote: false, error: "repository is not configured" };
884
1190
  }
885
1191
  const fetchFn = options.fetchFn ?? fetch;
886
1192
  try {
887
1193
  const response = await fetchFn(`https://api.github.com/repos/${repo.owner}/${repo.repo}/releases/latest`, {
888
- headers: { accept: "application/vnd.github+json", "user-agent": "dsh-git-panel" }
1194
+ headers: { accept: "application/vnd.github+json", "user-agent": "dsh-git-panel" },
1195
+ signal: AbortSignal.timeout(5e3)
889
1196
  });
890
1197
  if (!response.ok) {
891
1198
  return { ...base, checkedRemote: true, error: `GitHub responded with ${response.status}` };
@@ -897,13 +1204,15 @@ async function checkLatestVersion(options = {}) {
897
1204
  return { ...base, checkedRemote: true, error: "GitHub response did not include a release tag" };
898
1205
  }
899
1206
  const latest = tag.replace(/^v/i, "");
900
- return {
1207
+ const info = {
901
1208
  ...base,
902
1209
  checkedRemote: true,
903
1210
  latest,
904
1211
  updateAvailable: compareVersions(latest, base.current) > 0,
905
- releaseUrl: htmlUrl ?? `${base.repositoryUrl ?? ""}/releases/latest`
1212
+ releaseUrl: safeReleaseUrl(htmlUrl) ?? (base.repositoryUrl !== void 0 ? `${base.repositoryUrl}/releases/latest` : void 0)
906
1213
  };
1214
+ if (options.manifestPath === void 0 && options.fetchFn === void 0) remoteCache = { at: Date.now(), info };
1215
+ return info;
907
1216
  } catch (error) {
908
1217
  return { ...base, checkedRemote: true, error: error instanceof Error ? error.message : "Unable to reach GitHub" };
909
1218
  }
@@ -924,27 +1233,26 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
924
1233
  }
925
1234
  buildDeps(ctx, config) {
926
1235
  const rootCache = /* @__PURE__ */ new Map();
1236
+ const rootNegCache = /* @__PURE__ */ new Map();
927
1237
  const get = (key) => ctx.get(key);
1238
+ const fs = {
1239
+ realpath,
1240
+ stat: async (p) => stat(p),
1241
+ readFile: readFile3,
1242
+ readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
1243
+ remove: async (p) => {
1244
+ await rm(p, { force: true });
1245
+ }
1246
+ };
928
1247
  const subprocess = get("subprocess");
929
- if (subprocess === void 0) {
930
- return {
931
- run: { run: async () => {
932
- throw new Error("subprocess service unavailable");
933
- } },
934
- fs: { realpath, stat: async (p) => stat(p), readFile: readFile3, remove: async (p) => {
935
- await rm(p, { force: true });
936
- } },
937
- sessions: { liveCwd: () => void 0, persistedMeta: async () => void 0 },
938
- rootCache
939
- };
940
- }
1248
+ const run = subprocess === void 0 ? { run: async () => {
1249
+ throw new Error("subprocess service unavailable");
1250
+ } } : createGitRunner(subprocess, config.timeoutMs, config.maxBytes);
941
1251
  const sessions = get("sessions");
942
1252
  const persistence = get("sessionPersistence");
943
1253
  return {
944
- run: createGitRunner(subprocess, config.timeoutMs, config.maxBytes),
945
- fs: { realpath, stat: async (p) => stat(p), readFile: readFile3, remove: async (p) => {
946
- await rm(p, { force: true });
947
- } },
1254
+ run,
1255
+ fs,
948
1256
  sessions: {
949
1257
  liveCwd: (id) => sessions?.get(id)?.header?.cwd,
950
1258
  persistedMeta: async (id) => {
@@ -957,7 +1265,8 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
957
1265
  }
958
1266
  }
959
1267
  },
960
- rootCache
1268
+ rootCache,
1269
+ rootNegCache
961
1270
  };
962
1271
  }
963
1272
  async snapshot(request, signal) {
@@ -970,7 +1279,11 @@ var GitPanelService = class extends (_a = TypertRemoteService, _snapshot_dec = [
970
1279
  return runQuery(this.withSignal(signal), this.liveConfig(), request);
971
1280
  }
972
1281
  async version(request) {
973
- return request.check === true ? checkLatestVersion() : readVersionInfo();
1282
+ try {
1283
+ return request.check === true ? await checkLatestVersion() : await readVersionInfo();
1284
+ } catch (error) {
1285
+ return { current: "0.0.0", updateAvailable: false, checkedRemote: false, error: error instanceof Error ? error.message : "version unavailable" };
1286
+ }
974
1287
  }
975
1288
  /** Re-read config so a live-edited volatile field (showInputPill) is current. */
976
1289
  liveConfig() {
@@ -990,12 +1303,17 @@ __decorateElement(_init, 1, "version", _version_dec, GitPanelService);
990
1303
  __decoratorMetadata(_init, GitPanelService);
991
1304
  __publicField(GitPanelService, "inject", ["subprocess", "sessions", "sessionPersistence"]);
992
1305
  /**
993
- * Config schema surfaced on the plugin detail page. Only `showInputPill` is
994
- * `.volatile()`, so the settings host renders it as a live-editable toggle;
995
- * the operational limits stay profile-only and out of the UI form.
1306
+ * Config schema surfaced on the plugin detail page. `showInputPill` and
1307
+ * `defaultDiffView` are `.volatile()`, so the settings host renders them as
1308
+ * live-editable controls; the operational limits stay profile-only and out
1309
+ * of the UI form.
996
1310
  */
997
1311
  __publicField(GitPanelService, "Config", Schema.object({
998
- showInputPill: Schema.boolean().default(true).volatile().description("\u663E\u793A\u8F93\u5165\u6846\u7684 Git \u5206\u652F\u6807\u8BB0")
1312
+ showInputPill: Schema.boolean().default(true).volatile().description("\u663E\u793A\u8F93\u5165\u6846\u7684 Git \u5206\u652F\u6807\u8BB0"),
1313
+ defaultDiffView: Schema.union([
1314
+ Schema.const("unified").description("\u7EDF\u4E00\u89C6\u56FE\uFF08\u5355\u680F\u884C\u5185\u5BF9\u6BD4\uFF09"),
1315
+ Schema.const("split").description("\u5E76\u6392\u89C6\u56FE\uFF08\u5DE6\u53F3\u5206\u680F\u5BF9\u6BD4\uFF09")
1316
+ ]).default("unified").volatile().description("\u5DEE\u5F02\u5BF9\u6BD4\u9ED8\u8BA4\u89C6\u56FE")
999
1317
  }));
1000
1318
  var index_default = GitPanelService;
1001
1319
  export {