@xbghc/warden 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // bin/cli.ts
4
4
  import path8 from "path";
5
5
  import { spawn } from "child_process";
6
- import { existsSync } from "fs";
6
+ import { existsSync as existsSync2 } from "fs";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
9
  // packages/server/src/index.ts
@@ -2960,6 +2960,11 @@ function commentScopeKey(key) {
2960
2960
  if (!t || !isLocalTarget(t)) return key;
2961
2961
  return t.worktree ? `worktree:${t.worktree}:local` : "local";
2962
2962
  }
2963
+ function stageModeFor(t) {
2964
+ if (t.kind === "working") return "stage";
2965
+ if (t.kind === "staged") return "unstage";
2966
+ return void 0;
2967
+ }
2963
2968
  function localViewKeys(key) {
2964
2969
  const t = tryParseTargetKey(key);
2965
2970
  const wt = t?.worktree ? { worktree: t.worktree } : {};
@@ -2970,6 +2975,24 @@ function localViewKeys(key) {
2970
2975
  ];
2971
2976
  }
2972
2977
 
2978
+ // packages/shared/src/order.ts
2979
+ function insertAfter(list, item, after) {
2980
+ const idx = after === void 0 ? -1 : list.findIndex((x) => x.id === after);
2981
+ if (idx < 0) return [item, ...list];
2982
+ return [...list.slice(0, idx + 1), item, ...list.slice(idx + 1)];
2983
+ }
2984
+ function moveBefore(list, id, before) {
2985
+ const item = list.find((x) => x.id === id);
2986
+ if (!item) return null;
2987
+ if (before === id) return [...list];
2988
+ const rest = list.filter((x) => x.id !== id);
2989
+ if (before === null) return [...rest, item];
2990
+ const idx = rest.findIndex((x) => x.id === before);
2991
+ if (idx < 0) return null;
2992
+ rest.splice(idx, 0, item);
2993
+ return rest;
2994
+ }
2995
+
2973
2996
  // packages/server/src/errors.ts
