openspecui 2.1.7 → 2.2.4

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 (38) hide show
  1. package/dist/cli.mjs +2 -2
  2. package/dist/index.mjs +1 -1
  3. package/dist/{src-DrLkvh3j.mjs → src-dyNiaV8K.mjs} +996 -100
  4. package/package.json +2 -2
  5. package/web/assets/CanvasRenderer-CSU1mZCS.js +1 -0
  6. package/web/assets/WebGLRenderer-BWsZhyvF.js +1 -0
  7. package/web/assets/WebGPURenderer-Dhm03njt.js +1 -0
  8. package/web/assets/browserAll-C2SVTxiZ.js +1 -0
  9. package/web/assets/{dist-DzO1MTrd.js → dist-2Hdh5Vgr.js} +1 -1
  10. package/web/assets/{dist-DWYBeLMT.js → dist-37zOX9gO.js} +1 -1
  11. package/web/assets/{dist-CDInvCCs.js → dist-BiakFiXv.js} +1 -1
  12. package/web/assets/dist-Bpqiuij8.js +1 -0
  13. package/web/assets/{dist-BgLE97_T.js → dist-CK_RJW9a.js} +1 -1
  14. package/web/assets/{dist-NaI8Hvg8.js → dist-CUUb4d2o.js} +1 -1
  15. package/web/assets/{dist-BOT-LSL1.js → dist-CXNHfQrS.js} +1 -1
  16. package/web/assets/{dist-bwpilS1a.js → dist-CkDh_aHQ.js} +1 -1
  17. package/web/assets/dist-D8_Nhb_E.js +1 -0
  18. package/web/assets/{dist-B85SBQfj.js → dist-DW-savk9.js} +1 -1
  19. package/web/assets/dist-chDDg3tV.js +1 -0
  20. package/web/assets/{dist-CmZt_B9S.js → dist-vGxOx14l.js} +1 -1
  21. package/web/assets/{ghostty-web-CcnM9hkf.js → ghostty-web-vPHHRwzY.js} +1 -1
  22. package/web/assets/index-BIBAwnwo.css +1 -0
  23. package/web/assets/index-Drwz4q1f.js +1548 -0
  24. package/web/assets/{init-CMMqpKx9.js → init-C9VTlvZ6.js} +1 -1
  25. package/web/assets/trpc-DhmVhQ6w.js +1 -0
  26. package/web/assets/webworkerAll-BjzNDx-n.js +1 -0
  27. package/web/index.html +2 -2
  28. package/web/assets/CanvasRenderer-CEihxCHH.js +0 -1
  29. package/web/assets/WebGLRenderer-Cu3Icqhe.js +0 -1
  30. package/web/assets/WebGPURenderer-bKW1hz4s.js +0 -1
  31. package/web/assets/browserAll-Bt9-O0-U.js +0 -1
  32. package/web/assets/dist-BDrvYrXC.js +0 -1
  33. package/web/assets/dist-CaTQfD5H.js +0 -1
  34. package/web/assets/dist-DLdn2I9d.js +0 -1
  35. package/web/assets/index-B37nt-5A.css +0 -1
  36. package/web/assets/index-t9ce2ju7.js +0 -1552
  37. package/web/assets/trpc-O7HpEu-K.js +0 -1
  38. package/web/assets/webworkerAll-9GybEQOT.js +0 -1
@@ -7,17 +7,17 @@ import crypto from "crypto";
7
7
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
8
8
  import { dirname, join } from "path";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
- import { mkdir as mkdir$1, readFile as readFile$1, readdir, rm, stat, writeFile as writeFile$1 } from "node:fs/promises";
10
+ import { mkdir as mkdir$1, readFile as readFile$1, readdir, realpath, rm, stat, writeFile as writeFile$1 } from "node:fs/promises";
11
11
  import { basename as basename$1, dirname as dirname$1, join as join$1, matchesGlob, relative as relative$1, resolve as resolve$1, sep } from "node:path";
12
12
  import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
13
13
  import { EventEmitter } from "events";
14
14
  import { watch } from "fs";
15
- import { exec, spawn } from "child_process";
15
+ import { exec, execFile, spawn } from "child_process";
16
16
  import { promisify } from "util";
17
17
  import { homedir } from "node:os";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { EventEmitter as EventEmitter$1 } from "node:events";
20
- import { execFile } from "node:child_process";
20
+ import { execFile as execFile$1, spawn as spawn$1 } from "node:child_process";
21
21
  import { promisify as promisify$1 } from "node:util";
22
22
  import * as pty from "@lydell/node-pty";
23
23
  import { Worker as Worker$1 } from "node:worker_threads";
