feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* @file Reads a lane's code changes as structured hunks, for the Workspace's
|
||||
* Changes panel (overview / review / single-file / history / file). Three
|
||||
* ranges, because the questions a lane raises are different: `work` is "what
|
||||
* has the agent touched and not committed yet" (HEAD vs the working tree,
|
||||
* untracked files included), `branch` is "what does this lane add on top of
|
||||
* its base" (`<base>...HEAD`), and `commit` is one recorded commit.
|
||||
*
|
||||
* Everything here is READ-ONLY and every git call goes through worktree.js's
|
||||
* `git()` argv wrapper — no shell strings, same rule as the rest of the lane
|
||||
* git surface. Untracked files are rendered as an all-added patch from the file
|
||||
* itself rather than via `git diff --no-index`, which exits non-zero on a
|
||||
* difference and would read as a failure.
|
||||
*
|
||||
* Output is capped in three places (files listed, bytes per file, bytes in
|
||||
* total) because this rides an HTTP response a browser has to render: a lane
|
||||
* mid-`npm install` or holding a committed lockfile would otherwise ship
|
||||
* megabytes of patch to a React list. A cap that bites sets `truncated`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { git } = require("./worktree");
|
||||
|
||||
/** Most files one response names. Beyond this the list is `truncated`. */
|
||||
const MAX_FILES = 300;
|
||||
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
||||
const MAX_FILE_BYTES = 200_000;
|
||||
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
||||
const MAX_TOTAL_BYTES = 1_000_000;
|
||||
/** An untracked file larger than this is reported without content. */
|
||||
const MAX_UNTRACKED_BYTES = 1_000_000;
|
||||
/** Most bytes `readFileAt` returns before cutting the tail off. */
|
||||
const MAX_FILE_VIEW_BYTES = 1_000_000;
|
||||
/** Most paths `listPaths`/`listTree` answer with. */
|
||||
const MAX_PATHS = 500;
|
||||
/** Lines of unchanged context around a hunk, and the ceiling "expand all"
|
||||
* is clamped to — a whole large file rendered as context is the one way this
|
||||
* panel can be made to ship more bytes than the caps above allow. */
|
||||
const DEFAULT_CONTEXT = 3;
|
||||
const MAX_CONTEXT = 5000;
|
||||
|
||||
const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
||||
/** A commit-ish this module will pass to git. Deliberately strict: a sha, or a
|
||||
* ref name without the characters that would let it read as an option or a
|
||||
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
||||
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
||||
|
||||
/** Throw a coded error the routes map onto a 400. */
|
||||
function badRequest(code, message) {
|
||||
const err = new Error(message);
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Clamp a caller's context request into [0, MAX_CONTEXT]. */
|
||||
function normalizeContext(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return DEFAULT_CONTEXT;
|
||||
return Math.min(Math.floor(n), MAX_CONTEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one file's unified diff into hunks with both gutters resolved, so the
|
||||
* client renders rows instead of re-parsing `@@` headers.
|
||||
*
|
||||
* Returns `[]` for an empty patch (an unchanged file, or a mode-only change).
|
||||
*/
|
||||
function parseUnifiedDiff(patch) {
|
||||
const hunks = [];
|
||||
let current = null;
|
||||
let oldLine = 0;
|
||||
let newLine = 0;
|
||||
|
||||
for (const raw of patch.split("\n")) {
|
||||
const header = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
|
||||
if (header) {
|
||||
oldLine = Number(header[1]);
|
||||
newLine = Number(header[2]);
|
||||
current = {
|
||||
header: raw,
|
||||
context: header[3].trim(),
|
||||
oldStart: oldLine,
|
||||
newStart: newLine,
|
||||
lines: [],
|
||||
};
|
||||
hunks.push(current);
|
||||
continue;
|
||||
}
|
||||
if (!current) continue; // preamble: ---/+++/index/diff lines
|
||||
// "\ No newline at end of file" annotates the previous line; it is not a
|
||||
// row of its own and carries no line number.
|
||||
if (raw.startsWith("\\")) continue;
|
||||
const marker = raw[0];
|
||||
const text = raw.slice(1);
|
||||
if (marker === "+") {
|
||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
|
||||
} else if (marker === "-") {
|
||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
|
||||
} else if (marker === " ") {
|
||||
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
||||
// string is the tail of the final "\n", not a row.
|
||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Whether a buffer looks binary — the same NUL-byte heuristic git uses. */
|
||||
function looksBinary(buf) {
|
||||
return buf.includes(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ref a `branch`-range diff measures against: `origin/<base>` when
|
||||
* the remote-tracking ref exists, else the local branch, else null (a lane with
|
||||
* no base, or a base that has since been deleted — reported, not guessed).
|
||||
*/
|
||||
async function resolveBaseRef(dir, base) {
|
||||
if (!base) return null;
|
||||
for (const ref of [`origin/${base}`, base]) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "--verify", "--quiet", ref]);
|
||||
return ref;
|
||||
} catch {
|
||||
/* try the next candidate */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` output → Map<path, {added, deleted, binary}>. */
|
||||
function parseNumstat(stdout) {
|
||||
const stats = new Map();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
if (!m) continue;
|
||||
// A rename reports "old => new" (or "dir/{a => b}/file"); the path that
|
||||
// exists now is what every other source here is keyed by.
|
||||
const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3];
|
||||
stats.set(file, {
|
||||
added: m[1] === "-" ? null : Number(m[1]),
|
||||
deleted: m[2] === "-" ? null : Number(m[2]),
|
||||
binary: m[1] === "-",
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */
|
||||
function renameTarget(spec) {
|
||||
const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/");
|
||||
return spec.split(" => ").pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Porcelain codes that mean "unmerged" — a merge or a rebase left this file
|
||||
* with conflict markers. git's own list; `UU` is the common one, but a
|
||||
* delete/modify conflict reports `DU`/`UD` and an add/add reports `AA`.
|
||||
*/
|
||||
const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
||||
|
||||
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
|
||||
async function summarizeWork(dir) {
|
||||
const [status, numstat] = await Promise.all([
|
||||
git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]),
|
||||
git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
]);
|
||||
const stats = parseNumstat(numstat.stdout);
|
||||
const files = [];
|
||||
for (const line of status.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
const untracked = code === "??";
|
||||
const stat = stats.get(file);
|
||||
// One read per untracked file, not two: its line count and its
|
||||
// binary-ness are the same buffer answered twice.
|
||||
const fresh = untracked ? untrackedFacts(dir, file) : null;
|
||||
files.push({
|
||||
path: file,
|
||||
status: code,
|
||||
untracked,
|
||||
// The two porcelain columns are the index and the working tree, which
|
||||
// is the whole of what per-file staging needs to render: a file can be
|
||||
// BOTH (edited, staged, edited again) and the row has to say so rather
|
||||
// than pick one.
|
||||
staged: !untracked && code[0] !== " " && code[0] !== "?",
|
||||
unstaged: untracked || (code[1] !== " " && code[1] !== "?"),
|
||||
conflicted: CONFLICT_CODES.has(code),
|
||||
added: untracked ? fresh.lines : (stat?.added ?? 0),
|
||||
deleted: untracked ? 0 : (stat?.deleted ?? 0),
|
||||
binary: untracked ? fresh.binary : !!stat?.binary,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* An untracked file's line count and binary-ness, from ONE read.
|
||||
*
|
||||
* `lines` is null when the file is too big, binary, or unreadable — git has no
|
||||
* delta for an untracked file either, and a null there already means "unknown"
|
||||
* everywhere else in this response. A file over the cap counts as binary so
|
||||
* nothing downstream tries to render it.
|
||||
*/
|
||||
function untrackedFacts(dir, file) {
|
||||
try {
|
||||
const abs = path.join(dir, file);
|
||||
if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true };
|
||||
const buf = fs.readFileSync(abs);
|
||||
if (looksBinary(buf)) return { lines: null, binary: true };
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return { lines: 0, binary: false };
|
||||
const lines = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
||||
return { lines, binary: false };
|
||||
} catch {
|
||||
// A directory entry (git reports untracked DIRS with a trailing slash), a
|
||||
// dangling symlink, a file deleted between the status call and this read.
|
||||
return { lines: null, binary: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Branch summary: `<baseRef>...HEAD`, name-status for the letter, numstat for counts. */
|
||||
async function summarizeBranch(dir, baseRef) {
|
||||
const spec = `${baseRef}...HEAD`;
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, ["diff", "--name-status", spec]),
|
||||
git(dir, ["diff", "--numstat", spec]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
|
||||
/**
|
||||
* One commit's summary. `git show` against a MERGE commit prints nothing by
|
||||
* default (it diffs against no single parent), so `-m --first-parent` picks
|
||||
* the mainline side — which is what "what did this commit bring in" means to
|
||||
* a reader scrolling a branch's history.
|
||||
*/
|
||||
async function summarizeCommit(dir, sha) {
|
||||
const base = ["show", "--format=", "-m", "--first-parent", sha];
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, [...base, "--name-status"]),
|
||||
git(dir, [...base, "--numstat"]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
|
||||
/** Shared `--name-status` reader for the ranges that have no working tree. */
|
||||
function parseNameStatus(stdout, stats) {
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split("\t");
|
||||
const letter = parts[0][0];
|
||||
// A rename/copy line is "R100\told\tnew" — the new path is the live one.
|
||||
const file = parts.length > 2 ? parts[2] : parts[1];
|
||||
// `-m` prints one diff per parent for a merge; --first-parent keeps it to
|
||||
// one, but a defensive de-dup costs nothing and a doubled row is a lie.
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
const stat = stats.get(file);
|
||||
files.push({
|
||||
path: file,
|
||||
status: `${letter} `,
|
||||
untracked: false,
|
||||
staged: false,
|
||||
unstaged: false,
|
||||
conflicted: false,
|
||||
added: stat?.added ?? 0,
|
||||
deleted: stat?.deleted ?? 0,
|
||||
binary: !!stat?.binary,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** One file's patch text, for any range. Empty string when there is none. */
|
||||
async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) {
|
||||
if (untracked) return synthesizeAddedPatch(dir, file);
|
||||
const u = `-U${context}`;
|
||||
const args =
|
||||
range === "commit"
|
||||
? ["show", "--format=", "-m", "--first-parent", u, sha, "--", file]
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* An untracked file has no git-side "before", so its patch is synthesized: one
|
||||
* hunk starting at line 1 with every line added. This is what `git diff
|
||||
* --no-index /dev/null <file>` would print, without the non-zero exit that
|
||||
* wrapper treats as a failure.
|
||||
*/
|
||||
function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(path.join(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
if (looksBinary(buf)) return "";
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return "";
|
||||
const lines = text.split("\n");
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
const body = lines.map((l) => `+${l}`).join("\n");
|
||||
return `@@ -0,0 +1,${lines.length} @@\n${body}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's changes for one range.
|
||||
*
|
||||
* @param {string} dir Lane working directory.
|
||||
* @param {object} opts
|
||||
* @param {"work"|"branch"|"commit"} [opts.range="work"]
|
||||
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`.
|
||||
* @param {string|null} [opts.sha] The commit to read, for `range="commit"`.
|
||||
* @param {string|null} [opts.file] Only this path, with its hunks (single-file mode).
|
||||
* @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when
|
||||
* `file` is set, which is already a full read of one file.
|
||||
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
|
||||
* raises this to expand a diff towards the whole file.
|
||||
* @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>}
|
||||
*/
|
||||
async function collectChanges(
|
||||
dir,
|
||||
{ range = "work", base = null, sha = null, file = null, full = false, context } = {}
|
||||
) {
|
||||
if (!VALID_RANGES.has(range)) {
|
||||
throw badRequest("EBADRANGE", `unknown range ${range}`);
|
||||
}
|
||||
const ctx = normalizeContext(context);
|
||||
|
||||
let baseRef = null;
|
||||
if (range === "branch") {
|
||||
baseRef = await resolveBaseRef(dir, base);
|
||||
if (!baseRef) {
|
||||
throw badRequest("ENOBASE", "no base branch to compare against");
|
||||
}
|
||||
}
|
||||
if (range === "commit") {
|
||||
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
|
||||
try {
|
||||
// Resolve it once, so a bad ref is a 400 here rather than an empty diff
|
||||
// three calls later, and so the response reports the full sha.
|
||||
sha = (await git(dir, ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`])).stdout.trim();
|
||||
} catch {
|
||||
throw badRequest("EBADREV", "no such commit in this repository");
|
||||
}
|
||||
}
|
||||
|
||||
let files =
|
||||
range === "branch"
|
||||
? await summarizeBranch(dir, baseRef)
|
||||
: range === "commit"
|
||||
? await summarizeCommit(dir, sha)
|
||||
: await summarizeWork(dir);
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
let truncated = false;
|
||||
if (file) {
|
||||
const one = files.find((f) => f.path === file);
|
||||
if (!one) {
|
||||
throw badRequest("ENOFILECHANGE", "file has no changes in this range");
|
||||
}
|
||||
files = [one];
|
||||
} else if (files.length > MAX_FILES) {
|
||||
files = files.slice(0, MAX_FILES);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
if (file || full) {
|
||||
let budget = file ? MAX_FILE_BYTES : MAX_TOTAL_BYTES;
|
||||
for (const entry of files) {
|
||||
if (entry.binary) {
|
||||
entry.hunks = [];
|
||||
continue;
|
||||
}
|
||||
if (budget <= 0) {
|
||||
entry.hunks = [];
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
let patch = await patchFor(dir, {
|
||||
range,
|
||||
baseRef,
|
||||
sha,
|
||||
file: entry.path,
|
||||
untracked: entry.untracked,
|
||||
context: ctx,
|
||||
});
|
||||
const cap = Math.min(MAX_FILE_BYTES, budget);
|
||||
if (patch.length > cap) {
|
||||
// Cut on a line boundary so the last row is a whole line — but only
|
||||
// when that boundary is near the cap. A minified file is one enormous
|
||||
// line, so its last newline inside the cap is the HUNK HEADER's, and
|
||||
// honouring it would return a hunk with no lines at all (and with no
|
||||
// newline anywhere, `lastIndexOf` is -1 and the patch came back empty)
|
||||
// — a file changed by a megabyte rendering as "no textual change".
|
||||
// Past the halfway mark the boundary is worth keeping; before it, the
|
||||
// content is, so the last row is cut mid-line instead.
|
||||
const nl = patch.lastIndexOf("\n", cap);
|
||||
patch = patch.slice(0, nl > cap / 2 ? nl + 1 : cap);
|
||||
entry.truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
budget -= patch.length;
|
||||
entry.hunks = parseUnifiedDiff(patch);
|
||||
}
|
||||
}
|
||||
|
||||
return { range, base, baseRef, sha, context: ctx, files, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent commits on the lane's current branch, for the history list.
|
||||
*
|
||||
* `%x1f`/`%x1e` (unit/record separators) delimit the format instead of a
|
||||
* printable character: a commit SUBJECT can legally contain a tab, a pipe or
|
||||
* any punctuation a hand-picked delimiter would have used.
|
||||
*
|
||||
* @param {string} dir Lane working directory.
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.limit=30] Commits to read, clamped to 200.
|
||||
* @param {string|null} [opts.file] Only commits touching this path.
|
||||
*/
|
||||
async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
|
||||
const args = ["log", `-${n}`, `--format=${format}%x1e`];
|
||||
if (file) args.push("--", file);
|
||||
const result = await git(dir, args);
|
||||
const commits = [];
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
const line = record.replace(/^\n/, "");
|
||||
if (!line.trim()) continue;
|
||||
const [sha, short, subject, author, date, refs] = line.split("\x1f");
|
||||
commits.push({
|
||||
sha,
|
||||
short,
|
||||
subject,
|
||||
author,
|
||||
date,
|
||||
// `%D` is "HEAD -> main, origin/main, tag: v1" — the decorations, split
|
||||
// into the chips a history row shows.
|
||||
refs: (refs || "")
|
||||
.split(", ")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's full content at a revision, for the "read the whole file" view
|
||||
* that a diff's three lines of context cannot answer.
|
||||
*
|
||||
* `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a
|
||||
* commit-ish. The path is resolved against git's own index/tree rather than
|
||||
* trusted: for `work` it is realpath-confined to the lane directory, because
|
||||
* that is the one branch that reads the filesystem directly.
|
||||
*/
|
||||
async function readFileAt(dir, file, rev = "work") {
|
||||
if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
if (rev !== "work" && !SAFE_REV.test(rev)) {
|
||||
throw badRequest("EBADREV", "rev is not a valid revision");
|
||||
}
|
||||
|
||||
let text;
|
||||
let binary = false;
|
||||
if (rev === "work") {
|
||||
const abs = path.resolve(dir, file);
|
||||
// `..` in the request must not walk out of the lane. Compare resolved
|
||||
// paths, not the string the caller sent.
|
||||
const root = path.resolve(dir);
|
||||
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
}
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(abs);
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
binary = looksBinary(buf);
|
||||
text = binary ? "" : buf.toString("utf8");
|
||||
} else {
|
||||
let out;
|
||||
try {
|
||||
out = await git(dir, ["show", `${rev}:${file}`]);
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file at that revision");
|
||||
}
|
||||
text = out.stdout;
|
||||
binary = text.includes("\0");
|
||||
if (binary) text = "";
|
||||
}
|
||||
|
||||
let truncated = false;
|
||||
if (text.length > MAX_FILE_VIEW_BYTES) {
|
||||
const nl = text.lastIndexOf("\n", MAX_FILE_VIEW_BYTES);
|
||||
text = text.slice(0, nl > MAX_FILE_VIEW_BYTES / 2 ? nl + 1 : MAX_FILE_VIEW_BYTES);
|
||||
truncated = true;
|
||||
}
|
||||
const lines = text ? text.split("\n") : [];
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return { path: file, rev, binary, truncated, lines };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracked paths matching a substring query — the quick-open list.
|
||||
*
|
||||
* `git ls-files` rather than a filesystem walk: it already honours
|
||||
* `.gitignore`, so `node_modules` and build output never enter the list and no
|
||||
* skip-list has to be maintained here.
|
||||
*/
|
||||
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
const result = await git(dir, ["ls-files", "-z"]);
|
||||
const needle = String(q || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS);
|
||||
const all = result.stdout.split("\0").filter(Boolean);
|
||||
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
|
||||
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
|
||||
// every file under `src/api/`.
|
||||
if (needle) {
|
||||
hits.sort((a, b) => {
|
||||
const ab = path.basename(a).toLowerCase().includes(needle) ? 0 : 1;
|
||||
const bb = path.basename(b).toLowerCase().includes(needle) ? 0 : 1;
|
||||
return ab - bb || a.length - b.length || a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* One directory level of the working tree, for the file explorer. Tracked
|
||||
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
|
||||
* directories at this level — a tree that lists a repo's every file at once is
|
||||
* both slow to render and not what "expand a folder" means.
|
||||
*/
|
||||
async function listTree(dir, { subdir = "" } = {}) {
|
||||
const clean = String(subdir || "")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
|
||||
const args = ["ls-files", "-z"];
|
||||
if (clean) args.push("--", `${clean}/`);
|
||||
const result = await git(dir, args);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
for (const p of result.stdout.split("\0").filter(Boolean)) {
|
||||
const rest = p.slice(prefix.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash === -1) files.push({ name: rest, path: p, dir: false });
|
||||
else dirs.add(rest.slice(0, slash));
|
||||
}
|
||||
const entries = [
|
||||
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
return {
|
||||
path: clean,
|
||||
entries: entries.slice(0, MAX_PATHS),
|
||||
truncated: entries.length > MAX_PATHS,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
listCommits,
|
||||
listPaths,
|
||||
listTree,
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
resolveBaseRef,
|
||||
DEFAULT_CONTEXT,
|
||||
MAX_CONTEXT,
|
||||
MAX_FILES,
|
||||
MAX_FILE_BYTES,
|
||||
MAX_TOTAL_BYTES,
|
||||
};
|
||||
Reference in New Issue
Block a user