2974
2997
  var HttpError = class extends Error {
2975
2998
  constructor(status, message, code) {
@@ -3025,7 +3048,8 @@ function parseCommitLog(stdout) {
3025
3048
 
3026
3049
  // packages/server/src/git.ts
3027
3050
  import { execFile } from "child_process";
3028
- var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base"]);
3051
+ import { existsSync } from "fs";
3052
+ var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base", "rev-list"]);
3029
3053
  var FORBIDDEN_OPTION_PREFIXES = ["--output", "--ext-diff", "--textconv", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree"];
3030
3054
  var DEFAULT_GIT_TIMEOUT_MS = 3e4;
3031
3055
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -3087,6 +3111,10 @@ async function runGit(args, opts) {
3087
3111
  return;
3088
3112
  }
3089
3113
  if (err.code === "ENOENT") {
3114
+ if (opts.cwd && !existsSync(opts.cwd)) {
3115
+ reject(new GitError(`working directory no longer exists: ${opts.cwd}`, null, "", 400, "unknown_worktree"));
3116
+ return;
3117
+ }
3090
3118
  reject(new GitError("git executable not found in PATH", null, "", 500, "git_missing"));
3091
3119
  return;
3092
3120
  }
@@ -3104,6 +3132,64 @@ async function runGit(args, opts) {
3104
3132
  );
3105
3133
  });
3106
3134
  }
3135
+ async function applyToIndex(cwd, patch, opts = {}) {
3136
+ for (let attempt = 1; ; attempt++) {
3137
+ try {
3138
+ await applyOnce(cwd, patch, opts);
3139
+ return;
3140
+ } catch (e) {
3141
+ if (!(e instanceof GitError && e.code === "index_locked") || attempt >= 3) throw e;
3142
+ await new Promise((r) => setTimeout(r, 150 * attempt));
3143
+ }
3144
+ }
3145
+ }
3146
+ async function applyOnce(cwd, patch, opts) {
3147
+ const args = ["apply", "--cached", "--whitespace=nowarn"];
3148
+ if (opts.reverse) args.push("--reverse");
3149
+ args.push("-");
3150
+ await new Promise((resolve, reject) => {
3151
+ const child = execFile(
3152
+ "git",
3153
+ args,
3154
+ {
3155
+ cwd,
3156
+ timeout: opts.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
3157
+ maxBuffer: 16 * 1024 * 1024,
3158
+ encoding: "utf8",
3159
+ env: { ...process.env, LC_ALL: "C", GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" },
3160
+ windowsHide: true
3161
+ },
3162
+ (error, _stdout, stderr) => {
3163
+ const err = error;
3164
+ if (!err) {
3165
+ resolve();
3166
+ return;
3167
+ }
3168
+ if (err.killed || err.signal === "SIGTERM") {
3169
+ reject(new GitError("git apply timed out", null, stderr, 504, "git_timeout"));
3170
+ return;
3171
+ }
3172
+ if (err.code === "ENOENT") {
3173
+ if (!existsSync(cwd)) {
3174
+ reject(new GitError(`working directory no longer exists: ${cwd}`, null, "", 400, "unknown_worktree"));
3175
+ return;
3176
+ }
3177
+ reject(new GitError("git executable not found in PATH", null, "", 500, "git_missing"));
3178
+ return;
3179
+ }
3180
+ const code = typeof err.code === "number" ? err.code : -1;
3181
+ const first = stderr.trim().split("\n")[0] || err.message;
3182
+ if (/index\.lock/.test(stderr)) {
3183
+ reject(new GitError("the index is locked by another git process; try again", code, stderr, 409, "index_locked"));
3184
+ return;
3185
+ }
3186
+ reject(new GitError(first.replace(/^error: /, ""), code, stderr, 409, "apply_failed"));
3187
+ }
3188
+ );
3189
+ child.stdin?.on("error", () => void 0);
3190
+ child.stdin?.end(patch);
3191
+ });
3192
+ }
3107
3193
  async function refExists(cwd, ref) {
3108
3194
  try {
3109
3195
  await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd });
@@ -3159,7 +3245,7 @@ async function listWorktrees(ctx) {
3159
3245
  const out = [];
3160
3246
  let cur = null;
3161
3247
  const flush = () => {
3162
- if (cur && cur.path) {
3248
+ if (cur && cur.path && !cur.prunable) {
3163
3249
  out.push({
3164
3250
  path: cur.path,
3165
3251
  head: cur.head ?? "",
@@ -3185,6 +3271,7 @@ async function listWorktrees(ctx) {
3185
3271
  else if (line.startsWith("branch ")) cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3186
3272
  else if (line === "detached") cur.detached = true;
3187
3273
  else if (line === "bare") cur.bare = true;
3274
+ else if (line.startsWith("prunable")) cur.prunable = true;
3188
3275
  }
3189
3276
  flush();
3190
3277
  for (const wt of out) {
@@ -3387,11 +3474,11 @@ function parseUnifiedDiff(text) {
3387
3474
  continue;
3388
3475
  }
3389
3476
  if (line.startsWith("--- ")) {
3390
- c.fromMinus = stripPrefix(line.slice(4), "a/");
3477
+ c.fromMinus = stripPrefix(line.slice(4).replace(/\t$/, ""), "a/");
3391
3478
  continue;
3392
3479
  }
3393
3480
  if (line.startsWith("+++ ")) {
3394
- c.fromPlus = stripPrefix(line.slice(4), "b/");
3481
+ c.fromPlus = stripPrefix(line.slice(4).replace(/\t$/, ""), "b/");
3395
3482
  continue;
3396
3483
  }
3397
3484
  if (line.startsWith("new file mode ")) {
@@ -3464,6 +3551,10 @@ function resolveTargetContext(repo, worktrees, key) {
3464
3551
  }
3465
3552
  return { key, target, cwd };
3466
3553
  }
3554
+ function assertRepoPath(p) {
3555
+ if (!p || p.startsWith("/") || p.split("/").includes("..")) throw badRequest(`invalid path: ${p}`, "invalid_path");
3556
+ }
3557
+ var literal = (p) => `:(literal)${p}`;
3467
3558
  async function hasHead(cwd) {
3468
3559
  return refExists(cwd, "HEAD");
3469
3560
  }
@@ -3491,6 +3582,7 @@ async function diffArgs(ctx) {
3491
3582
  case "range": {
3492
3583
  if (!await refExists(ctx.cwd, t.base)) throw badRequest(`unknown ref: ${t.base}`, "unknown_ref");
3493
3584
  if (!await refExists(ctx.cwd, t.head)) throw badRequest(`unknown ref: ${t.head}`, "unknown_ref");
3585
+ if (!await mergeBase(ctx.cwd, t.base, t.head)) throw badRequest(`${t.base} and ${t.head} share no history`, "no_merge_base");
3494
3586
  return [...DIFF_BASE_ARGS, `${t.base}...${t.head}`];
3495
3587
  }
3496
3588
  case "base":
@@ -3505,7 +3597,7 @@ async function listUntracked(cwd) {
3505
3597
  return r.stdout.split("\0").filter(Boolean);
3506
3598
  }
3507
3599
  async function isUntracked(cwd, file) {
3508
- const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", file], { cwd });
3600
+ const r = await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", literal(file)], { cwd });
3509
3601
  return r.stdout.split("\0").filter(Boolean).includes(file);
3510
3602
  }
3511
3603
  async function untrackedDiff(cwd, file) {
@@ -3551,15 +3643,14 @@ function toSummary(f) {
3551
3643
  return rest;
3552
3644
  }
3553
3645
  async function getFileDiff(ctx, filePath, hints = {}) {
3554
- if (!filePath || filePath.startsWith("/") || filePath.split("/").includes("..")) {
3555
- throw badRequest(`invalid path: ${filePath}`, "invalid_path");
3556
- }
3646
+ assertRepoPath(filePath);
3647
+ if (hints.oldPath) assertRepoPath(hints.oldPath);
3557
3648
  if (includesUntracked(ctx.target) && (hints.untracked || await isUntracked(ctx.cwd, filePath))) {
3558
3649
  return untrackedDiff(ctx.cwd, filePath);
3559
3650
  }
3560
3651
  const args = await diffArgs(ctx);
3561
- const pathspec = [filePath];
3562
- if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(hints.oldPath);
3652
+ const pathspec = [literal(filePath)];
3653
+ if (hints.oldPath && hints.oldPath !== filePath) pathspec.push(literal(hints.oldPath));
3563
3654
  const r = await runGit([...args, "--", ...pathspec], { cwd: ctx.cwd });
3564
3655
  const files = parseUnifiedDiff(r.stdout);
3565
3656
  return files.find((f) => f.path === filePath) ?? files[0];
@@ -3583,9 +3674,7 @@ async function refForSide(ctx, side) {
3583
3674
  }
3584
3675
  }
3585
3676
  async function getFullFile(ctx, filePath, side) {
3586
- if (!filePath || filePath.startsWith("/") || filePath.split("/").includes("..")) {
3587
- throw badRequest(`invalid path: ${filePath}`, "invalid_path");
3588
- }
3677
+ assertRepoPath(filePath);
3589
3678
  const ref = await refForSide(ctx, side);
3590
3679
  if (ref === void 0) {
3591
3680
  try {
@@ -3722,6 +3811,122 @@ function reanchorComment(comment, diff, now = (/* @__PURE__ */ new Date()).toISO
3722
3811
  };
3723
3812
  }
3724
3813
 
3814
+ // packages/server/src/patch.ts
3815
+ function quotePath(p) {
3816
+ if (!/[\s"\\\x00-\x1f\x7f]|[^\x00-\x7f]/.test(p)) return p;
3817
+ let out = '"';
3818
+ for (const byte of Buffer.from(p, "utf8")) {
3819
+ if (byte === 34) out += '\\"';
3820
+ else if (byte === 92) out += "\\\\";
3821
+ else if (byte === 10) out += "\\n";
3822
+ else if (byte === 9) out += "\\t";
3823
+ else if (byte < 32 || byte >= 127) out += "\\" + byte.toString(8).padStart(3, "0");
3824
+ else out += String.fromCharCode(byte);
3825
+ }
3826
+ return out + '"';
3827
+ }
3828
+ function cutHunk(hunk, picked, mode) {
3829
+ const out = [];
3830
+ hunk.lines.forEach((l, i) => {
3831
+ const noNewline = !!l.noNewline;
3832
+ if (l.type === "context") out.push({ ch: " ", content: l.content, noNewline });
3833
+ else if (picked.has(i)) out.push({ ch: l.type === "add" ? "+" : "-", content: l.content, noNewline });
3834
+ else if (l.type === "del" === (mode === "stage")) out.push({ ch: " ", content: l.content, noNewline });
3835
+ });
3836
+ out.forEach((l, j) => {
3837
+ if (!l.noNewline) return;
3838
+ const rest = out.slice(j + 1);
3839
+ const ok = l.ch === " " ? rest.length === 0 : l.ch === "-" ? rest.every((r) => r.ch === "+") : rest.every((r) => r.ch === "-");
3840
+ if (!ok) throw badRequest("the selection splits the missing newline at the end of the file; include the neighbouring changed lines", "newline_split");
3841
+ });
3842
+ return out;
3843
+ }
3844
+ function pickedLines(diff, selection) {
3845
+ const picked = /* @__PURE__ */ new Map();
3846
+ const changed = (h) => h.lines.map((l, i) => l.type === "context" ? -1 : i).filter((i) => i >= 0);
3847
+ if (selection === void 0) {
3848
+ diff.hunks.forEach((h, i) => picked.set(i, new Set(changed(h))));
3849
+ return picked;
3850
+ }
3851
+ if (!Array.isArray(selection)) throw badRequest("hunks must be an array", "bad_selection");
3852
+ for (const sel of selection) {
3853
+ if (!sel || !Number.isInteger(sel.index) || sel.index < 0 || sel.index >= diff.hunks.length) throw badRequest(`no such hunk: ${sel?.index}`, "bad_selection");
3854
+ const hunk = diff.hunks[sel.index];
3855
+ const set = picked.get(sel.index) ?? /* @__PURE__ */ new Set();
3856
+ picked.set(sel.index, set);
3857
+ if (sel.lines === void 0) {
3858
+ for (const i of changed(hunk)) set.add(i);
3859
+ continue;
3860
+ }
3861
+ if (!Array.isArray(sel.lines)) throw badRequest("lines must be an array of indices", "bad_selection");
3862
+ for (const i of sel.lines) {
3863
+ if (!Number.isInteger(i) || i < 0 || i >= hunk.lines.length) throw badRequest(`no such line in hunk ${sel.index}: ${i}`, "bad_selection");
3864
+ if (hunk.lines[i].type !== "context") set.add(i);
3865
+ }
3866
+ }
3867
+ return picked;
3868
+ }
3869
+ function buildStagePatch(diff, mode, selection) {
3870
+ if (diff.binary) throw badRequest("binary files cannot be staged from here; use git add", "not_stageable");
3871
+ const whole = selection === void 0;
3872
+ const picked = pickedLines(diff, selection);
3873
+ let lines = 0;
3874
+ for (const set of picked.values()) lines += set.size;
3875
+ const changedTotal = diff.hunks.reduce((n, h) => n + h.lines.filter((l) => l.type !== "context").length, 0);
3876
+ const full = lines === changedTotal;
3877
+ const modeChange = !!(diff.oldMode && diff.newMode && diff.oldMode !== diff.newMode);
3878
+ const rename2 = diff.status === "renamed" && !!diff.oldPath && diff.oldPath !== diff.path;
3879
+ const oldPath = diff.oldPath ?? diff.path;
3880
+ const newPath = diff.path;
3881
+ const gitLine = (a, b) => `diff --git ${quotePath(`a/${a}`)} ${quotePath(`b/${b}`)}`;
3882
+ const from = (p) => p === null ? "--- /dev/null" : `--- ${quotePath(`a/${p}`)}`;
3883
+ const to = (p) => p === null ? "+++ /dev/null" : `+++ ${quotePath(`b/${p}`)}`;
3884
+ const header = [];
3885
+ if (diff.status === "added" && (mode === "stage" || full)) {
3886
+ header.push(gitLine(newPath, newPath), `new file mode ${diff.newMode ?? "100644"}`);
3887
+ if (lines > 0) header.push(from(null), to(newPath));
3888
+ } else if (diff.status === "deleted" && full) {
3889
+ header.push(gitLine(oldPath, oldPath), `deleted file mode ${diff.oldMode ?? "100644"}`);
3890
+ if (lines > 0) header.push(from(oldPath), to(null));
3891
+ } else if (diff.status === "deleted" && mode === "unstage") {
3892
+ throw badRequest("a staged deletion can only be unstaged as a whole", "whole_file_only");
3893
+ } else if (rename2 && full) {
3894
+ header.push(gitLine(oldPath, newPath));
3895
+ if (whole && modeChange) header.push(`old mode ${diff.oldMode}`, `new mode ${diff.newMode}`);
3896
+ header.push(`rename from ${quotePath(oldPath)}`, `rename to ${quotePath(newPath)}`);
3897
+ if (lines > 0) header.push(from(oldPath), to(newPath));
3898
+ } else {
3899
+ header.push(gitLine(newPath, newPath));
3900
+ if (whole && modeChange) header.push(`old mode ${diff.oldMode}`, `new mode ${diff.newMode}`);
3901
+ if (lines > 0) header.push(from(newPath), to(newPath));
3902
+ }
3903
+ if (lines === 0) {
3904
+ const headerOnly = whole && (modeChange || rename2 || (diff.status === "added" || diff.status === "deleted") && diff.hunks.length === 0);
3905
+ if (!headerOnly) throw badRequest("no changed lines selected", "empty_selection");
3906
+ return { patch: header.join("\n") + "\n", lines: 0 };
3907
+ }
3908
+ let delta = 0;
3909
+ const hunks = [];
3910
+ diff.hunks.forEach((hunk, index) => {
3911
+ const set = picked.get(index);
3912
+ if (!set || set.size === 0) return;
3913
+ const out = cutHunk(hunk, set, mode);
3914
+ const oldLines = out.filter((l) => l.ch !== "+").length;
3915
+ const newLines = out.filter((l) => l.ch !== "-").length;
3916
+ const oldBegin = hunk.oldLines === 0 ? hunk.oldStart + 1 : hunk.oldStart;
3917
+ const oldStart = oldLines === 0 ? oldBegin - 1 : oldBegin;
3918
+ const newStart = newLines === 0 ? oldBegin + delta - 1 : oldBegin + delta;
3919
+ delta += newLines - oldLines;
3920
+ const text = [`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@${hunk.header ? " " + hunk.header : ""}`];
3921
+ for (const l of out) {
3922
+ text.push(l.ch + l.content);
3923
+ if (l.noNewline) text.push("\");
3924
+ }
3925
+ hunks.push(text.join("\n"));
3926
+ });
3927
+ return { patch: [...header, ...hunks].join("\n") + "\n", lines };
3928
+ }
3929
+
3725
3930
  // packages/server/src/state.ts
3726
3931
  import os from "os";
3727
3932
  import path3 from "path";
@@ -3751,6 +3956,8 @@ function ensureTarget(state, key) {
3751
3956
  }
3752
3957
  return t;
3753
3958
  }
3959
+ var usable = (v) => !!v && typeof v === "object" && typeof v.id === "string";
3960
+ var usableComment = (v) => usable(v) && typeof v.anchor === "object" && !!v.anchor;
3754
3961
  function normalise(raw2, repoRoot) {
3755
3962
  const base = defaultState(repoRoot);
3756
3963
  if (!raw2 || typeof raw2 !== "object") return base;
@@ -3761,7 +3968,7 @@ function normalise(raw2, repoRoot) {
3761
3968
  if (!v || typeof v !== "object") continue;
3762
3969
  targets[k] = {
3763
3970
  viewed: typeof v.viewed === "object" && v.viewed ? v.viewed : {},
3764
- comments: Array.isArray(v.comments) ? v.comments : [],
3971
+ comments: Array.isArray(v.comments) ? v.comments.filter(usableComment) : [],
3765
3972
  ...typeof v.head === "string" ? { head: v.head } : {}
3766
3973
  };
3767
3974
  }
@@ -3770,8 +3977,8 @@ function normalise(raw2, repoRoot) {
3770
3977
  schemaVersion: 1,
3771
3978
  repoRoot: r.repoRoot ?? repoRoot,
3772
3979
  targets,
3773
- issues: Array.isArray(r.issues) ? r.issues : [],
3774
- todos: Array.isArray(r.todos) ? r.todos : [],
3980
+ issues: Array.isArray(r.issues) ? r.issues.filter(usable) : [],
3981
+ todos: Array.isArray(r.todos) ? r.todos.filter(usable) : [],
3775
3982
  prefs: {
3776
3983
  ...defaultPrefs(),
3777
3984
  ...r.prefs ?? {},
@@ -4166,7 +4373,9 @@ var RepoWatcher = class {
4166
4373
  }
4167
4374
  async snapshot() {
4168
4375
  const [statusRes, headRes, branch] = await Promise.all([
4169
- runGit(["status", "--porcelain", "-z"], { cwd: this.root }),
4376
+ // Untracked files one by one: folded into their directory, an edit inside a directory the
4377
+ // agent just created would leave the directory's own mtime and size untouched.
4378
+ runGit(["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: this.root }),
4170
4379
  runGit(["rev-parse", "HEAD"], { cwd: this.root }).catch(() => ({ stdout: "" })),
4171
4380
  // The same label getRepoInfo reports, so the branch identity the client filters todos by
4172
4381
  // never flips between `HEAD` and a short sha depending on which of the two spoke last.
@@ -4275,13 +4484,6 @@ function createApp(opts) {
4275
4484
  throw e;
4276
4485
  }
4277
4486
  };
4278
- const decodeKey = (raw2) => {
4279
- try {
4280
- return decodeURIComponent(raw2);
4281
- } catch {
4282
- throw badRequest("malformed target key", "invalid_target");
4283
- }
4284
- };
4285
4487
  const fileDiffWithHints = async (ctx, filePath, explicit) => {
4286
4488
  const cached = listings.get(ctx.key);
4287
4489
  const hint = cached && Date.now() - cached.at < LISTING_HINT_TTL_MS ? cached.files.find((f) => f.path === filePath) : void 0;
@@ -4308,8 +4510,13 @@ function createApp(opts) {
4308
4510
  api.get("/ping", (c) => c.text(opts.instanceToken ?? ""));
4309
4511
  api.get("/repo", async (c) => {
4310
4512
  const state = await store.load();
4311
- const info = await getRepoInfo(repo, state.prefs.lastTarget ?? "working");
4513
+ const info = await getRepoInfo(repo, "working");
4312
4514
  worktreeCache = { at: Date.now(), list: info.worktrees };
4515
+ const last = state.prefs.lastTarget;
4516
+ const target = last ? tryParseTargetKey(last) : void 0;
4517
+ const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree));
4518
+ if (last && usable2) info.defaultTarget = last;
4519
+ else if (last) await store.update((s) => void (s.prefs.lastTarget = "working"));
4313
4520
  return c.json(info);
4314
4521
  });
4315
4522
  api.get("/state", async (c) => c.json(await store.load()));
@@ -4327,7 +4534,7 @@ function createApp(opts) {
4327
4534
  return c.json(prefs);
4328
4535
  });
4329
4536
  api.get("/targets/:key/files", async (c) => {
4330
- const key = decodeKey(c.req.param("key"));
4537
+ const key = c.req.param("key");
4331
4538
  const ctx = await targetCtx(key);
4332
4539
  const diffs = await listTargetDiffs(ctx);
4333
4540
  listings.set(key, { at: Date.now(), files: diffs });
@@ -4363,7 +4570,7 @@ function createApp(opts) {
4363
4570
  return c.json(res);
4364
4571
  });
4365
4572
  api.get("/targets/:key/file", async (c) => {
4366
- const key = decodeKey(c.req.param("key"));
4573
+ const key = c.req.param("key");
4367
4574
  const filePath = c.req.query("path");
4368
4575
  if (!filePath) throw badRequest("missing path");
4369
4576
  const ctx = await targetCtx(key);
@@ -4374,7 +4581,7 @@ function createApp(opts) {
4374
4581
  return c.json(diff);
4375
4582
  });
4376
4583
  api.get("/targets/:key/file/full", async (c) => {
4377
- const key = decodeKey(c.req.param("key"));
4584
+ const key = c.req.param("key");
4378
4585
  const filePath = c.req.query("path");
4379
4586
  if (!filePath) throw badRequest("missing path");
4380
4587
  const side = c.req.query("side") === "old" ? "old" : "new";
@@ -4384,7 +4591,7 @@ function createApp(opts) {
4384
4591
  return c.json(res);
4385
4592
  });
4386
4593
  api.put("/targets/:key/viewed", async (c) => {
4387
- const key = decodeKey(c.req.param("key"));
4594
+ const key = c.req.param("key");
4388
4595
  await targetCtx(key);
4389
4596
  const body = await c.req.json();
4390
4597
  if (!body.path) throw badRequest("missing path");
@@ -4397,13 +4604,31 @@ function createApp(opts) {
4397
4604
  changedNotices.get(key)?.delete(body.path);
4398
4605
  return c.json({ viewed });
4399
4606
  });
4607
+ api.post("/targets/:key/stage", async (c) => {
4608
+ const key = c.req.param("key");
4609
+ const ctx = await targetCtx(key);
4610
+ const mode = stageModeFor(ctx.target);
4611
+ if (!mode) throw badRequest("only the working (stage) and staged (unstage) views can be staged from", "not_stageable");
4612
+ const body = await c.req.json();
4613
+ if (!body.path || typeof body.path !== "string") throw badRequest("missing path");
4614
+ if (!body.contentHash || typeof body.contentHash !== "string") throw badRequest("contentHash is required");
4615
+ if (body.hunks !== void 0 && !Array.isArray(body.hunks)) throw badRequest("hunks must be an array", "bad_selection");
4616
+ const diff = await fileDiffWithHints(ctx, body.path);
4617
+ if (!diff) throw badRequest(`file ${body.path} is not part of ${key}`, "no_diff");
4618
+ if (diff.contentHash !== body.contentHash) throw new HttpError(409, "the diff changed since it was loaded; refresh and pick the lines again", "diff_changed");
4619
+ const { patch, lines } = buildStagePatch(diff, mode, body.hunks);
4620
+ await applyToIndex(ctx.cwd, patch, { reverse: mode === "unstage" });
4621
+ void watchers.get(ctx.cwd)?.poll();
4622
+ const res = { ok: true, lines };
4623
+ return c.json(res);
4624
+ });
4400
4625
  api.get("/targets/:key/comments", async (c) => {
4401
- const key = decodeKey(c.req.param("key"));
4626
+ const key = c.req.param("key");
4402
4627
  const state = await store.load();
4403
4628
  return c.json({ comments: state.targets[commentScopeKey(key)]?.comments ?? [] });
4404
4629
  });
4405
4630
  api.post("/targets/:key/comments", async (c) => {
4406
- const key = decodeKey(c.req.param("key"));
4631
+ const key = c.req.param("key");
4407
4632
  const ctx = await targetCtx(key);
4408
4633
  const body = await c.req.json();
4409
4634
  if (!body.filePath || typeof body.body !== "string" || !body.body.trim()) throw badRequest("filePath and body are required");
@@ -4436,7 +4661,7 @@ function createApp(opts) {
4436
4661
  return c.json(comment, 201);
4437
4662
  });
4438
4663
  api.patch("/targets/:key/comments/:id", async (c) => {
4439
- const key = decodeKey(c.req.param("key"));
4664
+ const key = c.req.param("key");
4440
4665
  const id = c.req.param("id");
4441
4666
  const ctx = await targetCtx(key);
4442
4667
  const body = await c.req.json();
@@ -4481,7 +4706,7 @@ function createApp(opts) {
4481
4706
  return c.json(updated);
4482
4707
  });
4483
4708
  api.delete("/targets/:key/comments/:id", async (c) => {
4484
- const key = decodeKey(c.req.param("key"));
4709
+ const key = c.req.param("key");
4485
4710
  const id = c.req.param("id");
4486
4711
  const removed = await store.update((s) => {
4487
4712
  const t = s.targets[commentScopeKey(key)];
@@ -4495,7 +4720,7 @@ function createApp(opts) {
4495
4720
  return c.json({ ok: true });
4496
4721
  });
4497
4722
  api.post("/targets/:key/comments/reanchor", async (c) => {
4498
- const key = decodeKey(c.req.param("key"));
4723
+ const key = c.req.param("key");
4499
4724
  const ctx = await targetCtx(key);
4500
4725
  const scope = commentScopeKey(key);
4501
4726
  const local = isLocalTarget(ctx.target);
@@ -4619,16 +4844,28 @@ function createApp(opts) {
4619
4844
  id: randomUUID(),
4620
4845
  title: body.title.trim(),
4621
4846
  body: typeof body.body === "string" ? body.body : "",
4622
- status: "open",
4847
+ status: body.status === "closed" ? "closed" : "open",
4623
4848
  commentIds: Array.isArray(body.commentIds) ? body.commentIds.filter((x) => typeof x === "string") : [],
4624
4849
  createdAt: now,
4625
4850
  updatedAt: now
4626
4851
  };
4627
4852
  await store.update((s) => {
4628
- s.issues.push(issue);
4853
+ s.issues = insertAfter(s.issues, issue, typeof body.after === "string" ? body.after : void 0);
4629
4854
  });
4630
4855
  return c.json(issue, 201);
4631
4856
  });
4857
+ api.post("/issues/:id/move", async (c) => {
4858
+ const id = c.req.param("id");
4859
+ const body = await c.req.json();
4860
+ if (body.before !== null && typeof body.before !== "string") throw badRequest("before must be an id or null");
4861
+ const issues = await store.update((s) => {
4862
+ const next = moveBefore(s.issues, id, body.before);
4863
+ if (!next) throw notFound("issue not found");
4864
+ s.issues = next;
4865
+ return s.issues;
4866
+ });
4867
+ return c.json({ issues });
4868
+ });
4632
4869
  api.patch("/issues/:id", async (c) => {
4633
4870
  const id = c.req.param("id");
4634
4871
  const body = await c.req.json();
@@ -4702,15 +4939,28 @@ function createApp(opts) {
4702
4939
  branch,
4703
4940
  title: body.title.trim(),
4704
4941
  body: typeof body.body === "string" ? body.body : "",
4705
- status: "open",
4942
+ status: body.status === "done" ? "done" : "open",
4706
4943
  createdAt: now,
4707
4944
  updatedAt: now
4708
4945
  };
4709
4946
  await store.update((s) => {
4710
- s.todos.push(todo);
4947
+ s.todos = insertAfter(s.todos, todo, typeof body.after === "string" ? body.after : void 0);
4711
4948
  });
4712
4949
  return c.json(todo, 201);
4713
4950
  });
4951
+ api.post("/todos/:id/move", async (c) => {
4952
+ const id = c.req.param("id");
4953
+ const body = await c.req.json();
4954
+ if (body.before !== null && typeof body.before !== "string") throw badRequest("before must be an id or null");
4955
+ const todos = await store.update((s) => {
4956
+ const next = moveBefore(s.todos, id, body.before);
4957
+ if (!next) throw notFound("todo not found");
4958
+ s.todos = next;
4959
+ return s.todos;
4960
+ });
4961
+ const res = { todos };
4962
+ return c.json(res);
4963
+ });
4714
4964
  api.patch("/todos/:id", async (c) => {
4715
4965
  const id = c.req.param("id");
4716
4966
  const body = await c.req.json();
@@ -4782,6 +5032,17 @@ function createApp(opts) {
4782
5032
  const res = { commits, hasMore };
4783
5033
  return c.json(res);
4784
5034
  });
5035
+ api.get("/fork-point", async (c) => {
5036
+ const base = (c.req.query("base") ?? "").trim();
5037
+ if (!isValidRef(base)) throw badRequest("invalid base ref");
5038
+ const cwd = await knownRoot(c.req.query("root") || void 0);
5039
+ if (!await refExists(cwd, base)) throw badRequest(`unknown ref: ${base}`, "unknown_ref");
5040
+ const sha = await refExists(cwd, "HEAD") ? await mergeBase(cwd, base, "HEAD") : void 0;
5041
+ if (!sha) throw badRequest(`${base} and HEAD share no history`, "no_merge_base");
5042
+ const counts = (await runGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], { cwd })).stdout.trim().split(/\s+/);
5043
+ const res = { base, sha, ahead: Number(counts[1] ?? 0), behind: Number(counts[0] ?? 0) };
5044
+ return c.json(res);
5045
+ });
4785
5046
  api.get("/events", async (c) => {
4786
5047
  const root = c.req.query("root") || repo.root;
4787
5048
  const known = root === repo.root || root === repo.commonRoot || (await worktrees()).some((w) => w.path === root);
@@ -4959,7 +5220,7 @@ async function startServer(opts) {
4959
5220
  }
4960
5221
 
4961
5222
  // bin/cli.ts
4962
- var VERSION = true ? "0.6.0" : "dev";
5223
+ var VERSION = true ? "0.7.0" : "dev";
4963
5224
  function parseArgs(argv) {
4964
5225
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
4965
5226
  for (let i = 0; i < argv.length; i++) {
@@ -5029,7 +5290,7 @@ function resolveWebDir() {
5029
5290
  // bin/cli.ts (tsx dev) -> dist/web
5030
5291
  ].filter((p) => !!p);
5031
5292
  for (const dir of candidates) {
5032
- if (existsSync(path8.join(dir, "index.html"))) return dir;
5293
+ if (existsSync2(path8.join(dir, "index.html"))) return dir;
5033
5294
  }
5034
5295
  return void 0;
5035
5296
  }
@@ -0,0 +1 @@
1
+ @font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2) format("woff2"),url(/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff) format("woff")}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2) format("woff2"),url(/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff) format("woff")}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2) format("woff2"),url(/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff) format("woff")}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff")}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff) format("woff")}:root{--ground: #eef0f3;--surface: #ffffff;--surface-2: #f5f6f8;--surface-3: #e4e7ec;--line: #d9dde3;--line-strong: #b7bdc7;--ink: #16181d;--ink-2: #454b58;--muted: #626977;--accent: #5b47d9;--accent-strong: #4a38bf;--accent-soft: #ebe8fb;--accent-line: #c4bbf2;--add-bg: #e3f5e7;--add-bg-strong: #bfe9c9;--add-fg: #1e7a3a;--del-bg: #fce8e8;--del-bg-strong: #f5c2c2;--del-fg: #c42b2b;--warn-bg: #f6e7b8;--warn-soft: #fdf8e6;--warn-fg: #8a6100;--warn-line: #d9b64a;--r-ctl: 3px;--r-card: 6px;--ctl-h: 26px;--sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{font:13px/1.5 var(--sans);color:var(--ink);background:var(--ground);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button{height:var(--ctl-h);display:inline-flex;align-items:center;gap:6px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-ctl);padding:0 10px;cursor:pointer;white-space:nowrap}button:hover:not(:disabled){border-color:var(--line-strong);background:var(--surface-2)}button:disabled{opacity:.45;cursor:default}button.primary{background:var(--ink);border-color:var(--ink);color:#fff}button.primary:hover:not(:disabled){background:#2b2f38;border-color:#2b2f38}button.accent{background:var(--accent);border-color:var(--accent);color:#fff}button.accent:hover:not(:disabled){background:var(--accent-strong);border-color:var(--accent-strong)}button.link{height:auto;background:none;border:none;color:var(--ink-2);padding:0 4px}button.link:hover:not(:disabled){background:none;color:var(--ink);text-decoration:underline;text-underline-offset:2px}button.link.danger{color:var(--del-fg)}button.link.active{color:var(--accent);font-weight:500}button.icon{padding:0 7px}button.toggle:before{content:"";width:6px;height:6px;border-radius:50%;border:1.5px solid currentColor}button.toggle[aria-pressed=true]{border-color:var(--ink)}button.toggle[aria-pressed=true]:before{background:currentColor}input,select,textarea{height:var(--ctl-h);border:1px solid var(--line);border-radius:var(--r-ctl);padding:0 8px;background:var(--surface)}select{padding-right:4px}textarea{height:auto;width:100%;resize:vertical;font-family:var(--mono);font-size:12px;line-height:1.6;padding:6px 8px}input[type=checkbox]{height:auto;margin:0;accent-color:var(--ink)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}button:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--accent);outline-offset:1px}kbd{font-family:var(--mono);font-size:11px;background:var(--surface);border:1px solid var(--line);border-bottom-width:2px;border-radius:3px;padding:0 5px;color:var(--ink-2)}.mono{font-family:var(--mono);font-size:12px}.muted{color:var(--muted)}.small{font-size:11px}.spacer{flex:1}.add{color:var(--add-fg)}.del{color:var(--del-fg)}.check{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;cursor:pointer}.badge{display:inline-flex;align-items:center;height:17px;padding:0 6px;border-radius:var(--r-ctl);font-family:var(--mono);font-size:11px;background:var(--surface-3);color:var(--ink-2);white-space:nowrap}.badge-active{background:var(--accent-soft);color:var(--accent)}.badge-exported{background:var(--surface-3);color:var(--muted)}.badge-orphaned{background:var(--warn-bg);color:var(--warn-fg)}.badge-open{background:var(--ink);color:#fff}.badge-closed{background:var(--surface-3);color:var(--muted)}.seg{display:inline-flex;gap:1px;padding:2px;background:var(--surface-3);border-radius:4px}.seg button{height:22px;padding:0 9px;background:transparent;border:1px solid transparent;border-radius:var(--r-ctl);color:var(--ink-2)}.seg button:hover:not(:disabled){background:transparent;border-color:transparent;color:var(--ink)}.seg button.active{background:var(--surface);border-color:var(--line);color:var(--ink);font-weight:500}.seg.small button{height:20px;padding:0 7px;font-size:12px}.seg button.active .tab-count{color:var(--ink)}.tabs{display:inline-flex;align-self:stretch;align-items:stretch;gap:2px}.tab{height:auto;background:none;border:none;border-radius:0;padding:0 6px;color:var(--ink-2);position:relative}.tab:hover:not(:disabled){background:none;color:var(--ink)}.tab.active{color:var(--ink);font-weight:500}.tab.active:after{content:"";position:absolute;left:6px;right:6px;bottom:-1px;height:2px;background:var(--ink)}.tab-count{font-family:var(--mono);font-size:11px;color:var(--muted)}.tab.active .tab-count{color:var(--ink)}.tabs.small .tab{padding:0 5px;font-size:12px}.error-box{color:var(--del-fg);background:var(--del-bg);padding:8px 12px;border-radius:var(--r-card);margin:8px}.empty{padding:20px 12px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6}.app{display:flex;flex-direction:column;height:100%}.body{flex:1;display:flex;min-height:0}.main{flex:1;min-width:0;display:flex;flex-direction:column;background:var(--surface)}.loading-screen,.fatal{padding:48px;text-align:center;color:var(--muted)}.fatal h1{font-family:var(--mono);font-weight:500;font-size:20px;letter-spacing:-.02em;color:var(--ink)}.placeholder{padding:48px 24px;text-align:center;color:var(--muted)}.placeholder p{margin:6px 0}.topbar{display:flex;align-items:center;gap:14px;padding:0 12px;min-height:42px;background:var(--ground);border-bottom:1px solid var(--line);flex-wrap:wrap}.brand{display:flex;align-items:center;gap:7px}.mark{width:16px;height:16px;flex:none}.wordmark{font-family:var(--mono);font-weight:500;font-size:15px;letter-spacing:-.02em;color:var(--ink)}.scope{display:flex;align-items:center;gap:8px}.repo-name{font-weight:500}.branch{font-family:var(--mono);font-size:11px;color:var(--ink-2);background:var(--surface-3);border-radius:var(--r-ctl);padding:1px 6px}.inline-form{display:inline-flex;align-items:center;gap:4px}.inline-form input{width:130px;font-family:var(--mono);font-size:12px}.topbar-right{margin-left:auto;align-self:stretch;display:flex;align-items:stretch;gap:14px}.tools{display:flex;align-items:center;gap:8px}.nvim{display:inline-flex;align-items:center;gap:4px;max-width:320px}.nvim select{max-width:260px}.nvim-one{font-family:var(--mono);font-size:12px;color:var(--ink-2)}.nvim .muted{font-size:12px}.sidebar{width:280px;min-width:200px;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--ground);resize:horizontal;overflow:hidden}.tree-block{display:flex;flex-direction:column;flex:0 1 auto;min-height:66px;min-width:0}.tree-block+.tree-block{border-top:1px solid var(--line)}.sidebar-target{flex:none;display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);white-space:nowrap}.sidebar-target-name{font-family:var(--mono);font-size:12px;color:var(--ink-2);min-width:0;overflow:hidden;text-overflow:ellipsis}.sidebar-target .link{font-size:12px}.block-head{display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:12px;white-space:nowrap}.block-title{font-weight:600}.block-n{font-family:var(--mono);font-size:11px;color:var(--muted)}.block-head .counts{font-family:var(--mono);font-size:11px;display:inline-flex;gap:4px}.block-viewed{font-family:var(--mono);font-size:11px;color:var(--muted)}.block-progress{height:2px;margin:0 10px 4px;background:var(--line);border-radius:1px;overflow:hidden}.block-progress>span{display:block;height:100%;background:var(--ink);transition:width .2s}.tree-tools{display:inline-flex;gap:2px}.tree-tools .link{font-size:11px;color:var(--muted)}.tree{flex:1 1 auto;min-height:0;overflow:auto;padding:2px 0 6px}.tree-row{display:flex;align-items:center;gap:6px;padding:0 10px;height:24px;cursor:pointer;white-space:nowrap;-webkit-user-select:none;user-select:none}.tree-row:hover{background:var(--surface-3)}.tree-row.active{background:var(--surface);box-shadow:inset 2px 0 var(--ink)}.tree-row.viewed .name{color:var(--muted)}.tree-row .name{overflow:hidden;text-overflow:ellipsis}.tree-row.dir .name{font-weight:500;color:var(--ink-2)}.chev{display:inline-block;width:12px;font-size:10px;color:var(--muted);transition:transform .1s}.chev.open{transform:rotate(90deg)}.tree-row .counts{margin-left:auto;font-family:var(--mono);font-size:11px;display:inline-flex;gap:4px}.tree-row .count{font-family:var(--mono);font-size:11px}.viewed-box{margin:0}.status{font-family:var(--mono);font-size:11px;font-weight:500;width:14px;text-align:center}.status-added{color:var(--add-fg)}.status-modified{color:var(--warn-fg)}.status-deleted{color:var(--del-fg)}.status-renamed{color:var(--ink-2)}.changed{font-size:11px;background:var(--warn-bg);color:var(--warn-fg);border-radius:var(--r-ctl);padding:0 5px}.cc{font-family:var(--mono);font-size:11px;background:var(--accent-soft);color:var(--accent);border-radius:var(--r-ctl);padding:0 5px}.diff-panel{flex:1;display:flex;flex-direction:column;min-height:0;position:relative}.file-head{display:flex;align-items:center;gap:10px;padding:0 12px;min-height:38px;border-bottom:1px solid var(--line);background:var(--surface);flex-wrap:wrap}.file-head .path{font-family:var(--mono);font-size:12px}.file-head .path .dir{color:var(--muted)}.file-head .path .base{font-weight:500}.file-head .counts{font-family:var(--mono);font-size:11px;display:inline-flex;gap:4px}.file-head .check{font-size:12px}.diff-scroll{flex:1;overflow:auto;font-family:var(--mono);font-size:12px;line-height:20px;tab-size:4;background:var(--surface)}.diff-scroll.selecting,.diff-scroll.picking{-webkit-user-select:none;user-select:none;cursor:row-resize}.diff-scroll.reattaching .add-btn{background:var(--warn-fg)}.vrow{will-change:transform}.row{display:flex;min-height:20px}.row.line,.cell{position:relative}.pin{position:absolute;right:0;top:0;bottom:0;width:3px;background:var(--accent)}.pin.muted{background:var(--line-strong)}.pill{position:absolute;right:10px;top:2px;height:16px;min-width:16px;padding:0 5px;justify-content:center;border:none;border-radius:8px;background:var(--accent);color:#fff;font-family:var(--sans);font-size:11px;line-height:16px}.pill:hover:not(:disabled){background:var(--accent-strong);border-color:transparent}.pill.muted{background:var(--line-strong)}.row.hunk-head{align-items:center;background:var(--surface-2);color:var(--muted);font-size:11.5px;padding:3px 12px 3px 128px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);white-space:pre-wrap}.hunk-text{flex:1;min-width:0}.hunk-stage{font-family:var(--sans);font-size:11px;color:var(--muted);white-space:nowrap}.stagec{flex:0 0 18px;position:relative;-webkit-user-select:none;user-select:none}.stage-box{position:absolute;left:4px;top:4px;width:12px;height:12px;padding:0;border:1.5px solid var(--ink-2);border-radius:2px;background:var(--surface);opacity:0;cursor:pointer;z-index:1}.stage-box:hover:not(:disabled){background:var(--surface-3);border-color:var(--ink)}.row:hover .stage-box,.cell:hover .stage-box,.stage-box.on{opacity:1}.stage-box.on,.stage-box.on:hover:not(:disabled){background:var(--ink);border-color:var(--ink)}.stage-box.on:after{content:"";position:absolute;left:3px;top:0;width:3px;height:6px;border:solid #fff;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}.row.line.picked,.cell.picked{box-shadow:inset 3px 0 var(--ink)}.row.line.add.picked,.cell.add.picked{background:var(--add-bg-strong)}.row.line.del.picked,.cell.del.picked{background:var(--del-bg-strong)}.stage-bar{position:absolute;left:50%;bottom:16px;transform:translate(-50%);display:flex;align-items:center;gap:8px;padding:6px 8px 6px 12px;background:var(--surface);border:1px solid var(--line-strong);border-radius:var(--r-card);box-shadow:0 6px 20px #16181d24;font-size:12px;white-space:nowrap;z-index:3}.stage-bar-count{margin-right:4px}.stage-bar kbd{margin-left:2px}.stage-bar .primary kbd{background:#ffffff29;border-color:#ffffff4d;color:#fff}.tree-row .stage-file{display:none;font-size:11px;padding:0 2px}.tree-row:hover .stage-file{display:inline-flex}.row.gap-row{background:var(--surface-2);justify-content:center;gap:16px;padding:2px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);font-family:var(--sans);font-size:12px}.gap-row .link{color:var(--muted)}.row.attach{background:var(--surface-2);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:6px 12px 6px 60px;font-family:var(--sans);font-size:13px;display:block}.gut{flex:0 0 48px;text-align:right;padding-right:6px;font-size:11px;color:var(--muted);cursor:pointer;-webkit-user-select:none;user-select:none}.gut:hover{color:var(--ink)}.addc{flex:0 0 18px;position:relative}.add-btn{position:absolute;left:0;top:2px;width:16px;height:16px;padding:0;justify-content:center;line-height:16px;border-radius:var(--r-ctl);background:var(--accent);color:#fff;border:none;font-weight:600;font-size:13px;opacity:0;cursor:pointer;z-index:1}.add-btn:hover:not(:disabled){background:var(--accent-strong);border-color:transparent}.row:hover .add-btn,.cell:hover .add-btn{opacity:1}.add-btn.static{position:static;display:inline-flex;opacity:1;width:16px;height:16px;font-size:11px;vertical-align:-3px}.marker{flex:0 0 14px;text-align:center;-webkit-user-select:none;user-select:none;color:var(--muted)}.code{flex:1;min-width:0;white-space:pre-wrap;word-break:break-all;padding-right:40px}.nonl{color:var(--del-fg);margin-left:6px}.row.line.add,.cell.add{background:var(--add-bg)}.row.line.del,.cell.del{background:var(--del-bg)}.row.line.add .marker{color:var(--add-fg)}.row.line.del .marker{color:var(--del-fg)}.row.expanded,.row.pair.expanded .cell{background:var(--surface);color:var(--muted)}.row.line.selected,.cell.selected{background:var(--accent-soft)!important}.row.line.focused,.cell.focused{background:var(--accent-soft)!important;box-shadow:inset 3px 0 var(--accent)}.row.line.flash,.cell.flash{animation:flash 1.6s ease-out}@keyframes flash{0%{background:var(--accent-line)}to{background:transparent}}.row.pair{display:grid;grid-template-columns:1fr 1fr}.cell{display:flex;min-width:0;border-left:1px solid var(--line)}.cell:first-child{border-left:none}.cell.empty{background:var(--surface-2)}.diff-list.split .row.hunk-head{padding-left:78px}.comment-card{position:relative;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--r-card);padding:7px 10px 8px}.comment-card.status-exported{border-left-color:var(--line-strong)}.comment-card.status-orphaned{border-color:var(--warn-line);border-left-style:dashed;background:var(--warn-soft)}.comment-card.selected{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.comment-head{display:flex;align-items:center;gap:8px;min-height:20px;font-size:12px;color:var(--ink-2);flex-wrap:wrap}.comment-head .mono{font-size:11px}.comment-head .time{font-size:11px;color:var(--muted)}.card-actions{position:absolute;right:6px;top:5px;display:inline-flex;align-items:center;gap:2px;padding-left:8px;border-radius:var(--r-ctl);background:inherit;opacity:0;transition:opacity .1s}.card-actions .link{font-size:12px}.comment-card:hover .card-actions,.comment-card:focus-within .card-actions,.comment-card.reattaching .card-actions{opacity:1}.comment-editor{background:var(--surface);border:1px solid var(--accent);border-left-width:3px;border-radius:var(--r-card);padding:8px 10px;max-width:900px}.comment-editor-head{font-family:var(--mono);font-size:11px;color:var(--muted);margin-bottom:6px}.comment-editor textarea{border-color:var(--line)}.comment-editor-actions{display:flex;gap:6px;margin-top:8px}.comment-editor .primary{background:var(--accent);border-color:var(--accent)}.comment-editor .primary:hover:not(:disabled){background:var(--accent-strong);border-color:var(--accent-strong)}.snippet{font-family:var(--mono);font-size:11.5px;line-height:18px;background:var(--surface-2);border-radius:var(--r-ctl);padding:4px 8px;margin:6px 0;overflow-x:auto;white-space:pre}.snippet .ln{display:inline-block;width:40px;color:var(--muted);text-align:right;margin-right:10px;-webkit-user-select:none;user-select:none}.md{font-size:13px}.md p{margin:4px 0}.md pre{background:var(--surface-2);padding:6px 8px;border-radius:var(--r-ctl);overflow-x:auto}.md code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:0 3px;border-radius:3px}.md pre code{background:none;padding:0}.md ul,.md ol{padding-left:20px;margin:4px 0}.md blockquote{margin:4px 0;padding-left:10px;border-left:3px solid var(--line);color:var(--muted)}.commits-panel{flex:1;display:flex;flex-direction:column;min-height:0}.commits-panel[hidden]{display:none}.commits-tools{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--ground);flex-wrap:wrap}.commits-tools input{width:140px}.commits-tools input.search{width:260px}.commits-tools input.path{font-size:12px}.commits-count{margin-left:auto;font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}.commits-compare{display:flex;align-items:center;gap:20px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--ground);flex-wrap:wrap}.compare-label{font-size:12px;color:var(--ink-2);margin-right:2px}.commits-list{flex:1;overflow:auto;background:var(--surface)}.commit-day{position:sticky;top:0;z-index:1;display:flex;align-items:center;height:24px;padding:0 12px;background:var(--ground);border-bottom:1px solid var(--line);color:var(--ink-2);font-size:12px;font-weight:500}.commit-row{display:flex;align-items:center;gap:10px;height:30px;padding:0 12px;border-bottom:1px solid var(--surface-3);cursor:pointer;white-space:nowrap}.commit-row:hover{background:var(--surface-2)}.commit-row.active{background:var(--surface-2);box-shadow:inset 2px 0 var(--ink)}.commit-row .sha{flex:0 0 auto;min-width:7ch;color:var(--ink-2)}.commit-row .subject{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-row .subject .pr{margin-left:6px;color:var(--muted);font-family:var(--mono);font-size:12px}.commit-row .refs{display:inline-flex;gap:4px;flex:0 1 auto;min-width:0;overflow:hidden}.ref{display:inline-block;max-width:180px;height:17px;padding:0 7px;border:1px solid var(--line-strong);border-radius:9px;color:var(--ink-2);font-family:var(--mono);font-size:11px;line-height:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ref-tag{background:var(--surface-3);border-color:var(--surface-3)}.ref-head{background:var(--ink);border-color:var(--ink);color:#fff}.ref-more{border-style:dashed;color:var(--muted)}.ref-fork{border-style:dashed;border-color:var(--ink-2);color:var(--ink)}.commit-row.fork{border-top:1px dashed var(--line-strong)}.fork-note{margin-left:6px;font-size:12px;white-space:nowrap}.fork-note .link{padding:0;font-size:12px}.commit-row .author{flex:0 0 110px;overflow:hidden;text-overflow:ellipsis;color:var(--ink-2);font-size:12px}.commit-row .time{flex:0 0 40px;text-align:right;color:var(--muted);font-family:var(--mono);font-size:11px}.commits-foot{display:flex;justify-content:center;align-items:center;min-height:44px;font-size:12px}.issues-drawer{width:440px;border-left:1px solid var(--line);display:flex;flex-direction:column;background:var(--ground);min-height:0}.drawer-head{display:flex;align-items:center;gap:10px;padding:0 10px;min-height:36px;border-bottom:1px solid var(--line)}.drawer-title{font-weight:600}.rail-list.tasks{padding:4px 0 12px;gap:0}.task-add{height:34px;margin:0 4px 2px;padding:0 10px 0 24px;justify-content:flex-start;gap:10px;border:none;border-radius:var(--r-card);background:none;color:var(--ink-2);font-weight:500}.task-add:hover:not(:disabled){background:var(--surface-3);border-color:transparent;color:var(--ink)}.task-add-plus{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:var(--ink);color:#fff;font-size:15px;line-height:1}.task-list{display:flex;flex-direction:column}.task-row{position:relative;display:flex;align-items:flex-start;gap:6px;margin:0 4px;padding:5px 10px 5px 4px;border-radius:var(--r-card)}.task-row:hover,.task-row.open{background:var(--surface)}.task-row.open{box-shadow:0 0 0 1px var(--line)}.task-grip{flex:0 0 14px;height:20px;display:inline-flex;align-items:center;justify-content:center;color:var(--muted);cursor:grab;visibility:hidden}.task-row:hover .task-grip{visibility:visible}.task-list.dragging .task-grip{visibility:hidden}.task-check{position:relative;flex:0 0 18px;width:18px;height:18px;margin-top:1px;padding:0;border:1.5px solid var(--ink-2);border-radius:50%;background:var(--surface)}.task-check:after{content:"";position:absolute;left:5px;top:1.5px;width:4px;height:8px;border:solid var(--ink);border-width:0 1.5px 1.5px 0;transform:rotate(45deg);opacity:0}.task-check:hover:not(:disabled){background:var(--surface);border-color:var(--ink)}.task-check:hover:not(:disabled):after{opacity:.45}.task-row.done .task-check,.task-row.completing .task-check{background:var(--ink);border-color:var(--ink)}.task-row.done .task-check:after,.task-row.completing .task-check:after{border-color:#fff;opacity:1}.task-row.draft .task-check{border-style:dashed;border-color:var(--muted)}.task-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.task-line{display:flex;align-items:flex-start;gap:6px}.task-ta{width:100%;min-width:0;height:auto;padding:0;border:none;border-radius:0;background:transparent;resize:none;overflow:hidden;font-family:var(--sans);line-height:20px}.task-ta:focus{box-shadow:none}.task-title{flex:1;font-size:13px}.task-body{font-size:12px;line-height:18px;color:var(--ink-2)}.task-details{font-size:12px;line-height:18px;color:var(--muted);cursor:text}.task-details .md{font-size:12px}.task-details .md p{margin:0 0 2px}.task-row.done .task-title,.task-row.completing .task-title{text-decoration:line-through;color:var(--muted)}.task-row.completing{opacity:.55;transition:opacity .3s}.task-row .badge{margin-top:2px}.task-count{font-family:var(--mono);font-size:11px;line-height:20px;color:var(--muted);white-space:nowrap}.task-actions{display:inline-flex;align-items:center;gap:2px;margin-left:auto;line-height:20px;white-space:nowrap;visibility:hidden}.task-actions .link{font-size:12px}.task-row:hover .task-actions,.task-row:focus-within .task-actions,.task-sub:hover .task-actions{visibility:visible}.task-row.dragging{opacity:.4}.task-row.drop-before{box-shadow:0 -2px 0 0 var(--ink)}.task-row.drop-after{box-shadow:0 2px 0 0 var(--ink)}.task-empty{padding:14px 20px;color:var(--muted);font-size:12px}.task-add-hint{padding:0 10px 6px 46px;font-size:12px;color:var(--muted)}.task-done{margin-top:8px;padding-top:4px;border-top:1px solid var(--line)}.task-done-head{display:flex;align-items:center;min-height:28px;padding:0 14px 0 10px}.task-done-toggle{height:auto;padding:0 4px;gap:6px;border:none;background:none;color:var(--ink-2);font-weight:500}.task-done-toggle:hover:not(:disabled){background:none;border-color:transparent;color:var(--ink)}.task-list-pick{max-width:220px}.task-subs{display:flex;flex-direction:column;gap:4px;margin-top:6px}.task-sub{display:flex;align-items:flex-start;gap:8px;padding:4px 6px;border-radius:var(--r-ctl);background:var(--surface-2);font-size:12px}.task-sub-dot{flex:0 0 8px;width:8px;height:8px;margin-top:6px;border-radius:50%;background:var(--accent)}.task-sub.status-exported .task-sub-dot{background:var(--line-strong)}.task-sub.status-orphaned .task-sub-dot{background:var(--warn-line)}.task-sub-main{flex:1;min-width:0}.task-sub-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.task-sub-head .mono{font-size:11px}.task-sub-body{color:var(--muted);white-space:pre-wrap;max-height:54px;overflow:hidden}.task-sub-add{align-self:flex-start;font-size:12px}.task-sub-none{font-size:12px;color:var(--muted)}.toast{position:fixed;bottom:20px;left:50%;transform:translate(-50%);background:var(--ink);color:#fff;padding:8px 16px;border-radius:var(--r-card);box-shadow:0 6px 20px #16181d40;z-index:100;max-width:80vw}.toast-error{background:var(--del-fg)}.toast-action{height:22px;margin-left:14px;padding:0 8px;background:#ffffff24;border-color:#ffffff59;color:#fff;font-weight:500}.toast-action:hover:not(:disabled){background:#ffffff42;border-color:#ffffff80}.rail{width:360px;min-width:300px;border-left:1px solid var(--line);display:flex;flex-direction:column;background:var(--ground);min-height:0}.rail-head{display:flex;align-items:center;gap:10px;padding:0 10px;min-height:36px;border-bottom:1px solid var(--line)}.rail-tools{display:flex;align-items:center;gap:8px;padding:0 10px;min-height:34px;border-bottom:1px solid var(--line);font-size:12px}.rail-notice{padding:6px 10px;background:var(--warn-bg);color:var(--warn-fg);border-bottom:1px solid var(--warn-line);display:flex;gap:8px;align-items:center;font-size:12px}.rail-notice .link{color:var(--warn-fg)}.rail-list{flex:1;overflow:auto;padding:10px;display:flex;flex-direction:column;gap:8px}.rail-slot{display:flex;flex-direction:column;gap:8px}.rail-foot{display:flex;flex-direction:column;gap:8px;padding:10px;border-top:1px solid var(--line);background:var(--ground)}.rail-foot-row{display:flex;align-items:center;gap:10px}.send{height:30px;padding:0 14px;background:var(--accent);border-color:var(--accent);color:#fff;font-weight:500}.send:hover:not(:disabled){background:var(--accent-strong);border-color:var(--accent-strong)}.send-n{font-family:var(--mono);font-size:11px;font-weight:400;line-height:16px;padding:0 5px;border-radius:var(--r-ctl);background:#ffffff38}.rail .comment-card{cursor:pointer}.rail .comment-card.focused{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.comment-head .file{color:var(--muted);max-width:120px;overflow:hidden;text-overflow:ellipsis}.rail .comment-editor{max-width:none}.sidebar.dual{overflow:hidden}@media(prefers-reduced-motion:reduce){.chev,.card-actions,.task-row.completing,.block-progress>span{transition:none}}