@@ -5919,6 +5919,7 @@ var OpenSpecWatcher = class extends EventEmitter {
5919
5919
  //#endregion
5920
5920
  //#region ../core/src/config.ts
5921
5921
  const execAsync = promisify(exec);
5922
+ const execFileAsync$2 = promisify(execFile);
5922
5923
  const CLI_PROBE_TIMEOUT_MS = 2e4;
5923
5924
  const THEME_VALUES = [
5924
5925
  "light",
@@ -6078,6 +6079,53 @@ function commandToString(commandParts) {
6078
6079
  };
6079
6080
  return commandParts.map(formatToken).join(" ").trim();
6080
6081
  }
6082
+ function isBareExecutableCommand(command) {
6083
+ if (!command) return false;
6084
+ if (command === "." || command === "..") return false;
6085
+ return !/[\\/]/.test(command);
6086
+ }
6087
+ function quotePosixShellArg(value) {
6088
+ return `'${value.replace(/'/g, `'\\''`)}'`;
6089
+ }
6090
+ async function resolveShellExecutablePath(command, cwd, env) {
6091
+ if (!isBareExecutableCommand(command)) return null;
6092
+ try {
6093
+ if (process.platform === "win32") {
6094
+ const { stdout: stdout$1 } = await execFileAsync$2("where", [command], {
6095
+ cwd,
6096
+ env,
6097
+ encoding: "utf8",
6098
+ timeout: 5e3
6099
+ });
6100
+ return stdout$1.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0) || null;
6101
+ }
6102
+ const { stdout } = await execFileAsync$2(env.SHELL || process.env.SHELL || "/bin/sh", ["-lc", `command -v -- ${quotePosixShellArg(command)}`], {
6103
+ cwd,
6104
+ env,
6105
+ encoding: "utf8",
6106
+ timeout: 5e3
6107
+ });
6108
+ return stdout.split("\n").map((line) => line.trim()).find((line) => line.startsWith("/")) || null;
6109
+ } catch {
6110
+ return null;
6111
+ }
6112
+ }
6113
+ async function expandCliRunnerCandidates(candidates, cwd, env) {
6114
+ const expanded = [];
6115
+ for (const candidate of candidates) {
6116
+ const [command, ...rest] = candidate.commandParts;
6117
+ if ((candidate.id === "openspec" || candidate.id === "configured" && command.trim().toLowerCase() === "openspec") && command) {
6118
+ const shellResolved = await resolveShellExecutablePath(command, cwd, env);
6119
+ if (shellResolved && shellResolved !== command) expanded.push({
6120
+ ...candidate,
6121
+ source: `${candidate.source} (shell)`,
6122
+ commandParts: [shellResolved, ...rest]
6123
+ });
6124
+ }
6125
+ expanded.push(candidate);
6126
+ }
6127
+ return expanded;
6128
+ }
6081
6129
  function getRunnerPriorityFromUserAgent(userAgent) {
6082
6130
  if (!userAgent) return null;
6083
6131
  if (userAgent.startsWith("bun")) return "bunx";
@@ -6183,8 +6231,9 @@ async function probeCliRunner(candidate, cwd, env) {
6183
6231
  });
6184
6232
  }
6185
6233
  async function resolveCliRunner(candidates, cwd, env) {
6234
+ const expandedCandidates = await expandCliRunnerCandidates(candidates, cwd, env);
6186
6235
  const attempts = [];
6187
- for (const candidate of candidates) {
6236
+ for (const candidate of expandedCandidates) {
6188
6237
  const attempt = await probeCliRunner(candidate, cwd, env);
6189
6238
  attempts.push(attempt);
6190
6239
  if (attempt.success) return {
@@ -6237,7 +6286,13 @@ async function fetchLatestVersion() {
6237
6286
  * 每次调用都会重新检测,不使用缓存。
6238
6287
  */
6239
6288
  async function sniffGlobalCli() {
6240
- const [localResult, latestVersion] = await Promise.all([execAsync("openspec --version", { timeout: 1e4 }).catch((err) => ({ error: err })), fetchLatestVersion()]);
6289
+ const env = createCleanCliEnv();
6290
+ const resolvedCommand = await resolveShellExecutablePath("openspec", process.cwd(), env) ?? "openspec";
6291
+ const [localResult, latestVersion] = await Promise.all([execFileAsync$2(resolvedCommand, ["--version"], {
6292
+ env,
6293
+ timeout: 1e4,
6294
+ encoding: "utf8"
6295
+ }).catch((err) => ({ error: err })), fetchLatestVersion()]);
6241
6296
  if ("error" in localResult) {
6242
6297
  const error = localResult.error instanceof Error ? localResult.error.message : String(localResult.error);
6243
6298
  if (error.includes("not found") || error.includes("ENOENT") || error.includes("not recognized")) return {
@@ -23207,8 +23262,8 @@ function selectRecentDashboardItems(items, limit = DASHBOARD_RECENT_LIST_LIMIT)
23207
23262
  }
23208
23263
 
23209
23264
  //#endregion
23210
- //#region ../server/src/dashboard-git-snapshot.ts
23211
- const execFileAsync$1 = promisify$1(execFile);
23265
+ //#region ../server/src/git-shared.ts
23266
+ const execFileAsync$1 = promisify$1(execFile$1);
23212
23267
  const EMPTY_DIFF = {
23213
23268
  files: 0,
23214
23269
  insertions: 0,
@@ -23240,6 +23295,14 @@ async function defaultReadPathTimestampMs(absolutePath) {
23240
23295
  return null;
23241
23296
  }
23242
23297
  }
23298
+ async function pathExists(absolutePath) {
23299
+ try {
23300
+ await stat(absolutePath);
23301
+ return true;
23302
+ } catch {
23303
+ return false;
23304
+ }
23305
+ }
23243
23306
  function parseShortStat(output) {
23244
23307
  const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
23245
23308
  const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
@@ -23250,33 +23313,48 @@ function parseShortStat(output) {
23250
23313
  deletions: Number.isFinite(deletions) ? deletions : 0
23251
23314
  };
23252
23315
  }
23253
- function parseNumStat(output) {
23254
- let files = 0;
23255
- let insertions = 0;
23256
- let deletions = 0;
23257
- for (const line of output.split("\n")) {
23258
- const trimmed = line.trim();
23259
- if (!trimmed) continue;
23260
- const [addRaw, deleteRaw] = trimmed.split(" ");
23261
- if (!addRaw || !deleteRaw) continue;
23262
- files += 1;
23263
- if (addRaw !== "-") insertions += Number(addRaw) || 0;
23264
- if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
23265
- }
23266
- return {
23267
- files,
23268
- insertions,
23269
- deletions
23270
- };
23271
- }
23272
23316
  function normalizeGitPath(path$1) {
23273
23317
  return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
23274
23318
  }
23319
+ function extractGitPathVariants(rawPath) {
23320
+ const trimmed = rawPath.trim();
23321
+ if (!trimmed) return [];
23322
+ const normalizedRaw = normalizeGitPath(trimmed);
23323
+ const braceRenameMatch = /^(.*?)\{(.*?) => (.*?)\}(.*)$/.exec(trimmed);
23324
+ if (braceRenameMatch) {
23325
+ const [, prefix = "", left = "", right = "", suffix = ""] = braceRenameMatch;
23326
+ const variants = /* @__PURE__ */ new Set();
23327
+ variants.add(normalizeGitPath(`${prefix}${left}${suffix}`));
23328
+ variants.add(normalizeGitPath(`${prefix}${right}${suffix}`));
23329
+ return [...variants];
23330
+ }
23331
+ const renameParts = trimmed.split(" => ");
23332
+ if (renameParts.length === 2) {
23333
+ const [left = "", right = ""] = renameParts;
23334
+ const variants = /* @__PURE__ */ new Set();
23335
+ variants.add(normalizeGitPath(left));
23336
+ variants.add(normalizeGitPath(right));
23337
+ return [...variants];
23338
+ }
23339
+ return [normalizedRaw];
23340
+ }
23275
23341
  function relativePath(fromDir, target) {
23276
23342
  const rel = relative$1(fromDir, target);
23277
23343
  if (!rel || rel.length === 0) return ".";
23278
23344
  return rel;
23279
23345
  }
23346
+ async function canonicalGitPath(path$1) {
23347
+ const resolved = resolve$1(path$1);
23348
+ try {
23349
+ return await realpath(resolved);
23350
+ } catch {
23351
+ return resolved;
23352
+ }
23353
+ }
23354
+ async function sameGitPath(left, right) {
23355
+ const [canonicalLeft, canonicalRight] = await Promise.all([canonicalGitPath(left), canonicalGitPath(right)]);
23356
+ return canonicalLeft === canonicalRight;
23357
+ }
23280
23358
  function parseBranchName(branchRef, detached) {
23281
23359
  if (detached) return "(detached)";
23282
23360
  if (!branchRef) return "(unknown)";
@@ -23305,10 +23383,7 @@ function parseWorktreeList(porcelain) {
23305
23383
  current.branchRef = line.slice(7).trim();
23306
23384
  continue;
23307
23385
  }
23308
- if (line === "detached") {
23309
- current.detached = true;
23310
- continue;
23311
- }
23386
+ if (line === "detached") current.detached = true;
23312
23387
  }
23313
23388
  flush();
23314
23389
  return entries;
@@ -23317,16 +23392,17 @@ function parseRelatedChanges(paths) {
23317
23392
  const related = /* @__PURE__ */ new Set();
23318
23393
  for (const path$1 of paths) {
23319
23394
  const normalized = normalizeGitPath(path$1);
23395
+ if (normalized.includes("{") || normalized.includes("=>")) continue;
23396
+ const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23397
+ if (archiveMatch?.[1]) {
23398
+ related.add(archiveMatch[1].replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23399
+ continue;
23400
+ }
23320
23401
  const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
23321
23402
  if (activeMatch?.[1]) {
23322
23403
  related.add(activeMatch[1]);
23323
23404
  continue;
23324
23405
  }
23325
- const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23326
- if (archiveMatch?.[1]) {
23327
- const fullName = archiveMatch[1];
23328
- related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23329
- }
23330
23406
  }
23331
23407
  return [...related].sort((a, b) => a.localeCompare(b));
23332
23408
  }
@@ -23348,41 +23424,112 @@ async function resolveDefaultBranch(projectDir, runGit) {
23348
23424
  if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
23349
23425
  return "main";
23350
23426
  }
23351
- async function collectCommitEntries(options) {
23352
- const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
23353
- const commitEntries = [];
23427
+ async function listGitWorktrees(projectDir, runGit) {
23428
+ const resolvedProjectDir = resolve$1(projectDir);
23429
+ const worktreeResult = await runGit(resolvedProjectDir, [
23430
+ "worktree",
23431
+ "list",
23432
+ "--porcelain"
23433
+ ]);
23434
+ const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23435
+ if (parsed.length > 0) return parsed;
23436
+ return [{
23437
+ path: resolvedProjectDir,
23438
+ branchRef: null,
23439
+ detached: false
23440
+ }];
23441
+ }
23442
+
23443
+ //#endregion
23444
+ //#region ../server/src/git-entry-summary.ts
23445
+ function createEmptyCommitRecord(hash, committedAt, title) {
23446
+ return {
23447
+ hash,
23448
+ committedAt,
23449
+ title,
23450
+ diff: { ...EMPTY_DIFF },
23451
+ changedPaths: []
23452
+ };
23453
+ }
23454
+ function parseGitLogNumstatRecords(stdout) {
23455
+ const records = [];
23456
+ for (const block of stdout.split("")) {
23457
+ const trimmedBlock = block.trim();
23458
+ if (!trimmedBlock) continue;
23459
+ const lines = trimmedBlock.split("\n");
23460
+ const header = lines.shift()?.trim();
23461
+ if (!header) continue;
23462
+ const [hash, committedAtRaw = "0", title = ""] = header.split("");
23463
+ if (!hash) continue;
23464
+ const committedAtSeconds = Number(committedAtRaw);
23465
+ const record = createEmptyCommitRecord(hash, Number.isFinite(committedAtSeconds) && committedAtSeconds > 0 ? committedAtSeconds * 1e3 : 0, title.trim() || hash.slice(0, 7));
23466
+ for (const line of lines) {
23467
+ const trimmedLine = line.trim();
23468
+ if (!trimmedLine) continue;
23469
+ const parts = trimmedLine.split(" ");
23470
+ if (parts.length < 3) continue;
23471
+ const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
23472
+ const rawPath = pathParts.join(" ").trim();
23473
+ if (!rawPath) continue;
23474
+ record.diff.files += 1;
23475
+ if (insertionsRaw !== "-") record.diff.insertions += Number(insertionsRaw) || 0;
23476
+ if (deletionsRaw !== "-") record.diff.deletions += Number(deletionsRaw) || 0;
23477
+ record.changedPaths.push(...extractGitPathVariants(rawPath));
23478
+ }
23479
+ records.push(record);
23480
+ }
23481
+ return records;
23482
+ }
23483
+ async function listGitCommitEntriesPage(options) {
23484
+ const { worktreePath, defaultBranch, offset, limit, runGit } = options;
23354
23485
  const commits = await runGit(worktreePath, [
23355
23486
  "log",
23356
- "--format=%H%x1f%ct%x1f%s",
23357
- `-n${maxCommitEntries}`,
23487
+ "--format=%x1e%H%x1f%ct%x1f%s",
23488
+ "--numstat",
23489
+ `--skip=${offset}`,
23490
+ `-n${limit + 1}`,
23358
23491
  `${defaultBranch}..HEAD`
23359
23492
  ]);
23360
- if (commits.ok) for (const line of commits.stdout.split("\n")) {
23361
- if (!line.trim()) continue;
23362
- const [hash, committedAtRaw = "0", title = ""] = line.split("");
23363
- if (!hash) continue;
23364
- const diffResult = await runGit(worktreePath, [
23365
- "show",
23366
- "--numstat",
23367
- "--format=",
23368
- hash
23369
- ]);
23370
- const changedFiles = (await runGit(worktreePath, [
23371
- "show",
23372
- "--name-only",
23373
- "--format=",
23374
- hash
23375
- ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23376
- const committedAt = Number(committedAtRaw) * 1e3;
23377
- commitEntries.push({
23493
+ if (!commits.ok) return {
23494
+ items: [],
23495
+ nextCursor: null
23496
+ };
23497
+ const records = parseGitLogNumstatRecords(commits.stdout);
23498
+ return {
23499
+ items: records.slice(0, limit).map((record) => ({
23378
23500
  type: "commit",
23379
- hash,
23380
- title: title.trim() || hash.slice(0, 7),
23381
- committedAt: Number.isFinite(committedAt) && committedAt > 0 ? committedAt : 0,
23382
- relatedChanges: parseRelatedChanges(changedFiles),
23383
- diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
23384
- });
23385
- }
23501
+ hash: record.hash,
23502
+ title: record.title,
23503
+ committedAt: record.committedAt,
23504
+ relatedChanges: parseRelatedChanges(record.changedPaths),
23505
+ diff: record.diff
23506
+ })),
23507
+ nextCursor: records.length > limit ? String(offset + limit) : null
23508
+ };
23509
+ }
23510
+ async function readGitCommitEntryByHash(options) {
23511
+ const { worktreePath, hash, runGit } = options;
23512
+ const result = await runGit(worktreePath, [
23513
+ "show",
23514
+ "--numstat",
23515
+ "--format=%x1e%H%x1f%ct%x1f%s",
23516
+ hash
23517
+ ]);
23518
+ if (!result.ok) return null;
23519
+ const record = parseGitLogNumstatRecords(result.stdout)[0];
23520
+ if (!record) return null;
23521
+ return {
23522
+ type: "commit",
23523
+ hash: record.hash,
23524
+ title: record.title,
23525
+ committedAt: record.committedAt,
23526
+ relatedChanges: parseRelatedChanges(record.changedPaths),
23527
+ diff: record.diff
23528
+ };
23529
+ }
23530
+ async function collectUncommittedEntrySummary(options) {
23531
+ const { worktreePath, runGit } = options;
23532
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
23386
23533
  const trackedResult = await runGit(worktreePath, [
23387
23534
  "diff",
23388
23535
  "--numstat",
@@ -23400,32 +23547,57 @@ async function collectCommitEntries(options) {
23400
23547
  ]);
23401
23548
  const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23402
23549
  const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23550
+ const trackedDiff = parseGitLogNumstatRecords(`\u001ehead\u001f0\u001fUncommitted\n${trackedResult.stdout}`)[0]?.diff ?? EMPTY_DIFF;
23403
23551
  const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
23404
- const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
23405
- const updatedAt = (await Promise.all([...allUncommittedFiles].map((path$1) => readPathTimestampMs(resolve$1(worktreePath, path$1))))).reduce((latest, current) => {
23406
- if (!current || !Number.isFinite(current) || current <= 0) return latest;
23407
- return latest === null || current > latest ? current : latest;
23408
- }, null) ?? null;
23409
- commitEntries.sort((left, right) => {
23410
- if (left.type !== "commit" || right.type !== "commit") return 0;
23411
- return right.committedAt - left.committedAt;
23412
- });
23413
- return [{
23552
+ return {
23414
23553
  type: "uncommitted",
23415
23554
  title: "Uncommitted",
23416
- updatedAt,
23555
+ updatedAt: (await Promise.all([...allUncommittedFiles].map((path$1) => readPathTimestampMs(resolve$1(worktreePath, path$1))))).reduce((latest, current) => {
23556
+ if (!current || !Number.isFinite(current) || current <= 0) return latest;
23557
+ return latest === null || current > latest ? current : latest;
23558
+ }, null) ?? null,
23417
23559
  relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
23418
23560
  diff: {
23419
23561
  files: allUncommittedFiles.size,
23420
23562
  insertions: trackedDiff.insertions,
23421
23563
  deletions: trackedDiff.deletions
23422
23564
  }
23423
- }, ...commitEntries];
23565
+ };
23566
+ }
23567
+ async function listRecentGitEntries(options) {
23568
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
23569
+ const [uncommitted, commitsPage] = await Promise.all([collectUncommittedEntrySummary({
23570
+ worktreePath,
23571
+ runGit,
23572
+ readPathTimestampMs
23573
+ }), listGitCommitEntriesPage({
23574
+ worktreePath,
23575
+ defaultBranch,
23576
+ offset: 0,
23577
+ limit: maxCommitEntries,
23578
+ runGit
23579
+ })]);
23580
+ return [uncommitted, ...commitsPage.items];
23581
+ }
23582
+
23583
+ //#endregion
23584
+ //#region ../server/src/dashboard-git-snapshot.ts
23585
+ async function collectCommitEntries(options) {
23586
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit, readPathTimestampMs } = options;
23587
+ return listRecentGitEntries({
23588
+ worktreePath,
23589
+ defaultBranch,
23590
+ maxCommitEntries,
23591
+ runGit,
23592
+ readPathTimestampMs
23593
+ });
23424
23594
  }
23425
23595
  async function collectWorktree(options) {
23426
23596
  const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries, readPathTimestampMs } = options;
23427
23597
  const worktreePath = resolve$1(worktree.path);
23428
23598
  const resolvedProjectDir = resolve$1(projectDir);
23599
+ const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
23600
+ const pathAvailable = await pathExists(worktreePath);
23429
23601
  const aheadBehindResult = await runGit(worktreePath, [
23430
23602
  "rev-list",
23431
23603
  "--left-right",
@@ -23455,9 +23627,10 @@ async function collectWorktree(options) {
23455
23627
  return {
23456
23628
  path: worktreePath,
23457
23629
  relativePath: relativePath(resolvedProjectDir, worktreePath),
23630
+ pathAvailable,
23458
23631
  branchName: parseBranchName(worktree.branchRef, worktree.detached),
23459
23632
  detached: worktree.detached,
23460
- isCurrent: resolvedProjectDir === worktreePath,
23633
+ isCurrent,
23461
23634
  ahead,
23462
23635
  behind,
23463
23636
  diff,
@@ -23468,14 +23641,13 @@ async function removeDetachedDashboardGitWorktree(options) {
23468
23641
  const runGit = options.runGit ?? defaultRunGit;
23469
23642
  const resolvedProjectDir = resolve$1(options.projectDir);
23470
23643
  const resolvedTargetPath = resolve$1(options.targetPath);
23471
- if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
23472
- const worktreeResult = await runGit(resolvedProjectDir, [
23473
- "worktree",
23474
- "list",
23475
- "--porcelain"
23476
- ]);
23477
- if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
23478
- const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve$1(worktree.path) === resolvedTargetPath);
23644
+ if (await sameGitPath(resolvedTargetPath, resolvedProjectDir)) throw new Error("Cannot remove the current worktree.");
23645
+ const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
23646
+ let matched;
23647
+ for (const worktree of worktrees) if (await sameGitPath(worktree.path, resolvedTargetPath)) {
23648
+ matched = worktree;
23649
+ break;
23650
+ }
23479
23651
  if (!matched) throw new Error("Worktree not found.");
23480
23652
  if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
23481
23653
  if (!(await runGit(resolvedProjectDir, [
@@ -23491,17 +23663,7 @@ async function buildDashboardGitSnapshot(options) {
23491
23663
  const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
23492
23664
  const resolvedProjectDir = resolve$1(options.projectDir);
23493
23665
  const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
23494
- const worktreeResult = await runGit(resolvedProjectDir, [
23495
- "worktree",
23496
- "list",
23497
- "--porcelain"
23498
- ]);
23499
- const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23500
- const baseWorktrees = parsed.length > 0 ? parsed : [{
23501
- path: resolvedProjectDir,
23502
- branchRef: null,
23503
- detached: false
23504
- }];
23666
+ const baseWorktrees = await listGitWorktrees(resolvedProjectDir, runGit);
23505
23667
  const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
23506
23668
  projectDir: resolvedProjectDir,
23507
23669
  worktree,
@@ -23613,7 +23775,7 @@ function buildDashboardTimeTrends(options) {
23613
23775
 
23614
23776
  //#endregion
23615
23777
  //#region ../server/src/dashboard-overview.ts
23616
- const execFileAsync = promisify$1(execFile);
23778
+ const execFileAsync = promisify$1(execFile$1);
23617
23779
  const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
23618
23780
  const dashboardGitTaskStatusEmitter = new EventEmitter$1();
23619
23781
  dashboardGitTaskStatusEmitter.setMaxListeners(200);
@@ -24531,6 +24693,457 @@ function createCliStreamObservable(startStream) {
24531
24693
  });
24532
24694
  }
24533
24695
 
24696
+ //#endregion
24697
+ //#region ../server/src/git-panel-cache.ts
24698
+ const gitPanelCaches = {
24699
+ overview: /* @__PURE__ */ new Map(),
24700
+ entries: /* @__PURE__ */ new Map(),
24701
+ shell: /* @__PURE__ */ new Map(),
24702
+ patch: /* @__PURE__ */ new Map()
24703
+ };
24704
+ function buildCacheKey(projectDir, key) {
24705
+ return `${resolve$1(projectDir)}::${key}`;
24706
+ }
24707
+ function getCacheVersion() {
24708
+ return getDashboardGitTaskStatus().lastFinishedAt ?? 0;
24709
+ }
24710
+ async function getCachedGitPanelValue(scope, projectDir, key, load) {
24711
+ const cache = gitPanelCaches[scope];
24712
+ const cacheKey$1 = buildCacheKey(projectDir, key);
24713
+ const version = getCacheVersion();
24714
+ const hit = cache.get(cacheKey$1);
24715
+ if (hit && hit.version === version) return hit.value;
24716
+ const value = await load();
24717
+ cache.set(cacheKey$1, {
24718
+ version,
24719
+ value
24720
+ });
24721
+ return value;
24722
+ }
24723
+
24724
+ //#endregion
24725
+ //#region ../server/src/git-panel-data.ts
24726
+ const DEFAULT_ENTRY_PAGE_SIZE = 50;
24727
+ const MAX_ENTRY_PAGE_SIZE = 100;
24728
+ const MAX_PATCH_BYTES = 2e5;
24729
+ const MAX_SYNTHETIC_TEXT_BYTES = 2e5;
24730
+ function clampEntryLimit(limit) {
24731
+ if (!Number.isFinite(limit)) return DEFAULT_ENTRY_PAGE_SIZE;
24732
+ return Math.max(1, Math.min(MAX_ENTRY_PAGE_SIZE, Math.trunc(limit ?? DEFAULT_ENTRY_PAGE_SIZE)));
24733
+ }
24734
+ function parseCursor(cursor) {
24735
+ const value = Number(cursor);
24736
+ if (!Number.isFinite(value) || value < 0) return 0;
24737
+ return Math.trunc(value);
24738
+ }
24739
+ function createGitFileId(path$1, previousPath) {
24740
+ return JSON.stringify([previousPath ?? null, path$1]);
24741
+ }
24742
+ function parseGitNameStatus(stdout) {
24743
+ const entries = [];
24744
+ for (const line of stdout.split("\n")) {
24745
+ const trimmed = line.trim();
24746
+ if (!trimmed) continue;
24747
+ const parts = trimmed.split(" ");
24748
+ const normalized = (parts[0] ?? "")[0] ?? "";
24749
+ if (!normalized) continue;
24750
+ if ((normalized === "R" || normalized === "C") && parts.length >= 3) {
24751
+ entries.push({
24752
+ previousPath: parts[1] ?? null,
24753
+ path: parts[2] ?? "",
24754
+ changeType: normalized === "R" ? "renamed" : "copied"
24755
+ });
24756
+ continue;
24757
+ }
24758
+ if (parts.length < 2) continue;
24759
+ entries.push({
24760
+ previousPath: null,
24761
+ path: parts[1] ?? "",
24762
+ changeType: normalized === "A" ? "added" : normalized === "M" ? "modified" : normalized === "D" ? "deleted" : normalized === "T" ? "typechanged" : normalized === "U" ? "unmerged" : "unknown"
24763
+ });
24764
+ }
24765
+ return entries;
24766
+ }
24767
+ function parseNumStatMap(stdout) {
24768
+ const diffByPath = /* @__PURE__ */ new Map();
24769
+ for (const line of stdout.split("\n")) {
24770
+ const trimmed = line.trim();
24771
+ if (!trimmed) continue;
24772
+ const parts = trimmed.split(" ");
24773
+ if (parts.length < 3) continue;
24774
+ const [insertionsRaw = "0", deletionsRaw = "0", ...pathParts] = parts;
24775
+ const rawPath = pathParts.join(" ").trim();
24776
+ const diff = {
24777
+ files: 1,
24778
+ insertions: insertionsRaw === "-" ? 0 : Number(insertionsRaw) || 0,
24779
+ deletions: deletionsRaw === "-" ? 0 : Number(deletionsRaw) || 0
24780
+ };
24781
+ for (const path$1 of extractGitPathVariants(rawPath)) diffByPath.set(path$1, diff);
24782
+ }
24783
+ return diffByPath;
24784
+ }
24785
+ function resolveTrackedDiff(diffByPath, status) {
24786
+ return diffByPath.get(status.path) ?? (status.previousPath ? diffByPath.get(status.previousPath) : void 0) ?? {
24787
+ files: 1,
24788
+ insertions: 0,
24789
+ deletions: 0
24790
+ };
24791
+ }
24792
+ function readyFileDiff(diff) {
24793
+ return {
24794
+ state: "ready",
24795
+ ...diff
24796
+ };
24797
+ }
24798
+ function loadingFileDiff(files = 1) {
24799
+ return {
24800
+ state: "loading",
24801
+ files
24802
+ };
24803
+ }
24804
+ function unavailableFileDiff(files = 1) {
24805
+ return {
24806
+ state: "unavailable",
24807
+ files
24808
+ };
24809
+ }
24810
+ function buildTrackedFileSummaries(statuses, numStatOutput) {
24811
+ const diffByPath = parseNumStatMap(numStatOutput);
24812
+ return statuses.map((status) => ({
24813
+ fileId: createGitFileId(status.path, status.previousPath),
24814
+ source: "tracked",
24815
+ path: status.path,
24816
+ displayPath: status.previousPath ? `${status.previousPath} -> ${status.path}` : status.path,
24817
+ previousPath: status.previousPath,
24818
+ changeType: status.changeType,
24819
+ diff: readyFileDiff(resolveTrackedDiff(diffByPath, status))
24820
+ })).sort((left, right) => left.path.localeCompare(right.path));
24821
+ }
24822
+ function buildUntrackedFileSummary(path$1) {
24823
+ return {
24824
+ fileId: createGitFileId(path$1, null),
24825
+ source: "untracked",
24826
+ path: path$1,
24827
+ displayPath: path$1,
24828
+ previousPath: null,
24829
+ changeType: "added",
24830
+ diff: loadingFileDiff()
24831
+ };
24832
+ }
24833
+ async function collectWorktreeSummary(options) {
24834
+ const { projectDir, worktree, defaultBranch, runGit } = options;
24835
+ const worktreePath = resolve$1(worktree.path);
24836
+ const resolvedProjectDir = resolve$1(projectDir);
24837
+ const isCurrent = await sameGitPath(worktreePath, resolvedProjectDir);
24838
+ const pathAvailable = await pathExists(worktreePath);
24839
+ const aheadBehindResult = await runGit(worktreePath, [
24840
+ "rev-list",
24841
+ "--left-right",
24842
+ "--count",
24843
+ `${defaultBranch}...HEAD`
24844
+ ]);
24845
+ let ahead = 0;
24846
+ let behind = 0;
24847
+ if (aheadBehindResult.ok) {
24848
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
24849
+ ahead = Number(aheadRaw) || 0;
24850
+ behind = Number(behindRaw) || 0;
24851
+ }
24852
+ const diffResult = await runGit(worktreePath, [
24853
+ "diff",
24854
+ "--shortstat",
24855
+ `${defaultBranch}...HEAD`
24856
+ ]);
24857
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
24858
+ return {
24859
+ path: worktreePath,
24860
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
24861
+ pathAvailable,
24862
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
24863
+ detached: worktree.detached,
24864
+ isCurrent,
24865
+ ahead,
24866
+ behind,
24867
+ diff
24868
+ };
24869
+ }
24870
+ function normalizePatchState(patch) {
24871
+ const trimmed = patch.trimEnd();
24872
+ if (!trimmed) return {
24873
+ state: "unavailable",
24874
+ patch: null
24875
+ };
24876
+ if (/^GIT binary patch$/m.test(trimmed) || /^Binary files .* differ$/m.test(trimmed)) return {
24877
+ state: "binary",
24878
+ patch: null
24879
+ };
24880
+ if (Buffer.byteLength(trimmed, "utf8") > MAX_PATCH_BYTES) return {
24881
+ state: "too-large",
24882
+ patch: null
24883
+ };
24884
+ return {
24885
+ state: "available",
24886
+ patch: trimmed
24887
+ };
24888
+ }
24889
+ function splitPatchLines(text) {
24890
+ if (!text) return [];
24891
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
24892
+ if (lines.at(-1) === "") lines.pop();
24893
+ return lines;
24894
+ }
24895
+ async function buildTrackedPatchFile(options) {
24896
+ const { worktreePath, file, runGit, selector } = options;
24897
+ const patchResult = await runGit(worktreePath, selector.type === "commit" ? [
24898
+ "show",
24899
+ "--patch",
24900
+ "--find-renames",
24901
+ "--format=",
24902
+ selector.hash,
24903
+ "--",
24904
+ file.path
24905
+ ] : [
24906
+ "diff",
24907
+ "--patch",
24908
+ "--find-renames",
24909
+ "HEAD",
24910
+ "--",
24911
+ file.path
24912
+ ]);
24913
+ const normalized = normalizePatchState(patchResult.stdout);
24914
+ return {
24915
+ ...file,
24916
+ patch: normalized.patch,
24917
+ state: patchResult.ok ? normalized.state : "unavailable"
24918
+ };
24919
+ }
24920
+ async function buildUntrackedPatchFile(worktreePath, file) {
24921
+ try {
24922
+ const buffer = await readFile$1(resolve$1(worktreePath, file.path));
24923
+ if (buffer.byteLength > MAX_SYNTHETIC_TEXT_BYTES) return {
24924
+ ...file,
24925
+ diff: unavailableFileDiff(),
24926
+ patch: null,
24927
+ state: "too-large"
24928
+ };
24929
+ if (buffer.includes(0)) return {
24930
+ ...file,
24931
+ diff: unavailableFileDiff(),
24932
+ patch: null,
24933
+ state: "binary"
24934
+ };
24935
+ const lines = splitPatchLines(buffer.toString("utf8"));
24936
+ const hunkHeader = lines.length > 0 ? `@@ -0,0 +1,${lines.length} @@` : null;
24937
+ const body = lines.map((line) => `+${line}`);
24938
+ const patch = [
24939
+ `diff --git a/${file.path} b/${file.path}`,
24940
+ "new file mode 100644",
24941
+ "--- /dev/null",
24942
+ `+++ b/${file.path}`,
24943
+ ...hunkHeader ? [hunkHeader] : [],
24944
+ ...body
24945
+ ].join("\n");
24946
+ return {
24947
+ ...file,
24948
+ diff: readyFileDiff({
24949
+ files: 1,
24950
+ insertions: lines.length,
24951
+ deletions: 0
24952
+ }),
24953
+ patch: patch.trimEnd(),
24954
+ state: "available"
24955
+ };
24956
+ } catch {
24957
+ return {
24958
+ ...file,
24959
+ diff: unavailableFileDiff(),
24960
+ patch: null,
24961
+ state: "unavailable"
24962
+ };
24963
+ }
24964
+ }
24965
+ async function buildCommitShell(options) {
24966
+ const { worktreePath, hash, runGit } = options;
24967
+ const [entry, nameStatusResult, numStatResult] = await Promise.all([
24968
+ readGitCommitEntryByHash({
24969
+ worktreePath,
24970
+ hash,
24971
+ runGit
24972
+ }),
24973
+ runGit(worktreePath, [
24974
+ "show",
24975
+ "--name-status",
24976
+ "--find-renames",
24977
+ "--format=",
24978
+ hash
24979
+ ]),
24980
+ runGit(worktreePath, [
24981
+ "show",
24982
+ "--numstat",
24983
+ "--format=",
24984
+ hash
24985
+ ])
24986
+ ]);
24987
+ if (!entry) return {
24988
+ entry: null,
24989
+ files: []
24990
+ };
24991
+ return {
24992
+ entry,
24993
+ files: buildTrackedFileSummaries(nameStatusResult.ok ? parseGitNameStatus(nameStatusResult.stdout) : [], numStatResult.stdout)
24994
+ };
24995
+ }
24996
+ async function buildUncommittedShell(options) {
24997
+ const { worktreePath, runGit, readPathTimestampMs } = options;
24998
+ const [entry, trackedStatusResult, trackedNumStatResult, untrackedResult] = await Promise.all([
24999
+ collectUncommittedEntrySummary({
25000
+ worktreePath,
25001
+ runGit,
25002
+ readPathTimestampMs
25003
+ }),
25004
+ runGit(worktreePath, [
25005
+ "diff",
25006
+ "--name-status",
25007
+ "--find-renames",
25008
+ "HEAD"
25009
+ ]),
25010
+ runGit(worktreePath, [
25011
+ "diff",
25012
+ "--numstat",
25013
+ "HEAD"
25014
+ ]),
25015
+ runGit(worktreePath, [
25016
+ "ls-files",
25017
+ "--others",
25018
+ "--exclude-standard"
25019
+ ])
25020
+ ]);
25021
+ const trackedFiles = buildTrackedFileSummaries(trackedStatusResult.ok ? parseGitNameStatus(trackedStatusResult.stdout) : [], trackedNumStatResult.stdout);
25022
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0).map((path$1) => buildUntrackedFileSummary(path$1));
25023
+ return {
25024
+ entry,
25025
+ files: [...trackedFiles, ...untrackedFiles].sort((left, right) => left.path.localeCompare(right.path))
25026
+ };
25027
+ }
25028
+ async function loadGitEntryShell(options) {
25029
+ const runGit = options.runGit ?? defaultRunGit;
25030
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
25031
+ const resolvedProjectDir = resolve$1(options.projectDir);
25032
+ if (options.selector.type === "uncommitted") return buildUncommittedShell({
25033
+ worktreePath: resolvedProjectDir,
25034
+ runGit,
25035
+ readPathTimestampMs
25036
+ });
25037
+ return buildCommitShell({
25038
+ worktreePath: resolvedProjectDir,
25039
+ hash: options.selector.hash,
25040
+ runGit
25041
+ });
25042
+ }
25043
+ async function buildGitWorktreeOverview(options) {
25044
+ const resolvedProjectDir = resolve$1(options.projectDir);
25045
+ return getCachedGitPanelValue("overview", resolvedProjectDir, "overview", async () => {
25046
+ const runGit = options.runGit ?? defaultRunGit;
25047
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
25048
+ const worktrees = await listGitWorktrees(resolvedProjectDir, runGit);
25049
+ const summaries = await Promise.all(worktrees.map((worktree) => collectWorktreeSummary({
25050
+ projectDir: resolvedProjectDir,
25051
+ worktree,
25052
+ defaultBranch,
25053
+ runGit
25054
+ })));
25055
+ summaries.sort((left, right) => {
25056
+ if (left.isCurrent !== right.isCurrent) return left.isCurrent ? -1 : 1;
25057
+ return left.branchName.localeCompare(right.branchName);
25058
+ });
25059
+ return {
25060
+ defaultBranch,
25061
+ currentWorktree: summaries.find((worktree) => worktree.isCurrent) ?? null,
25062
+ otherWorktrees: summaries.filter((worktree) => !worktree.isCurrent)
25063
+ };
25064
+ });
25065
+ }
25066
+ async function listCurrentWorktreeGitEntries(options) {
25067
+ const resolvedProjectDir = resolve$1(options.projectDir);
25068
+ const limit = clampEntryLimit(options.limit);
25069
+ const offset = parseCursor(options.cursor);
25070
+ return getCachedGitPanelValue("entries", resolvedProjectDir, `entries:${offset}:${limit}`, async () => {
25071
+ const runGit = options.runGit ?? defaultRunGit;
25072
+ const readPathTimestampMs = options.readPathTimestampMs ?? defaultReadPathTimestampMs;
25073
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
25074
+ const uncommitted = await collectUncommittedEntrySummary({
25075
+ worktreePath: resolvedProjectDir,
25076
+ runGit,
25077
+ readPathTimestampMs
25078
+ });
25079
+ const includeUncommitted = offset === 0 && uncommitted.diff.files > 0;
25080
+ const commitLimit = includeUncommitted ? Math.max(0, limit - 1) : limit;
25081
+ const commitsPage = commitLimit > 0 ? await listGitCommitEntriesPage({
25082
+ worktreePath: resolvedProjectDir,
25083
+ defaultBranch,
25084
+ offset,
25085
+ limit: commitLimit,
25086
+ runGit
25087
+ }) : {
25088
+ items: [],
25089
+ nextCursor: null
25090
+ };
25091
+ return {
25092
+ items: includeUncommitted ? [uncommitted, ...commitsPage.items] : commitsPage.items,
25093
+ nextCursor: commitsPage.nextCursor
25094
+ };
25095
+ });
25096
+ }
25097
+ async function getCurrentWorktreeGitEntryShell(options) {
25098
+ const resolvedProjectDir = resolve$1(options.projectDir);
25099
+ return getCachedGitPanelValue("shell", resolvedProjectDir, options.selector.type === "commit" ? `commit:${options.selector.hash}` : "uncommitted", () => loadGitEntryShell({
25100
+ ...options,
25101
+ projectDir: resolvedProjectDir
25102
+ }));
25103
+ }
25104
+ async function getCurrentWorktreeGitEntryPatch(options) {
25105
+ const resolvedProjectDir = resolve$1(options.projectDir);
25106
+ return getCachedGitPanelValue("patch", resolvedProjectDir, `${options.selector.type === "commit" ? `commit:${options.selector.hash}` : "uncommitted"}:${options.fileId}`, async () => {
25107
+ const runGit = options.runGit ?? defaultRunGit;
25108
+ const shell = await getCurrentWorktreeGitEntryShell({
25109
+ ...options,
25110
+ projectDir: resolvedProjectDir
25111
+ });
25112
+ const file = shell.files.find((candidate) => candidate.fileId === options.fileId) ?? null;
25113
+ if (!shell.entry || !file) return {
25114
+ entry: shell.entry,
25115
+ file: null
25116
+ };
25117
+ const patch = file.source === "untracked" ? await buildUntrackedPatchFile(resolvedProjectDir, file) : await buildTrackedPatchFile({
25118
+ worktreePath: resolvedProjectDir,
25119
+ file,
25120
+ runGit,
25121
+ selector: options.selector
25122
+ });
25123
+ return {
25124
+ entry: shell.entry,
25125
+ file: patch
25126
+ };
25127
+ });
25128
+ }
25129
+ async function getCurrentWorktreeGitEntryDetail(options) {
25130
+ const shell = await getCurrentWorktreeGitEntryShell(options);
25131
+ if (!shell.entry) return {
25132
+ entry: null,
25133
+ files: []
25134
+ };
25135
+ const patches = await Promise.all(shell.files.map(async (file) => {
25136
+ return (await getCurrentWorktreeGitEntryPatch({
25137
+ ...options,
25138
+ fileId: file.fileId
25139
+ })).file;
25140
+ }));
25141
+ return {
25142
+ entry: shell.entry,
25143
+ files: patches.filter((file) => file !== null)
25144
+ };
25145
+ }
25146
+
24534
25147
  //#endregion
24535
25148
  //#region ../server/src/reactive-kv.ts
24536
25149
  /**
@@ -24657,6 +25270,10 @@ const OPSX_CORE_PROFILE_WORKFLOWS = [
24657
25270
  "apply",
24658
25271
  "archive"
24659
25272
  ];
25273
+ const gitEntrySelectorSchema = discriminatedUnionType("type", [objectType({ type: literalType("uncommitted") }), objectType({
25274
+ type: literalType("commit"),
25275
+ hash: stringType().min(1)
25276
+ })]);
24660
25277
  function requireChangeId(changeId) {
24661
25278
  if (!changeId) throw new Error("change is required");
24662
25279
  return changeId;
@@ -25601,11 +26218,63 @@ const dashboardRouter = router({
25601
26218
  });
25602
26219
  })
25603
26220
  });
26221
+ const gitRouter = router({
26222
+ overview: publicProcedure.query(async ({ ctx }) => {
26223
+ return buildGitWorktreeOverview({ projectDir: ctx.projectDir });
26224
+ }),
26225
+ listEntries: publicProcedure.input(objectType({
26226
+ cursor: stringType().optional(),
26227
+ limit: numberType().int().min(1).max(100).optional()
26228
+ }).optional()).query(async ({ ctx, input }) => {
26229
+ return listCurrentWorktreeGitEntries({
26230
+ projectDir: ctx.projectDir,
26231
+ cursor: input?.cursor,
26232
+ limit: input?.limit
26233
+ });
26234
+ }),
26235
+ getEntryDetail: publicProcedure.input(objectType({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
26236
+ return getCurrentWorktreeGitEntryDetail({
26237
+ projectDir: ctx.projectDir,
26238
+ selector: input.selector
26239
+ });
26240
+ }),
26241
+ getEntryShell: publicProcedure.input(objectType({ selector: gitEntrySelectorSchema })).query(async ({ ctx, input }) => {
26242
+ return getCurrentWorktreeGitEntryShell({
26243
+ projectDir: ctx.projectDir,
26244
+ selector: input.selector
26245
+ });
26246
+ }),
26247
+ getEntryPatch: publicProcedure.input(objectType({
26248
+ selector: gitEntrySelectorSchema,
26249
+ fileId: stringType().min(1)
26250
+ })).query(async ({ ctx, input }) => {
26251
+ return getCurrentWorktreeGitEntryPatch({
26252
+ projectDir: ctx.projectDir,
26253
+ selector: input.selector,
26254
+ fileId: input.fileId
26255
+ });
26256
+ }),
26257
+ switchWorktree: publicProcedure.input(objectType({ path: stringType().min(1) })).mutation(async ({ ctx, input }) => {
26258
+ if (!ctx.gitWorktreeHandoff) throw new Error("Worktree handoff is unavailable in this runtime.");
26259
+ const overview = await buildGitWorktreeOverview({ projectDir: ctx.projectDir });
26260
+ const resolvedInputPath = resolve$1(input.path);
26261
+ let target = null;
26262
+ if (overview.currentWorktree && await sameGitPath(overview.currentWorktree.path, resolvedInputPath)) target = overview.currentWorktree;
26263
+ else for (const worktree of overview.otherWorktrees) if (await sameGitPath(worktree.path, resolvedInputPath)) {
26264
+ target = worktree;
26265
+ break;
26266
+ }
26267
+ if (!target) throw new Error("Worktree not found.");
26268
+ if (!target.pathAvailable) throw new Error("Worktree path is no longer available. Remove the stale worktree entry first.");
26269
+ return ctx.gitWorktreeHandoff.ensureWorktreeServer({ targetPath: target.path });
26270
+ })
26271
+ });
25604
26272
  /**
25605
26273
  * Main app router
25606
26274
  */
25607
26275
  const appRouter = router({
25608
26276
  dashboard: dashboardRouter,
26277
+ git: gitRouter,
25609
26278
  spec: specRouter,
25610
26279
  change: changeRouter,
25611
26280
  archive: archiveRouter,
@@ -25906,6 +26575,7 @@ function createServer$2(config) {
25906
26575
  kernel,
25907
26576
  searchService,
25908
26577
  dashboardOverviewService,
26578
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
25909
26579
  watcher,
25910
26580
  projectDir: config.projectDir
25911
26581
  })
@@ -25918,6 +26588,7 @@ function createServer$2(config) {
25918
26588
  kernel,
25919
26589
  searchService,
25920
26590
  dashboardOverviewService,
26591
+ gitWorktreeHandoff: config.gitWorktreeHandoff,
25921
26592
  watcher,
25922
26593
  projectDir: config.projectDir
25923
26594
  });
@@ -26034,6 +26705,214 @@ function getWebAssetsDirCandidates(runtimeDir) {
26034
26705
  return [join$1(runtimeDir, "..", "..", "web", "dist"), prodPath];
26035
26706
  }
26036
26707
 
26708
+ //#endregion
26709
+ //#region src/worktree-instance-manager.ts
26710
+ const DEFAULT_CHILD_TIMEOUT_MS = 15e3;
26711
+ const DEFAULT_PORT_START = 3100;
26712
+ const DEFAULT_PORT_ATTEMPTS = 200;
26713
+ function resolveLocalCliWorkspace(runtimeDir) {
26714
+ const repoRoot = resolve$1(runtimeDir, "..", "..", "..");
26715
+ const rootPackageJson = join$1(repoRoot, "package.json");
26716
+ const cliPackageJson = join$1(repoRoot, "packages", "cli", "package.json");
26717
+ const cliSourceEntry = join$1(repoRoot, "packages", "cli", "src", "cli.ts");
26718
+ if (!existsSync(rootPackageJson) || !existsSync(cliPackageJson) || !existsSync(cliSourceEntry)) return null;
26719
+ return {
26720
+ repoRoot,
26721
+ cliPackageDir: join$1(repoRoot, "packages", "cli")
26722
+ };
26723
+ }
26724
+ function resolvePnpmCommand() {
26725
+ return process.platform === "win32" ? "pnpm.cmd" : "pnpm";
26726
+ }
26727
+ function createWorktreeServerCommand(options) {
26728
+ const workspace = resolveLocalCliWorkspace(options.runtimeDir);
26729
+ if (workspace) return {
26730
+ command: resolvePnpmCommand(),
26731
+ args: [
26732
+ "--filter",
26733
+ "openspecui",
26734
+ "run",
26735
+ "dev",
26736
+ "--dir",
26737
+ options.projectDir,
26738
+ "--port",
26739
+ String(options.port),
26740
+ "--no-open"
26741
+ ],
26742
+ cwd: workspace.repoRoot,
26743
+ env: { ...process.env }
26744
+ };
26745
+ return {
26746
+ command: process.execPath,
26747
+ args: [
26748
+ join$1(options.runtimeDir, "cli.mjs"),
26749
+ "start",
26750
+ options.projectDir,
26751
+ "--port",
26752
+ String(options.port),
26753
+ "--no-open"
26754
+ ],
26755
+ cwd: options.projectDir,
26756
+ env: { ...process.env }
26757
+ };
26758
+ }
26759
+ async function waitForServerReady(options) {
26760
+ let exitMessage = null;
26761
+ let startupError = null;
26762
+ options.child.once("error", (error) => {
26763
+ startupError = error;
26764
+ });
26765
+ options.child.once("exit", (code, signal) => {
26766
+ exitMessage = signal ? `signal ${signal}` : `exit ${code ?? "unknown"}`;
26767
+ });
26768
+ const deadline = Date.now() + options.timeoutMs;
26769
+ while (Date.now() < deadline) {
26770
+ if (startupError) throw startupError;
26771
+ if (exitMessage) throw new Error(`Worktree server exited before becoming ready (${exitMessage})`);
26772
+ try {
26773
+ const response = await fetch(`${options.serverUrl}/api/health`, {
26774
+ headers: { accept: "application/json" },
26775
+ cache: "no-store"
26776
+ });
26777
+ if (response.ok) {
26778
+ if ((await response.json()).projectDir === options.projectDir) return;
26779
+ }
26780
+ } catch {}
26781
+ await delay(250);
26782
+ }
26783
+ throw new Error(`Timed out waiting for worktree server at ${options.serverUrl}`);
26784
+ }
26785
+ async function isHealthyInstance(instance) {
26786
+ try {
26787
+ const response = await fetch(`${instance.serverUrl}/api/health`, {
26788
+ headers: { accept: "application/json" },
26789
+ cache: "no-store"
26790
+ });
26791
+ if (!response.ok) return false;
26792
+ return (await response.json()).projectDir === instance.projectDir;
26793
+ } catch {
26794
+ return false;
26795
+ }
26796
+ }
26797
+ function killChildProcess(child, signal) {
26798
+ if (process.platform !== "win32" && child.pid) try {
26799
+ process.kill(-child.pid, signal);
26800
+ return;
26801
+ } catch {}
26802
+ child.kill(signal);
26803
+ }
26804
+ function waitForChildExit(child, timeoutMs) {
26805
+ return new Promise((resolvePromise) => {
26806
+ const timer = setTimeout(() => {
26807
+ cleanup();
26808
+ resolvePromise(false);
26809
+ }, timeoutMs);
26810
+ const onExit = () => {
26811
+ cleanup();
26812
+ resolvePromise(true);
26813
+ };
26814
+ const cleanup = () => {
26815
+ clearTimeout(timer);
26816
+ child.off("exit", onExit);
26817
+ };
26818
+ child.once("exit", onExit);
26819
+ });
26820
+ }
26821
+ async function stopChildProcess(child) {
26822
+ if (child.exitCode !== null || child.signalCode !== null) return;
26823
+ killChildProcess(child, "SIGTERM");
26824
+ if (await waitForChildExit(child, 5e3)) return;
26825
+ killChildProcess(child, "SIGKILL");
26826
+ await waitForChildExit(child, 1e3);
26827
+ }
26828
+ function delay(ms) {
26829
+ return new Promise((resolvePromise) => {
26830
+ setTimeout(resolvePromise, ms);
26831
+ });
26832
+ }
26833
+ function createWorktreeInstanceManager(options) {
26834
+ const currentProjectDir = resolve$1(options.currentProjectDir);
26835
+ const instances = /* @__PURE__ */ new Map();
26836
+ const pending = /* @__PURE__ */ new Map();
26837
+ const ensureWorktreeServer = async (input) => {
26838
+ const targetPath = resolve$1(input.targetPath);
26839
+ if (targetPath === currentProjectDir) return {
26840
+ projectDir: currentProjectDir,
26841
+ serverUrl: options.currentServerUrl
26842
+ };
26843
+ const existing = instances.get(targetPath);
26844
+ if (existing && await isHealthyInstance(existing)) {
26845
+ existing.lastUsedAt = Date.now();
26846
+ return {
26847
+ projectDir: existing.projectDir,
26848
+ serverUrl: existing.serverUrl
26849
+ };
26850
+ }
26851
+ if (existing) {
26852
+ instances.delete(targetPath);
26853
+ await stopChildProcess(existing.child);
26854
+ }
26855
+ const pendingInstance = pending.get(targetPath);
26856
+ if (pendingInstance) return pendingInstance;
26857
+ const promise = (async () => {
26858
+ const port = await findAvailablePort(options.preferredPortStart ?? DEFAULT_PORT_START, DEFAULT_PORT_ATTEMPTS);
26859
+ const command = createWorktreeServerCommand({
26860
+ runtimeDir: options.runtimeDir,
26861
+ projectDir: targetPath,
26862
+ port
26863
+ });
26864
+ const child = spawn$1(command.command, command.args, {
26865
+ cwd: command.cwd,
26866
+ env: command.env,
26867
+ stdio: "inherit",
26868
+ detached: process.platform !== "win32"
26869
+ });
26870
+ const serverUrl = `http://localhost:${port}`;
26871
+ try {
26872
+ await waitForServerReady({
26873
+ serverUrl,
26874
+ projectDir: targetPath,
26875
+ child,
26876
+ timeoutMs: options.readinessTimeoutMs ?? DEFAULT_CHILD_TIMEOUT_MS
26877
+ });
26878
+ } catch (error) {
26879
+ await stopChildProcess(child);
26880
+ throw error;
26881
+ }
26882
+ const instance = {
26883
+ projectDir: targetPath,
26884
+ serverUrl,
26885
+ child,
26886
+ lastUsedAt: Date.now()
26887
+ };
26888
+ instances.set(targetPath, instance);
26889
+ child.once("exit", () => {
26890
+ if (instances.get(targetPath)?.child === child) instances.delete(targetPath);
26891
+ });
26892
+ return {
26893
+ projectDir: targetPath,
26894
+ serverUrl
26895
+ };
26896
+ })();
26897
+ pending.set(targetPath, promise);
26898
+ try {
26899
+ return await promise;
26900
+ } finally {
26901
+ pending.delete(targetPath);
26902
+ }
26903
+ };
26904
+ const close = async () => {
26905
+ await Promise.all([...instances.values()].map(async (instance) => {
26906
+ instances.delete(instance.projectDir);
26907
+ await stopChildProcess(instance.child);
26908
+ }));
26909
+ };
26910
+ return {
26911
+ ensureWorktreeServer,
26912
+ close
26913
+ };
26914
+ }
26915
+
26037
26916
  //#endregion
26038
26917
  //#region src/index.ts
26039
26918
  const __dirname = dirname$1(fileURLToPath(import.meta.url));
@@ -26079,12 +26958,29 @@ function setupStaticFiles(app) {
26079
26958
  }
26080
26959
  async function startServer$1(options = {}) {
26081
26960
  const { projectDir = process.cwd(), port = 3100, enableWatcher = true, corsOrigins } = options;
26082
- return await startServer({
26961
+ let worktreeManager = null;
26962
+ const server = await startServer({
26083
26963
  projectDir,
26084
26964
  port,
26085
26965
  enableWatcher,
26086
- corsOrigins
26966
+ corsOrigins,
26967
+ gitWorktreeHandoff: { ensureWorktreeServer: async ({ targetPath }) => {
26968
+ if (!worktreeManager) throw new Error("Worktree handoff is not ready yet.");
26969
+ return worktreeManager.ensureWorktreeServer({ targetPath });
26970
+ } }
26087
26971
  }, setupStaticFiles);
26972
+ worktreeManager = createWorktreeInstanceManager({
26973
+ currentProjectDir: projectDir,
26974
+ currentServerUrl: server.url,
26975
+ runtimeDir: __dirname
26976
+ });
26977
+ return {
26978
+ ...server,
26979
+ close: async () => {
26980
+ await worktreeManager?.close();
26981
+ await server.close();
26982
+ }
26983
+ };
26088
26984
  }
26089
26985
 
26090
26986
  //#endregion