Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b | |||
| 841263bce1 | |||
| e1f433804c | |||
| 2b5d7268ed | |||
| 2cc492bb88 | |||
| f6eb39eb73 | |||
| 2b26fe1efa | |||
| 1f18f151d9 | |||
| a27589418c | |||
| 57cbab916a | |||
| a7a8bfd24c | |||
| 0c964909e0 | |||
| 6511e4ec62 | |||
| 220309db12 | |||
| c3d88c0685 | |||
| e4ecd116a2 | |||
| 8d0477e0f7 | |||
| e7e9cb20d8 | |||
| ebfae9b891 | |||
| 3ab484ddc0 | |||
| 6bcef26446 | |||
| 240444eafd | |||
| d09c9d6633 | |||
| bc44cbc8b7 | |||
| e7060a5e42 | |||
| 4b8f59695b | |||
| da7b86d5c8 | |||
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 | |||
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 | |||
| 9f8dc9082e | |||
| f1a90e120e | |||
| db5a7776cc | |||
| 6c2c094f64 | |||
| 609a35f251 | |||
| 97c39d5e15 | |||
| 4a82790734 | |||
| 80000793f4 | |||
| 39bea78a9c | |||
| 364fe4a286 | |||
| 38ea78b489 | |||
| 7b5abc6ad9 | |||
| ca55ff8ec5 | |||
| 0841d3e5c8 | |||
| 33d30f5e1f | |||
| 14e979837e | |||
| fa27b299e7 | |||
| 9e3d6f4ca1 | |||
| 26aabd6660 | |||
| 9d07f64154 | |||
| 0f17efe3b4 | |||
| 4999325210 | |||
| 1c6dfea5b5 | |||
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 | |||
| 6b602c76b8 | |||
| f2f1cd9b6c | |||
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 | |||
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e | |||
| 9dd97a369c | |||
| 62b4520507 | |||
| 241e19b713 | |||
| 685eb748e8 | |||
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf | |||
| fa74cce3fc | |||
| 3113479cd2 | |||
| 4eb7734ab9 |
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run:*)",
|
||||
@@ -36,11 +40,8 @@
|
||||
"Bash(node -e ':*)",
|
||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
"Bash(gh pr *)",
|
||||
"Bash(grep *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
+17
-16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+114
-83
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
|
||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
@@ -1693,7 +1693,7 @@ function cmdOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
||||
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||
*/
|
||||
async function cmdLanesAdd(args) {
|
||||
const flag = (name) => {
|
||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
||||
if (base) body.base = base;
|
||||
if (slug) body.slug = slug;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
||||
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lane = provisioning;
|
||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
||||
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||
}
|
||||
if (lane.status === "provisioning") {
|
||||
console.error(
|
||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
||||
const body = { cwd, title };
|
||||
if (pipeline) body.pipeline = pipeline;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane } = await post("/api/lanes", body);
|
||||
const { lane } = await post("/api/workspaces", body);
|
||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||
* action, no HTTP round-trip.
|
||||
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||
* talk to the dashboard server at all.
|
||||
*/
|
||||
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
||||
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
||||
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||
* against a bare repo right after `profile init`.
|
||||
*/
|
||||
async function cmdLanesProfileCheck(args) {
|
||||
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
||||
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||
async function cmdLanesLifecycle(action, args) {
|
||||
const id = args.find((arg) => !arg.startsWith("--"));
|
||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const body = { confirm: true, expect };
|
||||
if (args.includes("--force")) body.force = true;
|
||||
if (keepDb) body.keepDb = true;
|
||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
const error = result.data?.error || {};
|
||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* whether the driving session is still breathing.
|
||||
*/
|
||||
/**
|
||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
||||
}
|
||||
|
||||
async function cmdLanes() {
|
||||
const { lanes, counts } = await get("/api/lanes");
|
||||
const { lanes, counts } = await get("/api/workspaces");
|
||||
if (!lanes.length) {
|
||||
console.log(
|
||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
|
||||
*
|
||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||
* Scanning the whole argv for a number would swallow flag values —
|
||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
||||
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||
*
|
||||
* @param {string[]} args - Arguments after the subcommand.
|
||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
|
||||
return i > -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const explicit = flagValue("lane") || flagValue("workspace");
|
||||
const explicit = flagValue("workspace");
|
||||
if (explicit) return { laneId: explicit, rest: args };
|
||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||
|
||||
const tool = flagValue("tool") || toolFromEnv();
|
||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||
const { lanes } = await get("/api/lanes");
|
||||
const { lanes } = await get("/api/workspaces");
|
||||
|
||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||
// against the live lane list AND the directory: a pane outlives the lane it
|
||||
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||
* lane id.
|
||||
*
|
||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const { laneId, rest: laneArgs } = resolved;
|
||||
|
||||
if (sub === "runtime") {
|
||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const body = {};
|
||||
if (laneArgs.includes("--no-build")) body.build = false;
|
||||
if (laneArgs.includes("--qc")) body.qc = true;
|
||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
||||
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||
if (runtime.healthy) {
|
||||
printRuntime(runtime);
|
||||
return;
|
||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
if (sub === "down") {
|
||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(
|
||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
const i = laneArgs.indexOf("--tail");
|
||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
if (!log.available) {
|
||||
console.log("no logs for this workspace yet.");
|
||||
return;
|
||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
return;
|
||||
}
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
{ args: laneArgs.slice(1) },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
: "merge";
|
||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/sync-base`,
|
||||
`/api/workspaces/${laneId}/sync-base`,
|
||||
{ mode, branch },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
||||
|
||||
const resolved = await resolveLaneArg(argsAfterName);
|
||||
if (!resolved) return null;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||
}
|
||||
|
||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
||||
async function cmdFeatureList(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||
if (!features.length) {
|
||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||
return;
|
||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
||||
};
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
||||
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||
slug,
|
||||
title: flag("title"),
|
||||
});
|
||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
||||
}
|
||||
|
||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||
async function cmdLanesProofLink(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
||||
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||
console.log(
|
||||
linked
|
||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesMcpSync(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
||||
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||
async function cmdLanesIntegration(args) {
|
||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||
if (!resolved) return;
|
||||
const { enabled } = await get(
|
||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
);
|
||||
console.log(enabled ? "on" : "off");
|
||||
process.exitCode = enabled ? 0 : 1;
|
||||
}
|
||||
|
||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
||||
const resolved = await resolveLaneArg(args.slice(2));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* lane's pipeline instead of the whole thing.
|
||||
*
|
||||
* Every node already carries the one command that drives it (`command`, added
|
||||
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
|
||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||
*
|
||||
* - a slash command starts `claude` with it as the prompt;
|
||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
||||
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||
*
|
||||
* With no node id it lists the nodes and their commands, which is also the
|
||||
* error path's answer. `--print` shows the argv and runs nothing.
|
||||
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||
// the template does. Merge, so the listing can still mark the current node.
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||
...n,
|
||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
||||
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||
* was stuck on `default` with no way back to a 16-node template.
|
||||
*/
|
||||
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
|
||||
if (!resolved) return;
|
||||
|
||||
if (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
||||
async function cmdLanesShell(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const sessionName = `agenthub-lane-${lane.id}`;
|
||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||
stdio: "inherit",
|
||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
||||
if (!resolved) return;
|
||||
const laneId = resolved.laneId;
|
||||
|
||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||
stage,
|
||||
status: flag("status"),
|
||||
evidence: flag("evidence"),
|
||||
@@ -2845,18 +2845,18 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"setup|off|status [--hostname name] [--admin name]",
|
||||
"setup|install-system|tools|off|status [--hostname name] [--admin name]",
|
||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||
],
|
||||
[
|
||||
"users",
|
||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||
"list|add|passwd|role|disable|enable|provision|assign|audit [name]",
|
||||
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
|
||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
|
||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
||||
/** Detailed help for one command (or command prefix): every catalog entry
|
||||
* whose invocation starts with the token. Returns false if none matched. */
|
||||
function commandHelp(token) {
|
||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
||||
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||
const canon = (words) => {
|
||||
const [head, ...tail] = words.split(" ");
|
||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||
// decides whether to fall back to an offline reader or report the refusal.
|
||||
|
||||
/** Command names that are another command under the user-facing word.
|
||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
||||
* release after the first one that shipped these aliases. */
|
||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
||||
/** Command names that are another command under the user-facing word. */
|
||||
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||
const canonicalArgv = (argv) =>
|
||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||
|
||||
/** One stderr line when an old name is typed, so people and agents move over
|
||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
||||
function warnOldNames(argv) {
|
||||
const old = [];
|
||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
||||
if (old.length) {
|
||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
||||
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||
* — never "Unknown command" or help, so a script written before says why. */
|
||||
function refuseOldNames(argv) {
|
||||
if (argv[0] === "lanes") {
|
||||
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (argv.includes("--lane")) {
|
||||
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
|
||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||
}
|
||||
set.add("help");
|
||||
// The old names stay typeable (and completable) for scripts written before.
|
||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||
return [...set];
|
||||
})();
|
||||
@@ -3812,8 +3810,8 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
server: ["setup", "install-system", "tools", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
@@ -3905,7 +3903,7 @@ async function newPassword(label) {
|
||||
return first;
|
||||
}
|
||||
|
||||
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||
/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
|
||||
async function cmdServer(sub, flags) {
|
||||
const { settingsEnv, auth } = serverAuth();
|
||||
const file = settingsEnv.settingsFilePath();
|
||||
@@ -4062,7 +4060,7 @@ async function installSystem(flags, settingsEnv) {
|
||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||
}
|
||||
const which = (bin) =>
|
||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
||||
spawnSync("sh", ["-c", 'command -v "$1"', "sh", bin], { encoding: "utf8" }).stdout.trim();
|
||||
const host = os.userInfo().username;
|
||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const data = getDataDir();
|
||||
@@ -4328,8 +4326,36 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||
async function cmdCursorAccounts(sub, name) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||
);
|
||||
const added = pool.addAccount(name, {
|
||||
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||
});
|
||||
console.log(`${c.green("✔")} ${added.name} added`);
|
||||
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub !== undefined && sub !== "list") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
|
||||
}
|
||||
table(
|
||||
["cursor account", "live", "pinned"],
|
||||
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||
*/
|
||||
async function cmdAccountPin(sub, positional, tool) {
|
||||
@@ -4337,7 +4363,9 @@ async function cmdAccountPin(sub, positional, tool) {
|
||||
const [first, second] = positional;
|
||||
const username = sub === "pin" ? second : first;
|
||||
if (!username || (sub === "pin" && !first)) {
|
||||
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
||||
throw new Error(
|
||||
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
);
|
||||
}
|
||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||
@@ -4387,14 +4415,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||
throw new Error("--tool is claude (the default), codex or cursor");
|
||||
}
|
||||
if (sub === "pin" || sub === "unpin")
|
||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4450,11 +4479,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
@@ -4465,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
* points, not here, to avoid recursive REPLs. */
|
||||
async function runCommand(argv) {
|
||||
warnOldNames(argv);
|
||||
refuseOldNames(argv);
|
||||
const [cmd, ...rest] = canonicalArgv(argv);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
switch (cmd) {
|
||||
@@ -4538,7 +4569,7 @@ async function runCommand(argv) {
|
||||
return cmdReinstallHooks();
|
||||
case "update-check":
|
||||
return cmdUpdateCheck();
|
||||
case "lanes":
|
||||
case "workspace":
|
||||
if (rest[0] === "add") {
|
||||
return cmdLanesAdd(rest.slice(1));
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||
|
||||
The client is a single-page application (SPA) built with modern web technologies:
|
||||
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **React 19** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
@@ -1215,7 +1215,7 @@ graph TB
|
||||
|
||||
The client is a production-ready React application with:
|
||||
|
||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
||||
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||
- 🧪 **Tested** - Vitest + React Testing Library
|
||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||
|
||||
Generated
+462
-162
@@ -15,36 +15,36 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
@@ -450,10 +450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -478,10 +477,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"version": "6.43.15",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1251,15 +1249,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1670,7 +1659,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
@@ -2117,30 +2105,21 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -2149,6 +2128,326 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||
@@ -2291,59 +2590,38 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-clipboard": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
||||
"license": "MIT",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
@@ -2740,6 +3018,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
@@ -3703,9 +3993,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.5.11",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -3720,7 +4010,6 @@
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
@@ -3731,12 +4020,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
"@babel/runtime": "^7.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -3921,6 +4209,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -4019,18 +4308,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -4049,12 +4326,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.474.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
||||
"license": "ISC",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
@@ -5375,35 +5657,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||
"license": "MIT",
|
||||
"version": "17.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
@@ -5472,35 +5747,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
"react-router": "7.18.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
@@ -5748,13 +6027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -5766,6 +6041,11 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -6171,17 +6451,37 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
|
||||
+14
-14
@@ -18,36 +18,36 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -49,7 +49,7 @@
|
||||
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
|
||||
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
|
||||
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
|
||||
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
|
||||
</g>
|
||||
<g transform="translate(792,300)">
|
||||
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* @file BackgroundJobsDock.tsx
|
||||
* @description The one bottom-right stack of background work, rendered once
|
||||
* by `Layout`: a chip per background clone (`lib/cloneJobs.ts`, kept by the
|
||||
* server, so it is back after F5 or a server restart) and per File Explorer
|
||||
* upload (`lib/uploadJobs.ts`). A clone chip shows its queue place, then
|
||||
* git's progress, with **Stop** (click twice) while it runs; once finished,
|
||||
* **Open workspace** (done), the reason plus **Retry** (failed, interrupted,
|
||||
* stopped) — an inline login form when the host wants one, a link to
|
||||
* Settings → Git when it refused the SSH key — and × to dismiss. An upload
|
||||
* chip shows its progress with **Stop**; one a reload cut short says how far
|
||||
* it got. Several chips collapse to one small pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
|
||||
import type { CloneJob } from "../lib/api";
|
||||
import {
|
||||
cancelCloneJob,
|
||||
cloneJobError,
|
||||
dismissCloneJob,
|
||||
ensureCloneJobs,
|
||||
isActiveJob,
|
||||
retryCloneJob,
|
||||
useCloneJobs,
|
||||
} from "../lib/cloneJobs";
|
||||
import { dismissUpload, stopUpload, useUploadJobs, type UploadJob } from "../lib/uploadJobs";
|
||||
import { useArmed } from "../hooks/useArmed";
|
||||
import { CloneProgress } from "./lanes/CloneProgress";
|
||||
|
||||
const chipClass =
|
||||
"pointer-events-auto w-full space-y-1.5 rounded-lg border border-border bg-surface-1 p-2.5 text-xs shadow-xl shadow-black/40";
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
danger,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
danger?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
className={`-m-0.5 shrink-0 rounded p-1 hover:bg-surface-2 ${
|
||||
danger ? "text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
const [login, setLogin] = useState({ username: "", token: "", remember: true });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const active = isActiveJob(job);
|
||||
const code = job.error?.code;
|
||||
const laneId = job.kind === "workspace" ? (job.result?.laneId ?? null) : job.laneId;
|
||||
const title =
|
||||
job.state === "done"
|
||||
? t("cloneJobs.done", { name: job.name })
|
||||
: job.state === "cancelled"
|
||||
? t("cloneJobs.cancelledTitle", { name: job.name })
|
||||
: active
|
||||
? t("cloneJobs.cloning", { name: job.name })
|
||||
: t("cloneJobs.failedTitle", { name: job.name });
|
||||
|
||||
const retry = async (withLogin: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryCloneJob(
|
||||
job.id,
|
||||
withLogin
|
||||
? { username: login.username.trim(), password: login.token, remember: login.remember }
|
||||
: undefined
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
void cancelCloneJob(job.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={chipClass} data-testid="clone-job" data-state={job.state} title={job.target}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">{title}</span>
|
||||
{active ? (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("cloneJobs.stopConfirm") : t("cloneJobs.stop")}
|
||||
onClick={stop}
|
||||
danger={isArmed("stop")}
|
||||
testId="clone-job-stop"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("cloneJobs.dismiss")}
|
||||
onClick={() => void dismissCloneJob(job.id)}
|
||||
testId="clone-job-dismiss"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{active && isArmed("stop") && (
|
||||
<p className="text-[11px] text-status-danger">{t("cloneJobs.stopConfirm")}</p>
|
||||
)}
|
||||
{active && <CloneProgress view={job} />}
|
||||
{job.state === "done" && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{job.result?.path || job.target}
|
||||
</span>
|
||||
{laneId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
onClick={() => {
|
||||
navigate(`/workspace?lane=${laneId}`);
|
||||
void dismissCloneJob(job.id);
|
||||
}}
|
||||
>
|
||||
{job.kind === "workspace" ? t("cloneJobs.openWorkspace") : t("cloneJobs.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!active && job.state !== "done" && (
|
||||
<>
|
||||
<p
|
||||
role={job.state === "cancelled" ? undefined : "alert"}
|
||||
className={`text-[11px] [overflow-wrap:anywhere] ${
|
||||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{cloneJobError(job)}{" "}
|
||||
{code === "ESSHAUTH" && (
|
||||
// A router link: a plain <a href> reloaded the whole app.
|
||||
<Link to="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
{code === "ECREDENTIALS" && job.state === "failed" ? (
|
||||
<form
|
||||
data-testid="clone-job-login"
|
||||
className="space-y-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void retry(true);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="username"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="current-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="input w-full text-xs"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !login.username.trim() || !login.token}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("cloneJobs.retryLogin")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
code !== "ESSHAUTH" && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void retry(false)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("cloneJobs.retry")}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadChip({ job }: { job: UploadJob }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { arm, isArmed, disarm } = useArmed();
|
||||
if (job.state === "interrupted") {
|
||||
return (
|
||||
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("uploads.interrupted", {
|
||||
total: job.total,
|
||||
folder: job.folder,
|
||||
done: job.done,
|
||||
})}
|
||||
</p>
|
||||
<IconButton label={t("cloneJobs.dismiss")} onClick={() => dismissUpload(job.id)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const stopping = job.state === "stopping";
|
||||
return (
|
||||
<div className={chipClass} data-testid="fx-progress" data-state={job.state}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-fg-primary">
|
||||
{stopping
|
||||
? t("uploads.stopping")
|
||||
: t("fx.uploading", { done: Math.min(job.done + 1, job.total), total: job.total })}
|
||||
</span>
|
||||
{!stopping && (
|
||||
<IconButton
|
||||
label={isArmed("stop") ? t("uploads.stopConfirm") : t("uploads.stop")}
|
||||
danger={isArmed("stop")}
|
||||
testId="upload-job-stop"
|
||||
onClick={() => {
|
||||
if (!isArmed("stop")) return arm("stop");
|
||||
disarm();
|
||||
stopUpload(job.id);
|
||||
}}
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{isArmed("stop") && !stopping && (
|
||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
</p>
|
||||
<div className="h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{ width: `${Math.min(100, (job.sent / Math.max(1, job.bytes)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundJobsDock() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const jobs = useCloneJobs();
|
||||
const uploads = useUploadJobs();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => ensureCloneJobs(), []);
|
||||
|
||||
const count = jobs.length + uploads.length;
|
||||
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
|
||||
// sit on the same corner and cover a chip's × / Retry / Stop.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const el = dockRef.current;
|
||||
if (!count || !el) {
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
return;
|
||||
}
|
||||
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
|
||||
set();
|
||||
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
|
||||
ro?.observe(el);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
};
|
||||
}, [count, collapsed]);
|
||||
if (!count) return null;
|
||||
const running =
|
||||
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
role="region"
|
||||
aria-label={t("jobs.dock")}
|
||||
data-testid="jobs-dock"
|
||||
className="pointer-events-none fixed bottom-10 right-4 z-[60] flex max-h-[70vh] w-[min(20rem,calc(100vw-2rem))] flex-col items-end gap-2 overflow-y-auto"
|
||||
>
|
||||
{count > 1 && collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-pill"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title={t("jobs.expand")}
|
||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border bg-surface-1 px-3 py-1 text-xs text-fg-primary shadow-lg shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-fg-muted" />
|
||||
{t("jobs.count", { count })}
|
||||
{running > 0 && (
|
||||
<span className="text-fg-muted">· {t("jobs.running", { count: running })}</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{count > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="jobs-dock-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border bg-surface-1 px-2 py-0.5 text-[11px] text-fg-secondary shadow hover:bg-surface-2"
|
||||
>
|
||||
{t("jobs.count", { count })}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{uploads.map((u) => (
|
||||
<UploadChip key={u.id} job={u} />
|
||||
))}
|
||||
{jobs.map((j) => (
|
||||
<CloneJobChip key={j.id} job={j} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,10 +24,18 @@ export function DocumentTitle() {
|
||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/snapshot/")) {
|
||||
title = t("snapshot");
|
||||
} else if (path.startsWith("/workspace")) {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path.startsWith("/artifacts")) {
|
||||
title = t("artifacts");
|
||||
} else if (path.startsWith("/files")) {
|
||||
title = t("files");
|
||||
} else if (path.startsWith("/admin")) {
|
||||
title = t("admin");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file HtmlFrame.tsx
|
||||
* @description An HTML file rendered as a page, the one way every file view
|
||||
* shows one (a Chat file preview, the Changes panel's Preview, the File
|
||||
* Explorer's viewer): its relative CSS, scripts and images pulled in through
|
||||
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
|
||||
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
|
||||
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
|
||||
* file itself through the same `fetchFile`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||
|
||||
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||
* page (a slide deck with its video and images inlined as base64) is easily
|
||||
* past 10 MB; the iframe renders far larger documents than this. */
|
||||
const HTML_MAX = 64 * 1024 * 1024;
|
||||
|
||||
export function HtmlFrame({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
title,
|
||||
className = "",
|
||||
testId = "html-frame",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
/** Reads the page's relative files; none = the page as it is. */
|
||||
fetchFile?: FetchFile | null;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [doc, setDoc] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// The page shown stays until its successor is ready (a version step, a
|
||||
// re-read): no flash of the spinner between two renders of one file.
|
||||
setFailed(false);
|
||||
(async () => {
|
||||
let text = html;
|
||||
if (text === undefined) {
|
||||
if (!fetchFile) throw new Error("nothing to read the page with");
|
||||
const b = await fetchFile(path);
|
||||
if (b.size > HTML_MAX) throw new Error("too big");
|
||||
text = await blobText(b);
|
||||
}
|
||||
const page = text;
|
||||
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
|
||||
})().then(
|
||||
(d) => live && setDoc(d),
|
||||
() => live && setFailed(true)
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [html, path, fetchFile]);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
<p className="break-all font-mono text-xs">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (doc === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
srcDoc={artifactDocument("html", doc, false) ?? ""}
|
||||
sandbox={frameSandbox("html")}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
|
||||
export function fetchVia(url: (relPath: string) => string): FetchFile {
|
||||
return async (rel) => {
|
||||
const r = await fetch(url(rel), { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.blob();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file HtmlViewerModal.tsx
|
||||
* @description An HTML file shown as the page in a modal over the whole
|
||||
* screen — the one viewer the File Explorer and the Changes panel's Preview
|
||||
* open (the Chat's file preview opens its own panel as a modal instead, for
|
||||
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
|
||||
* artifact's sandbox, its relative files read through the caller's guarded
|
||||
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
|
||||
* underneath (the explorer's Delete) never fires from inside it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { HtmlFrame } from "./HtmlFrame";
|
||||
import type { FetchFile } from "../lib/htmlInline";
|
||||
|
||||
export function HtmlViewerModal({
|
||||
path,
|
||||
html,
|
||||
fetchFile,
|
||||
onDownload,
|
||||
onClose,
|
||||
testId = "html-viewer",
|
||||
}: {
|
||||
/** The file's path relative to the place `fetchFile` reads from; also its title. */
|
||||
path: string;
|
||||
/** The page's text; read through `fetchFile` when absent. */
|
||||
html?: string;
|
||||
fetchFile?: FetchFile | null;
|
||||
onDownload?: () => void;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const name = path.slice(path.lastIndexOf("/") + 1);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
data-testid={testId}
|
||||
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{onDownload && (
|
||||
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
||||
<Download className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
)}
|
||||
<HeadButton label={t("fx.close")} onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
</div>
|
||||
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@
|
||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||
* layer, under anything stacked above it.
|
||||
* layer, under anything stacked above it. `useDialog` takes the focus while it
|
||||
* is open and gives it back, and its Escape closes only this, the top dialog.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
@@ -20,18 +22,18 @@ export function ImageLightbox({
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
data-testid="image-lightbox"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
@@ -247,13 +248,9 @@ export function ImportHistory() {
|
||||
|
||||
const copyArchiveCmd = async () => {
|
||||
if (!guide) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(guide.archive_command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
if (!(await copyText(guide.archive_command, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const progressText = (() => {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./BackgroundJobsDock` (background clones and uploads, app-wide)
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { BackgroundJobsDock } from "./BackgroundJobsDock";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<BackgroundJobsDock />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
|
||||
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||
@@ -863,7 +864,7 @@ export function RemoteSources() {
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const rafRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
@@ -70,7 +71,7 @@ export function PetNameButton({
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
|
||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||
* header looks as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
void copyText(snapshotUrl(snapshot.token));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
|
||||
* user's OWN spend even for an admin — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
@@ -21,6 +22,9 @@
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* Server mode: the user's OWN spend quota (`GET /api/me/quota`, read with the
|
||||
* costs) as "Quota N%" — amber from 80 %, red once used up, when the user can
|
||||
* start nothing and their prompts are refused until it opens again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,9 +33,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -50,9 +54,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
@@ -281,7 +282,7 @@ function QuotaChips() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
@@ -371,6 +372,8 @@ export function StatusBar({
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
// Server mode: this user's own spend quota (null: none, or local mode).
|
||||
const [myQuota, setMyQuota] = useState<UserQuotaStatus | null>(null);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
@@ -406,7 +409,7 @@ export function StatusBar({
|
||||
const gen = costGen.current;
|
||||
const live = () => gen === costGen.current;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.totalCost({ date: localDate(), mine: true })
|
||||
.then((r) => live() && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
@@ -416,6 +419,10 @@ export function StatusBar({
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
api.me
|
||||
.quota()
|
||||
.then((r) => live() && setMyQuota(r?.quota ?? null))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
@@ -526,6 +533,26 @@ export function StatusBar({
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
{myQuota && (
|
||||
<span
|
||||
data-testid="status-bar-user-quota"
|
||||
title={t(myQuota.exceeded ? "statusBar.userQuotaBlocked" : "statusBar.userQuotaTitle", {
|
||||
spend: usd(myQuota.spend),
|
||||
limit: usd(myQuota.limit_usd),
|
||||
period: t(`statusBar.userQuotaPeriod.${myQuota.period}`),
|
||||
at: myQuota.resetsAt ? formatDateTime(myQuota.resetsAt) : "",
|
||||
})}
|
||||
className={`tabular-nums ${
|
||||
myQuota.exceeded
|
||||
? "text-status-danger"
|
||||
: myQuota.pct >= 80
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{t("statusBar.userQuota", { pct: Math.min(999, Math.round(myQuota.pct)) })}
|
||||
</span>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
|
||||
{item.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
@@ -44,8 +46,10 @@ export function Toaster() {
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
const errors = items.filter((i) => i.kind === "error");
|
||||
const rest = items.filter((i) => i.kind !== "error");
|
||||
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
|
||||
// to its height), so a toast never covers a chip's buttons.
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||
{rest.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* this page load only — also while an update applies: it keeps running in the
|
||||
* background (the page still reloads itself once the new server is up), and a
|
||||
* failure re-opens the modal with its error. While hidden — or once the status
|
||||
* stops offering the update mid-run — a small chip in the corner keeps showing
|
||||
* it, with the plugin chain's current step from `plugin_update_progress`, and a
|
||||
* click on it shows the modal again. A chain the server reports as running
|
||||
* (started before a reload or from another tab) is adopted the same way;
|
||||
* "Skip this version" is the one
|
||||
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
@@ -182,6 +189,8 @@ export function UpdateNotifier() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
/** The running plugin chain's step, from the polls while it runs. */
|
||||
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
@@ -236,20 +245,23 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
// Updating replaces the host's install: only an admin is offered it.
|
||||
// Updating replaces the host's install: only an admin is offered it. A
|
||||
// running update stays shown (unless closed) even once the status stops
|
||||
// offering it — the new version may already be on disk before it restarts.
|
||||
const show = Boolean(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
closedKey !== dismissKey &&
|
||||
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
|
||||
);
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
// Closing while an update applies only hides it: the apply request and the
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
if (!dismissKey) return;
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
}, [dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -261,8 +273,8 @@ export function UpdateNotifier() {
|
||||
setDismissedSha(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
// Escape closes for now — an applying update carries on in the background.
|
||||
useDialog(show, panelRef, closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
@@ -307,6 +319,7 @@ export function UpdateNotifier() {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
setProgress(s.plugin_update_progress ?? null);
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
@@ -315,6 +328,7 @@ export function UpdateNotifier() {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
else setClosedKey(null); // closed while it ran: show why it failed
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
@@ -333,9 +347,23 @@ export function UpdateNotifier() {
|
||||
[syncFromPayload, t]
|
||||
);
|
||||
|
||||
// A chain already running on the server (started before a reload, in another
|
||||
// tab, or from the old bundle) is adopted: this tab shows its steps, then
|
||||
// reloads onto the new server like the tab that started it.
|
||||
const running = status?.plugin_update_progress ?? null;
|
||||
const adoptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
|
||||
adoptedRef.current = true;
|
||||
setProgress(running);
|
||||
setRestarting(true);
|
||||
pollUntilBack(true, status?.local_version ?? null);
|
||||
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
|
||||
|
||||
const applyNow = async () => {
|
||||
if (applying || restarting) return;
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await api.updates.apply();
|
||||
@@ -359,6 +387,8 @@ export function UpdateNotifier() {
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||
setClosedKey(null);
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
@@ -384,6 +414,54 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
const stepText = progress?.step
|
||||
? t(`pluginStep.${progress.step}`, {
|
||||
index: progress.index,
|
||||
total: progress.total,
|
||||
defaultValue: progress.step,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Hidden while it runs — closed, or the status no longer offers the update
|
||||
// (the new version is already on disk): a chip keeps it in sight.
|
||||
if (!show && isAdmin && (applying || restarting)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="update-chip"
|
||||
onClick={() => setClosedKey(null)}
|
||||
title={t("chipHint")}
|
||||
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||
<span className="truncate">
|
||||
{status?.latest_release
|
||||
? t("chipTitle", { version: status.latest_release })
|
||||
: t("updating")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||
</span>
|
||||
{progress && progress.total > 0 ? (
|
||||
<span
|
||||
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress.index}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progress.total}
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-accent-solid transition-all"
|
||||
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -436,7 +514,6 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
@@ -513,7 +590,14 @@ export function UpdateNotifier() {
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
<span>
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
{stepText ? (
|
||||
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||
{stepText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
|
||||
* server like the other preferences) arrange the list under foldable headers —
|
||||
* each group in its order, then "Ungrouped" — inside each owner group when
|
||||
* there are owner groups. A workspace joins a group from its menu, by a drop
|
||||
* on the group's header, by a drag that lands it below a row of that
|
||||
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
|
||||
* another to reorder the groups, and its menu renames, moves, folds every
|
||||
* group, adds a new workspace straight into it or deletes it (its
|
||||
* workspaces stay, ungrouped). A folded header still shows the worst state
|
||||
* of its workspaces (needs you, error, running) and marks the selected one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -30,7 +40,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
@@ -46,7 +56,31 @@ import {
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import {
|
||||
arrangeByGroup,
|
||||
assignGroup,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
groupOf,
|
||||
moveGroup,
|
||||
moveGroupBefore,
|
||||
pruneWorkspaceGroups,
|
||||
renameGroup,
|
||||
setAllFolded,
|
||||
setGroupFolded,
|
||||
useWorkspaceGroups,
|
||||
type WorkspaceGroup,
|
||||
} from "../lib/workspaceGroups";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
|
||||
import {
|
||||
LANE_DRAG_TYPE,
|
||||
WorkspaceRow,
|
||||
isLaneDrag,
|
||||
workspaceDot,
|
||||
workspaceDotLabel,
|
||||
workspaceName,
|
||||
} from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||
// "New workspace in this group": the group the added workspace joins.
|
||||
const [addToGroup, setAddToGroup] = useState<string | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
const groups = useWorkspaceGroups();
|
||||
const hasGroups = groups.groups.length > 0;
|
||||
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
|
||||
const [groupMenu, setGroupMenu] = useState<{
|
||||
group: WorkspaceGroup;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
// A removed workspace leaves the stored groups (they hold ids only).
|
||||
const laneIdsKey = lanes.map((l) => l.id).join(",");
|
||||
useEffect(() => {
|
||||
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
|
||||
}, [laneIdsKey]);
|
||||
|
||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddToGroup(groupId);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(l) => !toolFilter || l.tool === toolFilter
|
||||
const list = arrangeByGroup(
|
||||
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
groups
|
||||
);
|
||||
if (!grouped) return list;
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
}, [lanes, order, toolFilter, grouped, meId, groups]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
|
||||
// screen and joins the next group instead (the arrangement would otherwise
|
||||
// put it straight back): last of the group above, first of the one below —
|
||||
// opened if folded, so the focused row stays shown. The shown order is
|
||||
// written as it stands, so the store agrees. Never across an owner's section.
|
||||
const nudge = (from: number, to: number) => {
|
||||
const lane = sorted[from];
|
||||
const next = sorted[to];
|
||||
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
|
||||
const target = groupOf(groups, next.id);
|
||||
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
|
||||
assignGroup(lane.id, target?.id ?? null);
|
||||
if (target?.folded) setGroupFolded(target.id, false);
|
||||
reorder((ids) => [...ids]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
reorder((ids) => moveLane(ids, from, to));
|
||||
};
|
||||
|
||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||
// workspace's session list, the space above or below a row — lands too.
|
||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
if (!Number.isInteger(from)) return;
|
||||
const lane = sorted[from];
|
||||
// Dropped on a group's header: into that group, at its top.
|
||||
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
|
||||
if (header && lane) {
|
||||
const gid = header.dataset.groupDrop || null;
|
||||
assignGroup(lane.id, gid);
|
||||
const first = sorted.findIndex(
|
||||
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
|
||||
);
|
||||
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
|
||||
return;
|
||||
}
|
||||
const before = dropIndexAt(e.clientY);
|
||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
||||
// Elsewhere it joins the group of the row it now sits under (the line
|
||||
// drawn above a header closes the section before it).
|
||||
if (lane && hasGroups) {
|
||||
const rest = sorted.filter((l) => l.id !== lane.id);
|
||||
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
|
||||
const neighbour = rest[at - 1] ?? rest[0];
|
||||
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
|
||||
}
|
||||
reorder((ids) => moveBefore(ids, from, before));
|
||||
},
|
||||
};
|
||||
|
||||
// A group no workspace is in yet still shows (to be renamed, or dropped on),
|
||||
// just above the ungrouped ones; a search hides it.
|
||||
const emptyGroups = q
|
||||
? []
|
||||
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
|
||||
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
|
||||
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
|
||||
<GroupHeader
|
||||
key={g?.id ?? "none"}
|
||||
group={g}
|
||||
count={members.length}
|
||||
// What a folded group hides: the most urgent of its workspaces, and
|
||||
// whether the open one is among them.
|
||||
worst={worstOf(members)}
|
||||
holdsSelected={members.some((l) => l.id === selected.lane)}
|
||||
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
|
||||
folded={!!g?.folded && !q}
|
||||
renaming={!!g && renamingGroup === g.id}
|
||||
first={first}
|
||||
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
|
||||
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
|
||||
onRenameEnd={(name) => {
|
||||
setRenamingGroup(null);
|
||||
if (g && name !== null) renameGroup(g.id, name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const newGroup = (laneIds: number[] = []) => {
|
||||
const id = createGroup(t("tree.groupDefaultName"), laneIds);
|
||||
setRenamingGroup(id);
|
||||
};
|
||||
const removeGroup = async (g: WorkspaceGroup) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tree.groupDelete"),
|
||||
message: t("tree.groupDeleteConfirm", { name: g.name }),
|
||||
confirmLabel: t("tree.groupDelete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
deleteGroup(g.id);
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-new-group"
|
||||
onClick={() => newGroup()}
|
||||
aria-label={t("tree.newGroup")}
|
||||
title={t("tree.newGroup")}
|
||||
className={headBtn}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-add"
|
||||
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
// The user's own group, inside the owner's.
|
||||
const ug = groupOf(groups, lane.id);
|
||||
const ugKey = ug?.id ?? "";
|
||||
const sameSection = (l: Lane) =>
|
||||
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
|
||||
const prev = sorted[i - 1];
|
||||
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
|
||||
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
|
||||
const sectionFolded = !!ug?.folded && !q;
|
||||
const hiddenRow = folded || sectionFolded;
|
||||
return (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
|
||||
{sectionStart &&
|
||||
sectionShown &&
|
||||
!folded &&
|
||||
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && !folded && (
|
||||
{visible(lane) && !hiddenRow && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onReorder={nudge}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && !folded && (
|
||||
{expanded(lane.id) && !hiddenRow && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{firstUngrouped === -1 &&
|
||||
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
groups={groups.groups}
|
||||
groupId={groups.of[menu.lane.id] ?? null}
|
||||
onGroup={(target) => {
|
||||
const id = menu.lane.id;
|
||||
setMenu(null);
|
||||
if (target === "new") newGroup([id]);
|
||||
else assignGroup(id, target);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{groupMenu && (
|
||||
<ContextMenu
|
||||
x={groupMenu.x}
|
||||
y={groupMenu.y}
|
||||
onClose={() => setGroupMenu(null)}
|
||||
testId={`workspace-group-menu-${groupMenu.group.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
setRenamingGroup(groupMenu.group.id);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupRename")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={groups.groups[0]?.id === groupMenu.group.id}
|
||||
onSelect={() => {
|
||||
moveGroup(groupMenu.group.id, -1);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupMoveUp")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
|
||||
onSelect={() => {
|
||||
moveGroup(groupMenu.group.id, 1);
|
||||
setGroupMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupMoveDown")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
testId="workspace-group-add"
|
||||
onSelect={() => {
|
||||
const g = groupMenu.group;
|
||||
setGroupMenu(null);
|
||||
openAdd(undefined, g.id);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupAddWorkspace")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="workspace-group-fold-all"
|
||||
onSelect={() => {
|
||||
setGroupMenu(null);
|
||||
setAllFolded(true);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupFoldAll")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="workspace-group-unfold-all"
|
||||
onSelect={() => {
|
||||
setGroupMenu(null);
|
||||
setAllFolded(false);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupUnfoldAll")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
testId="workspace-group-delete"
|
||||
onSelect={() => {
|
||||
const g = groupMenu.group;
|
||||
setGroupMenu(null);
|
||||
void removeGroup(g);
|
||||
}}
|
||||
>
|
||||
{t("tree.groupDelete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
setAddToGroup(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
if (addToGroup) assignGroup(lane.id, addToGroup);
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What a group header's drag carries: the group's id. */
|
||||
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
|
||||
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
|
||||
|
||||
// needsYou outranks an error, an error a running agent; idle/active say nothing.
|
||||
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
|
||||
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
|
||||
function worstOf(lanes: Lane[]): Lane | null {
|
||||
let best: Lane | null = null;
|
||||
let top = 0;
|
||||
for (const l of lanes) {
|
||||
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
|
||||
if (u > top) [best, top] = [l, u];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A workspace's owner group: its user id, or "none". */
|
||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||
|
||||
/**
|
||||
* A section's header: a user group (foldable, menu, inline rename) or, with
|
||||
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
|
||||
*/
|
||||
function GroupHeader({
|
||||
group,
|
||||
count,
|
||||
worst,
|
||||
holdsSelected,
|
||||
folded,
|
||||
renaming,
|
||||
first,
|
||||
onToggle,
|
||||
onMenu,
|
||||
onRenameEnd,
|
||||
onGroupDrop,
|
||||
}: {
|
||||
group: WorkspaceGroup | null;
|
||||
count: number;
|
||||
/** The most urgent workspace inside, drawn as its dot while folded. */
|
||||
worst: Lane | null;
|
||||
/** The open workspace is in this group (marked while folded). */
|
||||
holdsSelected: boolean;
|
||||
folded: boolean;
|
||||
renaming: boolean;
|
||||
first: boolean;
|
||||
onToggle: () => void;
|
||||
onMenu: (x: number, y: number) => void;
|
||||
/** The typed name, or null when the edit was cancelled. */
|
||||
onRenameEnd: (name: string | null) => void;
|
||||
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
|
||||
onGroupDrop: (draggedId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [draft, setDraft] = useState(group?.name ?? "");
|
||||
const [dropOver, setDropOver] = useState(false);
|
||||
useEffect(() => {
|
||||
if (renaming) setDraft(group?.name ?? "");
|
||||
}, [renaming, group?.name]);
|
||||
const label = group ? group.name : t("tree.ungrouped");
|
||||
return (
|
||||
<div
|
||||
data-group-drop={group?.id ?? ""}
|
||||
data-testid={`workspace-group-${group?.id ?? "none"}`}
|
||||
onContextMenu={(e) => {
|
||||
if (!group) return;
|
||||
e.preventDefault();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
}}
|
||||
// A group (never "Ungrouped") is dragged by its header to reorder.
|
||||
draggable={!!group && !renaming}
|
||||
onDragStart={(e) => {
|
||||
if (!group) return;
|
||||
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isGroupDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDropOver(false)}
|
||||
onDrop={(e) => {
|
||||
setDropOver(false);
|
||||
if (!isGroupDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
|
||||
if (id) onGroupDrop(id);
|
||||
}}
|
||||
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
|
||||
>
|
||||
{renaming && group ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={60}
|
||||
aria-label={t("tree.groupNameLabel")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => onRenameEnd(draft)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRenameEnd(draft);
|
||||
else if (e.key === "Escape") onRenameEnd(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={group ? !folded : undefined}
|
||||
disabled={!group}
|
||||
title={group ? t("tree.groupToggle", { name: label }) : undefined}
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
|
||||
>
|
||||
{group ? (
|
||||
folded ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
|
||||
{folded && worst && (
|
||||
<span
|
||||
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
|
||||
className="ml-1 flex shrink-0 items-center"
|
||||
>
|
||||
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{group && !renaming && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("tree.groupMenu")}
|
||||
title={t("tree.groupMenu")}
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
onMenu(r.left, r.bottom);
|
||||
}}
|
||||
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
|
||||
function renderCard(element: JSX.Element) {
|
||||
function renderCard(element: React.JSX.Element) {
|
||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file Tests for the background jobs store (`lib/cloneJobs.ts`,
|
||||
* `lib/uploadJobs.ts`) and its dock: a clone started in the background is
|
||||
* announced once when the store sees it finish (toast + window event); a
|
||||
* fresh mount (F5) shows what the server kept — an interrupted clone with
|
||||
* Retry, a login failure with an inline login form that retries with it —
|
||||
* without toasting it again; × dismisses; Stop (click twice) cancels a
|
||||
* running clone, which then reads as stopped and can be retried; an older
|
||||
* server that cannot stop says so. An upload shows with Stop, and one a
|
||||
* reload cut short is shown as interrupted until dismissed. Several chips
|
||||
* collapse to one pill.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../i18n";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
clone: vi.fn(),
|
||||
cloneFolder: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
retryCloneJob: vi.fn(),
|
||||
dismissCloneJob: vi.fn(),
|
||||
cancelCloneJob: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { api, type CloneJob } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
CLONE_JOB_EVENT,
|
||||
refreshCloneJobs,
|
||||
resetCloneJobsForTests,
|
||||
startClone,
|
||||
} from "../../lib/cloneJobs";
|
||||
import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
|
||||
import { BackgroundJobsDock } from "../BackgroundJobsDock";
|
||||
|
||||
const base: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "running",
|
||||
url: "https://git.example/o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an",
|
||||
target: "/home/an/repo",
|
||||
laneId: null,
|
||||
position: null,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 40,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
|
||||
};
|
||||
const job = (over: Partial<CloneJob> = {}): CloneJob => ({ ...base, ...over });
|
||||
|
||||
const renderDock = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BackgroundJobsDock />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
reloadUploadsForTests();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
|
||||
vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
describe("cloneJobs store", () => {
|
||||
it("announces a background clone once, when it sees it finish", async () => {
|
||||
const onDone = vi.fn();
|
||||
const events: CloneJob[] = [];
|
||||
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
|
||||
window.addEventListener(CLONE_JOB_EVENT, listen);
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
|
||||
await startClone(
|
||||
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
|
||||
{ onDone }
|
||||
);
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
|
||||
await refreshCloneJobs();
|
||||
await refreshCloneJobs();
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
|
||||
expect(events).toEqual([done]);
|
||||
expect(onDone).toHaveBeenCalledWith(done, undefined);
|
||||
window.removeEventListener(CLONE_JOB_EVENT, listen);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackgroundJobsDock", () => {
|
||||
it("shows a running clone's progress after a fresh mount, without a toast", async () => {
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("Cloning repo");
|
||||
expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an interrupted clone after F5 and retries it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "interrupted",
|
||||
error: { code: "EINTERRUPTED", message: "server restarted" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
|
||||
job: job({ id: "j2", state: "queued", position: 1 }),
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(within(chip).getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and retries with it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
error: {
|
||||
code: "ECREDENTIALS",
|
||||
message: "login",
|
||||
auth: { protocol: "https", host: "git.example" },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const form = await screen.findByTestId("clone-job-login");
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(form).getByLabelText("Username"), "ann");
|
||||
await user.type(within(form).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
|
||||
username: "ann",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finished clone", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
|
||||
});
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("clone-job");
|
||||
expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
|
||||
await user.click(within(chip).getByTestId("clone-job-dismiss"));
|
||||
expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops a running clone on a second click, then lets it be retried", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
|
||||
job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
|
||||
});
|
||||
vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
|
||||
renderDock();
|
||||
const stop = await screen.findByTestId("clone-job-stop");
|
||||
await user.click(stop);
|
||||
expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
|
||||
const chip = await screen.findByText("You stopped this clone.");
|
||||
expect(chip).toBeInTheDocument();
|
||||
// The chip says it; no toast repeats it on top of the chip.
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when the server cannot stop a clone yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
|
||||
vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
|
||||
Object.assign(new Error("HTTP 404"), { status: 404 })
|
||||
);
|
||||
renderDock();
|
||||
await user.click(await screen.findByTestId("clone-job-stop"));
|
||||
await user.click(screen.getByTestId("clone-job-stop"));
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an upload with Stop, which aborts it", async () => {
|
||||
renderDock();
|
||||
let up!: ReturnType<typeof beginUpload>;
|
||||
act(() => {
|
||||
up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
|
||||
up.update({ name: "a.txt", done: 1, sent: 150 });
|
||||
});
|
||||
const chip = await screen.findByTestId("fx-progress");
|
||||
expect(chip).toHaveTextContent("Uploading 2/3");
|
||||
expect(chip).toHaveTextContent("one/src · a.txt");
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(false);
|
||||
fireEvent.click(within(chip).getByTestId("upload-job-stop"));
|
||||
expect(up.signal.aborted).toBe(true);
|
||||
act(() => up.end());
|
||||
expect(screen.queryByTestId("fx-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an upload a reload cut short until it is dismissed", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.uploads.inflight",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "up-old",
|
||||
folder: "one/src",
|
||||
name: "b.txt",
|
||||
done: 2,
|
||||
total: 5,
|
||||
sent: 10,
|
||||
bytes: 50,
|
||||
state: "running",
|
||||
beat: Date.now() - 60_000,
|
||||
},
|
||||
])
|
||||
);
|
||||
act(() => reloadUploadsForTests());
|
||||
renderDock();
|
||||
const chip = await screen.findByTestId("upload-job");
|
||||
expect(chip).toHaveTextContent(
|
||||
"The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
|
||||
);
|
||||
fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByTestId("upload-job")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
|
||||
});
|
||||
|
||||
it("collapses several chips to one pill", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [job(), job({ id: "j2", name: "other" })],
|
||||
});
|
||||
renderDock();
|
||||
await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
|
||||
await user.click(screen.getByTestId("jobs-dock-collapse"));
|
||||
expect(screen.queryByTestId("clone-job")).toBeNull();
|
||||
expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
|
||||
await user.click(screen.getByTestId("jobs-dock-pill"));
|
||||
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
|
||||
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
url: long,
|
||||
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
renderDock();
|
||||
const alert = await screen.findByRole("alert");
|
||||
// A URL with no space to break at must still wrap inside the chip.
|
||||
expect(alert.className).toContain("[overflow-wrap:anywhere]");
|
||||
// A router link, not a full page reload.
|
||||
const link = screen.getByRole("link", { name: /Settings/ });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
|
||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
||||
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
|
||||
expect(titleFor("/files")).toMatch(/^Files · /);
|
||||
expect(titleFor("/admin")).toMatch(/^Administration · /);
|
||||
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
|
||||
});
|
||||
|
||||
it("titles an unknown route as not found", () => {
|
||||
expect(titleFor("/nope")).toMatch(/^Not found · /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,20 @@ vi.mock("../../lib/api", () => ({
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
me: {
|
||||
// Server mode: this user's own spend quota, used up.
|
||||
quota: vi.fn().mockResolvedValue({
|
||||
quota: {
|
||||
limit_usd: 10,
|
||||
period: "day",
|
||||
spend: 12,
|
||||
pct: 120,
|
||||
exceeded: true,
|
||||
resetsAt: "2026-10-29T17:00:00.000Z",
|
||||
since: "2026-10-28T17:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
},
|
||||
budgets: {
|
||||
status: vi.fn().mockResolvedValue({
|
||||
budgets: [
|
||||
@@ -151,7 +165,7 @@ import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: JSX.Element) =>
|
||||
const at = (url: string, ui: React.JSX.Element) =>
|
||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
@@ -162,7 +176,7 @@ describe("StatusBar", () => {
|
||||
const conn = screen.getByRole("button", { name: /Live/ });
|
||||
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
|
||||
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
|
||||
expect(cost).toHaveAttribute(
|
||||
"title",
|
||||
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
|
||||
@@ -183,6 +197,16 @@ describe("StatusBar", () => {
|
||||
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
|
||||
});
|
||||
|
||||
it("shows the user's own spend quota, red once used up", async () => {
|
||||
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
const chip = await screen.findByTestId("status-bar-user-quota");
|
||||
expect(chip.textContent).toBe("Quota 120%");
|
||||
expect(chip.className).toContain("text-status-danger");
|
||||
expect(chip.getAttribute("title")).toMatch(
|
||||
/^Your spend quota is used up \(\$12\.00 of \$10\.00 today\)/
|
||||
);
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
|
||||
@@ -238,7 +262,7 @@ describe("StatusBar", () => {
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
|
||||
expect(personal).not.toHaveTextContent("in use");
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
|
||||
@@ -298,12 +298,102 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||
let fail: (e: Error) => void = () => {};
|
||||
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
fail(new Error("Command failed: npm run build"));
|
||||
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — hidden while it runs", () => {
|
||||
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||
const PLUGIN: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
plugin_update_progress: {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
|
||||
const running: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
// The new version is already installed: the status no longer offers it.
|
||||
update_available: false,
|
||||
plugin_update_progress: {
|
||||
step: "bootstrap",
|
||||
index: 3,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
statusMock.mockResolvedValue(running);
|
||||
render(<UpdateNotifier />);
|
||||
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
|
||||
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
|
||||
});
|
||||
|
||||
it("shows no chip when nothing is running", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:title"));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* @description Tests for the sidebar's workspace list: one flat row per
|
||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||
* a live session opening its pane and an ended one its transcript, the
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
||||
* workspace's OWN sessions only, a session's state dot, rename and archive
|
||||
* (a live one's pane stopped first),
|
||||
* selection
|
||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||
* search, the row menu's items by kind and tool, and the status dot.
|
||||
* search, the row menu's items by kind and tool, the status dot, and the
|
||||
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
|
||||
* state dot, dragging a header to reorder, fold all).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -91,6 +94,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
|
||||
vi.mock("../lanes/AddLaneModal", () => ({
|
||||
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stub-add-lane"
|
||||
onClick={() => {
|
||||
onAdded(lane(2, "/r/sub"));
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { WorkspaceTree } from "../WorkspaceTree";
|
||||
import { workspaceDot } from "../lanes/WorkspaceRow";
|
||||
@@ -209,6 +226,8 @@ describe("WorkspaceTree", () => {
|
||||
await user.type(input, "Better name{Enter}");
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
@@ -576,6 +595,185 @@ describe("WorkspaceTree", () => {
|
||||
menu = screen.getByTestId("workspace-row-menu-3");
|
||||
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
// No group yet: no headers at all.
|
||||
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||
|
||||
// A new group from the row menu holds that workspace and asks for a name.
|
||||
await user.click(screen.getByTestId("workspace-menu-3"));
|
||||
await user.click(screen.getByTestId("workspace-move-group-new"));
|
||||
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "Backend{Enter}");
|
||||
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
|
||||
const gid = stored.groups[0].id as string;
|
||||
expect(stored.of).toEqual({ 3: gid });
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||
);
|
||||
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
|
||||
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
|
||||
|
||||
// Another workspace joins it from its menu.
|
||||
await user.click(screen.getByTestId("workspace-menu-2"));
|
||||
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
|
||||
);
|
||||
|
||||
// Folding hides its rows; the ungrouped one stays.
|
||||
await user.click(
|
||||
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
|
||||
expanded: true,
|
||||
})
|
||||
);
|
||||
expect(rowIds()).toEqual(["workspace-row-1"]);
|
||||
|
||||
// Deleting the group only ungroups: every workspace is still listed.
|
||||
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
|
||||
await user.click(screen.getByTestId("workspace-group-delete"));
|
||||
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
await user.click(screen.getByTestId("workspace-tree-new-group"));
|
||||
const name = await screen.findByRole("textbox", { name: "Group name" });
|
||||
await user.clear(name);
|
||||
await user.type(name, "Later{Enter}");
|
||||
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
|
||||
const header = screen.getByTestId(`workspace-group-${gid}`);
|
||||
expect(header).toHaveTextContent("Later0");
|
||||
expect(
|
||||
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
expect(rowIds()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceTree groups", () => {
|
||||
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
|
||||
const seed = (extra: { folded?: boolean } = {}) =>
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.groups",
|
||||
JSON.stringify({
|
||||
groups: [
|
||||
{ id: "ga", name: "Alpha", ...extra },
|
||||
{ id: "gb", name: "Beta" },
|
||||
],
|
||||
of: { 1: "ga", 3: "gb" },
|
||||
})
|
||||
);
|
||||
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
|
||||
|
||||
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||
);
|
||||
// The last of Alpha, Alt+↓: the first of Beta.
|
||||
screen.getByTestId("workspace-open-1").focus();
|
||||
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
|
||||
);
|
||||
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
|
||||
// The last of Beta, Alt+↓: out into the ungrouped ones.
|
||||
screen.getByTestId("workspace-open-3").focus();
|
||||
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
|
||||
expect(stored().of).toEqual({ 1: "gb" });
|
||||
expect(api.lanes.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a folded group's most urgent state and marks the open workspace", async () => {
|
||||
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
|
||||
seed({ folded: true });
|
||||
renderTree("/workspace?lane=1");
|
||||
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
|
||||
const state = screen.getByTestId("workspace-group-state-ga");
|
||||
expect(state).toHaveTextContent("running");
|
||||
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
|
||||
"text-accent"
|
||||
);
|
||||
// An open group shows its rows instead.
|
||||
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
|
||||
});
|
||||
|
||||
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
|
||||
seed();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-gb");
|
||||
const data = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
get types() {
|
||||
return [...data.keys()];
|
||||
},
|
||||
setData: (k: string, v: string) => data.set(k, v),
|
||||
getData: (k: string) => data.get(k) ?? "",
|
||||
dropEffect: "move",
|
||||
effectAllowed: "move",
|
||||
};
|
||||
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
|
||||
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
|
||||
await waitFor(() =>
|
||||
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
|
||||
);
|
||||
data.clear();
|
||||
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
|
||||
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
|
||||
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
|
||||
// A header drag never moves a workspace.
|
||||
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
|
||||
});
|
||||
|
||||
it("puts a workspace added from a group's menu into that group", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-gb");
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||
await user.click(screen.getByTestId("workspace-group-add"));
|
||||
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
|
||||
// The header's own Add belongs to no group: what it adds stays where it is.
|
||||
act(() =>
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.groups",
|
||||
JSON.stringify({ ...stored(), of: { 1: "ga" } })
|
||||
)
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-tree-add"));
|
||||
await user.click(await screen.findByTestId("stub-add-lane"));
|
||||
expect(stored().of).toEqual({ 1: "ga" });
|
||||
});
|
||||
|
||||
it("folds and unfolds every group from a header's menu", async () => {
|
||||
seed();
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-group-ga");
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
|
||||
await user.click(screen.getByTestId("workspace-group-fold-all"));
|
||||
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
|
||||
await user.click(screen.getByTestId("workspace-group-unfold-all"));
|
||||
await waitFor(() => expect(rowIds()).toHaveLength(3));
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspaceDot", () => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file MiniChart.tsx
|
||||
* @description The Administration page's small single-series charts: a bar
|
||||
* chart (a user's daily spend) and a line chart (System Health's CPU and
|
||||
* memory history), plain SVG in the app's theme tokens. One series each — the
|
||||
* card's title names it, so there is no legend — thin marks, rounded bar ends
|
||||
* on the baseline, a recessive grid with a max label, and a hover tooltip
|
||||
* (crosshair for the line, per bar for the bars) whose text is the caller's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
export type ChartPoint = { x: string; y: number };
|
||||
|
||||
const H = 120;
|
||||
const PAD_TOP = 14;
|
||||
const PAD_BOTTOM = 18;
|
||||
|
||||
/**
|
||||
* @param props.points - In x order; `x` is the axis/tooltip label.
|
||||
* @param props.kind - `bar` or `line`.
|
||||
* @param props.format - Formats a value for the max label and the tooltip.
|
||||
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
|
||||
* @param props.label - The chart's accessible name.
|
||||
*/
|
||||
export function MiniChart({
|
||||
points,
|
||||
kind,
|
||||
format,
|
||||
max,
|
||||
label,
|
||||
emptyText,
|
||||
}: {
|
||||
points: ChartPoint[];
|
||||
kind: "bar" | "line";
|
||||
format: (v: number) => string;
|
||||
max?: number;
|
||||
label: string;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (points.length === 0)
|
||||
return (
|
||||
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
|
||||
{emptyText ?? "—"}
|
||||
</div>
|
||||
);
|
||||
|
||||
const W = 600;
|
||||
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
|
||||
const plotH = H - PAD_TOP - PAD_BOTTOM;
|
||||
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
|
||||
const step = W / points.length;
|
||||
const xOf = (i: number) =>
|
||||
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
|
||||
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return;
|
||||
const fx = ((e.clientX - r.left) / r.width) * W;
|
||||
const i =
|
||||
kind === "bar"
|
||||
? Math.floor(fx / step)
|
||||
: Math.round((fx / W) * Math.max(1, points.length - 1));
|
||||
setHover(Math.max(0, Math.min(points.length - 1, i)));
|
||||
};
|
||||
|
||||
const h = hover != null ? points[hover] : null;
|
||||
const barW = Math.max(2, Math.min(24, step - 2));
|
||||
const line = points
|
||||
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="block h-[120px] w-full"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={0}
|
||||
x2={W}
|
||||
y1={yOf(top * f)}
|
||||
y2={yOf(top * f)}
|
||||
className="stroke-border"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeDasharray={f === 0 ? undefined : "3 3"}
|
||||
/>
|
||||
))}
|
||||
{kind === "bar" ? (
|
||||
points.map((p, i) => {
|
||||
const y = yOf(p.y);
|
||||
const bh = Math.max(0, PAD_TOP + plotH - y);
|
||||
const r = Math.min(4, barW / 2, bh);
|
||||
const x = xOf(i) - barW / 2;
|
||||
const base = PAD_TOP + plotH;
|
||||
// Rounded top only; the base sits flat on the axis.
|
||||
const d =
|
||||
bh <= 0
|
||||
? ""
|
||||
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
|
||||
return (
|
||||
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
|
||||
className="fill-accent-solid"
|
||||
opacity={0.12}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
className="stroke-accent-solid"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{hover != null && (
|
||||
<line
|
||||
x1={xOf(hover)}
|
||||
x2={xOf(hover)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + plotH}
|
||||
className="stroke-fg-muted"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
|
||||
{format(top)}
|
||||
</span>
|
||||
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
|
||||
<span>{points[0]!.x}</span>
|
||||
<span>{points[points.length - 1]!.x}</span>
|
||||
</div>
|
||||
{h && hover != null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
|
||||
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
|
||||
role="status"
|
||||
>
|
||||
<div className="text-fg-muted">{h.x}</div>
|
||||
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
import type {
|
||||
TranscriptMessage,
|
||||
TranscriptInfo,
|
||||
TranscriptResult,
|
||||
WSMessage,
|
||||
} from "../../lib/types";
|
||||
|
||||
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
|
||||
// PostToolUse / Stop, which means a user-typed message (no hook) and any
|
||||
@@ -175,6 +180,8 @@ function ConversationViewPane({
|
||||
initialTranscriptId ?? null
|
||||
);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
|
||||
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
@@ -283,6 +290,7 @@ function ConversationViewPane({
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -343,6 +351,7 @@ function ConversationViewPane({
|
||||
// Seed the view in a single render so the user sees the whole
|
||||
// catch-up batch instead of a blank panel followed by a partial one.
|
||||
setMessages(result.messages);
|
||||
setLeadEdits(result.turn_edits);
|
||||
firstLineRef.current = result.first_line;
|
||||
setHasMore(result.has_more);
|
||||
} else {
|
||||
@@ -487,6 +496,7 @@ function ConversationViewPane({
|
||||
firstLineRef.current = result.first_line;
|
||||
|
||||
setMessages((prev) => [...result.messages, ...prev]);
|
||||
setLeadEdits(result.turn_edits);
|
||||
setHasMore(result.has_more);
|
||||
|
||||
// Preserve scroll position (don't jump to top)
|
||||
@@ -686,6 +696,7 @@ function ConversationViewPane({
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
leadEdits={leadEdits}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
|
||||
@@ -172,6 +172,9 @@ interface MessageListProps {
|
||||
/** A subagent's conversation inside its step: tight spacing, and the task
|
||||
* the orchestrator gave it reads as the prompt it is. */
|
||||
compact?: boolean;
|
||||
/** The files the first, cut-off turn changed before `messages` begins (the
|
||||
* transcript page's `turn_edits`): counted in that turn's card. */
|
||||
leadEdits?: TranscriptContent["edits"];
|
||||
}
|
||||
|
||||
/** Build a map from tool_use id → tool_result for matching */
|
||||
@@ -491,6 +494,10 @@ type Item =
|
||||
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
|
||||
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||
// A subagent's result carries the files IT changed (`edits`): they count too.
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
// The stand-in for the part of a turn the page cut off: its `edits` count.
|
||||
const LEAD = "__lead_edits";
|
||||
|
||||
/** A line that is only Claude Code's local-command plumbing — the caveat it
|
||||
* writes first, the command, what the command printed — or null for
|
||||
@@ -515,11 +522,18 @@ export function localCommand(
|
||||
return { name, output, error };
|
||||
}
|
||||
|
||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||
export function chatItems(
|
||||
messages: TranscriptMessage[],
|
||||
keys: string[],
|
||||
leadEdits?: TranscriptContent["edits"]
|
||||
): Item[] {
|
||||
const items: Item[] = [];
|
||||
// The file-changing calls of the turn so far, closed into one card when the
|
||||
// human speaks again (or the list ends).
|
||||
let edits: TranscriptContent[] = [];
|
||||
// human speaks again (or the list ends) — starting with what the page's
|
||||
// cut-off first turn changed before it.
|
||||
let edits: TranscriptContent[] = leadEdits?.length
|
||||
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
|
||||
: [];
|
||||
// The turn's prompt time, its newest assistant line and its output so far,
|
||||
// for the "Done in …" line under a turn that ended.
|
||||
let start: string | null = null;
|
||||
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
if (msg.usage) output.add(msg.usage);
|
||||
msg.content.forEach((block, b) => {
|
||||
const last = items[items.length - 1];
|
||||
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||
if (
|
||||
block.type === "tool_use" &&
|
||||
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
|
||||
)
|
||||
edits.push(block);
|
||||
// A question to the user is a turn of the conversation, not hidden work.
|
||||
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
|
||||
items.push({ kind: "ask", key: `${key}:${b}`, block });
|
||||
@@ -672,19 +690,38 @@ function EditedFilesCard({
|
||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||
const created = new Set<string>();
|
||||
// Changed only by a subagent: listed, never an artifact card — the chat
|
||||
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
|
||||
// subagent's are not.
|
||||
const byAgent = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
|
||||
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
|
||||
if (!files.has(e.path)) byAgent.add(e.path);
|
||||
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += e.added;
|
||||
f.removed += e.removed;
|
||||
f.counted ||= e.counted;
|
||||
files.set(e.path, f);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
const p = input.file_path ?? input.notebook_path;
|
||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||
if (b.name === "Write") created.add(p);
|
||||
byAgent.delete(p);
|
||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += r.diff?.added ?? 0;
|
||||
f.removed += r.diff?.removed ?? 0;
|
||||
f.counted ||= !!r.diff;
|
||||
files.set(p, f);
|
||||
}
|
||||
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
|
||||
const artifacts = [...files.keys()].filter(
|
||||
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
|
||||
);
|
||||
const cards = artifacts.length > 0 && (
|
||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||
{artifacts.map((p) => (
|
||||
@@ -773,6 +810,7 @@ function MessageListView({
|
||||
sessionId,
|
||||
agentLinks,
|
||||
compact = false,
|
||||
leadEdits,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
@@ -798,9 +836,10 @@ function MessageListView({
|
||||
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
|
||||
)
|
||||
: messages,
|
||||
keys
|
||||
keys,
|
||||
leadEdits
|
||||
),
|
||||
[messages, keys, compact]
|
||||
[messages, keys, compact, leadEdits]
|
||||
);
|
||||
const agents = useMemo(
|
||||
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, a row click handed
|
||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||
* attachments for a prompt box.
|
||||
* attachments for a prompt box. Files a subagent changed (its Agent
|
||||
* result's `edits`, read from its own transcript) are counted in the same
|
||||
* card, never as an artifact; so are the files a turn changed before the
|
||||
* loaded page began (`leadEdits`, the page's `turn_edits`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||
});
|
||||
|
||||
it("counts the files a subagent changed, beside the turn's own edits", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("run the task"),
|
||||
call("1", "Edit", "/w/a.go"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
{
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
|
||||
} as TranscriptMessage,
|
||||
{
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "2",
|
||||
output: "done",
|
||||
agent: { id: "x1" },
|
||||
edits: [
|
||||
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
|
||||
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
|
||||
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as TranscriptMessage,
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("edited-files");
|
||||
expect(card.textContent).toContain("Edited 3 files");
|
||||
expect(within(card).getByText("b_test.go")).toBeTruthy();
|
||||
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
|
||||
// A page only a subagent wrote is listed, not shown as an artifact card.
|
||||
expect(within(card).getByText("report.html")).toBeTruthy();
|
||||
expect(screen.queryByTestId("artifact-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
|
||||
messages={[
|
||||
call("1", "Write", "/w/handler_test.go"),
|
||||
result("1", { added: 113, removed: 0 }),
|
||||
user("what changed?"),
|
||||
call("2", "Edit", "/w/x.go"),
|
||||
result("2", { added: 1, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const [first, second] = screen.getAllByTestId("edited-files");
|
||||
expect(first!.textContent).toContain("Edited 2 files");
|
||||
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
|
||||
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
|
||||
// The next turn's card is its own.
|
||||
expect(second!.textContent).toContain("Edited 1 file");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
// ───────────────────────── Copy button ─────────────────────────
|
||||
|
||||
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
||||
}
|
||||
if (!(await copyText(text, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* @file HostHealth.tsx
|
||||
* @description The machine AgentHub runs on, as part of the Dashboard's
|
||||
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
||||
* signed-in user sees all of it). In the tab's own card style:
|
||||
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
||||
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
||||
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
||||
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
ListTree,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostHistory,
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
||||
export type HostState = {
|
||||
stats: HostStats | null;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
if (!pollMs) return;
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void reload();
|
||||
}, pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled, pollMs, reload]);
|
||||
return { stats, error, reload };
|
||||
}
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
export function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
||||
function HealthBar({
|
||||
label,
|
||||
value,
|
||||
tip,
|
||||
warn = 70,
|
||||
bad = 90,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
tip?: string;
|
||||
warn?: number;
|
||||
bad?: number;
|
||||
}) {
|
||||
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
||||
return (
|
||||
<Tip block raw={tip}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-fg-muted">{label}</span>
|
||||
<span className="text-fg-secondary font-mono">
|
||||
{value == null ? "—" : `${Math.round(v)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="w-full bg-surface-3 rounded-full h-2"
|
||||
>
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
||||
style={{ width: `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
||||
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-fg-muted">{n}</span>
|
||||
<span className={tone}>{Math.round(v)}%</span>
|
||||
</div>
|
||||
<Meter value={v} label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
tone: string;
|
||||
title: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
||||
export type HeapInfo = {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapLimit?: number;
|
||||
external: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab's first card: the machine and the AgentHub process on it.
|
||||
* `heap` comes from the tab's settings read.
|
||||
*/
|
||||
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const s = host.stats;
|
||||
const mem = s?.memory;
|
||||
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
||||
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
||||
return (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
||||
<CardHead
|
||||
icon={Server}
|
||||
tone="text-status-success"
|
||||
title={t("host.title")}
|
||||
right={
|
||||
s && (
|
||||
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
||||
{s.hostname}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!s ? (
|
||||
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2.5">
|
||||
<Row label={t("host.os")}>
|
||||
{s.os.distro ?? s.os.platform} · {s.os.release}
|
||||
</Row>
|
||||
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
||||
<Row label={t("host.cpuModel")}>
|
||||
<Tip raw={s.cpu.model ?? undefined}>
|
||||
<span>
|
||||
{s.cpu.physicalCores
|
||||
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
||||
: ""}
|
||||
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
{s.os.platform !== "win32" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||||
{t("host.load")}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{s.process && (
|
||||
<Row label={t("host.agenthub")}>
|
||||
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||
<span>
|
||||
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{s.usage && (
|
||||
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
||||
)}
|
||||
<HealthBar
|
||||
label={t("host.memory")}
|
||||
value={mem ? pct(mem.used, mem.total) : null}
|
||||
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
/>
|
||||
{mem && mem.swapTotal > 0 && (
|
||||
<HealthBar
|
||||
label={t("host.swap")}
|
||||
value={pct(mem.swapUsed, mem.swapTotal)}
|
||||
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
||||
/>
|
||||
)}
|
||||
{heap && (
|
||||
<HealthBar
|
||||
label={t("health.heap")}
|
||||
value={heapPct}
|
||||
warn={60}
|
||||
bad={85}
|
||||
tip={t("health.heapTip", {
|
||||
pct: (heapPct ?? 0).toFixed(1),
|
||||
used: formatBytes(heap.heapUsed),
|
||||
limit: formatBytes(heapMax),
|
||||
allocated: formatBytes(heap.heapTotal),
|
||||
external: formatBytes(heap.external),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
||||
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { stats } = host;
|
||||
const [range, setRange] = useState<HostHistoryRange>("1h");
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.history(range)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory(null)),
|
||||
[range]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void loadHistory();
|
||||
}, HISTORY_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [loadHistory]);
|
||||
|
||||
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
||||
if (!stats)
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const groups = stats.usage?.groups ?? [];
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("host.hostGroup");
|
||||
if (g.kind === "system") return t("host.systemGroup");
|
||||
return g.displayName || g.username || "";
|
||||
};
|
||||
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
||||
const ownerOf = (key: string) => {
|
||||
const g = groupByKey.get(key);
|
||||
if (g) return groupName(g);
|
||||
if (key === "host") return t("host.hostGroup");
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const histPoints = (key: "cpu" | "memory") =>
|
||||
(history?.points ?? [])
|
||||
.filter((p) => p[key] != null)
|
||||
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
||||
const perCore = stats.cpu.perCore ?? [];
|
||||
const network = stats.network ?? [];
|
||||
const temps = stats.temperatures ?? [];
|
||||
const topProcs = stats.topProcesses ?? [];
|
||||
const others = groups.filter((g) => g.kind !== "user");
|
||||
const people = groups.filter((g) => g.kind === "user");
|
||||
// History, per-thread CPU and processes are read from /proc: on Windows and
|
||||
// macOS their cards would only ever be empty, so they are left out.
|
||||
const linux = stats.os.platform === "linux";
|
||||
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
||||
const showProcs = !!stats.usage || others.length > 0;
|
||||
|
||||
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||
// mounted gets a row of its own.
|
||||
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
||||
const diskRows = stats.disks.map((d) => {
|
||||
const base = d.device.split("/").pop() ?? "";
|
||||
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
||||
const io = key ? ioLeft.get(key) : undefined;
|
||||
if (key) ioLeft.delete(key);
|
||||
return { disk: d, io };
|
||||
});
|
||||
const loneIo = [...ioLeft.values()];
|
||||
|
||||
const rate = (down?: number, up?: number) => (
|
||||
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowDown className="w-2.5 h-2.5" />
|
||||
{down == null ? "—" : perSec(down)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
{up == null ? "—" : perSec(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
|
||||
{/* CPU and memory over time. */}
|
||||
{linux && (
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
{showCores && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disks (with their I/O) and network interfaces. */}
|
||||
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
||||
<CardHead
|
||||
icon={HardDrive}
|
||||
tone="text-syntax-builtin"
|
||||
title={t("host.storageNetwork")}
|
||||
/>
|
||||
<div className="space-y-3" data-testid="server-disk-io">
|
||||
{diskRows.map(({ disk: d, io }) => (
|
||||
<div key={d.device} className="space-y-1">
|
||||
<HealthBar
|
||||
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
||||
value={pct(d.used, d.total)}
|
||||
warn={75}
|
||||
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
||||
/>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(d.free)} {t("host.free")}
|
||||
</span>
|
||||
{io && rate(io.read, io.write)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loneIo.map((io) => (
|
||||
<div key={io.name} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
||||
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{network.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-network"
|
||||
>
|
||||
{network.map((n) => (
|
||||
<div key={n.name} className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<Network className="w-3 h-3 text-fg-muted" />
|
||||
<span className="font-mono">{n.name}</span>
|
||||
</span>
|
||||
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The busiest processes, and what belongs to no user. */}
|
||||
{showProcs && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Each user's live share of the machine (server mode). */}
|
||||
{people.length > 0 && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
||||
<CardHead
|
||||
icon={Users}
|
||||
tone="text-syntax-string"
|
||||
title={t("host.usersTitle")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.usersCount", { n: people.length })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{people.map((g) => {
|
||||
const name = groupName(g);
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
userId={g.userId}
|
||||
avatarAt={g.avatarAt}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||
{g.top.map((p) => (
|
||||
<li
|
||||
key={p.pid}
|
||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||
>
|
||||
<span className="truncate text-fg-secondary">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-fg-muted">
|
||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file HostHealth.test.tsx
|
||||
* @description The machine on the Dashboard's System Health tab
|
||||
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
|
||||
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
|
||||
* rows under it show every CPU thread, temperatures, each disk with its I/O
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { HostStats } from "../../../lib/api";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const stats: HostStats = {
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||
uptime: 90000,
|
||||
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||
disks: [
|
||||
{
|
||||
device: "/dev/sdb2",
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
kind: "SSD",
|
||||
total: 200 * GB,
|
||||
used: 50 * GB,
|
||||
free: 150 * GB,
|
||||
},
|
||||
{
|
||||
device: "/dev/sda1",
|
||||
mount: "/data",
|
||||
type: "ext4",
|
||||
kind: "HDD",
|
||||
total: 500 * GB,
|
||||
used: 0,
|
||||
free: 500 * GB,
|
||||
},
|
||||
],
|
||||
homesMount: "/",
|
||||
gpus: [],
|
||||
usage: {
|
||||
cpu: 40,
|
||||
sampleMs: 1000,
|
||||
groups: [
|
||||
{
|
||||
key: "user:2",
|
||||
kind: "user",
|
||||
userId: 2,
|
||||
username: "mia",
|
||||
cpu: 25,
|
||||
memory: 2 * GB,
|
||||
memoryPercent: 12.5,
|
||||
processes: 3,
|
||||
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||
},
|
||||
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
users: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { HostSections, useHostStats } from "../HostHealth";
|
||||
|
||||
function Tab() {
|
||||
const host = useHostStats();
|
||||
return (
|
||||
<HostSections
|
||||
host={host}
|
||||
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const full: HostStats = {
|
||||
...stats,
|
||||
usage: {
|
||||
...stats.usage!,
|
||||
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
|
||||
},
|
||||
bootTime: "2026-10-14T00:00:00Z",
|
||||
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
|
||||
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
|
||||
diskIo: [{ name: "sda", read: 4096, write: 0 }],
|
||||
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
|
||||
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
|
||||
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: true,
|
||||
startedAt: "2026-10-15T00:00:00Z",
|
||||
points: [
|
||||
{
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
cpu: 30,
|
||||
memory: 50,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
{
|
||||
at: "2026-10-15T00:00:30Z",
|
||||
cpu: 40,
|
||||
memory: 52,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostHealth", () => {
|
||||
it("names the machine and the AgentHub process, with bars", async () => {
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText("box")).toBeTruthy();
|
||||
expect(card.textContent).toContain("Ubuntu 26.04");
|
||||
expect(card.textContent).toContain("1.0 GB");
|
||||
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"40"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
|
||||
"50"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
|
||||
render(<Tab />);
|
||||
expect(await screen.findByTestId("server-history")).toBeTruthy();
|
||||
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
|
||||
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
|
||||
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
|
||||
const disks = screen.getByTestId("server-disk-io").textContent;
|
||||
expect(disks).toContain("/data · HDD");
|
||||
expect(disks).toContain("4.0 KB/s");
|
||||
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
|
||||
const top = screen.getByTestId("server-top-processes");
|
||||
expect(await within(top).findByText("Mia")).toBeTruthy();
|
||||
const other = screen.getByTestId("server-usage-table");
|
||||
expect(within(other).queryByText("Mia")).toBeNull();
|
||||
expect(within(other).getByText(/^Host account/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
|
||||
expect(api.host.history).toHaveBeenLastCalledWith("24h");
|
||||
});
|
||||
|
||||
it("shows each user's live CPU and RAM, their processes on click", async () => {
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
expect(tile.textContent).toContain("Mia");
|
||||
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"25"
|
||||
);
|
||||
expect(tile.textContent).toContain("2.0 GB");
|
||||
expect(within(tile).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
|
||||
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
|
||||
disks: [],
|
||||
usage: null,
|
||||
});
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: false,
|
||||
points: [],
|
||||
});
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText(/win32/)).toBeTruthy();
|
||||
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
|
||||
expect(card.textContent).not.toContain("Load");
|
||||
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
|
||||
for (const id of [
|
||||
"server-history",
|
||||
"server-cores",
|
||||
"server-top-processes",
|
||||
"server-disks-table",
|
||||
])
|
||||
expect(screen.queryByTestId(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,11 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
|
||||
* Settings → Git; **Partial clone** makes it blobless). The clone is a
|
||||
* background job (`lib/cloneJobs.ts`): the modal closes once the server
|
||||
* accepted it, the app-wide dock shows its queue place, git's progress and
|
||||
* outcome, and the new workspace is selected when it ends while the user is
|
||||
* still on that page. There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { api } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -119,8 +122,6 @@ export function AddLaneModal({
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [clonePartial, setClonePartial] = useState(false);
|
||||
/** The running clone's progress token (CloneProgress polls it). */
|
||||
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
@@ -242,28 +243,46 @@ export function AddLaneModal({
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
const progressToken = newCloneToken();
|
||||
setCloneToken(progressToken);
|
||||
// The new workspace is selected when the clone ends only while the user
|
||||
// is still on the page they started it from; elsewhere the dock's
|
||||
// "Open workspace" does it.
|
||||
const from = window.location.pathname;
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
progressToken,
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
await startClone(
|
||||
{
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(clonePartial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onDone: (job, lane) => {
|
||||
if (window.location.pathname !== from) return;
|
||||
if (lane) return onAdded(lane);
|
||||
const id = job.result?.laneId;
|
||||
if (id == null) return;
|
||||
api.lanes
|
||||
.get(id)
|
||||
.then((r) => onAdded(r.lane))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
}
|
||||
);
|
||||
// Accepted: the dock carries the clone from here.
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -284,8 +303,6 @@ export function AddLaneModal({
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
} finally {
|
||||
setCloneToken(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -571,11 +588,7 @@ export function AddLaneModal({
|
||||
/>
|
||||
{t("clone.partial")}
|
||||
</label>
|
||||
{cloneToken && (
|
||||
<div className="mt-2">
|
||||
<CloneProgress token={cloneToken} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -856,7 +869,7 @@ export function AddLaneModal({
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
? t("cloneJobs.starting")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||
* to its allowlist and the folder to the user's own, and clones as the
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* a workspace on the new folder. The clone is a server-side background job
|
||||
* (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
|
||||
* and the app-wide dock (`BackgroundJobsDock`) carries its progress and
|
||||
* outcome, across pages, F5 and server restarts. A refusal before the clone
|
||||
* starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
|
||||
* `ESSHAUTH`) stays in the form: a login row (sent once, optionally
|
||||
* remembered), or a pointer to Settings → Git.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,10 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -25,13 +29,11 @@ export function CloneHereDialog({
|
||||
open,
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,7 +44,7 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -55,6 +57,7 @@ export function CloneHereDialog({
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
@@ -65,21 +68,25 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "workspace",
|
||||
body: {
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
onCloned(result.lane, result.path);
|
||||
// Accepted: the dock takes it from here.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
@@ -104,7 +111,7 @@ export function CloneHereDialog({
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={busy ? undefined : onClose}
|
||||
onClick={onClose}
|
||||
// A portal still bubbles through React: keep keys away from the File
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
@@ -119,7 +126,10 @@ export function CloneHereDialog({
|
||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
@@ -162,7 +172,10 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||
<div
|
||||
data-testid="fx-clone-login"
|
||||
className="space-y-2 rounded-md border border-border p-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
@@ -203,8 +216,9 @@ export function CloneHereDialog({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("cloneJobs.backgroundHint")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
@@ -213,7 +227,7 @@ export function CloneHereDialog({
|
||||
className="btn-primary text-xs"
|
||||
data-testid="fx-clone-submit"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||
{busy ? t("cloneJobs.starting") : t("fx.cloneSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,59 +2,35 @@
|
||||
* @file CloneProgress — the live line under a running clone: its place in the
|
||||
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||
* yet arrived, or an older server — just shows nothing yet.
|
||||
* with a bar. It renders the progress fields of a background clone job
|
||||
* (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type CloneProgressView } from "../../lib/api";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
export type CloneProgressFields = Pick<
|
||||
CloneJob,
|
||||
"state" | "position" | "phase" | "label" | "percent" | "detail"
|
||||
> & { running?: number; waiting?: number; limit?: number };
|
||||
|
||||
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||
export function newCloneToken(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function CloneProgress({ token }: { token: string }) {
|
||||
export function CloneProgress({ view }: { view: CloneProgressFields }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setView(null);
|
||||
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||
const poll = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.cloneProgress(token))
|
||||
.then((v) => {
|
||||
if (alive) setView(v);
|
||||
})
|
||||
.catch(() => {});
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||
if (view.state !== "queued" && view.state !== "running") return null;
|
||||
|
||||
if (view.state === "queued") {
|
||||
return (
|
||||
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||
{t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})}
|
||||
{view.waiting !== undefined && view.running !== undefined && view.limit !== undefined
|
||||
? t("clone.queued", {
|
||||
position: view.position ?? 1,
|
||||
waiting: view.waiting,
|
||||
running: view.running,
|
||||
limit: view.limit,
|
||||
})
|
||||
: t("cloneJobs.queued", { position: view.position ?? 1 })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +49,9 @@ export function CloneProgress({ token }: { token: string }) {
|
||||
{view.phase ? ` ${percent}%` : ""}
|
||||
</span>
|
||||
{view.detail && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||
<span className="shrink-0 truncate font-mono text-[10px] text-fg-muted">
|
||||
{view.detail}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{view.phase && (
|
||||
|
||||
@@ -10,16 +10,19 @@
|
||||
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||
* the login row up front (optional: a public repo needs none). The identity is
|
||||
* read again when the window regains focus, so a key made in another tab
|
||||
* counts at once. The running clone shows its queue place, then git's
|
||||
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||
* counts at once. Each clone is a background job (`lib/cloneJobs.ts`): once
|
||||
* the server accepted it the app-wide dock shows its queue place, git's
|
||||
* progress and outcome (it survives leaving the page, F5 and a server
|
||||
* restart), and the folder list is re-read when it ends; **Partial clone**
|
||||
* sends `partial: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { api, type CloneJob, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
import { CLONE_JOB_EVENT, startClone } from "../../lib/cloneJobs";
|
||||
|
||||
type Row = {
|
||||
url: string;
|
||||
@@ -27,8 +30,6 @@ type Row = {
|
||||
note?: string;
|
||||
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||
ssh?: boolean;
|
||||
/** While busy: the progress token CloneProgress polls. */
|
||||
token?: string;
|
||||
};
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -88,6 +89,23 @@ export function CloneSourcesForm({
|
||||
.catch(() => {}),
|
||||
[]
|
||||
);
|
||||
// A background clone into THIS workspace ended: its folder is now in the
|
||||
// list (the server added it), so read the list again.
|
||||
const onAddedRef = useRef(onAdded);
|
||||
onAddedRef.current = onAdded;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
const job = (e as CustomEvent<CloneJob>).detail;
|
||||
if (job?.state !== "done" || job.kind !== "source" || job.laneId !== laneId) return;
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.sources(laneId))
|
||||
.then((view) => onAddedRef.current(view))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadIdentity();
|
||||
const onFocus = () => void loadIdentity();
|
||||
@@ -121,25 +139,28 @@ export function CloneSourcesForm({
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
row.token = newCloneToken();
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
progressToken: row.token,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
await startClone({
|
||||
kind: "source",
|
||||
laneId,
|
||||
body: {
|
||||
url: row.url,
|
||||
...(partial ? { partial: true } : {}),
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Accepted: the dock shows its progress; the folder list is re-read
|
||||
// when it ends (the CLONE_JOB_EVENT listener above).
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
@@ -159,7 +180,6 @@ export function CloneSourcesForm({
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
row.token = undefined;
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
@@ -301,10 +321,8 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.state === "busy" && r.token && (
|
||||
<div className="ml-[18px] mt-0.5">
|
||||
<CloneProgress token={r.token} />
|
||||
</div>
|
||||
{r.state === "done" && (
|
||||
<span className="ml-[18px] block text-fg-muted">{t("cloneJobs.started")}</span>
|
||||
)}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
|
||||
@@ -15,13 +15,20 @@
|
||||
* workspace's Files tab. Behaviour: delete → the account's Trash (no confirm:
|
||||
* restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace
|
||||
* (the clipboard outlives the dialog); upload files or whole folders (picker
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch —
|
||||
* the progress lives in the app-wide dock (`lib/uploadJobs.ts`), so an
|
||||
* upload keeps going when the user opens Trash or another page, and can be
|
||||
* stopped there; New → Clone repository runs as a background clone job;
|
||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||
* itself, anything else as one zip — except an HTML file, which opens as the
|
||||
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
|
||||
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* The sidebar nests each workspace under its owner's home (a chevron drops
|
||||
* them down, remembered per browser); one with no owner home stays in a flat
|
||||
* Workspaces list.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -82,16 +89,24 @@ import {
|
||||
type FsTrashItem,
|
||||
type FsHome,
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { CLONE_JOB_EVENT } from "../../lib/cloneJobs";
|
||||
import { beginUpload } from "../../lib/uploadJobs";
|
||||
import type { CloneJob } from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
@@ -136,6 +151,11 @@ export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
const MIME = "application/x-agenthub-fx";
|
||||
const VIEW_KEY = "agenthub.files.view";
|
||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
|
||||
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
|
||||
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
|
||||
* viewer may not open that home (an analyst's view of other users). */
|
||||
type UserRow = FsHome & { noHome?: boolean };
|
||||
|
||||
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
@@ -155,6 +175,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
||||
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
||||
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
||||
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
|
||||
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
||||
const CODE = new Set([
|
||||
@@ -214,6 +235,8 @@ async function walkEntry(
|
||||
out.push({ file, rel: `${prefix}${entry.name}` });
|
||||
return;
|
||||
}
|
||||
// The server takes nothing into .git: never read one off the disk at all.
|
||||
if (entry.name === ".git") return;
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
// readEntries answers in pages until it answers [].
|
||||
for (;;) {
|
||||
@@ -273,7 +296,7 @@ export function FileBrowser({
|
||||
/** A home to open on (`me`, or another user's for an admin). */
|
||||
initialHome?: string | null;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
||||
onChanged?: () => void;
|
||||
@@ -310,14 +333,17 @@ export function FileBrowser({
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
const [askAll, setAskAll] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
name: string;
|
||||
done: number;
|
||||
total: number;
|
||||
sent: number;
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
|
||||
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
} catch {
|
||||
return new Set(["me"]);
|
||||
}
|
||||
});
|
||||
const [clip, setClip] = useState<Clip | null>(clipboard);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
|
||||
@@ -342,16 +368,21 @@ export function FileBrowser({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, initialHome, session, source, initialPath]);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-read after every change here too: deleting a workspace's folder takes
|
||||
// it out of the places (a workspace whose folder is gone has nothing to show).
|
||||
const loadLanes = useCallback(() => {
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadLanes();
|
||||
api.files
|
||||
.homes()
|
||||
.then((r) => setHomes(r.homes))
|
||||
.catch(() => setHomes([]));
|
||||
}, []);
|
||||
}, [loadLanes]);
|
||||
// Another user's home (an admin's view) is read-only.
|
||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||
@@ -412,6 +443,8 @@ export function FileBrowser({
|
||||
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||
else setListing(await api.files.list(target, path));
|
||||
} catch (e) {
|
||||
// Never leave the last folder's entries under another folder's error.
|
||||
if (view === "files") setListing(null);
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
setError(
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
@@ -428,8 +461,20 @@ export function FileBrowser({
|
||||
|
||||
const changed = () => {
|
||||
void load();
|
||||
loadLanes();
|
||||
onChanged?.();
|
||||
};
|
||||
// A background clone finished (the dock announced it): its folder and
|
||||
// workspace are new here, wherever it was started from.
|
||||
const changedRef = useRef(changed);
|
||||
changedRef.current = changed;
|
||||
useEffect(() => {
|
||||
const onJob = (e: Event) => {
|
||||
if ((e as CustomEvent<CloneJob>).detail?.state === "done") changedRef.current();
|
||||
};
|
||||
window.addEventListener(CLONE_JOB_EVENT, onJob);
|
||||
return () => window.removeEventListener(CLONE_JOB_EVENT, onJob);
|
||||
}, []);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = listing?.entries ?? [];
|
||||
@@ -440,13 +485,28 @@ export function FileBrowser({
|
||||
}, [listing, showHidden, query]);
|
||||
const selectedEntries = entries.filter((e) => selected.has(e.path));
|
||||
|
||||
// An upload outlives this view (its progress is in the dock): once the view
|
||||
// is gone nobody can answer a name clash, so it keeps both copies.
|
||||
const mountedRef = useRef(true);
|
||||
const pendingAsk = useRef<Ask["resolve"] | null>(null);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
pendingAsk.current?.({ choice: "keep", all: true });
|
||||
pendingAsk.current = null;
|
||||
};
|
||||
}, []);
|
||||
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
|
||||
const askConflict = (names: string[], many: boolean) =>
|
||||
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
|
||||
if (!mountedRef.current) return resolve({ choice: "keep", all: true });
|
||||
pendingAsk.current = resolve;
|
||||
setAskAll(false);
|
||||
setAsk({ names, many, resolve });
|
||||
});
|
||||
const answer = (choice: FsConflict | null) => {
|
||||
pendingAsk.current = null;
|
||||
ask?.resolve({ choice, all: askAll });
|
||||
setAsk(null);
|
||||
};
|
||||
@@ -477,8 +537,11 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
|
||||
const [viewing, setViewing] = useState<FsEntry | null>(null);
|
||||
const openEntry = (e: FsEntry) => {
|
||||
if (isFolder(e)) go(e.path);
|
||||
else if (isHtml(e)) setViewing(e);
|
||||
else download([e]);
|
||||
};
|
||||
const goUp = () => {
|
||||
@@ -586,8 +649,14 @@ export function FileBrowser({
|
||||
};
|
||||
|
||||
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
|
||||
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
|
||||
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
|
||||
setMenu(null);
|
||||
// A dropped/picked repo carries its .git, which the server refuses file by
|
||||
// file — skip it here with ONE notice instead of a toast per object.
|
||||
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
|
||||
if (files.length < picked.length) {
|
||||
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
|
||||
}
|
||||
if (!files.length) return;
|
||||
const bytes = files.reduce((n, f) => n + f.file.size, 0);
|
||||
if (bytes > FX_BATCH_MAX_BYTES) {
|
||||
@@ -597,19 +666,29 @@ export function FileBrowser({
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
// The place is fixed now: the user may open another folder (or leave the
|
||||
// page) while this keeps sending; its progress lives in the dock.
|
||||
const place = target;
|
||||
const job = beginUpload({
|
||||
folder: `${placeName(place)}${dir ? `/${dir}` : ""}`,
|
||||
total: files.length,
|
||||
bytes,
|
||||
});
|
||||
const { signal } = job;
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -620,7 +699,7 @@ export function FileBrowser({
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
@@ -640,9 +719,10 @@ export function FileBrowser({
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
place,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
@@ -653,7 +733,7 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -664,7 +744,7 @@ export function FileBrowser({
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
if (code === "ECANCEL" || code === "EABORT") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
@@ -688,9 +768,10 @@ export function FileBrowser({
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
job.end();
|
||||
setBusy(false);
|
||||
if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
if (signal.aborted) toast.info(t("uploads.stopped", { count: saved, total: files.length }));
|
||||
else if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
||||
changed();
|
||||
}
|
||||
};
|
||||
@@ -891,6 +972,59 @@ export function FileBrowser({
|
||||
onCancel={() => setRenaming(null)}
|
||||
/>
|
||||
) : null;
|
||||
// Each workspace drops down under its owner: the caller's own under My
|
||||
// home, another user's under theirs (`owner_name` = that home's id). An
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
// A workspace whose folder was deleted has nothing to browse — it stays a
|
||||
// place only while it is the one open (so its error has a row to sit on).
|
||||
const placeLanes = useMemo(
|
||||
() =>
|
||||
lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)),
|
||||
[lanes, target]
|
||||
);
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of placeLanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
: l.owner_name && l.owner_name !== "me"
|
||||
? l.owner_name
|
||||
: null;
|
||||
if (!home) continue;
|
||||
map.set(home, [...(map.get(home) ?? []), l]);
|
||||
if (!ids.has(home) && !extra.has(home)) {
|
||||
extra.set(home, {
|
||||
id: home,
|
||||
label: l.owner_display_name || home,
|
||||
readOnly: true,
|
||||
noHome: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rows: UserRow[] = [
|
||||
...homes.filter((h) => h.id !== "me"),
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, placeLanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = placeLanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
|
||||
const toggleHome = (id: string, own: Lane[]) => {
|
||||
const next = new Set(openHomes);
|
||||
if (homeIsOpen(id, own)) next.delete(id);
|
||||
else next.add(id);
|
||||
setOpenHomes(next);
|
||||
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
|
||||
};
|
||||
const placeClass = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
||||
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
||||
@@ -903,80 +1037,118 @@ export function FileBrowser({
|
||||
onClick={() => setMenu(null)}
|
||||
data-testid="file-explorer"
|
||||
>
|
||||
{cloneParent && (
|
||||
<CloneHereDialog
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||
changed();
|
||||
}}
|
||||
{viewing && (
|
||||
<HtmlViewer
|
||||
entry={viewing}
|
||||
place={target}
|
||||
onDownload={() => download([viewing])}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
{cloneParent && (
|
||||
<CloneHereDialog open={cloning} parent={cloneParent} onClose={() => setCloning(false)} />
|
||||
)}
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||
{homes.some((h) => h.id === "me") && (
|
||||
<div className="pt-3">
|
||||
{(() => {
|
||||
const laneRow = (l: Lane, nested: boolean) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={`${placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}${nested ? " pl-7" : ""}`}
|
||||
>
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.myHome")}</span>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{homes.some((h) => h.id !== "me") && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.userHomes")}
|
||||
</p>
|
||||
{homes
|
||||
.filter((h) => h.id !== "me")
|
||||
.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{h.label}</span>
|
||||
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{lanes.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
// A home row with a chevron: its user's workspaces drop down under it.
|
||||
const homeRow = (h: UserRow) => {
|
||||
const own = lanesOfHome.get(h.id) ?? [];
|
||||
const open = own.length > 0 && homeIsOpen(h.id, own);
|
||||
const mine = h.id === "me";
|
||||
// No home to open: the name drops the list down too.
|
||||
const browse = !h.noHome;
|
||||
return (
|
||||
<div key={h.id} data-testid={`fx-home-${h.id}`}>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("fx.toggleWorkspaces", {
|
||||
name: mine ? t("fx.myHome") : h.label,
|
||||
})}
|
||||
aria-expanded={open}
|
||||
disabled={!own.length}
|
||||
onClick={() => toggleHome(h.id, own)}
|
||||
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
|
||||
aria-expanded={browse ? undefined : open}
|
||||
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
|
||||
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
|
||||
className={placeClass(
|
||||
browse && view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
{mine ? (
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
|
||||
{own.length > 0 && (
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{own.length}
|
||||
</span>
|
||||
)}
|
||||
{!mine && browse && (
|
||||
<Eye
|
||||
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && own.map((l) => laneRow(l, true))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const me = homes.find((h) => h.id === "me");
|
||||
const others = userRows;
|
||||
return (
|
||||
<>
|
||||
{me && <div className="pt-3">{homeRow(me)}</div>}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
|
||||
</p>
|
||||
{others.map(homeRow)}
|
||||
</>
|
||||
)}
|
||||
{looseLanes.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{looseLanes.map((l) => laneRow(l, false))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
@@ -1522,25 +1694,6 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div
|
||||
data-testid="fx-progress"
|
||||
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
|
||||
>
|
||||
<p className="truncate text-xs font-medium">
|
||||
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
|
||||
<div
|
||||
className="h-full bg-accent-solid transition-[width]"
|
||||
style={{
|
||||
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
@@ -1548,13 +1701,16 @@ export function FileBrowser({
|
||||
{menuItem ? (
|
||||
<>
|
||||
<MenuItem onSelect={close(() => openEntry(menuItem))}>
|
||||
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
{isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
|
||||
</MenuItem>
|
||||
{(isFolder(menuItem) || menuTargets.length > 1) && (
|
||||
<MenuItem onSelect={close(() => download(menuTargets))}>
|
||||
{t("fx.downloadZip")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isHtml(menuItem) && menuTargets.length === 1 && (
|
||||
<MenuItem onSelect={close(() => download([menuItem]))}>{t("fx.download")}</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{!ro && (
|
||||
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
|
||||
@@ -1775,7 +1931,7 @@ export function FileExplorer({
|
||||
onClose: () => void;
|
||||
laneId: number;
|
||||
session?: string | null;
|
||||
source?: number | null;
|
||||
source?: GitSource | null;
|
||||
initialPath?: string;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
@@ -1808,6 +1964,36 @@ export function FileExplorer({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
|
||||
* its relative CSS, scripts and images read through the same download route. */
|
||||
function HtmlViewer({
|
||||
entry,
|
||||
place,
|
||||
onDownload,
|
||||
onClose,
|
||||
}: {
|
||||
entry: FsEntry;
|
||||
place: FsPlace;
|
||||
onDownload: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const placeKey = JSON.stringify(place);
|
||||
const fetchFile = useMemo(
|
||||
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[placeKey]
|
||||
);
|
||||
return (
|
||||
<HtmlViewerModal
|
||||
path={entry.path}
|
||||
fetchFile={fetchFile}
|
||||
onDownload={onDownload}
|
||||
onClose={onClose}
|
||||
testId="fx-html-viewer"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
@@ -1987,7 +2173,7 @@ function InlineName({
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") onDone();
|
||||
if (isSubmitEnter(e)) onDone();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={onDone}
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
* side, each block with the diff's Undo / Keep while nothing
|
||||
* is unsaved): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* whole markdown file can also be read rendered (Preview), an HTML
|
||||
* file's Preview beside Whole file opens the page in a modal
|
||||
* (`HtmlViewerModal`, its relative CSS, scripts and images read
|
||||
* like downloads), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* history — the commit graph (`git/HistoryView.tsx`: all branches, search,
|
||||
* details, multi-select cherry-pick/revert, compare, browse at a
|
||||
* revision); Show History on a file is `git/FileHistory.tsx`
|
||||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||||
* mode switcher's Files button; at full width the sidebar);
|
||||
* a row dragged onto a prompt box attaches it as `@path`
|
||||
@@ -51,9 +56,16 @@
|
||||
* .gitignore (`ignore`, untracked only). While a merge is open the box is
|
||||
* hidden: a partial commit cannot finish one, Complete does.
|
||||
*
|
||||
* History rewrites through its commit menu: Revert (any commit), Undo commit
|
||||
* (the top one; the server refuses a pushed one) and, while another branch's
|
||||
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
|
||||
* **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with
|
||||
* an optional one-line note), and the Shelves list (`LaneShelves`) above the
|
||||
* file list restores or deletes what was shelved — a git stash under the
|
||||
* hood, its recovery always a button here.
|
||||
*
|
||||
* History rewrites through HistoryView's commit menu and selection: Revert and
|
||||
* Cherry-pick (one or several commits, previewed in `CherryPickDialog`, a
|
||||
* paused one finished from `PickBanner`), Undo commit (the top one; the
|
||||
* server refuses a pushed one), Reset branch to here… and Edit commits after
|
||||
* this one… (`GitTools`). The commit row's Amend box
|
||||
* folds the ticked files into the last commit (none ticked: rewords it). All
|
||||
* four ask first (`confirmDialog`).
|
||||
*
|
||||
@@ -77,9 +89,19 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -103,7 +125,7 @@ import {
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
@@ -127,10 +149,17 @@ import { LaneConflictView } from "./LaneConflictView";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { fetchVia } from "../HtmlFrame";
|
||||
import { HtmlViewerModal } from "../HtmlViewerModal";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { LaneShelves, withAgentConfirm } from "./LaneShelves";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import { HistoryView } from "./git/HistoryView";
|
||||
import { FileHistory } from "./git/FileHistory";
|
||||
import { PickBanner } from "./git/PickBanner";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
@@ -150,6 +179,10 @@ const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||||
|
||||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||||
|
||||
/** A cherry-pick or revert: its banner is PickBanner, not the merge one (a
|
||||
* merge and a rebase keep theirs). */
|
||||
const isPick = (op: string | null | undefined) => op === "cherry-pick" || op === "revert";
|
||||
|
||||
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
|
||||
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
|
||||
* modified (and renamed) blue. */
|
||||
@@ -185,6 +218,9 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
|
||||
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
// CodeMirror is loaded only once a file is edited.
|
||||
@@ -754,54 +790,6 @@ function ChangeTreeNodes({
|
||||
);
|
||||
}
|
||||
|
||||
/** History's page size, and the most the server reads (`listCommits`). */
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
// The history's commit graph: one fixed-height row per commit so its lanes
|
||||
// join up row to row. Mid-tone hues that read on dark and light themes alike.
|
||||
const GRAPH_ROW = 34;
|
||||
const GRAPH_COL = 12;
|
||||
const GRAPH_COLORS = [
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#f59e0b",
|
||||
"#a855f7",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={width * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
@@ -827,7 +815,7 @@ export function LaneChangesPanel({
|
||||
session?: string;
|
||||
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||
* wins over `session`, and is keyed on like it. */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
@@ -844,6 +832,8 @@ export function LaneChangesPanel({
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session, source);
|
||||
// The open history-rewrite / remote / merge-editor tool (./git/GitTools).
|
||||
const [gitTool, setGitTool] = useState<GitTool | null>(null);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
@@ -859,6 +849,11 @@ export function LaneChangesPanel({
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
// Any git write on this workspace — from this panel's bar, another repo's
|
||||
// panel or the session view — re-reads everything here (`rereadAll`, set
|
||||
// once it is defined below).
|
||||
const rereadRef = useRef<(() => void) | null>(null);
|
||||
useGitChanged(laneId, () => rereadRef.current?.());
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
@@ -874,10 +869,13 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file, each
|
||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
// A whole markdown file shown rendered instead of as source; per file,
|
||||
// opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
|
||||
const [htmlOpen, setHtmlOpen] = useState(false);
|
||||
useEffect(() => setHtmlOpen(false), [openFile, mode]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -889,7 +887,6 @@ export function LaneChangesPanel({
|
||||
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||
setMode(full ? "overview" : "browse");
|
||||
}, [notRepo, mode, full]);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
|
||||
@@ -959,6 +956,9 @@ export function LaneChangesPanel({
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
// Shelve (n) opened its note row; the note typed there.
|
||||
const [shelving, setShelving] = useState(false);
|
||||
const [shelfNote, setShelfNote] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
@@ -1056,30 +1056,18 @@ export function LaneChangesPanel({
|
||||
}, [load, mode]);
|
||||
|
||||
// History and the file tree are their own reads, each fetched when its mode
|
||||
// is entered rather than alongside the diff nobody is looking at.
|
||||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||||
// (up to the server's 200).
|
||||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, {
|
||||
limit: historyLimit,
|
||||
...(historyRef ? { ref: historyRef } : {}),
|
||||
...(historyFile ? { file: historyFile } : {}),
|
||||
})
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||||
// is entered rather than alongside the diff nobody is looking at. The commit
|
||||
// graph and a file's history read themselves (git/HistoryView, FileHistory).
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitBranches(target)
|
||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||||
.catch(() => setHistoryBranches([]));
|
||||
.then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
|
||||
.catch(() => live && setHistoryBranches([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||
@@ -1173,24 +1161,33 @@ export function LaneChangesPanel({
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||
// Opening file B before file A's read came back must not show (and let the
|
||||
// editor save) A's text under B's header.
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
forLane(laneId, (r) =>
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
forLane(
|
||||
laneId,
|
||||
(r) =>
|
||||
live &&
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||
// The editor's original side, read beside the working file. A file that is
|
||||
// not there (new since) diffs against nothing: every line is added.
|
||||
@@ -1198,10 +1195,17 @@ export function LaneChangesPanel({
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||
let live = true;
|
||||
void api.lanes.gitFile(target, openFile, rev).then(
|
||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||||
forLane(laneId, () => setBaseText(""))
|
||||
forLane(
|
||||
laneId,
|
||||
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
|
||||
),
|
||||
forLane(laneId, () => live && setBaseText(""))
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1220,7 +1224,6 @@ export function LaneChangesPanel({
|
||||
setTreeBusy(false);
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setCommits(null);
|
||||
setData(null);
|
||||
setDraft(null);
|
||||
setCollapsed(new Set());
|
||||
@@ -1301,7 +1304,6 @@ export function LaneChangesPanel({
|
||||
if (!ok) return;
|
||||
setHistoryFile(path);
|
||||
setHistoryRef(null);
|
||||
setCommits(null);
|
||||
setMode("history");
|
||||
});
|
||||
};
|
||||
@@ -1424,25 +1426,78 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
// Discard selected: exactly what Commit would take — the ticked files, and
|
||||
// only the ticked blocks of a partly ticked one (#87: it used to discard
|
||||
// every file, ticked or not) — in one `discard`, asked once in a dialog
|
||||
// that names how many untracked files it will delete.
|
||||
const discardTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
// Tracked and untracked counted apart: an untracked file is deleted, not
|
||||
// reverted, and counting it in both told "10 changed + 10 new" for 10.
|
||||
const untracked = files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length;
|
||||
const tracked = tickedPaths.length - untracked;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
title: t("changes.discardTickedTitle"),
|
||||
message: t(
|
||||
untracked === 0
|
||||
? "changes.discardTickedConfirmTracked"
|
||||
: tracked === 0
|
||||
? "changes.discardTickedConfirmNew"
|
||||
: "changes.discardTickedConfirm",
|
||||
{ count: tracked, untracked }
|
||||
),
|
||||
confirmLabel: t("changes.discardTicked", { count: tickedPaths.length }),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
if (!ok) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { paths, hunks } = await selection();
|
||||
await api.lanes.gitAction(target, "discard", {
|
||||
paths,
|
||||
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
|
||||
confirm: true,
|
||||
});
|
||||
if (laneNow.current !== lane) return;
|
||||
setConfirmDiscard(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shelve (n): the ticked files (whole files — a stash has no hunks) set
|
||||
// aside with the note; all of them when every file is ticked.
|
||||
const shelveTicked = async () => {
|
||||
if (!tickedPaths.length) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const all = tickedPaths.length >= files.length;
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "shelve", {
|
||||
...(all ? {} : { paths: tickedPaths }),
|
||||
...(shelfNote.trim() ? { note: shelfNote.trim() } : {}),
|
||||
...(confirm ? { confirm } : {}),
|
||||
})
|
||||
);
|
||||
if (laneNow.current !== lane || !res) return;
|
||||
toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length }));
|
||||
setShelving(false);
|
||||
setShelfNote("");
|
||||
refreshGit();
|
||||
setReread((n) => n + 1);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
@@ -1532,7 +1587,6 @@ export function LaneChangesPanel({
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
const viewCommit = (sha: string) => {
|
||||
setSha(sha);
|
||||
setRange("commit");
|
||||
@@ -1708,68 +1762,15 @@ export function LaneChangesPanel({
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{commitCtx.menu && (
|
||||
<ContextMenu
|
||||
x={commitCtx.menu.x}
|
||||
y={commitCtx.menu.y}
|
||||
onClose={commitCtx.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||||
{t("changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{historyRef ? (
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("cherry-pick", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("revert", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
// Pushed: the server refuses it (EPUSHED), so say why here
|
||||
// instead of failing after the confirm.
|
||||
disabled={
|
||||
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
|
||||
}
|
||||
title={headPushed ? t("changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("undo-commit", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
<GitTools
|
||||
tool={gitTool}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
onClose={() => setGitTool(null)}
|
||||
onOpen={setGitTool}
|
||||
onChanged={() => void rereadAll()}
|
||||
onViewCommit={viewCommit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1951,6 +1952,7 @@ export function LaneChangesPanel({
|
||||
setReread((n) => n + 1);
|
||||
return load();
|
||||
};
|
||||
rereadRef.current = () => void rereadAll();
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
@@ -2018,6 +2020,13 @@ export function LaneChangesPanel({
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||
// from the working tree: a commit's page would get today's files.
|
||||
const htmlFetch = useMemo(
|
||||
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[range, JSON.stringify(target)]
|
||||
);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -2343,10 +2352,7 @@ export function LaneChangesPanel({
|
||||
aria-pressed={mode === m}
|
||||
onClick={() => {
|
||||
// The History button is the branch's; Show History, a file's.
|
||||
if (m === "history" && historyFile) {
|
||||
setHistoryFile(null);
|
||||
setCommits(null);
|
||||
}
|
||||
if (m === "history" && historyFile) setHistoryFile(null);
|
||||
setMode(m);
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
@@ -2361,6 +2367,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!notRepo && (
|
||||
<GitToolsButton
|
||||
onPick={setGitTool}
|
||||
hasRemote={git?.available ? git.hasRemote : undefined}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={runReread}
|
||||
@@ -2534,6 +2546,31 @@ export function LaneChangesPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* An HTML file opens as the page in a modal over the screen;
|
||||
a commit's page once its text is read (Whole file). */}
|
||||
{mode === "file" && isHtml(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
disabled={dirty || (range === "commit" && !text)}
|
||||
onClick={() => setHtmlOpen(true)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{htmlOpen && openFile && (
|
||||
<HtmlViewerModal
|
||||
path={openFile}
|
||||
// A commit's page is its text at that commit (no sibling
|
||||
// files); a working one is read whole from the disk.
|
||||
html={range === "commit" ? text?.lines.join("\n") : undefined}
|
||||
fetchFile={htmlFetch}
|
||||
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
|
||||
onClose={() => setHtmlOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2544,7 +2581,17 @@ export function LaneChangesPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{git?.available && git.merging && (
|
||||
{git?.available && git.merging && isPick(git.operation) && (
|
||||
<PickBanner
|
||||
target={target}
|
||||
conflicts={git.conflicts ?? 0}
|
||||
reread={reread}
|
||||
busy={busy}
|
||||
onBusy={setBusy}
|
||||
onDone={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
{git?.available && git.merging && !isPick(git.operation) && (
|
||||
<div
|
||||
data-testid="lane-merge-banner"
|
||||
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
@@ -2562,6 +2609,13 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
{git.operation === "rebase" && (
|
||||
<RebaseSkipButton
|
||||
target={target}
|
||||
disabled={busy}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
@@ -2578,6 +2632,12 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes set aside (git stash entries AgentHub made): Restore and
|
||||
Delete, never dropped on their own. Absent while there are none. */}
|
||||
{git?.available && mode !== "file" && (
|
||||
<LaneShelves target={target} reread={reread} onChanged={rereadAll} />
|
||||
)}
|
||||
|
||||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||||
bar sits at the bottom of the visible box instead of at the bottom of
|
||||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||||
@@ -2590,103 +2650,40 @@ export function LaneChangesPanel({
|
||||
}`}
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history">
|
||||
{historyFile && (
|
||||
<li
|
||||
data-testid="lane-changes-history-file"
|
||||
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("changes.historyOfFile", { path: historyFile })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHistoryFile(null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("changes.historyWholeBranch")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{historyBranches.length > 1 && (
|
||||
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
|
||||
<span className="shrink-0">{t("changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => {
|
||||
setHistoryRef(e.target.value || null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{git?.available ? git.branch : "HEAD"}</option>
|
||||
{historyBranches
|
||||
.filter((b) => !(git?.available && b === git.branch))
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
)}
|
||||
{(() => {
|
||||
const list = commits ?? [];
|
||||
const graph = commitGraph(list);
|
||||
return list.map((c, i) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
historyFile ? openAtCommit(historyFile, c.sha) : viewCommit(c.sha)
|
||||
}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
style={{ height: GRAPH_ROW }}
|
||||
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<GraphCell row={graph.rows[i]!} width={graph.width} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{c.short}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
));
|
||||
})()}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
{commits && commits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||||
)}
|
||||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={historyLoading}
|
||||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
historyFile ? (
|
||||
<FileHistory
|
||||
key={`${laneId}:${historyFile}`}
|
||||
target={target}
|
||||
path={historyFile}
|
||||
reread={reread}
|
||||
onOpenVersion={openAtCommit}
|
||||
onViewCommit={viewCommit}
|
||||
onWholeBranch={() => setHistoryFile(null)}
|
||||
/>
|
||||
) : (
|
||||
<HistoryView
|
||||
key={laneId}
|
||||
target={target}
|
||||
branch={git?.available ? git.branch : null}
|
||||
branches={historyBranches}
|
||||
historyRef={historyRef}
|
||||
onHistoryRef={setHistoryRef}
|
||||
reread={reread}
|
||||
disabled={busy || opOpen}
|
||||
headPushed={headPushed}
|
||||
onViewCommit={viewCommit}
|
||||
onOpenFileAtCommit={openAtCommit}
|
||||
onUndoCommit={(c) => void historyAct("undo-commit", c)}
|
||||
onResetHere={(c) => setGitTool({ kind: "reset", commit: c })}
|
||||
onEditAfter={(sha) => setGitTool({ kind: "interactive", base: sha })}
|
||||
onPaused={() => {
|
||||
setRange("work");
|
||||
setMode("overview");
|
||||
void rereadAll();
|
||||
}}
|
||||
onChanged={() => void rereadAll()}
|
||||
/>
|
||||
)
|
||||
) : mode === "browse" && !full ? (
|
||||
browsePane
|
||||
) : !data ? (
|
||||
@@ -2784,15 +2781,62 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void discardTicked()}
|
||||
title={t("changes.discardTickedTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-shelve"
|
||||
aria-expanded={shelving}
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => setShelving((v) => !v)}
|
||||
title={t("shelves.shelveHint")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.shelveTicked", { count: tickedPaths.length })}
|
||||
</button>
|
||||
</div>
|
||||
{shelving && (
|
||||
<div
|
||||
data-testid="lane-changes-shelve-row"
|
||||
className="flex w-full flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={shelfNote}
|
||||
autoFocus
|
||||
maxLength={200}
|
||||
onChange={(e) => setShelfNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) void shelveTicked();
|
||||
if (e.key === "Escape") setShelving(false);
|
||||
}}
|
||||
placeholder={t("shelves.notePlaceholder")}
|
||||
aria-label={t("shelves.notePlaceholder")}
|
||||
className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || tickedPaths.length === 0}
|
||||
onClick={() => void shelveTicked()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("shelves.shelveApply", { count: tickedPaths.length })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShelving(false)}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("shelves.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* The work range splits like JetBrains' Commit window: tracked
|
||||
@@ -2913,6 +2957,9 @@ export function LaneChangesPanel({
|
||||
editWanted.current = true;
|
||||
setWhole(true);
|
||||
}}
|
||||
onMergeEditor={() =>
|
||||
setGitTool({ kind: "merge", path: openFile, onResolved: goBack })
|
||||
}
|
||||
/>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) || openImage ? (
|
||||
@@ -2943,6 +2990,7 @@ export function LaneChangesPanel({
|
||||
{t("changes.annotate")}
|
||||
</button>
|
||||
)}
|
||||
{/* An HTML file's Preview sits beside Whole file instead. */}
|
||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* file to the panel's plain editor; Merge editor opens the 3-way editor
|
||||
* (`./git/MergeEditor`, base/current/incoming side by side).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
@@ -42,12 +43,15 @@ export function LaneConflictView({
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
onMergeEditor,
|
||||
}: {
|
||||
/** Open the 3-way merge editor (./git/MergeEditor) on this file. */
|
||||
onMergeEditor?: () => void;
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
@@ -176,6 +180,17 @@ export function LaneConflictView({
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
{onMergeEditor && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conflict-open-merge-editor"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={onMergeEditor}
|
||||
>
|
||||
{t("gitTools:merge.open")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||
function Elided({ n }: { n?: number }) {
|
||||
const { t, i18n } = useTranslation("lanes");
|
||||
if (!n) return null;
|
||||
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
return (
|
||||
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||
{t("changes.lineElided", { size })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
<Elided n={line.elided} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -621,6 +634,7 @@ function SplitLine({
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
{line ? <Elided n={line.elided} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* fetch, pull, push, switch branch (a local one, or one only a remote has —
|
||||
* listed under "On the remote" and checked out as a new tracking branch; the
|
||||
* list is re-read after every fetch and pull), create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
@@ -13,7 +15,16 @@
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* --ff-only, switch refuses a dirty tree unless told what to do with the
|
||||
* changes), and this only reports what it said. With uncommitted changes,
|
||||
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
|
||||
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
|
||||
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
|
||||
* — and the new-branch row offers **Create & switch** (`switch: true`, the
|
||||
* changes come along; the default on Enter while the tree is dirty) beside
|
||||
* plain Create. A switch onto a branch that has shelves asks whether to
|
||||
* restore the newest (never automatically), and a write the server answers
|
||||
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
@@ -21,6 +32,7 @@
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
|
||||
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
|
||||
* The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -38,11 +50,13 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
import { restoreShelf } from "./LaneShelves";
|
||||
import { BranchesButton } from "./git/BranchesButton";
|
||||
|
||||
type Action =
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
@@ -98,7 +112,7 @@ export function LaneGitBar({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
@@ -114,6 +128,8 @@ export function LaneGitBar({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
// Fetched remote branches with no local one yet (`origin/feature`).
|
||||
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
@@ -124,6 +140,9 @@ export function LaneGitBar({
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// A switch with uncommitted changes asks how to take them: set to the
|
||||
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
|
||||
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
@@ -138,6 +157,7 @@ export function LaneGitBar({
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setRemoteBranches([]);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
@@ -145,6 +165,7 @@ export function LaneGitBar({
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
setChoice(null);
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -152,6 +173,9 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
// A remote branch: switching creates its local tracking branch; it cannot
|
||||
// be deleted here or given a sibling worktree before that.
|
||||
const pickedRemote = remoteBranches.includes(picked);
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
@@ -160,6 +184,9 @@ export function LaneGitBar({
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
|
||||
// refused as a second action in flight.
|
||||
let askAgent = false;
|
||||
try {
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
@@ -182,17 +209,50 @@ export function LaneGitBar({
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setChoice(null);
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
||||
// A fetch may have brought branches the list does not show yet.
|
||||
await refreshBranches();
|
||||
}
|
||||
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
|
||||
onChanged();
|
||||
// The branch switched to has changes shelved on it: offer them back —
|
||||
// asking, never restoring on its own.
|
||||
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
|
||||
if (offer) {
|
||||
const yes = await confirmDialog({
|
||||
title: t("gitBar.restoreOfferTitle"),
|
||||
message: t("gitBar.restoreOffer", {
|
||||
branch: offer.branch,
|
||||
count: res?.shelves?.length ?? 1,
|
||||
files: offer.files.length,
|
||||
}),
|
||||
confirmLabel: t("shelves.restore"),
|
||||
destructive: false,
|
||||
});
|
||||
if (yes && laneNow.current === lane) {
|
||||
await restoreShelf(gitTarget(lane, session, source), offer);
|
||||
if (laneNow.current === lane) onChanged();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
|
||||
askAgent = true;
|
||||
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
|
||||
// git would overwrite these files: show them and offer the shelf.
|
||||
setChoice({
|
||||
branch: body.branch,
|
||||
conflict: (err as { files?: string[] }).files ?? [],
|
||||
});
|
||||
} else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
@@ -210,6 +270,15 @@ export function LaneGitBar({
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
if (askAgent) {
|
||||
const ok = await confirmDialog({
|
||||
title: t("shelves.agentTitle"),
|
||||
message: t("shelves.agentMessage"),
|
||||
confirmLabel: t("shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
@@ -217,6 +286,7 @@ export function LaneGitBar({
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setRemoteBranches(r.remotes || []);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
@@ -294,6 +364,20 @@ export function LaneGitBar({
|
||||
if (ok) void run("merge", { branch });
|
||||
};
|
||||
|
||||
// Switch branch: straight away on a clean tree; with changes, ask how.
|
||||
const switchTo = (branch: string) => {
|
||||
if (dirty > 0) {
|
||||
setChoice({ branch });
|
||||
setError(null);
|
||||
} else void run("switch", { branch });
|
||||
};
|
||||
// Enter in the new-branch box: with changes, Create & switch (they come
|
||||
// along); on a clean tree, plain Create as before.
|
||||
const createBranch = (andSwitch: boolean) => {
|
||||
const branch = newBranch.trim();
|
||||
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
@@ -412,17 +496,30 @@ export function LaneGitBar({
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
// With changes, Switch branch asks whether to bring or shelve
|
||||
// them; the hint says so before the click.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
? t("gitBar.switchDirtyHint", { count: dirty })
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
<BranchesButton
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
merging={merging}
|
||||
disabled={!!busy}
|
||||
onChanged={() => {
|
||||
// A rename, delete or new branch makes the switcher's list stale.
|
||||
if (branches !== null) void refreshBranches();
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -446,15 +543,26 @@ export function LaneGitBar({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{remoteBranches.length > 0 && (
|
||||
<optgroup label={t("gitBar.remoteBranches")}>
|
||||
{remoteBranches.map((b) => (
|
||||
<option key={`remote:${b}`} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={
|
||||
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
|
||||
}
|
||||
onClick={() => switchTo(picked)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
@@ -476,7 +584,7 @@ export function LaneGitBar({
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch || pickedRemote}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -486,7 +594,7 @@ export function LaneGitBar({
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() =>
|
||||
void confirmRun(
|
||||
@@ -500,34 +608,83 @@ export function LaneGitBar({
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && choice && (
|
||||
<div
|
||||
data-testid="lane-git-switch-choice"
|
||||
role="group"
|
||||
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
>
|
||||
<p className="break-words text-fg-primary">
|
||||
{choice.conflict
|
||||
? t("gitBar.carryConflict", { branch: choice.branch })
|
||||
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
|
||||
</p>
|
||||
{choice.conflict && choice.conflict.length > 0 && (
|
||||
<ul
|
||||
data-testid="lane-git-carry-conflicts"
|
||||
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
|
||||
>
|
||||
{choice.conflict.map((f) => (
|
||||
<li key={f} className="truncate" title={f}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{!choice.conflict && (
|
||||
<Btn
|
||||
label={t("gitBar.choiceCarry")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceCarryHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.choiceShelve")}
|
||||
busy={busy === "switch"}
|
||||
title={t("gitBar.choiceShelveHint")}
|
||||
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
|
||||
/>
|
||||
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
if (isSubmitEnter(e)) createBranch(dirty > 0);
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreateSwitch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// The working tree stays as it is: uncommitted changes come along.
|
||||
title={t("gitBar.branchCreateSwitchHint")}
|
||||
onClick={() => createBranch(true)}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
// Plain Create does not switch: the point is usually a sibling
|
||||
// lane, and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
onClick={() => createBranch(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* owned by the panel) and refresh each other after every write. Every other
|
||||
* reader of the facts (the rail counts, a second panel) hears a write through
|
||||
* `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,8 +21,9 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, type GitSource } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
@@ -37,8 +40,8 @@ export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** Read that session's own worktree instead (`?session=`). */
|
||||
session?: string,
|
||||
/** Read that extra folder's repo instead (`?source=`). */
|
||||
source?: number
|
||||
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
|
||||
source?: GitSource
|
||||
): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
@@ -76,6 +79,9 @@ export function useLaneGitFacts(
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, session, source, nonce]);
|
||||
// A git write from ANY view of this workspace (another panel, the session
|
||||
// view, a second tab of the same page) moves these facts too.
|
||||
useGitChanged(laneId, () => setNonce((n) => n + 1));
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
@@ -329,7 +335,7 @@ export function LaneGitRailCounts({
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
@@ -349,7 +355,7 @@ export function LaneGitHeader({
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
source?: GitSource;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @file The Changes panel's Shelves list: changes set aside by AgentHub (git
|
||||
* stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its
|
||||
* branch, note, time and files, and the two buttons that make a shelf safe to
|
||||
* offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only
|
||||
* when it applied cleanly; a conflicted restore keeps it and leaves the files
|
||||
* to the conflict list) and **Delete** (`shelf-drop`, two clicks through
|
||||
* `useArmed`, sent with `confirm: true`). Collapsed to one line until opened,
|
||||
* and absent while there are none. Also exports `withAgentConfirm`, the
|
||||
* one place a git write answered `EAGENTRUNNING` asks the user and is resent
|
||||
* with `confirm: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react";
|
||||
import i18n from "../../i18n";
|
||||
import { api, type GitShelf, type GitTarget } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
|
||||
/**
|
||||
* Run a git write; when the server answers `EAGENTRUNNING` (an agent of the
|
||||
* workspace is working and the write would move files under it), ask, and
|
||||
* resend it with `confirm: true`. Resolves null when the user declined.
|
||||
*/
|
||||
export async function withAgentConfirm<T>(
|
||||
send: (confirm: boolean) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await send(false);
|
||||
} catch (err) {
|
||||
if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err;
|
||||
const ok = await confirmDialog({
|
||||
title: i18n.t("lanes:shelves.agentTitle"),
|
||||
message: i18n.t("lanes:shelves.agentMessage"),
|
||||
confirmLabel: i18n.t("lanes:shelves.agentConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
return ok ? send(true) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by
|
||||
* the list below and the git bar's "restore what you shelved here" prompt.
|
||||
* @returns true when anything changed in the working tree.
|
||||
*/
|
||||
export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise<boolean> {
|
||||
try {
|
||||
const res = await withAgentConfirm((confirm) =>
|
||||
api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) })
|
||||
);
|
||||
if (!res) return false;
|
||||
if (res.conflicts?.length) {
|
||||
toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length }));
|
||||
} else {
|
||||
toast.success(i18n.t("lanes:shelves.restored"));
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
(err as { code?: string })?.code === "EDIRTY"
|
||||
? i18n.t("lanes:shelves.restoreDirty")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */
|
||||
function statusTone(status: string): string {
|
||||
if (status === "??" || status.startsWith("A")) return "text-status-success";
|
||||
if (status.startsWith("D")) return "text-status-danger";
|
||||
return "text-status-warning";
|
||||
}
|
||||
|
||||
export function LaneShelves({
|
||||
target,
|
||||
reread,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** Bumped by the panel after every git write: the list is read again. */
|
||||
reread: number;
|
||||
/** Called after a restore or a delete changed the tree or the list. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [shelves, setShelves] = useState<GitShelf[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filesOf, setFilesOf] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const del = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
const keyNow = useRef(key);
|
||||
keyNow.current = key;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const asked = key;
|
||||
try {
|
||||
const r = await api.lanes.gitShelves(target);
|
||||
if (keyNow.current === asked) setShelves(r.available ? r.shelves : []);
|
||||
} catch {
|
||||
if (keyNow.current === asked) setShelves([]);
|
||||
}
|
||||
// `target` is described by `key`; a new object with the same content is
|
||||
// the same tree.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, reread]);
|
||||
|
||||
if (!shelves.length) return null;
|
||||
|
||||
const restore = async (s: GitShelf) => {
|
||||
setBusy(s.sha);
|
||||
try {
|
||||
if (await restoreShelf(target, s)) onChanged();
|
||||
await load();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const drop = async (s: GitShelf) => {
|
||||
if (!del.isArmed(s.sha)) {
|
||||
del.arm(s.sha);
|
||||
return;
|
||||
}
|
||||
del.disarm();
|
||||
setBusy(s.sha);
|
||||
try {
|
||||
await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true });
|
||||
toast.success(t("shelves.deleted"));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
await load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-testid="lane-shelves" className="mb-2 rounded border border-border-light">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 px-2 py-1 text-left text-[11px] font-medium text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<Archive className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{t("shelves.title", { count: shelves.length })}
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="divide-y divide-border-light border-t border-border-light">
|
||||
{shelves.map((s) => {
|
||||
const when = s.createdAt ? new Date(s.createdAt) : null;
|
||||
const showFiles = filesOf === s.sha;
|
||||
return (
|
||||
<li key={s.sha} data-testid="lane-shelf" className="px-2 py-1 text-[11px]">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="min-w-0 max-w-full truncate font-mono font-semibold text-fg-primary"
|
||||
title={s.branch}
|
||||
>
|
||||
{s.branch}
|
||||
</span>
|
||||
{when && !Number.isNaN(when.getTime()) && (
|
||||
<span className="shrink-0 text-fg-muted" title={s.createdAt ?? undefined}>
|
||||
{when.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showFiles}
|
||||
onClick={() => setFilesOf(showFiles ? null : s.sha)}
|
||||
className="shrink-0 text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("shelves.files", { count: s.files.length })}
|
||||
</button>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void restore(s)}
|
||||
title={t("shelves.restoreHint")}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
{t("shelves.restore")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => void drop(s)}
|
||||
title={t("shelves.deleteHint")}
|
||||
className="flex items-center gap-1 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{del.isArmed(s.sha) ? t("shelves.deleteConfirm") : t("shelves.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.note && (
|
||||
<p className="mt-0.5 break-words text-fg-secondary" title={s.note}>
|
||||
{s.note}
|
||||
</p>
|
||||
)}
|
||||
{showFiles && (
|
||||
<ul data-testid="lane-shelf-files" className="mt-1 space-y-0.5 pl-2">
|
||||
{s.files.map((f) => (
|
||||
<li key={f.path} className="flex min-w-0 items-center gap-1.5 font-mono">
|
||||
<span className={`w-4 shrink-0 text-center ${statusTone(f.status)}`}>
|
||||
{f.status === "??" ? "U" : f.status.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-fg-secondary" title={f.path}>
|
||||
{f.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneShelves;
|
||||
@@ -10,7 +10,7 @@
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useGitChanged } from "../../lib/gitChanged";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -237,6 +239,17 @@ function HomeBody({
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
// A branch moved from the git bar: the main worktree's branch shown here is
|
||||
// the server's cached list, so it is refreshed rather than re-read.
|
||||
useGitChanged(lane.id, (action) => {
|
||||
if (!["switch", "branch-create", "branch-delete"].includes(action)) return;
|
||||
api.projects
|
||||
.refresh()
|
||||
.then(({ projects }) =>
|
||||
setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
});
|
||||
|
||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||
// at once — the same start the session view's tab bar `+` makes.
|
||||
@@ -307,30 +320,30 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
const archiveSession = async (s: Session) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
const run = liveRunFor(lane, liveRuns, s.id);
|
||||
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||
// archiving only its row left a pane running that no list showed.
|
||||
if (run) {
|
||||
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||
await patchSession(s, { archived: true });
|
||||
return;
|
||||
}
|
||||
const wt = sessionWorktreeName(lane, s);
|
||||
const ok = await confirmDialog(
|
||||
wt && !live
|
||||
wt
|
||||
? {
|
||||
title: t("tree.archiveWorktreeTitle"),
|
||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||
}
|
||||
: live
|
||||
? {
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
if (wt && !live) {
|
||||
if (wt) {
|
||||
try {
|
||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||
|
||||
@@ -161,12 +161,19 @@ export function WorkspaceRow({
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
<span
|
||||
data-folder-missing={lane.folder_missing ? "true" : undefined}
|
||||
className={`truncate text-[13px] font-medium ${
|
||||
lane.folder_missing ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Rename (the row's inline field), New workspace here (the
|
||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane. The server's guards are the real ones; the menu only hides
|
||||
* managed lane — plus Move to group (the sidebar's own groups,
|
||||
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||
* server's guards are the real ones; the menu only hides
|
||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -11,7 +13,9 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { Check } from "lucide-react";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
|
||||
y,
|
||||
onClose,
|
||||
onAction,
|
||||
groups = [],
|
||||
groupId = null,
|
||||
onGroup,
|
||||
}: {
|
||||
lane: Lane;
|
||||
x: number;
|
||||
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
|
||||
onClose: () => void;
|
||||
/** Items the tree itself carries out (rename field, modals). */
|
||||
onAction: (action: WorkspaceMenuAction) => void;
|
||||
/** The sidebar's groups and the one this workspace is in. */
|
||||
groups?: WorkspaceGroup[];
|
||||
groupId?: string | null;
|
||||
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
|
||||
onGroup?: (target: string | null | "new") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
return (
|
||||
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
{onGroup && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("menu.moveToGroup")}
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<MenuItem
|
||||
key={g.id}
|
||||
testId={`workspace-move-group-${g.id}`}
|
||||
disabled={g.id === groupId}
|
||||
onSelect={() => onGroup(g.id)}
|
||||
>
|
||||
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
|
||||
<span className="truncate">{g.name}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
|
||||
<span className="w-3 shrink-0" />
|
||||
{t("menu.newGroupWith")}
|
||||
</MenuItem>
|
||||
{groupId && (
|
||||
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
|
||||
<span className="w-3 shrink-0" />
|
||||
{t("menu.removeFromGroup")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
@@ -32,7 +32,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||
// left a pane running that no list showed any more.
|
||||
const run = runFor(s);
|
||||
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -26,6 +27,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
@@ -69,7 +71,7 @@ const TOOLS: AiToolsView = {
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("goose", "Goose", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -158,6 +160,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -790,6 +794,54 @@ describe("AddLaneModal — Clone", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and selects the workspace when it ends", async () => {
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const job = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@github.com:owner/repo.git",
|
||||
name: "repo",
|
||||
parent: "/home/an/code",
|
||||
target: "/home/an/code/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@github.com:owner/repo.git", parent: "/home/an/code", name: "repo" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job } });
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [{ ...job, state: "done", result: { laneId: 5, path: "/home/an/code/repo" } }],
|
||||
});
|
||||
vi.mocked(api.lanes.get).mockResolvedValue({ lane: laneFixture({ id: 5 }) });
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded, onClose });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }))
|
||||
);
|
||||
// Accepted: the modal closes at once, before the clone is done.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// The store's next read sees it done: the new workspace is selected.
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 5 })), {
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends partial only when the box is ticked", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 7 }),
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @file BranchManager.test.tsx
|
||||
* @description Pins the git bar's Branches panel: local branches with the
|
||||
* current one marked and ahead/behind shown, remote branches grouped by
|
||||
* remote, the search box, arrow-key walking, and each action's request —
|
||||
* rename, new branch from a remote branch (switch only offered on a clean
|
||||
* tree), set/unset upstream, merge into current, delete refused with its
|
||||
* reason — plus the Tags tab: annotated create, push one tag, delete with
|
||||
* `confirm: true` after the themed confirm, and git's error shown verbatim.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BranchManager } from "../git/BranchManager";
|
||||
import { api, type GitRefs } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number) => lane,
|
||||
api: { lanes: { gitRefs: vi.fn(), gitRefAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
const REFS: GitRefs = {
|
||||
available: true,
|
||||
current: "main",
|
||||
head: "aaaaaaa",
|
||||
truncated: false,
|
||||
local: [
|
||||
{
|
||||
name: "main",
|
||||
sha: "a1",
|
||||
current: true,
|
||||
upstream: "origin/main",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
gone: false,
|
||||
subject: "latest work",
|
||||
date: null,
|
||||
merged: true,
|
||||
worktree: null,
|
||||
protected: true,
|
||||
},
|
||||
{
|
||||
name: "feature/very-long-branch-name",
|
||||
sha: "b2",
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
gone: false,
|
||||
subject: "feature commit",
|
||||
date: null,
|
||||
merged: false,
|
||||
worktree: null,
|
||||
protected: false,
|
||||
},
|
||||
{
|
||||
name: "held",
|
||||
sha: "c3",
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
gone: false,
|
||||
subject: "x",
|
||||
date: null,
|
||||
merged: true,
|
||||
worktree: "/tmp/other",
|
||||
protected: false,
|
||||
},
|
||||
],
|
||||
remotes: [
|
||||
{
|
||||
remote: "origin",
|
||||
branches: [
|
||||
{ name: "origin/main", branch: "main", sha: "a1", subject: "s", date: null },
|
||||
{ name: "origin/remote-only", branch: "remote-only", sha: "d4", subject: "r", date: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "v1",
|
||||
sha: "a1b2c3d4",
|
||||
annotated: true,
|
||||
message: "release one",
|
||||
tagger: "T",
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function setup(over: { dirty?: number } = {}) {
|
||||
const onChanged = vi.fn();
|
||||
render(
|
||||
<BranchManager
|
||||
laneId={3}
|
||||
dirty={over.dirty ?? 0}
|
||||
merging={false}
|
||||
onClose={vi.fn()}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
return { onChanged };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitRefs).mockReset().mockResolvedValue(REFS);
|
||||
vi.mocked(api.lanes.gitRefAction)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ action: "x", ok: true, output: "" });
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
});
|
||||
|
||||
const row = (name: RegExp) => screen.getByRole("button", { name });
|
||||
|
||||
describe("BranchManager", () => {
|
||||
it("lists local branches with the current marked and remote ones grouped", async () => {
|
||||
setup();
|
||||
await screen.findByText("feature/very-long-branch-name");
|
||||
const main = row(/^main/);
|
||||
expect(within(main).getByText("current")).toBeInTheDocument();
|
||||
expect(main).toHaveTextContent("2");
|
||||
expect(main).toHaveTextContent("1");
|
||||
expect(screen.getByText("Remote · origin")).toBeInTheDocument();
|
||||
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
||||
// A long name truncates with its full text as the tooltip.
|
||||
expect(screen.getByText("feature/very-long-branch-name")).toHaveAttribute(
|
||||
"title",
|
||||
"feature/very-long-branch-name"
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by the search box and walks rows with the arrow keys", async () => {
|
||||
setup();
|
||||
await screen.findByText("held");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "Search branches" }), "remote");
|
||||
expect(screen.queryByText("held")).toBeNull();
|
||||
expect(screen.getByText("origin/remote-only")).toBeInTheDocument();
|
||||
await userEvent.clear(screen.getByRole("textbox", { name: "Search branches" }));
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(row(/^main/)).toHaveFocus();
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
expect(row(/^feature\/very-long/)).toHaveFocus();
|
||||
});
|
||||
|
||||
it("renames a branch and re-reads the list", async () => {
|
||||
const { onChanged } = setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
||||
const input = screen.getByRole("textbox", { name: "New name" });
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "feature/short{Enter}");
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-rename", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
name: "feature/short",
|
||||
});
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(api.lanes.gitRefs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refuses rename and delete of a protected current branch, with the reason", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("main"));
|
||||
expect(screen.getByRole("button", { name: /^Rename$/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
||||
expect(screen.getByText(/You are on this branch/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains a branch another worktree holds", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("held"));
|
||||
expect(screen.getByRole("button", { name: /^Delete$/ })).toBeDisabled();
|
||||
expect(screen.getByText(/another worktree: \/tmp\/other/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes an unmerged branch only after the confirm warns", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Delete$/ }));
|
||||
expect(vi.mocked(confirmDialog).mock.calls[0]?.[0].message).toMatch(/not merged/);
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-delete", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a branch from a remote branch, switch offered only on a clean tree", async () => {
|
||||
setup({ dirty: 2 });
|
||||
await userEvent.click(await screen.findByText("origin/remote-only"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /New branch from here/ }));
|
||||
expect(screen.getByRole("checkbox", { name: "Switch to it" })).toBeDisabled();
|
||||
const input = screen.getByRole("textbox", { name: "New branch name" });
|
||||
expect(input).toHaveValue("remote-only");
|
||||
await userEvent.type(input, "{Enter}");
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "remote-only",
|
||||
from: "origin/remote-only",
|
||||
});
|
||||
});
|
||||
|
||||
it("sets and unsets an upstream, and merges into the current branch", async () => {
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Set upstream/ }));
|
||||
await userEvent.selectOptions(screen.getByRole("combobox"), "origin/remote-only");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Apply/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
upstream: "origin/remote-only",
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: /Merge into main/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "merge", {
|
||||
branch: "feature/very-long-branch-name",
|
||||
});
|
||||
await userEvent.click(screen.getByText("main"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Unset upstream/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "branch-upstream", { branch: "main" });
|
||||
});
|
||||
|
||||
it("shows git's own words when a write is refused", async () => {
|
||||
vi.mocked(api.lanes.gitRefAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("branch x already exists"), { code: "EBRANCHEXISTS" })
|
||||
);
|
||||
setup();
|
||||
await userEvent.click(await screen.findByText("feature/very-long-branch-name"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^Rename$/ }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "New name" }), "x{Enter}");
|
||||
expect(await screen.findByTestId("branch-manager-error")).toHaveTextContent(
|
||||
"branch x already exists"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagManager (Tags tab)", () => {
|
||||
const openTags = async () => {
|
||||
setup();
|
||||
await screen.findByText("main");
|
||||
await userEvent.click(screen.getByRole("tab", { name: /Tags/ }));
|
||||
};
|
||||
|
||||
it("lists tags with their message and creates an annotated one", async () => {
|
||||
await openTags();
|
||||
expect(screen.getByText("v1")).toBeInTheDocument();
|
||||
expect(screen.getByText(/release one/)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /New tag/ }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /Tag name/ }), "v2");
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: /^On / }),
|
||||
"feature/very-long-branch-name"
|
||||
);
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /Message/ }), "notes");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Create annotated tag" }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-create", {
|
||||
tag: "v2",
|
||||
message: "notes",
|
||||
from: "feature/very-long-branch-name",
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes one tag and deletes one only with confirm: true", async () => {
|
||||
await openTags();
|
||||
await userEvent.click(screen.getByText("v1"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Push to origin/ }));
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-push", { tag: "v1" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
||||
expect(vi.mocked(confirmDialog).mock.lastCall?.[0].destructive).toBe(true);
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(3, "tag-delete", {
|
||||
tag: "v1",
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens from the git bar's button and closes on Escape", async () => {
|
||||
const { BranchesButton } = await import("../git/BranchesButton");
|
||||
render(<BranchesButton laneId={3} dirty={0} merging={false} onChanged={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Manage branches & tags" }));
|
||||
expect(await screen.findByTestId("branch-manager")).toBeInTheDocument();
|
||||
await screen.findByText("main");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("branch-manager")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not delete when the confirm is cancelled", async () => {
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await openTags();
|
||||
await userEvent.click(screen.getByText("v1"));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Delete tag/ }));
|
||||
expect(api.lanes.gitRefAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,19 @@
|
||||
/**
|
||||
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||
* server's clone queue, a running one git's phase, percent and speed with a
|
||||
* bar, and nothing shows before the server knows the token. Also that
|
||||
* `newCloneToken` makes a token the server accepts.
|
||||
* server's clone queue (with the machine-wide counts when it has them), a
|
||||
* running one git's phase, percent and speed with a bar, and a finished one
|
||||
* nothing — all rendered from a clone job's fields, no fetching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { CloneProgress, type CloneProgressFields } from "../CloneProgress";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneProgress: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type CloneProgressView } from "../../../lib/api";
|
||||
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||
|
||||
const base: CloneProgressView = {
|
||||
const base: CloneProgressFields = {
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 2,
|
||||
waiting: 1,
|
||||
limit: 2,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
@@ -30,50 +21,44 @@ const base: CloneProgressView = {
|
||||
};
|
||||
|
||||
describe("CloneProgress", () => {
|
||||
// Braces: a function returned from beforeEach is run as its teardown.
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||
it("shows the clone's place in the queue while it waits", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "queued", position: 2 }} />);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #2"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the clone's place in the queue while it waits", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
state: "queued",
|
||||
position: 1,
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||
it("adds the machine-wide counts when the view carries them", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{ ...base, state: "queued", position: 1, waiting: 1, running: 2, limit: 2 }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("clone-progress")).toHaveTextContent(
|
||||
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
});
|
||||
render(<CloneProgress token="t" />);
|
||||
const line = await screen.findByTestId("clone-progress");
|
||||
it("shows git's phase, percent and speed with a bar while it runs", () => {
|
||||
render(
|
||||
<CloneProgress
|
||||
view={{
|
||||
...base,
|
||||
phase: "receiving",
|
||||
label: "Receiving objects",
|
||||
percent: 45,
|
||||
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const line = screen.getByTestId("clone-progress");
|
||||
expect(line).toHaveTextContent("Downloading 45%");
|
||||
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
});
|
||||
|
||||
it("shows nothing while the server does not know the token yet", async () => {
|
||||
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||
);
|
||||
render(<CloneProgress token="t" />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
it("shows nothing once the clone finished", () => {
|
||||
render(<CloneProgress view={{ ...base, state: "done" }} />);
|
||||
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||
});
|
||||
|
||||
it("makes tokens the server accepts", () => {
|
||||
const a = newCloneToken();
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||
expect(newCloneToken()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,17 +14,23 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
api: { lanes: { cloneFolder: vi.fn(), sources: vi.fn(), cloneJobs: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||
// A finished clone (an older server's answer, or a job the store saw end)
|
||||
// makes the form read the folder list again.
|
||||
vi.mocked(api.lanes.sources).mockReset().mockResolvedValue(view);
|
||||
vi.mocked(api.lanes.cloneJobs).mockReset().mockResolvedValue({ jobs: [] });
|
||||
resetCloneJobsForTests();
|
||||
// Unknown identity: nothing is checked up front.
|
||||
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||
});
|
||||
@@ -49,7 +55,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
@@ -60,13 +66,52 @@ describe("CloneSourcesForm", () => {
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hands a background clone to the dock and re-reads the folders when it ends", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
const job = {
|
||||
id: "s1",
|
||||
kind: "source",
|
||||
state: "queued",
|
||||
url: "https://h/a.git",
|
||||
name: "a",
|
||||
parent: "/",
|
||||
target: "/a",
|
||||
laneId: 3,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: null,
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "https://h/a.git", parent: "/", name: "a" },
|
||||
} as const;
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue({ job: { ...job } });
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/a.git");
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
expect(await screen.findByText(/progress is in the corner/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
expect(onAdded).not.toHaveBeenCalled();
|
||||
// Another workspace's clone is not ours.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, laneId: 4, state: "done" } })
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.sources).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("asks for partial clones when the box is ticked", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||
@@ -76,7 +121,7 @@ describe("CloneSourcesForm", () => {
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
url: "https://h/big.git",
|
||||
partial: true,
|
||||
})
|
||||
@@ -112,9 +157,11 @@ describe("CloneSourcesForm", () => {
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||
url: "https://h.test/a.git",
|
||||
progressToken: expect.any(String),
|
||||
background: true,
|
||||
});
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,19 +3,26 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||
* batch over 500 MB before sending anything, restores from the user's own
|
||||
* batch over 500 MB before sending anything, leaves a repo's .git out of a
|
||||
* folder upload with one notice, restores from the user's own
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home); opens an HTML file as the page
|
||||
* instead of downloading it; New → Clone repository starts a background
|
||||
* clone (`background: true`), keeps a pre-start refusal in the form, and a
|
||||
* finished clone (the `agenthub:clone-job` event) re-reads the folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { api, type CloneJob, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { CLONE_JOB_EVENT, resetCloneJobsForTests } from "../../../lib/cloneJobs";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
@@ -28,6 +35,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneJobs: vi.fn(async () => ({ jobs: [] })),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -70,9 +78,30 @@ const src: FsListing = {
|
||||
truncated: false,
|
||||
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
|
||||
};
|
||||
const job: CloneJob = {
|
||||
id: "j1",
|
||||
kind: "workspace",
|
||||
state: "queued",
|
||||
url: "git@host:o/repo.git",
|
||||
name: "repo",
|
||||
parent: "/w/one",
|
||||
target: "/w/one/repo",
|
||||
laneId: null,
|
||||
position: 1,
|
||||
phase: null,
|
||||
label: null,
|
||||
percent: null,
|
||||
detail: null,
|
||||
error: null,
|
||||
result: { laneId: 9, path: "/w/one/repo" },
|
||||
createdAt: "",
|
||||
finishedAt: null,
|
||||
request: { url: "git@host:o/repo.git", parent: "/w/one", name: "repo" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetCloneJobsForTests();
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
@@ -133,6 +162,38 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
|
||||
});
|
||||
|
||||
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
|
||||
vi.mocked(api.files.list).mockResolvedValue({
|
||||
...root,
|
||||
entries: [
|
||||
...root.entries,
|
||||
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
|
||||
],
|
||||
});
|
||||
const body: Record<string, string> = {
|
||||
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
|
||||
"/dl/1/s.css": "p{color:red}",
|
||||
};
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
open();
|
||||
await screen.findByText("deck.html");
|
||||
fireEvent.doubleClick(row("deck.html"));
|
||||
const viewer = await screen.findByTestId("fx-html-viewer");
|
||||
await waitFor(() =>
|
||||
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
|
||||
"<style>p{color:red}</style>"
|
||||
)
|
||||
);
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
|
||||
fetchSpy.mockRestore();
|
||||
click.mockRestore();
|
||||
});
|
||||
|
||||
it("makes a folder inline", async () => {
|
||||
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
|
||||
const user = userEvent.setup();
|
||||
@@ -193,7 +254,8 @@ describe("FileExplorer", () => {
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -214,7 +276,8 @@ describe("FileExplorer", () => {
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
@@ -233,6 +296,23 @@ describe("FileExplorer", () => {
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const mk = (rel: string) => {
|
||||
const f = new File(["hi"], rel.split("/").pop()!);
|
||||
Object.defineProperty(f, "webkitRelativePath", { value: rel });
|
||||
return f;
|
||||
};
|
||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
@@ -405,6 +485,33 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a workspace whose folder was deleted out of the places", async () => {
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", folder_missing: true },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
await waitFor(() => expect(api.lanes.list).toHaveBeenCalled());
|
||||
await screen.findByText("one", { selector: "aside span" });
|
||||
expect(screen.queryByRole("button", { name: "two" })).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the last folder's entries when the next one cannot be read", async () => {
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => {
|
||||
if (p === "src") throw new Error("ENOENT: no such file or directory");
|
||||
return root;
|
||||
});
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
fireEvent.doubleClick(row("src"));
|
||||
await screen.findByText(/ENOENT/);
|
||||
expect(screen.queryByText("a.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
@@ -470,6 +577,59 @@ describe("FileExplorer", () => {
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
|
||||
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
await user.click(within(places).getByRole("button", { name: "two" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
|
||||
});
|
||||
|
||||
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [{ id: "me", label: "An", readOnly: false }],
|
||||
});
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{
|
||||
id: 3,
|
||||
title: "three",
|
||||
cwd: "/w/three",
|
||||
owner_id: 9,
|
||||
owner_name: "cara",
|
||||
owner_display_name: "Cara",
|
||||
},
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).getByText("Users")).toBeInTheDocument();
|
||||
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
|
||||
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
|
||||
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
|
||||
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorer — New → Clone repository", () => {
|
||||
@@ -479,12 +639,8 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
return screen.getByTestId("fx-clone-dialog");
|
||||
};
|
||||
|
||||
it("clones into the open folder of the workspace and says where", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||
created: true,
|
||||
path: "/w/one/src/repo",
|
||||
} as never);
|
||||
it("clones into the open folder of the workspace in the background", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: { ...job, state: "queued" } } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
fireEvent.doubleClick(await screen.findByText("src"));
|
||||
@@ -498,9 +654,36 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
background: true,
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
// Accepted: the dialog steps aside, the app-wide dock takes over.
|
||||
await waitFor(() => expect(screen.queryByTestId("fx-clone-dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("re-reads the folder and workspaces when a background clone ends", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const lists = vi.mocked(api.files.list).mock.calls.length;
|
||||
const laneReads = vi.mocked(api.lanes.list).mock.calls.length;
|
||||
window.dispatchEvent(new CustomEvent(CLONE_JOB_EVENT, { detail: { ...job, state: "done" } }));
|
||||
await waitFor(() => expect(vi.mocked(api.files.list).mock.calls.length).toBeGreaterThan(lists));
|
||||
expect(vi.mocked(api.lanes.list).mock.calls.length).toBeGreaterThan(laneReads);
|
||||
});
|
||||
|
||||
it("treats an older server's synchronous answer as a finished clone", async () => {
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -509,18 +692,16 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||
created: true,
|
||||
path: "/w/one/r",
|
||||
} as never);
|
||||
.mockResolvedValueOnce({ job } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
// A refusal before the clone starts stays in the form, with a login row.
|
||||
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent("git.example needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
@@ -528,6 +709,7 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
parent: "/w/one",
|
||||
background: true,
|
||||
credentials: { username: "ann", password: "tok", remember: true },
|
||||
})
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
@@ -84,6 +85,19 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitDownloadUrl: (id: number, path: string) =>
|
||||
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||
gitUpload: vi.fn(),
|
||||
gitHistory: {
|
||||
commit: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
fileHistory: vi.fn(),
|
||||
blameAt: vi.fn(),
|
||||
treeAt: vi.fn(),
|
||||
compare: vi.fn(),
|
||||
compareFile: vi.fn(),
|
||||
pickPlan: vi.fn(),
|
||||
sequence: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
act: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -153,6 +167,44 @@ function discardOf(path: string) {
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
const h = api.lanes.gitHistory;
|
||||
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
|
||||
vi.mocked(h.pick).mockReset();
|
||||
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
commits: shas.map((sha) => ({
|
||||
sha,
|
||||
short: sha.slice(0, 7),
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
})),
|
||||
branch: "feat/thing",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(h.fileHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
hasMore: false,
|
||||
commits: [
|
||||
{
|
||||
sha: "a".repeat(40),
|
||||
short: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
parents: [],
|
||||
status: "M",
|
||||
path: "server/lib/git-ops.js",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -382,7 +434,8 @@ describe("LaneChangesPanel", () => {
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
name: "Tick every file in server/lib",
|
||||
});
|
||||
expect(box).toBeChecked();
|
||||
// The default ticks land in an effect after the list renders.
|
||||
await waitFor(() => expect(box).toBeChecked());
|
||||
await user.click(box);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(folder);
|
||||
@@ -444,6 +497,84 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the NEW branch everywhere after a switch from the git bar", async () => {
|
||||
// The repo as the server sees it: which branch, which changes.
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
vi.mocked(api.lanes.changes).mockImplementation(async () =>
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => {
|
||||
if (action === "switch") branch = (body as { branch: string }).branch;
|
||||
return { action, ok: true, output: "", shelves: [] };
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
await screen.findByTitle("only-on-thing.ts");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("only-on-thing.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-reads when ANOTHER view of the lane changed its git state", async () => {
|
||||
let branch = "feat/thing";
|
||||
vi.mocked(api.lanes.git).mockImplementation(async () => ({
|
||||
available: true,
|
||||
branch,
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
}));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
const before = vi.mocked(api.lanes.changes).mock.calls.length;
|
||||
branch = "other";
|
||||
act(() => emitGitChanged(7, "switch"));
|
||||
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
|
||||
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before);
|
||||
// Another workspace's write is not this panel's business.
|
||||
const now = vi.mocked(api.lanes.git).mock.calls.length;
|
||||
act(() => emitGitChanged(8, "switch"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now);
|
||||
});
|
||||
|
||||
it("re-reads history too when Refresh is pressed there", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -543,6 +674,71 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "deck.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["<p>deck</p>"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("deck.html"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
const frame = await within(modal).findByTestId("html-frame");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
|
||||
// The source stayed where it was, under the modal.
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
|
||||
],
|
||||
})
|
||||
);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||
try {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await user.click(await screen.findByTitle("deck.html"));
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
await user.click(await screen.findByTestId("lane-file-preview"));
|
||||
const modal = await screen.findByTestId("html-viewer");
|
||||
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -771,22 +967,37 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
// The confirm button carries its count, and a tracked-only selection
|
||||
// says nothing about new files to delete.
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confirmLabel: "Discard (1)",
|
||||
message: expect.not.stringMatching(/new file|\{\{/),
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Nothing ticked: nothing to discard.
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
@@ -881,10 +1092,11 @@ describe("LaneChangesPanel", () => {
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 50,
|
||||
file: "server/lib/git-ops.js",
|
||||
})
|
||||
expect(api.lanes.gitHistory.fileHistory).toHaveBeenLastCalledWith(
|
||||
7,
|
||||
"server/lib/git-ops.js",
|
||||
{ limit: 100 }
|
||||
)
|
||||
);
|
||||
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
|
||||
"server/lib/git-ops.js"
|
||||
@@ -1011,7 +1223,7 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
await user.dblClick(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
@@ -1069,8 +1281,21 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "revert",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
from: "b".repeat(40),
|
||||
to: "c".repeat(40),
|
||||
commits: [{ sha: "c".repeat(40), short: "ccccccc", subject: 'Revert "x"' }],
|
||||
});
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "revert", ["a".repeat(40)])
|
||||
);
|
||||
|
||||
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
|
||||
await waitFor(() =>
|
||||
@@ -1107,10 +1332,22 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 100, ref: "other" })
|
||||
);
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
});
|
||||
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
|
||||
"a".repeat(40),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before a revert and an amend, and a No sends nothing", async () => {
|
||||
@@ -1126,9 +1363,11 @@ describe("LaneChangesPanel", () => {
|
||||
expect(confirmDialog).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
// A revert previews what it will do; Cancel sends nothing.
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
|
||||
await screen.findByTestId("pick-plan");
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1607,6 +1846,25 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
await user.click(
|
||||
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: [],
|
||||
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||
context: 3,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
|
||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — a row the server cut down", () => {
|
||||
const CUT: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("says how much of the row it hides, in both layouts", () => {
|
||||
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
unmount();
|
||||
render(<LaneDiffView file={CUT} layout="split" />);
|
||||
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* Changes panel's staged-only box owns that), that a dirty tree's switch asks
|
||||
* whether to bring the changes or shelve them (and offers the shelf when git
|
||||
* refuses to carry them), Create & switch, the offer to restore a branch's
|
||||
* shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list
|
||||
* is re-read after a fetch), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -201,16 +204,114 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
it("loads the branch list on demand and asks how to take the changes while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
// Nothing is sent before the user picks what happens to the changes.
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
const choice = screen.getByTestId("lane-git-switch-choice");
|
||||
expect(choice).toHaveTextContent("3 uncommitted file(s)");
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "carry",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Shelve and switch when git refuses to carry the changes", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("would be overwritten"), {
|
||||
code: "ECARRYCONFLICT",
|
||||
files: ["src/a-very-long-path/that/should/truncate.ts"],
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
|
||||
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
|
||||
"src/a-very-long-path/that/should/truncate.ts"
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "shelve",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before moving files under a working agent, then resends with confirm", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(
|
||||
Object.assign(new Error("agent"), { code: "EAGENTRUNNING" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "An agent is working here" })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", {
|
||||
branch: "development",
|
||||
changes: "shelve",
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => {
|
||||
const shelf = {
|
||||
ref: "stash@{0}",
|
||||
sha: "abc1234def",
|
||||
branch: "development",
|
||||
note: "",
|
||||
createdAt: "2026-11-02T00:00:00.000Z",
|
||||
files: [{ path: "a.txt", status: "M" }],
|
||||
};
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] })
|
||||
.mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] });
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
|
||||
);
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Restore shelved changes?" })
|
||||
);
|
||||
});
|
||||
|
||||
it("creates and switches in one step — the default on Enter while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "fix/ac-515",
|
||||
switch: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", async () => {
|
||||
@@ -236,6 +337,43 @@ describe("LaneGitBar", () => {
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
remotes: ["origin/feat/new"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
expect(
|
||||
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(select, "origin/feat/new");
|
||||
// Not a local branch yet: nothing to delete, no worktree to start on it.
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" })
|
||||
);
|
||||
|
||||
vi.mocked(api.lanes.gitBranches).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @file LaneShelves.test.tsx
|
||||
* @description Pins the Changes panel's Shelves list: hidden while there are
|
||||
* none, one collapsed line otherwise, each shelf's branch, note and files,
|
||||
* Restore sending `unshelve` by sha (and the conflict outcome told in a
|
||||
* toast), Delete needing a second click before `shelf-drop` with
|
||||
* `confirm: true`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneShelves } from "../LaneShelves";
|
||||
import { api } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } },
|
||||
}));
|
||||
|
||||
const shelf = {
|
||||
ref: "stash@{0}",
|
||||
sha: "abc1234def5678",
|
||||
branch: "fix/ac-515-a-rather-long-branch-name",
|
||||
note: "half way through the login fix",
|
||||
createdAt: "2026-11-02T03:04:05.000Z",
|
||||
files: [
|
||||
{ path: "src/login.ts", status: "M" },
|
||||
{ path: "src/new.ts", status: "??" },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitShelves)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
shelves: [shelf],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "unshelve",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneShelves", () => {
|
||||
it("renders nothing while there are no shelves", async () => {
|
||||
vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] });
|
||||
const { container } = render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("lists a shelf with its branch, note and files", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ }));
|
||||
const row = screen.getByTestId("lane-shelf");
|
||||
expect(row).toHaveTextContent(shelf.branch);
|
||||
expect(row).toHaveTextContent(shelf.note);
|
||||
await user.click(screen.getByRole("button", { name: "2 file(s)" }));
|
||||
expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts");
|
||||
});
|
||||
|
||||
it("restores by sha and reports a conflicted restore", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({
|
||||
action: "unshelve",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: ["src/login.ts"],
|
||||
kept: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={onChanged} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Restore" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha })
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file"));
|
||||
});
|
||||
|
||||
it("deletes only on the second click, with confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneShelves target={3} reread={0} onChanged={vi.fn()} />);
|
||||
await user.click(await screen.findByRole("button", { name: /Shelves/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Delete for good?" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", {
|
||||
sha: shelf.sha,
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file Blame / annotate at any revision (JetBrains' Annotate, GitLens'
|
||||
* blame): the file's lines with, on the first line of each run, the commit
|
||||
* that last changed it — short sha, author, relative date, summary. A commit
|
||||
* opens its whole change; "Blame before this change" re-reads the file at
|
||||
* that commit's parent (`previous`). Rows are windowed, so a 20 000-line
|
||||
* file scrolls as fast as a short one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { BlameAt } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { GitModal } from "./GitModal";
|
||||
|
||||
const ROW = 18;
|
||||
const OVERSCAN = 40;
|
||||
|
||||
export function BlameView({
|
||||
target,
|
||||
path,
|
||||
rev,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
/** A commit, or `work` for the file on disk. */
|
||||
rev: string;
|
||||
onClose: () => void;
|
||||
onOpenCommit: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [at, setAt] = useState({ path, rev });
|
||||
const [blame, setBlame] = useState<BlameAt | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setBlame(null);
|
||||
setError(null);
|
||||
void api.lanes.gitHistory
|
||||
.blameAt(target, at.path, at.rev)
|
||||
.then((r) => live && (r.available ? setBlame(r) : setError(t("noBlame"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
|
||||
}, [at.path, at.rev]);
|
||||
|
||||
// Shade alternate commit runs so a run reads as one block.
|
||||
const runs = useMemo(() => {
|
||||
const starts: boolean[] = [];
|
||||
const band: number[] = [];
|
||||
let b = 0;
|
||||
blame?.lines.forEach((l, i) => {
|
||||
const start = i === 0 || blame.lines[i - 1]!.sha !== l.sha;
|
||||
if (start && i) b++;
|
||||
starts.push(start);
|
||||
band.push(b);
|
||||
});
|
||||
return { starts, band };
|
||||
}, [blame]);
|
||||
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const height = 800;
|
||||
const n = blame?.lines.length ?? 0;
|
||||
const first = Math.max(0, Math.floor(top / ROW) - OVERSCAN);
|
||||
const last = Math.min(n, Math.ceil((top + height) / ROW) + OVERSCAN);
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
tall
|
||||
testId="blame-view"
|
||||
onClose={onClose}
|
||||
title={
|
||||
<span>
|
||||
{t("blameTitle", { path: at.path })}{" "}
|
||||
<span className="font-mono text-fg-muted">
|
||||
@ {at.rev === "work" ? t("workingTree") : at.rev.slice(0, 7)}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{error && <p className="text-[12px] text-status-danger [overflow-wrap:anywhere]">{error}</p>}
|
||||
{!blame && !error && (
|
||||
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{blame && (
|
||||
<div
|
||||
ref={box}
|
||||
onScroll={(e) => setTop(e.currentTarget.scrollTop)}
|
||||
className="min-h-0 flex-1 overflow-auto font-mono text-[11px] leading-[18px]"
|
||||
>
|
||||
<div style={{ height: n * ROW, position: "relative", minWidth: "100%" }}>
|
||||
{blame.lines.slice(first, last).map((l, k) => {
|
||||
const i = first + k;
|
||||
const c = l.sha ? blame.commits[l.sha] : null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="blame-line"
|
||||
style={{ position: "absolute", top: i * ROW, height: ROW, left: 0, right: 0 }}
|
||||
className={`flex ${runs.band[i]! % 2 ? "bg-surface-2/60" : ""}`}
|
||||
>
|
||||
<span className="flex w-[16rem] shrink-0 items-center gap-1 overflow-hidden border-r border-border-light pr-1 text-fg-muted">
|
||||
{runs.starts[i] &&
|
||||
(c && l.sha ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(l.sha!)}
|
||||
title={`${c.summary ?? ""}\n${c.author ?? ""} · ${c.date ? new Date(c.date).toLocaleString() : ""}`}
|
||||
className="shrink-0 text-accent hover:underline"
|
||||
>
|
||||
{c.short}
|
||||
</button>
|
||||
<span className="min-w-0 truncate font-sans" title={c.author}>
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="shrink-0 font-sans">
|
||||
{c.date ? timeAgo(c.date, true) : ""}
|
||||
</span>
|
||||
{c.previous && (
|
||||
<button
|
||||
type="button"
|
||||
title={t("blameBefore")}
|
||||
aria-label={t("blameBefore")}
|
||||
onClick={() => setAt({ path: at.path, rev: c.previous! })}
|
||||
className="shrink-0 font-sans hover:text-fg-primary"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-sans italic">{t("notCommitted")}</span>
|
||||
))}
|
||||
</span>
|
||||
<span className="w-10 shrink-0 select-none pr-1 text-right text-fg-muted">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre pl-1 text-fg-primary">{l.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* @file The git bar's Branches panel (JetBrains' branch popup, GitLens'
|
||||
* Branches view): a dialog over the workspace listing every local branch
|
||||
* (current marked, upstream with ahead/behind, last commit, why a delete would
|
||||
* be refused) and the fetched remote branches grouped by remote, filtered by a
|
||||
* search box and walked with the arrow keys; picking a row opens its actions —
|
||||
* new branch from it (optionally switching to it on a clean tree), rename,
|
||||
* set/unset upstream, merge into the current branch, delete. Its Tags tab is
|
||||
* `TagManager`. Everything it may do is decided by the server
|
||||
* (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled
|
||||
* button here is a courtesy, and git's own text is shown when it refuses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Link2,
|
||||
Link2Off,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Tag,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
gitTarget,
|
||||
type GitLocalBranch,
|
||||
type GitRefs,
|
||||
type GitRemoteBranch,
|
||||
type GitSource,
|
||||
} from "../../../lib/api";
|
||||
import { useDialog } from "../../../hooks/useDialog";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { TagManager } from "./TagManager";
|
||||
import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi";
|
||||
|
||||
type Mode = "new" | "rename" | "upstream" | null;
|
||||
|
||||
export function BranchManager({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
dirty,
|
||||
merging,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: GitSource;
|
||||
/** Uncommitted files: a "switch to it" is refused on a dirty tree. */
|
||||
dirty: number;
|
||||
/** A merge, cherry-pick or revert is open: every write here is refused. */
|
||||
merging: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after every write, so the git bar and the Changes panel re-read. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitRefs"]);
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
useDialog(true, panelRef, onClose, searchRef);
|
||||
|
||||
const target = gitTarget(laneId, session, source);
|
||||
const [refs, setRefs] = useState<GitRefs | null>(null);
|
||||
const [tab, setTab] = useState<"branches" | "tags">("branches");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<Mode>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [switchTo, setSwitchTo] = useState(false);
|
||||
const [upstream, setUpstream] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setRefs(await api.lanes.gitRefs(target));
|
||||
} catch {
|
||||
setRefs({
|
||||
available: false,
|
||||
current: null,
|
||||
head: null,
|
||||
local: [],
|
||||
remotes: [],
|
||||
tags: [],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [laneId, session, source]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run: RunRefAction = async (action, body, done) => {
|
||||
if (inFlight.current) return false;
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitRefAction(target, action, body);
|
||||
if (done) toast.success(done);
|
||||
setMode(null);
|
||||
setText("");
|
||||
await load();
|
||||
onChanged();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code;
|
||||
setError(
|
||||
code === "EGITAUTH"
|
||||
? t("errLogin")
|
||||
: code === "ESUBDIR"
|
||||
? t("errSubdir")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const match = (s: string) => !q || s.toLowerCase().includes(q);
|
||||
const local = useMemo(
|
||||
() => (refs?.local || []).filter((b) => match(b.name)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[refs, q]
|
||||
);
|
||||
const remoteGroups = useMemo(
|
||||
() =>
|
||||
(refs?.remotes || [])
|
||||
.map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) }))
|
||||
.filter((g) => g.branches.length),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[refs, q]
|
||||
);
|
||||
const allRemote = useMemo(
|
||||
() => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)),
|
||||
[refs]
|
||||
);
|
||||
const current = refs?.current ?? null;
|
||||
|
||||
const pick = (key: string) => {
|
||||
setSelected((s) => (s === key ? null : key));
|
||||
setMode(null);
|
||||
setText("");
|
||||
setError(null);
|
||||
};
|
||||
const open = (m: Mode, initial = "") => {
|
||||
setMode((v) => (v === m ? null : m));
|
||||
setText(initial);
|
||||
setSwitchTo(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const merge = async (branch: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("merge"),
|
||||
message: t("mergeConfirm", { branch, current: current ?? "HEAD" }),
|
||||
confirmLabel: t("merge"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok) await run("merge", { branch }, t("done.merge", { branch }));
|
||||
};
|
||||
const remove = async (b: GitLocalBranch) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("delete"),
|
||||
message: b.merged
|
||||
? t("deleteConfirm", { branch: b.name })
|
||||
: t("deleteUnmerged", { branch: b.name }),
|
||||
confirmLabel: t("delete"),
|
||||
destructive: true,
|
||||
});
|
||||
if (ok) {
|
||||
if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name })))
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
|
||||
/** Why `git branch -d` would refuse this branch, or null. */
|
||||
const deleteBlock = (b: GitLocalBranch) =>
|
||||
b.current
|
||||
? t("why.current")
|
||||
: b.protected
|
||||
? t("why.protected")
|
||||
: b.worktree
|
||||
? t("why.worktree", { path: b.worktree })
|
||||
: null;
|
||||
|
||||
const newBranchForm = (from: string) => (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && text.trim())
|
||||
void run(
|
||||
"branch-create",
|
||||
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
|
||||
t("done.create", { branch: text.trim() })
|
||||
);
|
||||
}}
|
||||
placeholder={t("newBranchName")}
|
||||
aria-label={t("newBranchName")}
|
||||
className={smallInput}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={switchTo}
|
||||
onChange={setSwitchTo}
|
||||
disabled={dirty > 0 || merging}
|
||||
label={t("switchToIt")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !text.trim()}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"branch-create",
|
||||
{ branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) },
|
||||
t("done.create", { branch: text.trim() })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("create")}
|
||||
</button>
|
||||
{dirty > 0 && <span className="text-[10px] text-fg-muted">{t("switchDirty")}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const localActions = (b: GitLocalBranch) => {
|
||||
const block = deleteBlock(b);
|
||||
return (
|
||||
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button type="button" className={smallBtn} disabled={busy} onClick={() => open("new")}>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("newFrom")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || b.protected || merging}
|
||||
title={b.protected ? t("why.protected") : t("renameHint")}
|
||||
onClick={() => open("rename", b.name)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || !allRemote.length}
|
||||
title={allRemote.length ? t("upstreamHint") : t("noRemoteBranches")}
|
||||
onClick={() => {
|
||||
open("upstream");
|
||||
setUpstream(b.upstream ?? "");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{t("setUpstream")}
|
||||
</button>
|
||||
{b.upstream && (
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging}
|
||||
onClick={() =>
|
||||
void run("branch-upstream", { branch: b.name }, t("done.unsetUpstream"))
|
||||
}
|
||||
>
|
||||
<Link2Off className="h-3 w-3" />
|
||||
{t("unsetUpstream")}
|
||||
</button>
|
||||
)}
|
||||
{!b.current && (
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || dirty > 0 || !current}
|
||||
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
|
||||
onClick={() => void merge(b.name)}
|
||||
>
|
||||
<GitMerge className="h-3 w-3" />
|
||||
{t("mergeInto", { current: current ?? "HEAD" })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${smallBtn} hover:text-status-danger`}
|
||||
disabled={busy || !!block}
|
||||
title={block ?? (b.merged ? t("deleteHint") : t("why.unmerged"))}
|
||||
onClick={() => void remove(b)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("delete")}
|
||||
</button>
|
||||
</div>
|
||||
{block && <p className="text-[10px] text-fg-muted [overflow-wrap:anywhere]">{block}</p>}
|
||||
{!block && !b.merged && (
|
||||
<p className="text-[10px] text-status-warning">{t("why.unmerged")}</p>
|
||||
)}
|
||||
{mode === "new" && newBranchForm(b.name)}
|
||||
{mode === "rename" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name)
|
||||
void run(
|
||||
"branch-rename",
|
||||
{ branch: b.name, name: text.trim() },
|
||||
t("done.rename", { branch: text.trim() })
|
||||
);
|
||||
}}
|
||||
aria-label={t("renameTo")}
|
||||
placeholder={t("renameTo")}
|
||||
className={smallInput}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !text.trim() || text.trim() === b.name}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"branch-rename",
|
||||
{ branch: b.name, name: text.trim() },
|
||||
t("done.rename", { branch: text.trim() })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("rename")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === "upstream" && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
value={upstream}
|
||||
onChange={(e) => setUpstream(e.target.value)}
|
||||
aria-label={t("setUpstream")}
|
||||
className="select min-w-0 flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("pickUpstream")}</option>
|
||||
{allRemote.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !upstream || upstream === b.upstream}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"branch-upstream",
|
||||
{ branch: b.name, upstream },
|
||||
t("done.setUpstream", { upstream })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("apply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const remoteActions = (r: GitRemoteBranch) => (
|
||||
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy}
|
||||
onClick={() => open("new", r.branch)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("newFrom")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || dirty > 0 || !current}
|
||||
title={dirty > 0 ? t("switchDirty") : t("mergeHint", { current: current ?? "" })}
|
||||
onClick={() => void merge(r.name)}
|
||||
>
|
||||
<GitMerge className="h-3 w-3" />
|
||||
{t("mergeInto", { current: current ?? "HEAD" })}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "new" && newBranchForm(r.name)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const row = (
|
||||
key: string,
|
||||
name: string,
|
||||
sub: React.ReactNode,
|
||||
badges: React.ReactNode,
|
||||
actions: () => React.ReactNode
|
||||
) => (
|
||||
<li key={key} className={selected === key ? "rounded bg-surface-2" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
data-ref-row
|
||||
aria-expanded={selected === key}
|
||||
onClick={() => pick(key)}
|
||||
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||
>
|
||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span title={name} className="truncate font-mono text-xs text-fg-primary">
|
||||
{name}
|
||||
</span>
|
||||
{badges}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-fg-muted">{sub}</span>
|
||||
</span>
|
||||
</button>
|
||||
{selected === key && actions()}
|
||||
</li>
|
||||
);
|
||||
|
||||
const badge = (label: string, tone = "text-fg-muted border-border-light") => (
|
||||
<span className={`shrink-0 rounded border px-1 text-[9px] uppercase ${tone}`}>{label}</span>
|
||||
);
|
||||
|
||||
const localRow = (b: GitLocalBranch) =>
|
||||
row(
|
||||
`l:${b.name}`,
|
||||
b.name,
|
||||
<>
|
||||
{b.subject}
|
||||
{b.date ? ` · ${timeAgo(b.date)}` : ""}
|
||||
</>,
|
||||
<>
|
||||
{b.current && badge(t("current"), "text-accent border-accent/40")}
|
||||
{b.protected && badge(t("protected"))}
|
||||
{b.worktree && (
|
||||
<span title={b.worktree} className="shrink-0">
|
||||
{badge(t("worktree"))}
|
||||
</span>
|
||||
)}
|
||||
{b.upstream && (
|
||||
<span
|
||||
title={b.upstream}
|
||||
className="flex shrink-0 items-center gap-0.5 font-mono text-[10px] text-fg-muted"
|
||||
>
|
||||
{b.gone ? (
|
||||
<span className="text-status-warning">{t("gone")}</span>
|
||||
) : (
|
||||
<>
|
||||
{(b.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
<ArrowUp className="h-3 w-3" aria-label={t("ahead")} />
|
||||
{b.ahead}
|
||||
</span>
|
||||
)}
|
||||
{(b.behind ?? 0) > 0 && (
|
||||
<span className="flex items-center text-accent">
|
||||
<ArrowDown className="h-3 w-3" aria-label={t("behind")} />
|
||||
{b.behind}
|
||||
</span>
|
||||
)}
|
||||
{!b.ahead && !b.behind && <Check className="h-3 w-3" aria-label={t("inSync")} />}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>,
|
||||
() => localActions(b)
|
||||
);
|
||||
|
||||
const remoteRow = (r: GitRemoteBranch) =>
|
||||
row(
|
||||
`r:${r.name}`,
|
||||
r.name,
|
||||
<>
|
||||
{r.subject}
|
||||
{r.date ? ` · ${timeAgo(r.date)}` : ""}
|
||||
</>,
|
||||
null,
|
||||
() => remoteActions(r)
|
||||
);
|
||||
|
||||
const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
setSelected(null);
|
||||
setMode(null);
|
||||
setError(null);
|
||||
}}
|
||||
className={`flex items-center gap-1 border-b-2 px-2 py-1 text-xs ${
|
||||
tab === id
|
||||
? "border-accent text-fg-primary"
|
||||
: "border-transparent text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[55] flex items-start justify-center bg-black/60 p-4 sm:pt-16"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid="branch-manager"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border-light px-3 pt-2">
|
||||
<h3 id={titleId} className="sr-only">
|
||||
{t("title")}
|
||||
</h3>
|
||||
<div role="tablist" className="flex min-w-0 flex-1 gap-1">
|
||||
{tabBtn("branches", t("branches"), <GitBranch className="h-3.5 w-3.5" />)}
|
||||
{tabBtn("tags", t("tags"), <Tag className="h-3.5 w-3.5" />)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon -mt-1"
|
||||
aria-label={t("close")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
panelRef.current?.querySelector<HTMLElement>("[data-ref-row]")?.focus();
|
||||
}
|
||||
}}
|
||||
placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")}
|
||||
aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")}
|
||||
className={`${smallInput} !text-xs`}
|
||||
/>
|
||||
</div>
|
||||
{merging && <p className="px-3 pb-1 text-[11px] text-status-warning">{t("merging")}</p>}
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
data-testid="branch-manager-error"
|
||||
className="mx-3 mb-2 max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3" onKeyDown={onListKeys}>
|
||||
{!refs ? (
|
||||
<p className="px-2 py-4 text-xs text-fg-muted">{t("loading")}</p>
|
||||
) : !refs.available ? (
|
||||
<p className="px-2 py-4 text-xs text-fg-muted">{t("unavailable")}</p>
|
||||
) : tab === "tags" ? (
|
||||
<TagManager
|
||||
tags={(refs.tags || []).filter((x) => match(x.name))}
|
||||
busy={busy}
|
||||
merging={merging}
|
||||
dirty={dirty}
|
||||
run={run}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="px-2 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("local")}
|
||||
</h4>
|
||||
{local.length ? (
|
||||
<ul>{local.map(localRow)}</ul>
|
||||
) : (
|
||||
<p className="px-2 text-xs text-fg-muted">{t("noMatch")}</p>
|
||||
)}
|
||||
{remoteGroups.map((g) => (
|
||||
<section key={g.remote}>
|
||||
<h4
|
||||
title={g.remote}
|
||||
className="truncate px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-fg-muted"
|
||||
>
|
||||
{t("remote", { remote: g.remote })}
|
||||
</h4>
|
||||
<ul>{g.branches.map(remoteRow)}</ul>
|
||||
</section>
|
||||
))}
|
||||
{refs.truncated && (
|
||||
<p className="px-2 pt-2 text-[10px] text-fg-muted">{t("truncated")}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BranchManager;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file The git bar's "Branches & tags" icon button, which owns the Branches
|
||||
* panel (`BranchManager`) it opens — portalled to `document.body`, so no
|
||||
* scrolling or transformed ancestor of the Changes panel can clip it. The
|
||||
* panel closes when the bar starts showing another workspace.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tags } from "lucide-react";
|
||||
import type { GitSource } from "../../../lib/api";
|
||||
import { BranchManager } from "./BranchManager";
|
||||
|
||||
export function BranchesButton({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
dirty,
|
||||
merging,
|
||||
disabled,
|
||||
onChanged,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: GitSource;
|
||||
dirty: number;
|
||||
merging: boolean;
|
||||
disabled?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitRefs"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [shown, setShown] = useState(`${laneId}|${session ?? ""}|${source ?? ""}`);
|
||||
const key = `${laneId}|${session ?? ""}|${source ?? ""}`;
|
||||
if (shown !== key) {
|
||||
setShown(key);
|
||||
setOpen(false);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
title={t("openPanelHint")}
|
||||
aria-label={t("openPanel")}
|
||||
aria-haspopup="dialog"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Tags className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<BranchManager
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
merging={merging}
|
||||
onClose={() => setOpen(false)}
|
||||
onChanged={onChanged}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BranchesButton;
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @file Browse files at a revision (GitHub's "Browse files", GitLens' "Open
|
||||
* files at revision"): a read-only tree of the workspace folder as it was at
|
||||
* one commit — folders open on a click, read lazily — and the picked file's
|
||||
* text (or image) beside it, with Blame at that commit. Every read goes
|
||||
* through the server's own guards: a secret file is neither listed nor
|
||||
* readable, a big file is capped, a binary one is named, not shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, File, Folder } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { TreeAtEntry } from "../../../lib/gitHistory";
|
||||
import type { LaneFileContent } from "../../../lib/types";
|
||||
import { LaneFileView } from "../LaneDiffView";
|
||||
import { GitModal } from "./GitModal";
|
||||
import { BlameView } from "./BlameView";
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;
|
||||
|
||||
export function BrowseAtRevision({
|
||||
target,
|
||||
sha,
|
||||
short,
|
||||
onClose,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
short: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
// Folder path → its entries; "" is the workspace folder.
|
||||
const [tree, setTree] = useState<Record<string, TreeAtEntry[]>>({});
|
||||
const [open, setOpen] = useState<Set<string>>(new Set([""]));
|
||||
const [file, setFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<LaneFileContent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blame, setBlame] = useState(false);
|
||||
|
||||
const readDir = (dir: string) => {
|
||||
if (tree[dir]) return;
|
||||
void api.lanes.gitHistory
|
||||
.treeAt(target, sha, dir)
|
||||
.then((r) => r.available && setTree((m) => ({ ...m, [dir]: r.entries })))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
useEffect(() => {
|
||||
readDir("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per commit
|
||||
}, [sha]);
|
||||
|
||||
const openFile = (p: string) => {
|
||||
setFile(p);
|
||||
setContent(null);
|
||||
setError(null);
|
||||
if (IMAGE.test(p)) return;
|
||||
void api.lanes
|
||||
.gitFile(target, p, sha)
|
||||
.then(setContent)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
const toggleDir = (p: string) => {
|
||||
setOpen((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
return next;
|
||||
});
|
||||
readDir(p);
|
||||
};
|
||||
|
||||
const rows = (dir: string, depth: number): ReactNode =>
|
||||
(tree[dir] ?? []).map((e) => (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (e.dir ? toggleDir(e.path) : e.submodule ? undefined : openFile(e.path))}
|
||||
title={e.path}
|
||||
style={{ paddingLeft: depth * 12 + 4 }}
|
||||
className={`flex w-full items-center gap-1 rounded py-0.5 pr-1 text-left text-[11px] hover:bg-surface-2 ${
|
||||
file === e.path ? "bg-surface-3 text-fg-primary" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
open.has(e.path) ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
{e.dir ? (
|
||||
<Folder className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
) : (
|
||||
<File className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{e.name}</span>
|
||||
</button>
|
||||
{e.dir && open.has(e.path) && <ul>{rows(e.path, depth + 1)}</ul>}
|
||||
</li>
|
||||
));
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
tall
|
||||
testId="browse-at-revision"
|
||||
onClose={onClose}
|
||||
title={t("browseTitle", { short })}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 sm:flex-row">
|
||||
<ul
|
||||
data-testid="browse-tree"
|
||||
className="max-h-[35vh] shrink-0 overflow-auto rounded border border-border-light p-1 sm:max-h-none sm:w-64"
|
||||
>
|
||||
{tree[""] ? (
|
||||
rows("", 0)
|
||||
) : (
|
||||
<li className="text-[11px] text-fg-muted">{t("lanes:changes.loading")}</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{file && (
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span className="min-w-0 flex-1 font-mono text-fg-primary [overflow-wrap:anywhere]">
|
||||
{file}
|
||||
</span>
|
||||
{!IMAGE.test(file) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame(true)}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!file && !error && <p className="text-[12px] text-fg-muted">{t("pickAFile")}</p>}
|
||||
{file && IMAGE.test(file) && (
|
||||
<img
|
||||
src={api.lanes.gitRawUrl(target, file, sha)}
|
||||
alt={file}
|
||||
className="max-h-full max-w-full self-start object-contain"
|
||||
/>
|
||||
)}
|
||||
{file && content && content.available && content.binary && (
|
||||
<p className="text-[12px] text-fg-muted">{t("binaryFile")}</p>
|
||||
)}
|
||||
{file && content && content.available && !content.binary && (
|
||||
<LaneFileView path={file} lines={content.lines} truncated={content.truncated} fill />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{blame && file && (
|
||||
<BlameView
|
||||
target={target}
|
||||
path={file}
|
||||
rev={sha}
|
||||
onClose={() => setBlame(false)}
|
||||
onOpenCommit={() => setBlame(false)}
|
||||
/>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file The confirmation a cherry-pick or revert opens with, showing exactly
|
||||
* what will happen before anything does (the reference tools' preview): the
|
||||
* commits in the order git will apply them (numbered — read from the server,
|
||||
* which orders them the same way the action will), the branch they land on,
|
||||
* whether the working tree is clean, and what happens on a conflict. Confirm
|
||||
* is disabled, with the reason, when the server would refuse (a dirty tree, a
|
||||
* subfolder workspace, another operation open, a merge commit).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
|
||||
import { GitModal, ModalButton } from "./GitModal";
|
||||
|
||||
export function CherryPickDialog({
|
||||
target,
|
||||
op,
|
||||
shas,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
op: "cherry-pick" | "revert";
|
||||
shas: string[];
|
||||
onClose: () => void;
|
||||
onDone: (r: PickResult) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [plan, setPlan] = useState<PickPlan | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.pickPlan(target, op, shas)
|
||||
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
|
||||
}, []);
|
||||
|
||||
const branch = plan?.branch ?? "HEAD";
|
||||
const blocker = !plan
|
||||
? null
|
||||
: plan.busy
|
||||
? t("blockBusy")
|
||||
: !plan.repoRoot
|
||||
? t("blockSubdir")
|
||||
: !plan.clean
|
||||
? t("blockDirty")
|
||||
: null;
|
||||
const n = plan?.commits.length ?? shas.length;
|
||||
const title =
|
||||
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(
|
||||
await api.lanes.gitHistory.pick(
|
||||
target,
|
||||
op,
|
||||
plan!.commits.map((c) => c.sha)
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
testId="pick-dialog"
|
||||
footer={
|
||||
<>
|
||||
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
||||
<ModalButton
|
||||
primary
|
||||
testId="pick-confirm"
|
||||
disabled={!plan || !!blocker || running}
|
||||
title={blocker ?? undefined}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{running
|
||||
? t("running")
|
||||
: op === "revert"
|
||||
? t("revertN", { count: n })
|
||||
: t("pickN", { count: n, branch })}
|
||||
</ModalButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 text-[12px] text-fg-secondary">
|
||||
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
|
||||
{plan && (
|
||||
<>
|
||||
<p className="[overflow-wrap:anywhere]">
|
||||
{op === "revert"
|
||||
? t("revertIntro", { count: n, branch })
|
||||
: t("pickIntro", { count: n, branch })}
|
||||
</p>
|
||||
<ol
|
||||
data-testid="pick-plan"
|
||||
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
|
||||
>
|
||||
{plan.commits.map((c, i) => (
|
||||
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="flex items-center gap-1.5">
|
||||
{plan.clean && plan.repoRoot && !plan.busy ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
|
||||
{blocker ?? t("treeClean")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
|
||||
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file The History tab's commit details pane (GitKraken's / JetBrains'
|
||||
* right-hand commit panel): the full message, author and committer with
|
||||
* dates, parents (each opens that commit), the commit's refs and the branches
|
||||
* and tags that contain it, its changed files with +/- counts (a file opens
|
||||
* that commit's diff of it in the panel's own diff viewer), and Copy SHA,
|
||||
* Copy patch, Browse files, Compare with current and View all changes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { CommitDetails as Details } from "../../../lib/gitHistory";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
import { RefChip, sortBadges } from "./CommitGraph";
|
||||
|
||||
export function CommitDetails({
|
||||
target,
|
||||
sha,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
onViewChanges,
|
||||
onOpenFile,
|
||||
onBrowse,
|
||||
onCompare,
|
||||
onCopyPatch,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
onClose: () => void;
|
||||
/** A parent clicked: focus that commit. */
|
||||
onOpenCommit: (sha: string) => void;
|
||||
onViewChanges: () => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
onBrowse: () => void;
|
||||
onCompare: () => void;
|
||||
onCopyPatch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [details, setDetails] = useState<Details | null>(null);
|
||||
const [files, setFiles] = useState<LaneChangeFile[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.commit(target, sha)
|
||||
.then((r) => live && (r.available ? setDetails(r) : setError(t("noDetails"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
void api.lanes
|
||||
.changes(target, { range: "commit", sha })
|
||||
.then((r) => live && setFiles(r.available ? r.files : []))
|
||||
.catch(() => live && setFiles([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on sha by the parent
|
||||
}, [sha]);
|
||||
|
||||
const button =
|
||||
"shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary";
|
||||
const added = files?.reduce((n, f) => n + (f.added ?? 0), 0) ?? 0;
|
||||
const deleted = files?.reduce((n, f) => n + (f.deleted ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="commit-details"
|
||||
aria-label={t("detailsTitle")}
|
||||
className="max-h-[45%] min-h-[6rem] shrink-0 overflow-y-auto rounded border border-border-light bg-surface-0 p-2 text-[11px]"
|
||||
>
|
||||
<div className="mb-1 flex items-start gap-1.5">
|
||||
<span className="font-mono text-fg-muted">{sha.slice(0, 10)}</span>
|
||||
<span className="min-w-0 flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("close")}
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-status-danger [overflow-wrap:anywhere]">{error}</p>}
|
||||
{!details && !error && <p className="text-fg-muted">{t("lanes:changes.loading")}</p>}
|
||||
{details && (
|
||||
<>
|
||||
<p
|
||||
data-testid="commit-details-message"
|
||||
className="mb-1.5 whitespace-pre-wrap text-fg-primary [overflow-wrap:anywhere]"
|
||||
>
|
||||
{details.message}
|
||||
</p>
|
||||
<dl className="mb-1.5 grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 text-fg-secondary">
|
||||
<dt className="text-fg-muted">{t("author")}</dt>
|
||||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{details.author} <{details.email}> · {new Date(details.date).toLocaleString()}
|
||||
</dd>
|
||||
{(details.committer !== details.author || details.committedAt !== details.date) && (
|
||||
<>
|
||||
<dt className="text-fg-muted">{t("committer")}</dt>
|
||||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{details.committer} · {new Date(details.committedAt).toLocaleString()}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-fg-muted">{t("parents", { count: details.parents.length })}</dt>
|
||||
<dd className="flex min-w-0 flex-wrap gap-1">
|
||||
{details.parents.length === 0 && <span>{t("rootCommit")}</span>}
|
||||
{details.parents.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(p)}
|
||||
className="font-mono text-accent hover:underline"
|
||||
>
|
||||
{p.slice(0, 7)}
|
||||
</button>
|
||||
))}
|
||||
</dd>
|
||||
{details.badges.length > 0 && (
|
||||
<>
|
||||
<dt className="text-fg-muted">{t("refs")}</dt>
|
||||
<dd className="flex min-w-0 flex-wrap gap-1">
|
||||
{sortBadges(details.badges).map((b) => (
|
||||
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
|
||||
))}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{(details.branches.length > 0 || details.tags.length > 0) && (
|
||||
<>
|
||||
<dt className="text-fg-muted">{t("containedIn")}</dt>
|
||||
<dd className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{[...details.branches, ...details.tags.map((x) => `tag ${x}`)].join(", ")}
|
||||
{details.containsTruncated ? " …" : ""}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<button type="button" className={button} onClick={onViewChanges}>
|
||||
{t("viewAllChanges")}
|
||||
</button>
|
||||
<button type="button" className={button} onClick={() => void copyText(details.sha)}>
|
||||
{t("lanes:changes.copySha")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={details.parents.length > 1}
|
||||
onClick={onCopyPatch}
|
||||
>
|
||||
{t("copyPatch")}
|
||||
</button>
|
||||
<button type="button" className={button} onClick={onBrowse}>
|
||||
{t("browseFiles")}
|
||||
</button>
|
||||
<button type="button" className={button} onClick={onCompare}>
|
||||
{t("compareWithCurrent")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{files && (
|
||||
<>
|
||||
<p className="mb-0.5 text-fg-muted">
|
||||
{t("filesChanged", { count: files.length })}{" "}
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</p>
|
||||
<ul data-testid="commit-details-files">
|
||||
{files.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenFile(f.path)}
|
||||
title={f.path}
|
||||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
|
||||
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
|
||||
{f.path}
|
||||
</span>
|
||||
{f.binary ? (
|
||||
<span className="shrink-0 text-fg-muted">{t("binary")}</span>
|
||||
) : (
|
||||
<span className="shrink-0 font-mono">
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file The History tab's commit rows, drawn like VS Code's Source Control
|
||||
* Graph / GitLens: the graph cell (lanes and merge edges from
|
||||
* `lib/commitGraph`), the ref badges (current branch, local and remote
|
||||
* branches, tags, a detached HEAD), the subject, author and relative date.
|
||||
* Colours are theme tokens (CSS variables), so a lane reads in every theme.
|
||||
* Long subjects and names truncate with the whole text in a tooltip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cloud, GitBranch, Tag, Crosshair } from "lucide-react";
|
||||
import type { GraphRow } from "../../../lib/commitGraph";
|
||||
import type { RefBadge } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
|
||||
/** One fixed-height row per commit so the lanes join up row to row. */
|
||||
export const GRAPH_ROW = 30;
|
||||
const GRAPH_COL = 12;
|
||||
/** Columns drawn at most; a wider graph is clipped, not the row. */
|
||||
const MAX_COLS = 12;
|
||||
/** Theme tokens, cycled per lane. */
|
||||
const GRAPH_COLORS = [
|
||||
"rgb(var(--accent))",
|
||||
"rgb(var(--status-success))",
|
||||
"rgb(var(--status-warning))",
|
||||
"rgb(var(--syn-property))",
|
||||
"rgb(var(--status-danger))",
|
||||
"rgb(var(--syn-function))",
|
||||
"rgb(var(--kept))",
|
||||
"rgb(var(--diff-mod))",
|
||||
];
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
|
||||
export function GraphCell({
|
||||
row,
|
||||
width,
|
||||
merge,
|
||||
head,
|
||||
}: {
|
||||
row: GraphRow;
|
||||
width: number;
|
||||
/** A merge commit: a hollow node. */
|
||||
merge?: boolean;
|
||||
/** HEAD: a ringed node. */
|
||||
head?: boolean;
|
||||
}) {
|
||||
const cols = Math.min(width, MAX_COLS);
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={cols * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0 overflow-hidden"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{head && (
|
||||
<circle
|
||||
cx={x(row.col)}
|
||||
cy={y(0.5)}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke={hue(row.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={x(row.col)}
|
||||
cy={y(0.5)}
|
||||
r={3.5}
|
||||
fill={merge ? "rgb(var(--surface-1))" : hue(row.color)}
|
||||
stroke={hue(row.color)}
|
||||
strokeWidth={merge ? 1.5 : 0}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ref badge: the kind's icon, the name truncated, the full name on hover. */
|
||||
export function RefChip({ badge }: { badge: RefBadge }) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const Icon =
|
||||
badge.kind === "tag"
|
||||
? Tag
|
||||
: badge.kind === "remote"
|
||||
? Cloud
|
||||
: badge.kind === "head"
|
||||
? Crosshair
|
||||
: GitBranch;
|
||||
const tone = badge.current
|
||||
? "border-accent/60 bg-accent/15 text-accent font-semibold"
|
||||
: badge.kind === "tag"
|
||||
? "border-status-warning/50 text-status-warning"
|
||||
: badge.kind === "remote"
|
||||
? "border-border text-fg-muted"
|
||||
: badge.kind === "head"
|
||||
? "border-status-danger/50 text-status-danger"
|
||||
: "border-status-success/50 text-status-success";
|
||||
const label = t(`badge.${badge.kind}`, { name: badge.name });
|
||||
return (
|
||||
<span
|
||||
title={badge.current ? t("badge.current", { name: badge.name }) : label}
|
||||
data-testid="ref-badge"
|
||||
data-kind={badge.kind}
|
||||
className={`inline-flex max-w-[10rem] shrink items-center gap-0.5 rounded border px-1 text-[10px] leading-4 ${tone}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{badge.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Badges in the order the reference tools show them: HEAD's branch first. */
|
||||
export function sortBadges(badges: RefBadge[]): RefBadge[] {
|
||||
const rank = (b: RefBadge) =>
|
||||
b.current ? 0 : b.kind === "head" ? 1 : b.kind === "local" ? 2 : b.kind === "remote" ? 3 : 4;
|
||||
return [...badges].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export interface GraphCommit {
|
||||
sha: string;
|
||||
short: string;
|
||||
subject: string;
|
||||
author: string;
|
||||
email?: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
badges?: RefBadge[];
|
||||
}
|
||||
|
||||
/** The text half of a history row (the graph cell is drawn beside it). */
|
||||
export const CommitText = memo(function CommitText({ c }: { c: GraphCommit }) {
|
||||
const badges = sortBadges(c.badges ?? []);
|
||||
const when = new Date(c.date);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
{badges.slice(0, 4).map((b) => (
|
||||
<RefChip key={`${b.kind}:${b.name}`} badge={b} />
|
||||
))}
|
||||
{badges.length > 4 && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-fg-muted"
|
||||
title={badges
|
||||
.slice(4)
|
||||
.map((b) => b.name)
|
||||
.join(", ")}
|
||||
>
|
||||
+{badges.length - 4}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 text-[10px] text-fg-muted">
|
||||
<span className="shrink-0 font-mono">{c.short}</span>
|
||||
<span className="min-w-0 truncate" title={c.email ? `${c.author} <${c.email}>` : c.author}>
|
||||
{c.author}
|
||||
</span>
|
||||
<span className="shrink-0" title={when.toLocaleString()}>
|
||||
{timeAgo(c.date)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @file Compare any two refs (GitLens' Search & Compare, GitHub's compare
|
||||
* view): branches, tags or commits A and B; the commits only in A and only in
|
||||
* B, and the files that differ — "Changes since they diverged" (A...B, what B
|
||||
* adds; GitHub's default) or "Difference between the two" (A B, the trees as
|
||||
* they stand). A file opens its diff in the panel's diff viewer; a commit
|
||||
* opens its whole change. Swap flips the two sides.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { CompareResult, CompareFile } from "../../../lib/gitHistory";
|
||||
import { LaneDiffView } from "../LaneDiffView";
|
||||
import { GitModal } from "./GitModal";
|
||||
|
||||
type Tab = "files" | "onlyA" | "onlyB";
|
||||
|
||||
export function CompareRefs({
|
||||
target,
|
||||
initialA,
|
||||
initialB,
|
||||
refs,
|
||||
onClose,
|
||||
onOpenCommit,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
initialA: string;
|
||||
initialB: string;
|
||||
/** Branch names offered in the inputs' suggestion lists. */
|
||||
refs: string[];
|
||||
onClose: () => void;
|
||||
onOpenCommit: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [a, setA] = useState(initialA);
|
||||
const [b, setB] = useState(initialB);
|
||||
const [asked, setAsked] = useState({ a: initialA, b: initialB });
|
||||
const [mode, setMode] = useState<"three" | "two">("three");
|
||||
const [result, setResult] = useState<CompareResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("files");
|
||||
const [file, setFile] = useState<CompareFile["file"] | null>(null);
|
||||
const [fileLoading, setFileLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setFile(null);
|
||||
void api.lanes.gitHistory
|
||||
.compare(target, asked.a, asked.b, mode)
|
||||
.then((r) => live && (r.available ? setResult(r) : setError(t("compareUnavailable"))))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable
|
||||
}, [asked, mode]);
|
||||
|
||||
const openFile = (path: string, from?: string) => {
|
||||
if (!result) return;
|
||||
setFileLoading(true);
|
||||
void api.lanes.gitHistory
|
||||
.compareFile(target, result.a, result.b, mode, path, from)
|
||||
.then((r) => r.available && setFile(r.file))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setFileLoading(false));
|
||||
};
|
||||
|
||||
const input =
|
||||
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5 font-mono text-[11px] text-fg-primary";
|
||||
const tabBtn = (k: Tab, label: string) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
aria-pressed={tab === k}
|
||||
onClick={() => {
|
||||
setTab(k);
|
||||
setFile(null);
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] ${
|
||||
tab === k ? "bg-accent-solid text-white" : "text-fg-secondary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
const commits = tab === "onlyA" ? result?.onlyA : result?.onlyB;
|
||||
|
||||
return (
|
||||
<GitModal tall testId="compare-refs" onClose={onClose} title={t("compareTitle")}>
|
||||
<form
|
||||
className="mb-2 flex flex-wrap items-center gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (a.trim() && b.trim()) setAsked({ a: a.trim(), b: b.trim() });
|
||||
}}
|
||||
>
|
||||
<datalist id="compare-refs-list">
|
||||
{refs.map((r) => (
|
||||
<option key={r} value={r} />
|
||||
))}
|
||||
</datalist>
|
||||
<input
|
||||
aria-label={t("compareA")}
|
||||
data-testid="compare-a"
|
||||
list="compare-refs-list"
|
||||
value={a}
|
||||
maxLength={200}
|
||||
onChange={(e) => setA(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("swap")}
|
||||
title={t("swap")}
|
||||
onClick={() => {
|
||||
setA(b);
|
||||
setB(a);
|
||||
setAsked({ a: asked.b, b: asked.a });
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
aria-label={t("compareB")}
|
||||
data-testid="compare-b"
|
||||
list="compare-refs-list"
|
||||
value={b}
|
||||
maxLength={200}
|
||||
onChange={(e) => setB(e.target.value)}
|
||||
className={input}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 rounded bg-accent-solid px-2 py-0.5 text-[11px] text-white hover:bg-accent-solid-hover"
|
||||
>
|
||||
{t("compare")}
|
||||
</button>
|
||||
<select
|
||||
data-testid="compare-mode"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value === "two" ? "two" : "three")}
|
||||
className="min-w-0 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="three">{t("modeThree")}</option>
|
||||
<option value="two">{t("modeTwo")}</option>
|
||||
</select>
|
||||
</form>
|
||||
{error && (
|
||||
<p role="alert" className="text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!result && !error && (
|
||||
<p className="text-[12px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{result && (
|
||||
<>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1 rounded border border-border bg-surface-2 p-0.5">
|
||||
{tabBtn("files", t("tabFiles", { count: result.files.length }))}
|
||||
{tabBtn("onlyA", t("tabOnly", { name: asked.a, count: result.onlyA.length }))}
|
||||
{tabBtn("onlyB", t("tabOnly", { name: asked.b, count: result.onlyB.length }))}
|
||||
</div>
|
||||
{tab === "files" && !file && (
|
||||
<ul data-testid="compare-files" className="min-h-0 flex-1 overflow-auto">
|
||||
{result.files.length === 0 && (
|
||||
<li className="text-[12px] text-fg-muted">{t("noDifference")}</li>
|
||||
)}
|
||||
{result.files.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={fileLoading}
|
||||
onClick={() => openFile(f.path, f.from)}
|
||||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
|
||||
>
|
||||
<span className="w-3 shrink-0 font-mono text-fg-muted">{f.status.trim()}</span>
|
||||
<span className="min-w-0 flex-1 text-fg-primary [overflow-wrap:anywhere]">
|
||||
{f.from ? `${f.from} → ${f.path}` : f.path}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono">
|
||||
<span className="text-status-success">+{f.added}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{result.filesTruncated && (
|
||||
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{tab === "files" && file && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFile(null)}
|
||||
className="mb-1 self-start rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("backToFiles")}
|
||||
</button>
|
||||
{file.secret ? (
|
||||
<p className="text-[12px] text-fg-muted">{t("secretFile")}</p>
|
||||
) : file.hunks?.length ? (
|
||||
<LaneDiffView file={file} layout="unified" fill />
|
||||
) : (
|
||||
<p className="text-[12px] text-fg-muted">{t("noTextChange")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{tab !== "files" && (
|
||||
<ul className="min-h-0 flex-1 overflow-auto">
|
||||
{commits?.length === 0 && (
|
||||
<li className="text-[12px] text-fg-muted">{t("noCommitsSide")}</li>
|
||||
)}
|
||||
{commits?.map((c) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCommit(c.sha)}
|
||||
className="flex w-full items-baseline gap-1.5 rounded px-1 text-left text-[11px] hover:bg-surface-2"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
||||
{c.subject}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-[30%] truncate text-fg-muted">{c.author}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{result.commitsTruncated && (
|
||||
<li className="text-[11px] text-status-warning">{t("lanes:changes.truncated")}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file "Create branch here…" and "Create tag here…" from a History commit's
|
||||
* menu: a name (and, for a tag, an optional message making it annotated),
|
||||
* then git-refs.js's `branch-create {branch, from}` / `tag-create {tag,
|
||||
* message?, from}` with that commit's sha as `from` — the server confirms the
|
||||
* sha itself. Creating a branch never switches to it here. Announces
|
||||
* `agenthub:git-changed` so the other views of the workspace read again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { emitGitChanged } from "../../../lib/gitChanged";
|
||||
import { GitModal, ModalButton } from "./GitModal";
|
||||
|
||||
export function CreateBranchDialog({
|
||||
target,
|
||||
sha,
|
||||
short,
|
||||
kind = "branch",
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
sha: string;
|
||||
short: string;
|
||||
kind?: "branch" | "tag";
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [name, setName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const tag = kind === "tag";
|
||||
|
||||
const run = async () => {
|
||||
const ref = name.trim();
|
||||
if (!ref) return;
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
const action = tag ? "tag-create" : "branch-create";
|
||||
try {
|
||||
await api.lanes.gitRefAction(
|
||||
target,
|
||||
action,
|
||||
tag
|
||||
? { tag: ref, from: sha, ...(message.trim() ? { message: message.trim() } : {}) }
|
||||
: { branch: ref, from: sha }
|
||||
);
|
||||
toast.success(t(tag ? "tagCreated" : "branchCreated", { name: ref, branch: ref, short }));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setRunning(false);
|
||||
} finally {
|
||||
emitGitChanged(typeof target === "number" ? target : target.lane, action);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
};
|
||||
const input =
|
||||
"rounded border border-border-light bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-primary";
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
testId="create-ref"
|
||||
onClose={onClose}
|
||||
title={t(tag ? "createTagTitle" : "createBranchTitle", { short })}
|
||||
footer={
|
||||
<>
|
||||
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
||||
<ModalButton
|
||||
primary
|
||||
testId="create-ref-confirm"
|
||||
disabled={!name.trim() || running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t(tag ? "createTag" : "createBranch")}
|
||||
</ModalButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="flex flex-col gap-1 text-[12px] text-fg-secondary">
|
||||
{t(tag ? "tagName" : "branchName")}
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="create-ref-name"
|
||||
value={name}
|
||||
maxLength={200}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={submit}
|
||||
className={input}
|
||||
/>
|
||||
</label>
|
||||
{tag && (
|
||||
<label className="mt-2 flex flex-col gap-1 text-[12px] text-fg-secondary">
|
||||
{t("tagMessage")}
|
||||
<input
|
||||
data-testid="create-ref-message"
|
||||
value={message}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={submit}
|
||||
className={input}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-fg-muted">
|
||||
{t(tag ? "createTagHint" : "createBranchHint")}
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1.5 text-[12px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file One file's history (Show History), like GitLens' File History: every
|
||||
* commit that touched it, newest first, followed across renames (`git log
|
||||
* --follow`). A version opens that commit's diff of the file under the name
|
||||
* it had there (the panel's own diff viewer); Blame opens the file as it was
|
||||
* at that version with each line's commit (BlameView), and a rename says
|
||||
* where it came from. Paged with Load more.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { FileVersion } from "../../../lib/gitHistory";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { BlameView } from "./BlameView";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
export function FileHistory({
|
||||
target,
|
||||
path,
|
||||
reread,
|
||||
onOpenVersion,
|
||||
onViewCommit,
|
||||
onWholeBranch,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
reread: number;
|
||||
/** That version's diff of the file: its name THEN, and the commit. */
|
||||
onOpenVersion: (path: string, sha: string) => void;
|
||||
onViewCommit: (sha: string) => void;
|
||||
onWholeBranch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes"]);
|
||||
const [versions, setVersions] = useState<FileVersion[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [blame, setBlame] = useState<{ path: string; rev: string } | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
setVersions(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
void api.lanes.gitHistory
|
||||
.fileHistory(target, path, { limit: PAGE })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setVersions(r.available ? r.commits : []);
|
||||
setHasMore(r.available && r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [path, reread]);
|
||||
|
||||
const more = useCallback(() => {
|
||||
if (!versions || loading) return;
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
void api.lanes.gitHistory
|
||||
.fileHistory(target, path, { limit: PAGE, skip: versions.length })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine || !r.available) return;
|
||||
setVersions((v) => [...(v ?? []), ...r.commits]);
|
||||
setHasMore(r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
}, [versions, loading, target, path]);
|
||||
|
||||
return (
|
||||
<div data-testid="file-history" className="flex min-h-0 flex-col gap-1">
|
||||
<div
|
||||
data-testid="lane-changes-history-file"
|
||||
className="flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
{t("lanes:changes.historyOfFile", { path })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame({ path, rev: "work" })}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWholeBranch}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("lanes:changes.historyWholeBranch")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{versions === null && !error && (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
)}
|
||||
{versions && versions.length === 0 && (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.noCommits")}</p>
|
||||
)}
|
||||
<ul>
|
||||
{versions?.map((v) => (
|
||||
<li key={v.sha} className="group flex items-center gap-1 rounded px-1 hover:bg-surface-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenVersion(v.path, v.sha)}
|
||||
className="flex min-w-0 flex-1 flex-col py-0.5 text-left"
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className="w-3 shrink-0 font-mono text-[10px] text-fg-muted"
|
||||
title={t(`status.${v.status}`, { defaultValue: v.status })}
|
||||
>
|
||||
{v.status}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{v.short}</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[11px] text-fg-primary"
|
||||
title={v.subject}
|
||||
>
|
||||
{v.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5 pl-[1.2rem] text-[10px] text-fg-muted">
|
||||
<span className="min-w-0 truncate">{v.author}</span>
|
||||
<span className="shrink-0" title={new Date(v.date).toLocaleString()}>
|
||||
{timeAgo(v.date)}
|
||||
</span>
|
||||
{v.from && (
|
||||
<span className="min-w-0 [overflow-wrap:anywhere]">
|
||||
{t("renamedFrom", { from: v.from })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{v.status !== "D" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBlame({ path: v.path, rev: v.sha })}
|
||||
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{t("blame")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewCommit(v.sha)}
|
||||
className="shrink-0 rounded px-1 text-[10px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{t("commit")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="file-history-more"
|
||||
disabled={loading}
|
||||
onClick={more}
|
||||
className="w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
{blame && (
|
||||
<BlameView
|
||||
target={target}
|
||||
path={blame.path}
|
||||
rev={blame.rev}
|
||||
onClose={() => setBlame(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setBlame(null);
|
||||
onViewCommit(sha);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @file ForcePushDialog.tsx
|
||||
* @description "Force push (with lease)" for the CURRENT branch only: shows
|
||||
* where it goes (remote and branch), the remote commit it will replace (the
|
||||
* lease — the server refuses with ESTALE if the remote moved since the last
|
||||
* fetch), how far ahead/behind HEAD is, and refuses a protected branch unless
|
||||
* the server allows it. Push is a two-click (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
|
||||
export function ForcePushDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<PushPlan | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planPush(target)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("push")) return arm("push");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true });
|
||||
toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch }));
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("forcePush.title")}
|
||||
onClose={onClose}
|
||||
testId="force-push-dialog"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="force-push-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !plan || !plan.allowed}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("push") ? t("forcePush.runConfirm") : t("forcePush.run")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p data-testid="force-push-where" className="break-words">
|
||||
{t("forcePush.where", {
|
||||
branch: plan.branch,
|
||||
remote: plan.remote,
|
||||
remoteBranch: plan.remoteBranch,
|
||||
})}
|
||||
</p>
|
||||
<p className="break-words">
|
||||
{plan.remoteSha
|
||||
? t("forcePush.replaces", {
|
||||
sha: plan.remoteSha.slice(0, 7),
|
||||
ahead: plan.ahead,
|
||||
behind: plan.behind,
|
||||
})
|
||||
: t("forcePush.newBranch")}
|
||||
</p>
|
||||
{plan.behind > 0 && <Warn>{t("forcePush.dropsRemote", { count: plan.behind })}</Warn>}
|
||||
{!plan.allowed && <Warn testId="force-push-protected">{t("forcePush.protected")}</Warn>}
|
||||
<p className="text-[11px] text-fg-muted">{t("forcePush.lease")}</p>
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file GitModal.tsx
|
||||
* @description The shared modal shell of the Changes panel's git tools (merge
|
||||
* editor, reflog, rebase, interactive rebase, reset, force push, remotes): a
|
||||
* portal over the screen with `useDialog`'s focus contract, a title row with a
|
||||
* close button, a scrolling body and an optional footer. `wide` widens it for
|
||||
* the three-pane merge editor; `tall` gives a viewer (blame, browse at a
|
||||
* revision, compare refs) the full height and a column body its own scrolling
|
||||
* child can fill; on a phone every size is the full width. `ModalButton` is
|
||||
* the History tools' footer button over the same BTN classes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../../../hooks/useDialog";
|
||||
|
||||
export const BTN =
|
||||
"rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const BTN_PRIMARY =
|
||||
"rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const BTN_DANGER =
|
||||
"rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
export function GitModal({
|
||||
title,
|
||||
onClose,
|
||||
testId,
|
||||
wide = false,
|
||||
tall = false,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
wide?: boolean;
|
||||
/** A viewer: the full height, and a flex-column body. */
|
||||
tall?: boolean;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-2 sm:p-3"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-testid={testId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${
|
||||
wide || tall ? "max-w-6xl" : "max-w-lg"
|
||||
} ${tall ? "h-[92vh]" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="min-w-0 flex-1 truncate text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("close")}
|
||||
className="shrink-0 rounded p-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`min-h-0 flex-1 px-3 py-2 text-xs text-fg-secondary ${
|
||||
tall ? "flex flex-col overflow-hidden" : "overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5 border-t border-border px-3 py-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** A red inline error line, or nothing. */
|
||||
export function ErrorLine({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<p role="alert" className="my-1 whitespace-pre-wrap break-words text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** An amber explanation box shown before a rewrite runs. */
|
||||
export function Warn({ children, testId }: { children: ReactNode; testId?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="my-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-[11px] text-status-warning"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A footer button: plain, `primary` (filled) or `danger` (red outline). */
|
||||
export function ModalButton({
|
||||
onClick,
|
||||
children,
|
||||
primary = false,
|
||||
danger = false,
|
||||
disabled = false,
|
||||
testId,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={primary ? BTN_PRIMARY : danger ? BTN_DANGER : BTN}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* @file GitTools.tsx
|
||||
* @description The Changes panel's entry points to the history-rewrite and
|
||||
* remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive
|
||||
* rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool
|
||||
* is open, so the panel holds one `GitTool | null` state), and
|
||||
* `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the
|
||||
* banner's own Complete/Abort, which the server maps onto the rebase).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { History } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu";
|
||||
import { ForcePushDialog } from "./ForcePushDialog";
|
||||
import { InteractiveRebase } from "./InteractiveRebase";
|
||||
import { MergeEditor } from "./MergeEditor";
|
||||
import { RebaseDialog } from "./RebaseDialog";
|
||||
import { ReflogView } from "./ReflogView";
|
||||
import { RemotesDialog } from "./RemotesDialog";
|
||||
import { ResetDialog } from "./ResetDialog";
|
||||
|
||||
export type GitTool =
|
||||
| { kind: "rebase" }
|
||||
| { kind: "interactive"; base?: string }
|
||||
| { kind: "reset"; commit: { sha: string; short?: string; subject?: string } }
|
||||
| { kind: "reflog" }
|
||||
| { kind: "force-push" }
|
||||
| { kind: "remotes" }
|
||||
| { kind: "merge"; path: string; onResolved?: () => void };
|
||||
|
||||
export function GitToolsButton({
|
||||
onPick,
|
||||
disabled,
|
||||
hasRemote,
|
||||
}: {
|
||||
onPick: (tool: GitTool) => void;
|
||||
disabled?: boolean;
|
||||
hasRemote?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const pick = (tool: GitTool) => () => {
|
||||
setAt(null);
|
||||
onPick(tool);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-testid="git-tools-button"
|
||||
aria-label={t("menu.label")}
|
||||
title={t("menu.label")}
|
||||
aria-haspopup="menu"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
setAt(r ? { x: r.left, y: r.bottom + 2 } : { x: 0, y: 0 });
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-fg-muted hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{at && (
|
||||
<ContextMenu x={at.x} y={at.y} onClose={() => setAt(null)} testId="git-tools-menu">
|
||||
<MenuItem testId="git-tools-rebase" onSelect={pick({ kind: "rebase" })}>
|
||||
{t("menu.rebase")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-interactive" onSelect={pick({ kind: "interactive" })}>
|
||||
{t("menu.interactive")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-reflog" onSelect={pick({ kind: "reflog" })}>
|
||||
{t("menu.reflog")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
testId="git-tools-force-push"
|
||||
danger
|
||||
disabled={hasRemote === false}
|
||||
onSelect={pick({ kind: "force-push" })}
|
||||
>
|
||||
{t("menu.forcePush")}
|
||||
</MenuItem>
|
||||
<MenuItem testId="git-tools-remotes" onSelect={pick({ kind: "remotes" })}>
|
||||
{t("menu.remotes")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitTools({
|
||||
tool,
|
||||
target,
|
||||
branch,
|
||||
onClose,
|
||||
onOpen,
|
||||
onChanged,
|
||||
onViewCommit,
|
||||
}: {
|
||||
tool: GitTool | null;
|
||||
target: GitTarget;
|
||||
/** The current branch, for titles. */
|
||||
branch: string | null;
|
||||
onClose: () => void;
|
||||
/** Open another tool (the reflog's "Reset here"). */
|
||||
onOpen: (tool: GitTool) => void;
|
||||
/** Re-read the panel's git facts after a write. */
|
||||
onChanged: () => void;
|
||||
onViewCommit?: (sha: string) => void;
|
||||
}) {
|
||||
if (!tool) return null;
|
||||
switch (tool.kind) {
|
||||
case "rebase":
|
||||
return (
|
||||
<RebaseDialog target={target} current={branch} onClose={onClose} onChanged={onChanged} />
|
||||
);
|
||||
case "interactive":
|
||||
return (
|
||||
<InteractiveRebase
|
||||
target={target}
|
||||
base={tool.base}
|
||||
onClose={onClose}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
case "reset":
|
||||
return (
|
||||
<ResetDialog target={target} commit={tool.commit} onClose={onClose} onChanged={onChanged} />
|
||||
);
|
||||
case "reflog":
|
||||
return (
|
||||
<ReflogView
|
||||
target={target}
|
||||
onClose={onClose}
|
||||
onViewCommit={
|
||||
onViewCommit &&
|
||||
((sha) => {
|
||||
onClose();
|
||||
onViewCommit(sha);
|
||||
})
|
||||
}
|
||||
onReset={(e) =>
|
||||
onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } })
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "force-push":
|
||||
return <ForcePushDialog target={target} onClose={onClose} onChanged={onChanged} />;
|
||||
case "remotes":
|
||||
return <RemotesDialog target={target} onClose={onClose} onChanged={onChanged} />;
|
||||
case "merge":
|
||||
return (
|
||||
<MergeEditor
|
||||
target={target}
|
||||
path={tool.path}
|
||||
onClose={onClose}
|
||||
onResolved={() => {
|
||||
onChanged();
|
||||
tool.onResolved?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The rebase banner's Skip: drops the commit the rebase stopped on. */
|
||||
export function RebaseSkipButton({
|
||||
target,
|
||||
disabled,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
disabled?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rebase-skip"
|
||||
title={t("rebaseBanner.skipHint")}
|
||||
disabled={disabled || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase-skip", {});
|
||||
if (r.conflicts?.length) toast.error(t("rebase.stopped", { count: r.conflicts.length }));
|
||||
else if (!r.stopped) toast.success(t("rebaseBanner.finished"));
|
||||
} catch (e) {
|
||||
toast.error(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
onChanged();
|
||||
}
|
||||
}}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("rebaseBanner.skip")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* @file The Changes panel's History tab, shaped like VS Code's Source Control
|
||||
* Graph, GitLens and the JetBrains git log: a commit graph (lanes and merge
|
||||
* edges, ref badges) read page by page and drawn windowed, so a repo with
|
||||
* tens of thousands of commits stays responsive; a search box (message, a SHA
|
||||
* prefix, `author:`, `path:`) and an all-branches switch; a details pane for
|
||||
* the focused commit; multi-select (checkbox, Ctrl/⌘-click, Shift-click) with
|
||||
* "Cherry-pick N" / "Revert N" that open a preview of exactly what will run
|
||||
* (CherryPickDialog); the reference tools' commit context menu; and, after a
|
||||
* pick or revert, a result banner with Undo while that is safe (the server
|
||||
* refuses it once the commits are pushed or HEAD moved on).
|
||||
*
|
||||
* Every write still goes through the server's guards (git-pick.js); a
|
||||
* disabled button here is a courtesy.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitCompare, Search, X } from "lucide-react";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { LaneCommit } from "../../../lib/types";
|
||||
import { commitGraph } from "../../../lib/commitGraph";
|
||||
import { parseSearch, inListOrder, type PickResult } from "../../../lib/gitHistory";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../../ContextMenu";
|
||||
import { GRAPH_ROW, GraphCell, CommitText } from "./CommitGraph";
|
||||
import { CommitDetails } from "./CommitDetails";
|
||||
import { CherryPickDialog } from "./CherryPickDialog";
|
||||
import { BrowseAtRevision } from "./BrowseAtRevision";
|
||||
import { CompareRefs } from "./CompareRefs";
|
||||
import { CreateBranchDialog } from "./CreateBranchDialog";
|
||||
|
||||
/** Commits one page reads. */
|
||||
export const PAGE = 100;
|
||||
/** Rows drawn beyond the visible ones, each side. */
|
||||
const OVERSCAN = 20;
|
||||
|
||||
type Op = "cherry-pick" | "revert";
|
||||
|
||||
export function HistoryView({
|
||||
target,
|
||||
branch,
|
||||
branches,
|
||||
historyRef,
|
||||
onHistoryRef,
|
||||
reread,
|
||||
disabled,
|
||||
headPushed,
|
||||
onViewCommit,
|
||||
onOpenFileAtCommit,
|
||||
onUndoCommit,
|
||||
onResetHere,
|
||||
onEditAfter,
|
||||
onPaused,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** The branch HEAD is on (null = detached / unknown). */
|
||||
branch: string | null;
|
||||
/** Local branches, for "History of branch". */
|
||||
branches: string[];
|
||||
historyRef: string | null;
|
||||
onHistoryRef: (ref: string | null) => void;
|
||||
/** Bumped by the panel's Refresh and after its own writes. */
|
||||
reread: number;
|
||||
/** A write is running, or a merge/pick is open: nothing new starts. */
|
||||
disabled: boolean;
|
||||
/** The branch's upstream already holds HEAD (Undo commit refused). */
|
||||
headPushed: boolean;
|
||||
onViewCommit: (sha: string) => void;
|
||||
onOpenFileAtCommit: (path: string, sha: string) => void;
|
||||
onUndoCommit: (c: LaneCommit) => void;
|
||||
/** "Reset branch to here…" — the rewrite tools' reset dialog (GitTools). */
|
||||
onResetHere: (c: LaneCommit) => void;
|
||||
/** "Edit commits after this one…" — the interactive rebase (GitTools). */
|
||||
onEditAfter: (sha: string) => void;
|
||||
/** A pick/revert stopped on a conflict: the overview resolves it. */
|
||||
onPaused: () => void;
|
||||
/** History changed (a pick, revert or undo): re-read everything. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory", "lanes", "gitTools"]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState(() => parseSearch(""));
|
||||
const [all, setAll] = useState(false);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const anchor = useRef<string | null>(null);
|
||||
const [dialog, setDialog] = useState<{ op: Op; shas: string[] } | null>(null);
|
||||
const [browse, setBrowse] = useState<{ sha: string; short: string } | null>(null);
|
||||
const [compare, setCompare] = useState<{ a: string; b: string } | null>(null);
|
||||
const [newRef, setNewRef] = useState<{
|
||||
sha: string;
|
||||
short: string;
|
||||
kind: "branch" | "tag";
|
||||
} | null>(null);
|
||||
const [lastPick, setLastPick] = useState<(PickResult & { op: Op }) | null>(null);
|
||||
const menu = useContextMenu<LaneCommit>();
|
||||
|
||||
// The search box applies after a pause in typing, not on every key.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setFilter(parseSearch(search)), 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [search]);
|
||||
|
||||
const filtered = !!(filter.q || filter.author || filter.path);
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
...(all ? { all: true } : historyRef ? { ref: historyRef } : {}),
|
||||
...(filter.q ? { q: filter.q } : {}),
|
||||
...(filter.author ? { author: filter.author } : {}),
|
||||
...(filter.path ? { path: filter.path } : {}),
|
||||
}),
|
||||
[all, historyRef, filter]
|
||||
);
|
||||
const queryKey = JSON.stringify(query);
|
||||
|
||||
// The first page whenever the question changes; a reply for an older
|
||||
// question is dropped.
|
||||
const seq = useRef(0);
|
||||
useEffect(() => {
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: PAGE, ...query })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setCommits(r.available ? r.commits : []);
|
||||
setHasMore(!!r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [queryKey, reread]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore || !commits) return;
|
||||
const mine = ++seq.current;
|
||||
setLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: PAGE, skip: commits.length, ...query })
|
||||
.then((r) => {
|
||||
if (seq.current !== mine) return;
|
||||
setCommits((prev) => {
|
||||
const seen = new Set((prev ?? []).map((c) => c.sha));
|
||||
return [...(prev ?? []), ...r.commits.filter((c) => !seen.has(c.sha))];
|
||||
});
|
||||
setHasMore(!!r.hasMore);
|
||||
})
|
||||
.catch(
|
||||
(err) => seq.current === mine && setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => seq.current === mine && setLoading(false));
|
||||
}, [loading, hasMore, commits, target, query]);
|
||||
|
||||
const list = useMemo(() => commits ?? [], [commits]);
|
||||
// A search result is not a connected history: dots, no lanes.
|
||||
const graph = useMemo(
|
||||
() => commitGraph(filtered ? list.map((c) => ({ sha: c.sha, parents: [] })) : list),
|
||||
[list, filtered]
|
||||
);
|
||||
const byShaIndex = useMemo(() => new Map(list.map((c, i) => [c.sha, i])), [list]);
|
||||
|
||||
// Which loaded commits HEAD already has: Revert applies to those,
|
||||
// Cherry-pick to the rest. On HEAD's own history, all of them.
|
||||
const onHead = useMemo(() => {
|
||||
if (!all && !historyRef) return null;
|
||||
const head = list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"));
|
||||
const set = new Set<string>();
|
||||
if (!head) return set;
|
||||
const stack = [head.sha];
|
||||
while (stack.length) {
|
||||
const s = stack.pop()!;
|
||||
if (set.has(s) || !byShaIndex.has(s)) continue;
|
||||
set.add(s);
|
||||
stack.push(...(list[byShaIndex.get(s)!]!.parents ?? []));
|
||||
}
|
||||
return set;
|
||||
}, [all, historyRef, list, byShaIndex]);
|
||||
// The commit HEAD is on: Edit commits after it has nothing to edit.
|
||||
const headSha = useMemo(
|
||||
() =>
|
||||
list.find((c) => (c.badges ?? []).some((b) => b.current || b.kind === "head"))?.sha ??
|
||||
(!all && !historyRef ? list[0]?.sha : undefined),
|
||||
[list, all, historyRef]
|
||||
);
|
||||
const isOnHead = useCallback((sha: string) => (onHead ? onHead.has(sha) : true), [onHead]);
|
||||
|
||||
// ---- selection -------------------------------------------------------
|
||||
const clickRow = (e: MouseEvent, c: LaneCommit) => {
|
||||
setFocused(c.sha);
|
||||
if (e.shiftKey && anchor.current && byShaIndex.has(anchor.current)) {
|
||||
const a = byShaIndex.get(anchor.current)!;
|
||||
const b = byShaIndex.get(c.sha)!;
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
setPicked(new Set(list.slice(lo, hi + 1).map((x) => x.sha)));
|
||||
return;
|
||||
}
|
||||
anchor.current = c.sha;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
toggle(c.sha);
|
||||
return;
|
||||
}
|
||||
setPicked(new Set());
|
||||
};
|
||||
const toggle = (sha: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sha)) next.delete(sha);
|
||||
else next.add(sha);
|
||||
return next;
|
||||
});
|
||||
const selected = inListOrder(list, picked);
|
||||
const mergeIn = selected.some((c) => c.parents.length > 1);
|
||||
const allOnHead = selected.length > 0 && selected.every((c) => isOnHead(c.sha));
|
||||
const noneOnHead = selected.length > 0 && selected.every((c) => !isOnHead(c.sha));
|
||||
const target_branch = branch ?? "HEAD";
|
||||
|
||||
const openPick = (op: Op, shas: string[]) => setDialog({ op, shas });
|
||||
|
||||
const afterPick = (op: Op, r: PickResult) => {
|
||||
setDialog(null);
|
||||
setPicked(new Set());
|
||||
if (r.paused) {
|
||||
toast.error(
|
||||
r.empty
|
||||
? t("pausedEmpty", { op: t(`op.${op}`) })
|
||||
: t("pausedConflicts", { op: t(`op.${op}`), count: r.conflicts.length })
|
||||
);
|
||||
onPaused();
|
||||
return;
|
||||
}
|
||||
const n = r.commits?.length ?? 0;
|
||||
toast.success(
|
||||
t(op === "revert" ? "revertedN" : "pickedN", { count: n, branch: target_branch })
|
||||
);
|
||||
if (r.from && r.to && n) setLastPick({ ...r, op });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const undoPick = async () => {
|
||||
if (!lastPick?.from || !lastPick.to) return;
|
||||
const n = lastPick.commits?.length ?? 0;
|
||||
const ok = await confirmDialog({
|
||||
title: t("undoTitle", { op: t(`op.${lastPick.op}`) }),
|
||||
message: t("undoBody", { count: n, branch: target_branch }),
|
||||
confirmLabel: t("undo"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.lanes.gitHistory.act(target, "undo-pick", {
|
||||
from: lastPick.from,
|
||||
to: lastPick.to,
|
||||
confirm: true,
|
||||
});
|
||||
toast.success(t("undone", { count: n }));
|
||||
setLastPick(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPatch = async (c: LaneCommit) => {
|
||||
try {
|
||||
const r = await api.lanes.gitHistory.patch(target, c.sha);
|
||||
if (!r.available) return void toast.error(t("noPatch"));
|
||||
if (r.omitted.length) toast.info(t("patchOmitted", { count: r.omitted.length }));
|
||||
await copyText(r.patch);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- windowing -------------------------------------------------------
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [top, setTop] = useState(0);
|
||||
const [height, setHeight] = useState(600);
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 600));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const first = Math.max(0, Math.floor(top / GRAPH_ROW) - OVERSCAN);
|
||||
const last = Math.min(list.length, Math.ceil((top + height) / GRAPH_ROW) + OVERSCAN);
|
||||
const onScroll = () => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
setTop(el.scrollTop);
|
||||
// Near the end: the next page, like an endless log.
|
||||
if (el.scrollTop + el.clientHeight > list.length * GRAPH_ROW - GRAPH_ROW * 10) loadMore();
|
||||
};
|
||||
|
||||
const focusedCommit = focused ? list.find((c) => c.sha === focused) : undefined;
|
||||
const menuItem = menu.menu?.item;
|
||||
const menuMulti = !!menuItem && picked.has(menuItem.sha) && picked.size > 1;
|
||||
const pick = (fn: () => void) => () => {
|
||||
menu.close();
|
||||
fn();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="lane-changes-history" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
{/* Toolbar: search, all branches, branch, compare. Wraps on a narrow panel. */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-1 text-[11px]">
|
||||
<label className="flex min-w-[10rem] flex-1 items-center gap-1 rounded border border-border-light bg-surface-0 px-1.5 py-0.5">
|
||||
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
|
||||
<input
|
||||
data-testid="history-search"
|
||||
value={search}
|
||||
maxLength={300}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
aria-label={t("searchLabel")}
|
||||
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("clearSearch")}
|
||||
onClick={() => setSearch("")}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={all}
|
||||
onChange={setAll}
|
||||
label={t("allBranches")}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
{!all && branches.length > 1 && (
|
||||
<label className="flex min-w-0 items-center gap-1 text-fg-secondary">
|
||||
<span className="shrink-0">{t("lanes:changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => onHistoryRef(e.target.value || null)}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{target_branch}</option>
|
||||
{branches
|
||||
.filter((b) => b !== branch)
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-compare"
|
||||
onClick={() =>
|
||||
setCompare({
|
||||
a: target_branch,
|
||||
b: historyRef ?? branches.find((b) => b !== branch) ?? target_branch,
|
||||
})
|
||||
}
|
||||
className="flex shrink-0 items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<GitCompare className="h-3 w-3" aria-hidden />
|
||||
{t("compare")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastPick && (
|
||||
<div
|
||||
data-testid="pick-result"
|
||||
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-status-success/40 bg-status-success/10 px-2 py-1 text-[11px] text-status-success"
|
||||
>
|
||||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
{t(lastPick.op === "revert" ? "revertedN" : "pickedN", {
|
||||
count: lastPick.commits?.length ?? 0,
|
||||
branch: target_branch,
|
||||
})}{" "}
|
||||
<span className="font-mono">
|
||||
{(lastPick.commits ?? []).map((c) => c.short).join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-undo"
|
||||
disabled={disabled}
|
||||
title={t("undoHint")}
|
||||
onClick={() => void undoPick()}
|
||||
className="shrink-0 rounded border border-status-success/50 px-1.5 py-0.5 hover:bg-status-success/15 disabled:opacity-40"
|
||||
>
|
||||
{t("undo")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("dismiss")}
|
||||
onClick={() => setLastPick(null)}
|
||||
className="shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div
|
||||
data-testid="history-selection"
|
||||
className="mx-1 flex flex-wrap items-center gap-1.5 rounded border border-accent/40 bg-accent/10 px-2 py-1 text-[11px] text-fg-primary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("selectedN", { count: selected.length })}</span>
|
||||
{noneOnHead && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-pick-selected"
|
||||
disabled={disabled || mergeIn}
|
||||
title={mergeIn ? t("mergeCommitRefused") : undefined}
|
||||
onClick={() =>
|
||||
openPick(
|
||||
"cherry-pick",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded bg-accent-solid px-1.5 py-0.5 text-white hover:bg-accent-solid-hover disabled:opacity-40"
|
||||
>
|
||||
{t("pickN", { count: selected.length, branch: target_branch })}
|
||||
</button>
|
||||
)}
|
||||
{allOnHead && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="history-revert-selected"
|
||||
disabled={disabled || mergeIn}
|
||||
title={mergeIn ? t("mergeCommitRefused") : undefined}
|
||||
onClick={() =>
|
||||
openPick(
|
||||
"revert",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded border border-border px-1.5 py-0.5 hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("revertN", { count: selected.length })}
|
||||
</button>
|
||||
)}
|
||||
{!noneOnHead && !allOnHead && (
|
||||
<span className="text-fg-muted">{t("mixedSelection")}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPicked(new Set())}
|
||||
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
{t("clearSelection")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="px-1 text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={onScroll}
|
||||
data-testid="history-list"
|
||||
className="min-h-[8rem] flex-1 overflow-y-auto"
|
||||
>
|
||||
{commits === null ? (
|
||||
<p className="px-1 text-[11px] text-fg-muted">{t("lanes:changes.loading")}</p>
|
||||
) : list.length === 0 ? (
|
||||
<p className="px-1 text-[11px] text-fg-muted">
|
||||
{filtered ? t("noMatches") : t("lanes:changes.noCommits")}
|
||||
</p>
|
||||
) : (
|
||||
<div role="list" style={{ height: list.length * GRAPH_ROW, position: "relative" }}>
|
||||
{list.slice(first, last).map((c, k) => {
|
||||
const i = first + k;
|
||||
const isPicked = picked.has(c.sha);
|
||||
const head = (c.badges ?? []).some((b) => b.current || b.kind === "head");
|
||||
return (
|
||||
<div
|
||||
key={c.sha}
|
||||
role="listitem"
|
||||
data-sha={c.sha}
|
||||
aria-selected={isPicked || focused === c.sha}
|
||||
onContextMenu={(e) => menu.open(e, c)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: i * GRAPH_ROW,
|
||||
height: GRAPH_ROW,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
className={`group flex items-center gap-1 rounded px-1 ${
|
||||
isPicked
|
||||
? "bg-accent/15"
|
||||
: focused === c.sha
|
||||
? "bg-surface-3"
|
||||
: "hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isPicked}
|
||||
onChange={() => {
|
||||
// A tick is an anchor too: Shift-click extends from it.
|
||||
anchor.current = c.sha;
|
||||
toggle(c.sha);
|
||||
}}
|
||||
title={t("selectCommit", { short: c.short })}
|
||||
className={isPicked || picked.size ? "" : "opacity-40 group-hover:opacity-100"}
|
||||
/>
|
||||
<GraphCell
|
||||
row={graph.rows[i]!}
|
||||
width={graph.width}
|
||||
merge={c.parents.length > 1}
|
||||
head={head}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => clickRow(e, c)}
|
||||
onDoubleClick={() => onViewCommit(c.sha)}
|
||||
className="flex h-full min-w-0 flex-1 items-center text-left"
|
||||
>
|
||||
<CommitText c={c} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={loading}
|
||||
onClick={loadMore}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{loading ? t("lanes:changes.loading") : t("lanes:changes.loadMore")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{focusedCommit && (
|
||||
<CommitDetails
|
||||
key={focusedCommit.sha}
|
||||
target={target}
|
||||
sha={focusedCommit.sha}
|
||||
onClose={() => setFocused(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setFocused(sha);
|
||||
const el = scroller.current?.querySelector(`[data-sha="${sha}"]`);
|
||||
(el as HTMLElement | null)?.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
onViewChanges={() => onViewCommit(focusedCommit.sha)}
|
||||
onOpenFile={(path) => onOpenFileAtCommit(path, focusedCommit.sha)}
|
||||
onBrowse={() => setBrowse({ sha: focusedCommit.sha, short: focusedCommit.short })}
|
||||
onCompare={() => setCompare({ a: target_branch, b: focusedCommit.sha })}
|
||||
onCopyPatch={() => void copyPatch(focusedCommit)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{menu.menu && menuItem && (
|
||||
<ContextMenu
|
||||
x={menu.menu.x}
|
||||
y={menu.menu.y}
|
||||
onClose={menu.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(() => onViewCommit(menuItem.sha))}>
|
||||
{t("lanes:changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setFocused(menuItem.sha))}>{t("showDetails")}</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setBrowse({ sha: menuItem.sha, short: menuItem.short }))}>
|
||||
{t("browseFiles")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => setCompare({ a: target_branch, b: menuItem.sha }))}>
|
||||
{t("compareWithCurrent")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(() => void copyText(menuItem.sha))}>
|
||||
{t("lanes:changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void copyText(menuItem.subject))}>
|
||||
{t("lanes:changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={menuItem.parents.length > 1}
|
||||
onSelect={pick(() => void copyPatch(menuItem))}
|
||||
>
|
||||
{t("copyPatch")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="history-menu-branch"
|
||||
disabled={disabled}
|
||||
onSelect={pick(() =>
|
||||
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "branch" })
|
||||
)}
|
||||
>
|
||||
{t("createBranchHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="history-menu-tag"
|
||||
disabled={disabled}
|
||||
onSelect={pick(() =>
|
||||
setNewRef({ sha: menuItem.sha, short: menuItem.short, kind: "tag" })
|
||||
)}
|
||||
>
|
||||
{t("createTagHere")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{menuMulti ? (
|
||||
<>
|
||||
{noneOnHead && (
|
||||
<MenuItem
|
||||
disabled={disabled || mergeIn}
|
||||
onSelect={pick(() =>
|
||||
openPick(
|
||||
"cherry-pick",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("pickN", { count: selected.length, branch: target_branch })}
|
||||
</MenuItem>
|
||||
)}
|
||||
{allOnHead && (
|
||||
<MenuItem
|
||||
disabled={disabled || mergeIn}
|
||||
onSelect={pick(() =>
|
||||
openPick(
|
||||
"revert",
|
||||
selected.map((c) => c.sha)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("revertN", { count: selected.length })}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
) : isOnHead(menuItem.sha) ? (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.parents.length !== 1}
|
||||
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(() => openPick("revert", [menuItem.sha]))}
|
||||
>
|
||||
{t("lanes:changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{!all && !historyRef && list[0]?.sha === menuItem.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={disabled || headPushed || menuItem.parents.length !== 1}
|
||||
title={headPushed ? t("lanes:changes.undoCommitPushed") : undefined}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(() => onUndoCommit(menuItem))}
|
||||
>
|
||||
{t("lanes:changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* History rewrites (GitTools): each opens a dialog that asks first. */}
|
||||
<MenuItem
|
||||
disabled={disabled}
|
||||
testId="lane-changes-menu-reset"
|
||||
onSelect={pick(() => onResetHere(menuItem))}
|
||||
>
|
||||
{t("gitTools:menu.resetHere")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.sha === headSha}
|
||||
testId="lane-changes-menu-interactive"
|
||||
onSelect={pick(() => onEditAfter(menuItem.sha))}
|
||||
>
|
||||
{t("gitTools:menu.interactiveHere")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem
|
||||
disabled={disabled || menuItem.parents.length !== 1}
|
||||
title={menuItem.parents.length > 1 ? t("mergeCommitRefused") : undefined}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(() => openPick("cherry-pick", [menuItem.sha]))}
|
||||
>
|
||||
{t("lanes:changes.cherryPick", { branch: target_branch })}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<CherryPickDialog
|
||||
target={target}
|
||||
op={dialog.op}
|
||||
shas={dialog.shas}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(r) => afterPick(dialog.op, r)}
|
||||
/>
|
||||
)}
|
||||
{browse && (
|
||||
<BrowseAtRevision
|
||||
target={target}
|
||||
sha={browse.sha}
|
||||
short={browse.short}
|
||||
onClose={() => setBrowse(null)}
|
||||
/>
|
||||
)}
|
||||
{compare && (
|
||||
<CompareRefs
|
||||
target={target}
|
||||
initialA={compare.a}
|
||||
initialB={compare.b}
|
||||
refs={branches}
|
||||
onClose={() => setCompare(null)}
|
||||
onOpenCommit={(sha) => {
|
||||
setCompare(null);
|
||||
onViewCommit(sha);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newRef && (
|
||||
<CreateBranchDialog
|
||||
target={target}
|
||||
sha={newRef.sha}
|
||||
short={newRef.short}
|
||||
kind={newRef.kind}
|
||||
onClose={() => setNewRef(null)}
|
||||
onDone={() => {
|
||||
setNewRef(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @file InteractiveRebase.tsx
|
||||
* @description Interactive rebase as a list (JetBrains' "Interactively Rebase
|
||||
* from Here"): the branch's commits since a base — by default the ones no
|
||||
* remote has yet, oldest first — each with Pick / Reword / Squash / Fixup /
|
||||
* Drop, moved up and down, a new message for a reword or a squash. Before it
|
||||
* runs it says what it rewrites ("This rewrites 4 commits; 2 are already
|
||||
* pushed, so a force-push will be needed") and pushed commits need an explicit
|
||||
* tick; Start is a two-click (`useArmed`). The server builds the todo and runs
|
||||
* `git rebase -i` with no editor; a clean run stays open with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import {
|
||||
errText,
|
||||
gitRewrite,
|
||||
type InteractivePlan,
|
||||
type PlanCommit,
|
||||
type RewriteResult,
|
||||
type TodoAction,
|
||||
} from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
import { blockedReason } from "./RebaseDialog";
|
||||
|
||||
const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"];
|
||||
|
||||
interface Row {
|
||||
commit: PlanCommit;
|
||||
action: TodoAction;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Why the list cannot run as it stands, or null. */
|
||||
export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null {
|
||||
const kept = rows.filter((r) => r.action !== "drop");
|
||||
if (!kept.length) return "allDropped";
|
||||
if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first";
|
||||
if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function InteractiveRebase({
|
||||
target,
|
||||
base,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** Rewrite the commits after this one; absent = the unpushed ones. */
|
||||
base?: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<InteractivePlan | null>(null);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [force, setForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planInteractive(target, base)
|
||||
.then((p) => {
|
||||
if (!live) return;
|
||||
setPlan(p);
|
||||
setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message })));
|
||||
})
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, base]);
|
||||
|
||||
const set = (i: number, patch: Partial<Row>) =>
|
||||
setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r)));
|
||||
const move = (i: number, step: number) =>
|
||||
setRows((prev) => {
|
||||
const j = i + step;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[i], next[j]] = [next[j]!, next[i]!];
|
||||
return next;
|
||||
});
|
||||
|
||||
const blocked = plan ? blockedReason(t, plan) : null;
|
||||
const problem = todoProblem(rows);
|
||||
const unchanged =
|
||||
!!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha);
|
||||
const pushed = plan?.pushed ?? 0;
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
if (!isArmed("run")) return arm("run");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase-interactive", {
|
||||
head: plan.head,
|
||||
...(base ? { base: plan.base } : {}),
|
||||
confirm: true,
|
||||
...(pushed ? { force: true } : {}),
|
||||
todo: rows.map((row) => ({
|
||||
sha: row.commit.sha,
|
||||
action: row.action,
|
||||
...((row.action === "reword" || row.action === "squash") &&
|
||||
row.message.trim() &&
|
||||
row.message.trim() !== row.commit.message
|
||||
? { message: row.message }
|
||||
: row.action === "reword"
|
||||
? { message: row.message }
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
onChanged();
|
||||
if (r.conflicts?.length || r.stopped) {
|
||||
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success(t("interactive.done"));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("interactive.title")}
|
||||
onClose={onClose}
|
||||
testId="interactive-rebase"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="interactive-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={
|
||||
busy || !plan || !!blocked || !!problem || unchanged || (pushed > 0 && !force)
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{isArmed("run") ? t("interactive.runConfirm") : t("interactive.run")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : !plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-1.5 text-[11px] text-fg-muted">{t("interactive.explain")}</p>
|
||||
{blocked && <Warn>{blocked}</Warn>}
|
||||
{plan.merges && <Warn>{t("interactive.merges")}</Warn>}
|
||||
{!rows.length ? (
|
||||
<p data-testid="interactive-empty">{t("interactive.nothing")}</p>
|
||||
) : (
|
||||
<>
|
||||
<Warn testId="interactive-summary">
|
||||
{pushed
|
||||
? t("rewritesPushed", { count: rows.length, pushed })
|
||||
: t("rewrites", { count: rows.length })}
|
||||
{pushed > 0 && (
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="interactive-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
)}
|
||||
</Warn>
|
||||
<ol data-testid="interactive-list" className="space-y-1">
|
||||
{rows.map((r, i) => (
|
||||
<li
|
||||
key={r.commit.sha}
|
||||
data-testid="interactive-row"
|
||||
className={`rounded border border-border-light p-1.5 ${
|
||||
r.action === "drop" ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("interactive.action", { short: r.commit.short })}
|
||||
data-testid="interactive-action"
|
||||
value={r.action}
|
||||
onChange={(e) => set(i, { action: e.target.value as TodoAction })}
|
||||
className="input py-0.5 text-[11px]"
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{t(`interactive.actions.${a}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{r.commit.short}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-[11px] text-fg-primary ${
|
||||
r.action === "drop" ? "line-through" : ""
|
||||
}`}
|
||||
title={r.commit.subject}
|
||||
>
|
||||
{r.commit.subject}
|
||||
</span>
|
||||
{r.commit.pushed && (
|
||||
<span className="shrink-0 text-[10px] text-status-warning">
|
||||
{t("pushed")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("interactive.up")}
|
||||
disabled={i === 0}
|
||||
onClick={() => move(i, -1)}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("interactive.down")}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{(r.action === "reword" || r.action === "squash") && (
|
||||
<textarea
|
||||
data-testid="interactive-message"
|
||||
aria-label={t("interactive.message")}
|
||||
value={r.message}
|
||||
rows={3}
|
||||
onChange={(e) => set(i, { message: e.target.value })}
|
||||
placeholder={
|
||||
r.action === "squash" ? t("interactive.squashHint") : undefined
|
||||
}
|
||||
className="input mt-1 w-full font-mono text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{problem && (
|
||||
<p
|
||||
data-testid="interactive-problem"
|
||||
className="mt-1 text-[11px] text-status-danger"
|
||||
>
|
||||
{t(`interactive.problem.${problem}`)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* @file MergeEditor.tsx
|
||||
* @description The 3-way merge editor for ONE conflicted file — a merge,
|
||||
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
|
||||
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
|
||||
* base (stage :1) one click away. Each conflict block of the result takes
|
||||
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
|
||||
* undoable; next/previous conflict and "N conflicts left"; or the whole result
|
||||
* edited as text. Save writes the result through the `conflict-save` action —
|
||||
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
|
||||
* then `git add` — which the server refuses while a marker is left.
|
||||
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
|
||||
* commit being replayed, so the labels say that rather than "mine".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
|
||||
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
export type BlockChoice =
|
||||
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
|
||||
|
||||
type Block = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
|
||||
/** The lines a block resolves to, or null while it is unresolved. */
|
||||
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
|
||||
if (!c) return null;
|
||||
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
|
||||
if (c.kind === "ours") return p.ours;
|
||||
if (c.kind === "theirs") return p.theirs;
|
||||
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
|
||||
return [...p.theirs, ...p.ours];
|
||||
}
|
||||
|
||||
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
|
||||
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for (const p of parts) {
|
||||
if (p.kind === "text") {
|
||||
out.push(...p.lines);
|
||||
continue;
|
||||
}
|
||||
const lines = blockLines(p, choices[i++]);
|
||||
if (lines) out.push(...lines);
|
||||
else
|
||||
out.push(
|
||||
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
||||
...p.ours,
|
||||
"=======",
|
||||
...p.theirs,
|
||||
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
||||
);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function Pane({
|
||||
title,
|
||||
text,
|
||||
testId,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
text: string | null;
|
||||
testId: string;
|
||||
tone: "incoming" | "current" | "base";
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const head =
|
||||
tone === "incoming"
|
||||
? "bg-accent/20 text-accent"
|
||||
: tone === "current"
|
||||
? "bg-status-success/20 text-status-success"
|
||||
: "bg-surface-3 text-fg-secondary";
|
||||
return (
|
||||
<section
|
||||
data-testid={testId}
|
||||
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
|
||||
>
|
||||
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
|
||||
{title}
|
||||
</h3>
|
||||
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
|
||||
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
|
||||
</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function MergeEditor({
|
||||
target,
|
||||
path,
|
||||
onClose,
|
||||
onResolved,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
/** The file was saved and marked resolved. */
|
||||
onResolved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [sides, setSides] = useState<ConflictSides | null>(null);
|
||||
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
|
||||
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
|
||||
const [whole, setWhole] = useState<string | null>(null);
|
||||
const [showBase, setShowBase] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [at, setAt] = useState(0);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
let live = true;
|
||||
setSides(null);
|
||||
setChoices([]);
|
||||
setEditing(null);
|
||||
setWhole(null);
|
||||
setError(null);
|
||||
gitRewrite
|
||||
.conflict(target, path)
|
||||
.then((s) => live && setSides(s))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `target` is rebuilt by the caller on every render; its parts are stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(target), path]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const parts =
|
||||
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
|
||||
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
|
||||
const total = blocks.length;
|
||||
const left = blocks.filter((_, i) => !choices[i]).length;
|
||||
|
||||
const rebase = sides?.operation === "rebase";
|
||||
const incomingLabel = sides?.incoming
|
||||
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
|
||||
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
|
||||
})
|
||||
: t("merge.incomingPlain");
|
||||
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
|
||||
branch: sides?.branch ?? "HEAD",
|
||||
});
|
||||
|
||||
const choose = (i: number, c: BlockChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = c;
|
||||
return next;
|
||||
});
|
||||
|
||||
const jump = (step: number) => {
|
||||
if (!total) return;
|
||||
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
|
||||
const pool = open.length ? open : blocks.map((_, i) => i);
|
||||
const from = pool.findIndex((i) => i > at);
|
||||
let i: number;
|
||||
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
|
||||
else {
|
||||
const before = pool.filter((x) => x < at);
|
||||
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
|
||||
}
|
||||
setAt(i);
|
||||
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!sides?.sha || !parts) return;
|
||||
const content = whole ?? composeResult(parts, choices);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
|
||||
toast.success(t("merge.saved", { path }));
|
||||
onResolved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
setError(
|
||||
code === "EMARKERS"
|
||||
? t("merge.markersLeft")
|
||||
: code === "ESTALE"
|
||||
? t("merge.stale")
|
||||
: errText(e)
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
|
||||
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
let blockIndex = -1;
|
||||
const result = parts && (
|
||||
<section
|
||||
data-testid="merge-result"
|
||||
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
|
||||
>
|
||||
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
|
||||
{t("merge.result")}
|
||||
</h3>
|
||||
{whole != null ? (
|
||||
<textarea
|
||||
data-testid="merge-whole"
|
||||
aria-label={t("merge.result")}
|
||||
value={whole}
|
||||
onChange={(e) => setWhole(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
|
||||
{parts.map((p, n) => {
|
||||
if (p.kind === "text")
|
||||
return (
|
||||
<pre
|
||||
key={n}
|
||||
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
|
||||
>
|
||||
{p.lines.length ? p.lines.join("\n") : " "}
|
||||
</pre>
|
||||
);
|
||||
const i = ++blockIndex;
|
||||
const c = choices[i];
|
||||
const lines = blockLines(p, c);
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
ref={(el) => {
|
||||
blockRefs.current[i] = el;
|
||||
}}
|
||||
data-testid="merge-block"
|
||||
className={`my-1 border-y-2 ${
|
||||
c ? "border-status-success/60" : "border-status-warning/70"
|
||||
} ${at === i ? "ring-1 ring-accent" : ""}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
|
||||
<span className="mr-auto text-fg-muted">
|
||||
{t("merge.blockOf", { n: i + 1, total })}
|
||||
</span>
|
||||
{editing?.i === i ? (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => {
|
||||
choose(i, { kind: "custom", text: editing.text });
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
{t("merge.editDone")}
|
||||
</button>
|
||||
) : c ? (
|
||||
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
||||
{t("merge.undoChoice")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "ours" },
|
||||
t("merge.acceptCurrent"),
|
||||
"merge-accept-current"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "theirs" },
|
||||
t("merge.acceptIncoming"),
|
||||
"merge-accept-incoming"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "ours-theirs" },
|
||||
t("merge.bothCurrentFirst"),
|
||||
"merge-both-ct"
|
||||
)}
|
||||
{choiceBtn(
|
||||
i,
|
||||
{ kind: "theirs-ours" },
|
||||
t("merge.bothIncomingFirst"),
|
||||
"merge-both-ic"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{editing?.i !== i && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-edit-block"
|
||||
className={BTN}
|
||||
onClick={() =>
|
||||
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
|
||||
}
|
||||
>
|
||||
{t("merge.editBlock")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing?.i === i ? (
|
||||
<textarea
|
||||
data-testid="merge-block-text"
|
||||
aria-label={t("merge.editBlock")}
|
||||
value={editing.text}
|
||||
onChange={(e) => setEditing({ i, text: e.target.value })}
|
||||
spellCheck={false}
|
||||
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
|
||||
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
|
||||
/>
|
||||
) : lines ? (
|
||||
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{lines.length ? lines.join("\n") : " "}
|
||||
</pre>
|
||||
) : (
|
||||
<>
|
||||
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{p.ours.length ? p.ours.join("\n") : " "}
|
||||
</pre>
|
||||
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
||||
{p.theirs.length ? p.theirs.join("\n") : " "}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
wide
|
||||
testId="merge-editor"
|
||||
title={t("merge.title", { path })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<ErrorLine error={error} />
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-save"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("merge.save")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!sides ? (
|
||||
!error && <p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !sides.usable || !parts ? (
|
||||
<p data-testid="merge-unusable" className="text-status-warning">
|
||||
{sides.binary
|
||||
? t("merge.binary")
|
||||
: sides.tooBig
|
||||
? t("merge.tooBig")
|
||||
: sides.working == null
|
||||
? t("merge.noWorking")
|
||||
: t("merge.unbalanced")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
data-testid="merge-left"
|
||||
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
|
||||
>
|
||||
{whole != null
|
||||
? t("merge.wholeMode")
|
||||
: left
|
||||
? t("merge.left", { count: left, total })
|
||||
: t("merge.allChosen")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("merge.prev")}
|
||||
disabled={!total || whole != null}
|
||||
onClick={() => jump(-1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("merge.next")}
|
||||
disabled={!total || whole != null}
|
||||
onClick={() => jump(1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<label className="ml-auto flex items-center gap-1 text-[11px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showBase}
|
||||
onChange={(e) => setShowBase(e.target.checked)}
|
||||
/>
|
||||
{t("merge.showBase")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="merge-whole-toggle"
|
||||
className={BTN}
|
||||
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
|
||||
>
|
||||
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
|
||||
</button>
|
||||
</div>
|
||||
{showBase && (
|
||||
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
|
||||
)}
|
||||
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
|
||||
<Pane
|
||||
title={incomingLabel}
|
||||
text={sides.theirs}
|
||||
testId="merge-incoming"
|
||||
tone="incoming"
|
||||
/>
|
||||
{result}
|
||||
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @file The banner a paused cherry-pick or revert shows above the Changes
|
||||
* panel (the reference tools' "sequencer" bar): "Cherry-pick paused at commit
|
||||
* X (2 of 5): resolve the conflicted files, then Continue — or Skip this
|
||||
* commit, or Abort to go back to where you were", with those three buttons.
|
||||
* Continue is disabled while a file is still conflicted; Skip and Abort throw
|
||||
* work away, so each takes a second click (`useArmed`) and sends
|
||||
* `confirm: true`. Reads GET /git/sequence; acts through git-pick.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import type { PickAction, PickSequence } from "../../../lib/gitHistory";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
|
||||
type Active = Extract<PickSequence, { active: true }>;
|
||||
|
||||
export function PickBanner({
|
||||
target,
|
||||
conflicts,
|
||||
reread,
|
||||
busy,
|
||||
onBusy,
|
||||
onDone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
/** The git facts' conflict count: a change re-reads the sequence. */
|
||||
conflicts: number;
|
||||
reread: number;
|
||||
busy: boolean;
|
||||
onBusy: (busy: boolean) => void;
|
||||
/** A button ran: re-read the facts and the changes. */
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitHistory"]);
|
||||
const [seq, setSeq] = useState<Active | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { arm, disarm, isArmed } = useArmed();
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void api.lanes.gitHistory
|
||||
.sequence(target)
|
||||
.then((r) => live && setSeq(r.available && r.active ? r : null))
|
||||
.catch(() => live && setSeq(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- target is stable per panel
|
||||
}, [conflicts, reread]);
|
||||
|
||||
if (!seq) return null;
|
||||
const op = t(`op.${seq.operation}`);
|
||||
const step = seq.done + 1;
|
||||
const total = seq.done + 1 + seq.remaining.length;
|
||||
const cur = seq.current;
|
||||
const left = seq.conflicts.length;
|
||||
|
||||
const act = async (action: PickAction) => {
|
||||
disarm();
|
||||
onBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.gitHistory.act(
|
||||
target,
|
||||
action,
|
||||
action === "pick-continue" ? {} : { confirm: true }
|
||||
);
|
||||
if (action === "pick-abort") toast.success(t("aborted", { op }));
|
||||
else if (r.paused) toast.error(t("pausedConflicts", { op, count: r.conflicts.length }));
|
||||
else toast.success(t("sequenceDone", { op, count: r.commits?.length ?? 0 }));
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
onBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const btn =
|
||||
"shrink-0 rounded border px-1.5 py-0.5 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
return (
|
||||
<div
|
||||
data-testid="pick-banner"
|
||||
className="mb-2 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
>
|
||||
<p className="[overflow-wrap:anywhere]">
|
||||
<strong>
|
||||
{cur
|
||||
? t("pausedAt", { op, short: cur.short, subject: cur.subject, step, total })
|
||||
: t("pausedBetween", { op, step, total })}
|
||||
</strong>{" "}
|
||||
{left
|
||||
? t("pausedHelpConflicts", { count: left })
|
||||
: cur
|
||||
? t("pausedHelpReady")
|
||||
: t("pausedHelpResume")}
|
||||
</p>
|
||||
{seq.remaining.length > 0 && (
|
||||
<p className="mt-0.5 text-fg-muted [overflow-wrap:anywhere]">
|
||||
{t("stillToApply", {
|
||||
list: seq.remaining.map((c) => c.short).join(", "),
|
||||
count: seq.remaining.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-continue"
|
||||
disabled={busy || left > 0}
|
||||
title={left ? t("continueBlocked") : undefined}
|
||||
onClick={() => void act("pick-continue")}
|
||||
className={`${btn} border-accent/40 bg-accent/15 text-accent hover:bg-accent/25`}
|
||||
>
|
||||
{t("continue")}
|
||||
</button>
|
||||
{cur && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-skip"
|
||||
disabled={busy}
|
||||
title={t("skipHint")}
|
||||
onClick={() => (isArmed("skip") ? void act("pick-skip") : arm("skip"))}
|
||||
className={`${btn} border-border text-fg-secondary hover:bg-surface-2`}
|
||||
>
|
||||
{isArmed("skip") ? t("skipConfirm") : t("skip")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pick-abort"
|
||||
disabled={busy}
|
||||
title={t("abortHint")}
|
||||
onClick={() => (isArmed("abort") ? void act("pick-abort") : arm("abort"))}
|
||||
className={`${btn} border-status-danger/40 text-status-danger hover:bg-status-danger/10`}
|
||||
>
|
||||
{isArmed("abort") ? t("abortConfirm", { op }) : t("abort")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-1 whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @file RebaseDialog.tsx
|
||||
* @description "Rebase current branch onto…": pick a local or fetched remote
|
||||
* branch, see the plan before anything runs (GET /git/rewrite-plan
|
||||
* kind=rebase — how many commits are replayed, how many are already pushed and
|
||||
* so will need a force-push, how far behind the branch is), then `rebase`.
|
||||
* A conflict closes the dialog: the Changes panel's banner then offers
|
||||
* Continue / Skip / Abort and the merge editor resolves the files. A clean
|
||||
* rebase stays open on its result with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
|
||||
/** Why a rewrite cannot start, from the plan's common facts — or null. */
|
||||
export function blockedReason(
|
||||
t: (k: string, o?: Record<string, unknown>) => string,
|
||||
p: { subdir: boolean; operation: string | null; dirty: boolean },
|
||||
needsClean = true
|
||||
): string | null {
|
||||
if (p.subdir) return t("blocked.subdir");
|
||||
if (p.operation) return t("blocked.operation", { op: p.operation });
|
||||
if (needsClean && p.dirty) return t("blocked.dirty");
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RebaseDialog({
|
||||
target,
|
||||
current,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
current: string | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [refs, setRefs] = useState<string[]>([]);
|
||||
const [onto, setOnto] = useState("");
|
||||
const [plan, setPlan] = useState<RebasePlan | null>(null);
|
||||
const [force, setForce] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.gitBranches(target)
|
||||
.then((b) => {
|
||||
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
|
||||
setRefs(list);
|
||||
})
|
||||
.catch((e) => setError(errText(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
setPlan(null);
|
||||
setForce(false);
|
||||
if (!onto) return;
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planRebase(target, onto)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, onto]);
|
||||
|
||||
const blocked = plan ? blockedReason(t, plan) : null;
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "rebase", {
|
||||
onto,
|
||||
head: plan.head,
|
||||
...(plan.pushed ? { force: true } : {}),
|
||||
});
|
||||
onChanged();
|
||||
if (r.conflicts?.length || r.stopped) {
|
||||
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
toast.success(t("rebase.done", { onto }));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("rebase.title", { branch: current ?? "HEAD" })}
|
||||
onClose={onClose}
|
||||
testId="rebase-dialog"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="rebase-run"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={
|
||||
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
|
||||
}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t("rebase.run")}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : (
|
||||
<>
|
||||
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
|
||||
{t("rebase.onto")}
|
||||
</label>
|
||||
<select
|
||||
id="rebase-onto"
|
||||
data-testid="rebase-onto"
|
||||
value={onto}
|
||||
onChange={(e) => setOnto(e.target.value)}
|
||||
className="input mb-2 w-full py-1 text-[11px]"
|
||||
>
|
||||
<option value="">{t("rebase.pick")}</option>
|
||||
{refs.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
|
||||
{plan && (
|
||||
<div data-testid="rebase-plan" className="space-y-1">
|
||||
{blocked ? (
|
||||
<Warn>{blocked}</Warn>
|
||||
) : !plan.commits.length ? (
|
||||
<p>{t("rebase.nothing", { onto })}</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{t("rebase.summary", {
|
||||
count: plan.commits.length,
|
||||
onto,
|
||||
behind: plan.behind,
|
||||
})}
|
||||
</p>
|
||||
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
|
||||
{plan.commits.map((c) => (
|
||||
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
|
||||
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
|
||||
{c.pushed && (
|
||||
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{plan.pushed > 0 && (
|
||||
<Warn testId="rebase-pushed">
|
||||
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="rebase-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
</Warn>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file ReflogView.tsx
|
||||
* @description The read-only reflog of HEAD or of one local branch, paged
|
||||
* (GET /git/reflog): each entry's selector, what moved it (commit, rebase,
|
||||
* reset, checkout…), git's message, when, and the commit it points at — a
|
||||
* click opens that commit in the Changes panel. "Reset here" hands the entry
|
||||
* to the reset dialog, which is how a lost commit is brought back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type ReflogEntry } from "../../../lib/gitRewrite";
|
||||
import { BTN, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
function when(time: number | null, lang: string): string {
|
||||
if (!time) return "";
|
||||
try {
|
||||
return new Date(time * 1000).toLocaleString(lang);
|
||||
} catch {
|
||||
return new Date(time * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function ReflogView({
|
||||
target,
|
||||
onClose,
|
||||
onViewCommit,
|
||||
onReset,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
/** Open one commit (the panel's commit view). */
|
||||
onViewCommit?: (sha: string) => void;
|
||||
/** Open the reset dialog at this commit. */
|
||||
onReset?: (entry: ReflogEntry) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["gitTools"]);
|
||||
const [ref, setRef] = useState("HEAD");
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [entries, setEntries] = useState<ReflogEntry[] | null>(null);
|
||||
const [more, setMore] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
api.lanes
|
||||
.gitBranches(target)
|
||||
.then((b) => setBranches(b.branches ?? []))
|
||||
.catch(() => setBranches([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
const page = async (skip: number) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.reflog(target, { ref, skip, limit: PAGE });
|
||||
setEntries((prev) => (skip && prev ? [...prev, ...r.entries] : r.entries));
|
||||
setMore(r.more);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
if (!skip) setEntries([]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setEntries(null);
|
||||
void page(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, ref]);
|
||||
|
||||
return (
|
||||
<GitModal title={t("reflog.title")} onClose={onClose} testId="reflog-view">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<label className="text-[11px]" htmlFor="reflog-ref">
|
||||
{t("reflog.of")}
|
||||
</label>
|
||||
<select
|
||||
id="reflog-ref"
|
||||
data-testid="reflog-ref"
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
className="input min-w-0 max-w-full flex-1 py-0.5 text-[11px]"
|
||||
>
|
||||
<option value="HEAD">HEAD</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="mb-1.5 text-[11px] text-fg-muted">{t("reflog.hint")}</p>
|
||||
<ErrorLine error={error} />
|
||||
{entries == null ? (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !entries.length ? (
|
||||
<p className="text-fg-muted">{t("reflog.empty")}</p>
|
||||
) : (
|
||||
<ul data-testid="reflog-list" className="divide-y divide-border">
|
||||
{entries.map((e) => (
|
||||
<li key={`${e.index}-${e.sha}`} className="flex flex-wrap items-start gap-1.5 py-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{e.selector}</span>
|
||||
<span className="shrink-0 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
|
||||
{e.action}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-words text-[11px] text-fg-primary">
|
||||
{e.message || e.subject}
|
||||
<span className="block text-[10px] text-fg-muted">
|
||||
{when(e.time, i18n.language)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{onViewCommit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-[11px] text-accent hover:underline"
|
||||
title={e.subject}
|
||||
onClick={() => onViewCommit(e.sha)}
|
||||
>
|
||||
{e.short}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-[11px]" title={e.subject}>
|
||||
{e.short}
|
||||
</span>
|
||||
)}
|
||||
{onReset && (
|
||||
<button type="button" className={BTN} onClick={() => onReset(e)}>
|
||||
{t("reflog.resetHere")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{more && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reflog-more"
|
||||
disabled={busy}
|
||||
className={`${BTN} mt-2 w-full`}
|
||||
onClick={() => void page(entries?.length ?? 0)}
|
||||
>
|
||||
{t("reflog.more")}
|
||||
</button>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @file RemotesDialog.tsx
|
||||
* @description The repo's remotes: each one's URL (no password is ever
|
||||
* shown — the server strips it) and default branch; add one (name + an
|
||||
* https:// with no login, ssh:// or user@host:path URL — the server's
|
||||
* allowlist), rename one, remove one (two clicks), and delete a branch ON a
|
||||
* remote (two clicks; never its default branch nor main/master/develop —
|
||||
* the server refuses those whatever this shows).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type GitRemote } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, BTN_DANGER, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
||||
|
||||
const PROTECTED = new Set(["main", "master", "development", "develop"]);
|
||||
|
||||
export function RemotesDialog({
|
||||
target,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [remotes, setRemotes] = useState<GitRemote[] | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [renaming, setRenaming] = useState<{ from: string; to: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
const load = useCallback(() => {
|
||||
gitRewrite
|
||||
.remotes(target)
|
||||
.then((r) => setRemotes(r.remotes))
|
||||
.catch((e) => setError(errText(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(ok);
|
||||
onChanged();
|
||||
load();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
const n = name.trim();
|
||||
const u = url.trim();
|
||||
if (
|
||||
await act(
|
||||
() => gitRewrite.run(target, "remote-add", { name: n, url: u }),
|
||||
t("remotes.added", { name: n })
|
||||
)
|
||||
) {
|
||||
setName("");
|
||||
setUrl("");
|
||||
}
|
||||
};
|
||||
const rename = async () => {
|
||||
if (!renaming) return;
|
||||
const { from, to } = renaming;
|
||||
if (
|
||||
await act(
|
||||
() => gitRewrite.run(target, "remote-rename", { name: from, to: to.trim() }),
|
||||
t("remotes.renamed", { from, to: to.trim() })
|
||||
)
|
||||
)
|
||||
setRenaming(null);
|
||||
};
|
||||
const remove = (n: string) => {
|
||||
if (!isArmed(`rm:${n}`)) return arm(`rm:${n}`);
|
||||
disarm();
|
||||
void act(
|
||||
() => gitRewrite.run(target, "remote-remove", { name: n, confirm: true }),
|
||||
t("remotes.removed", { name: n })
|
||||
);
|
||||
};
|
||||
const deleteBranch = (ref: string) => {
|
||||
if (!isArmed(`del:${ref}`)) return arm(`del:${ref}`);
|
||||
disarm();
|
||||
void act(
|
||||
() => gitRewrite.run(target, "remote-branch-delete", { branch: ref, confirm: true }),
|
||||
t("remotes.branchDeleted", { ref })
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal title={t("remotes.title")} onClose={onClose} testId="remotes-dialog">
|
||||
{remotes == null ? (
|
||||
!error && <p className="text-fg-muted">{t("loading")}</p>
|
||||
) : !remotes.length ? (
|
||||
<p className="mb-2">{t("remotes.none")}</p>
|
||||
) : (
|
||||
<ul data-testid="remotes-list" className="mb-3 space-y-2">
|
||||
{remotes.map((r) => (
|
||||
<li key={r.name} className="rounded border border-border-light p-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{renaming?.from === r.name ? (
|
||||
<input
|
||||
aria-label={t("remotes.newName")}
|
||||
data-testid="remote-rename-input"
|
||||
value={renaming.to}
|
||||
onChange={(e) => setRenaming({ from: r.name, to: e.target.value })}
|
||||
className="input min-w-0 flex-1 py-0.5 font-mono text-[11px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-semibold text-fg-primary">
|
||||
{r.name}
|
||||
</span>
|
||||
)}
|
||||
{renaming?.from === r.name ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !renaming.to.trim()}
|
||||
onClick={() => void rename()}
|
||||
>
|
||||
{t("remotes.rename")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setRenaming(null)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setRenaming({ from: r.name, to: r.name })}
|
||||
>
|
||||
{t("remotes.rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remote-remove-${r.name}`}
|
||||
className={BTN_DANGER}
|
||||
disabled={busy}
|
||||
onClick={() => remove(r.name)}
|
||||
>
|
||||
{isArmed(`rm:${r.name}`) ? t("remotes.removeConfirm") : t("remotes.remove")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 break-all font-mono text-[10px] text-fg-muted">{r.url}</p>
|
||||
{r.defaultBranch && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("remotes.default", { branch: r.defaultBranch })}
|
||||
</p>
|
||||
)}
|
||||
{!!r.branches?.length && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-[11px]">
|
||||
{t("remotes.branches", { count: r.branches.length })}
|
||||
</summary>
|
||||
<ul className="mt-1 max-h-40 space-y-0.5 overflow-y-auto">
|
||||
{r.branches.map((b) => {
|
||||
const ref = `${r.name}/${b}`;
|
||||
const locked = PROTECTED.has(b) || b === r.defaultBranch;
|
||||
return (
|
||||
<li key={b} className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{b}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`remote-branch-delete-${ref}`}
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || locked}
|
||||
title={locked ? t("remotes.branchLocked") : undefined}
|
||||
onClick={() => deleteBranch(ref)}
|
||||
>
|
||||
{isArmed(`del:${ref}`)
|
||||
? t("remotes.deleteBranchConfirm")
|
||||
: t("remotes.deleteBranch")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void add();
|
||||
}}
|
||||
className="space-y-1.5 rounded border border-border p-1.5"
|
||||
>
|
||||
<p className="text-[11px] font-medium text-fg-primary">{t("remotes.add")}</p>
|
||||
<input
|
||||
aria-label={t("remotes.name")}
|
||||
placeholder={t("remotes.name")}
|
||||
data-testid="remote-add-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
<input
|
||||
aria-label={t("remotes.url")}
|
||||
placeholder="https://host/owner/repo.git"
|
||||
data-testid="remote-add-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-fg-muted">{t("remotes.urlHint")}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="remote-add-submit"
|
||||
className={BTN_PRIMARY}
|
||||
disabled={busy || !name.trim() || !url.trim()}
|
||||
>
|
||||
{t("remotes.addButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<ErrorLine error={error} />
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @file ResetDialog.tsx
|
||||
* @description "Reset current branch to here": the plan first (GET
|
||||
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
|
||||
* how many of those leaving are already pushed), then one of four modes, each
|
||||
* explained: soft and mixed never touch the working tree, keep refuses to
|
||||
* overwrite a local change, and hard runs only on a clean tree with the branch
|
||||
* name typed back. Pushed commits need an explicit tick (a force-push will be
|
||||
* needed). A done reset stays open on its result with **Undo**.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import {
|
||||
errText,
|
||||
gitRewrite,
|
||||
type ResetMode,
|
||||
type ResetPlan,
|
||||
type RewriteResult,
|
||||
} from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
||||
import { RewriteDone } from "./RewriteDone";
|
||||
import { blockedReason } from "./RebaseDialog";
|
||||
|
||||
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
|
||||
|
||||
export function ResetDialog({
|
||||
target,
|
||||
commit,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
commit: { sha: string; short?: string; subject?: string };
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [plan, setPlan] = useState<ResetPlan | null>(null);
|
||||
const [mode, setMode] = useState<ResetMode>("mixed");
|
||||
const [force, setForce] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RewriteResult | null>(null);
|
||||
const key = JSON.stringify(target);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
gitRewrite
|
||||
.planReset(target, commit.sha)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch((e) => live && setError(errText(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, commit.sha]);
|
||||
|
||||
const branch = plan?.branch ?? "HEAD";
|
||||
const blocked = plan ? blockedReason(t, plan, false) : null;
|
||||
const hardBlocked = mode === "hard" && !!plan?.dirty;
|
||||
const ready =
|
||||
!!plan &&
|
||||
!blocked &&
|
||||
plan.related &&
|
||||
plan.head !== plan.target &&
|
||||
!(plan.pushed > 0 && !force) &&
|
||||
!hardBlocked &&
|
||||
(mode !== "hard" || typed === branch);
|
||||
|
||||
const run = async () => {
|
||||
if (!plan) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await gitRewrite.run(target, "reset", {
|
||||
target: plan.target,
|
||||
mode,
|
||||
head: plan.head,
|
||||
confirm: true,
|
||||
...(plan.pushed ? { force: true } : {}),
|
||||
...(mode === "hard" ? { confirmBranch: typed } : {}),
|
||||
});
|
||||
onChanged();
|
||||
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitModal
|
||||
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
|
||||
onClose={onClose}
|
||||
testId="reset-dialog"
|
||||
footer={
|
||||
result ? (
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("close")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={BTN} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="reset-run"
|
||||
className={BTN_DANGER}
|
||||
disabled={busy || !ready}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{t("reset.run", { mode })}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{result ? (
|
||||
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
||||
) : !plan ? (
|
||||
error ? (
|
||||
<ErrorLine error={error} />
|
||||
) : (
|
||||
<p className="text-fg-muted">{t("loading")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{commit.subject && (
|
||||
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
|
||||
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
|
||||
{commit.subject}
|
||||
</p>
|
||||
)}
|
||||
{blocked && <Warn>{blocked}</Warn>}
|
||||
{!plan.related ? (
|
||||
<Warn>{t("reset.unrelated")}</Warn>
|
||||
) : plan.head === plan.target ? (
|
||||
<p>{t("reset.already")}</p>
|
||||
) : (
|
||||
<p data-testid="reset-summary" className="mb-1.5">
|
||||
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
|
||||
</p>
|
||||
)}
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
|
||||
{t("reset.mode")}
|
||||
</legend>
|
||||
{MODES.map((m) => (
|
||||
<label key={m} className="flex items-start gap-1.5 text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="reset-mode"
|
||||
data-testid={`reset-mode-${m}`}
|
||||
value={m}
|
||||
checked={mode === m}
|
||||
onChange={() => setMode(m)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
|
||||
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{mode === "hard" &&
|
||||
(plan.dirty ? (
|
||||
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
|
||||
) : (
|
||||
<div className="mt-1.5">
|
||||
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
|
||||
{t("reset.typeBranch", { branch })}
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-branch"
|
||||
data-testid="reset-confirm-branch"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="input w-full font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{plan.pushed > 0 && (
|
||||
<Warn testId="reset-pushed">
|
||||
{t("reset.pushed", { count: plan.pushed })}
|
||||
<label className="mt-1 flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="reset-force"
|
||||
checked={force}
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
/>
|
||||
{t("forceAck")}
|
||||
</label>
|
||||
</Warn>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</>
|
||||
)}
|
||||
</GitModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file RewriteDone.tsx
|
||||
* @description What a finished rebase / interactive rebase / reset leaves on
|
||||
* screen: the result, a force-push reminder when the rewrite touched pushed
|
||||
* commits, and the recovery button — **Undo** = `undo-rewrite` (`reset --keep`
|
||||
* back to the recorded `before`), which the server runs only while HEAD is
|
||||
* still the `after` this dialog saw (409 ESTALE otherwise).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Undo2 } from "lucide-react";
|
||||
import type { GitTarget } from "../../../lib/api";
|
||||
import { errText, gitRewrite, type RewriteResult } from "../../../lib/gitRewrite";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { useArmed } from "../../../hooks/useArmed";
|
||||
import { BTN, ErrorLine, Warn } from "./GitModal";
|
||||
|
||||
export function RewriteDone({
|
||||
target,
|
||||
result,
|
||||
onChanged,
|
||||
onUndone,
|
||||
}: {
|
||||
target: GitTarget;
|
||||
result: RewriteResult;
|
||||
onChanged: () => void;
|
||||
onUndone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitTools"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { isArmed, arm, disarm } = useArmed();
|
||||
const canUndo = !!result.before && !!result.after && result.before !== result.after;
|
||||
|
||||
const undo = async () => {
|
||||
if (!isArmed("undo")) return arm("undo");
|
||||
disarm();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await gitRewrite.run(target, "undo-rewrite", {
|
||||
to: result.before,
|
||||
head: result.after,
|
||||
confirm: true,
|
||||
});
|
||||
toast.success(t("undone"));
|
||||
onChanged();
|
||||
onUndone();
|
||||
} catch (e) {
|
||||
setError(errText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="git-rewrite-done" className="space-y-1.5">
|
||||
<p className="flex items-center gap-1.5 text-status-success">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
{t("doneTitle")}
|
||||
</p>
|
||||
{result.output && (
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words rounded bg-surface-2 p-1.5 font-mono text-[10px] text-fg-muted">
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
{result.needsForcePush && <Warn>{t("needsForcePushAfter")}</Warn>}
|
||||
{canUndo && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="git-rewrite-undo"
|
||||
disabled={busy}
|
||||
onClick={() => void undo()}
|
||||
className={`${BTN} flex items-center gap-1`}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
{isArmed("undo") ? t("undoConfirm") : t("undo")}
|
||||
</button>
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
{t("undoHint", { sha: result.before!.slice(0, 7) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ErrorLine error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @file The Tags tab of the git bar's Branches panel (`BranchManager`): the
|
||||
* repo's tags (name, target commit, and for an annotated tag its message,
|
||||
* tagger and date), a form that creates one — lightweight, or annotated when a
|
||||
* message is typed — on HEAD or on a branch, tag or commit sha, and per tag:
|
||||
* new branch from it, push it to origin (one tag, never `--tags`, never
|
||||
* forced) and delete it locally, which is irreversible and so asks first. The
|
||||
* server looks every name up (server/lib/git-refs.js); nothing here decides.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Plus, Tag, Trash2, Upload } from "lucide-react";
|
||||
import type { GitTag } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../../lib/keys";
|
||||
import { timeAgo } from "../../../lib/format";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { smallBtn, smallInput, type RunRefAction } from "./refUi";
|
||||
|
||||
export function TagManager({
|
||||
tags,
|
||||
busy,
|
||||
merging,
|
||||
dirty,
|
||||
run,
|
||||
}: {
|
||||
/** Already filtered by the panel's search box. */
|
||||
tags: GitTag[];
|
||||
busy: boolean;
|
||||
merging: boolean;
|
||||
dirty: number;
|
||||
run: RunRefAction;
|
||||
}) {
|
||||
const { t } = useTranslation(["gitRefs"]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [branchName, setBranchName] = useState<string | null>(null);
|
||||
const [switchTo, setSwitchTo] = useState(false);
|
||||
|
||||
const create = async () => {
|
||||
const tag = name.trim();
|
||||
if (!tag) return;
|
||||
const ok = await run(
|
||||
"tag-create",
|
||||
{
|
||||
tag,
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(from.trim() ? { from: from.trim() } : {}),
|
||||
},
|
||||
t("done.tagCreate", { tag })
|
||||
);
|
||||
if (ok) {
|
||||
setName("");
|
||||
setMessage("");
|
||||
setFrom("");
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
const push = async (tag: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tagPush"),
|
||||
message: t("tagPushConfirm", { tag }),
|
||||
confirmLabel: t("tagPush"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok) await run("tag-push", { tag }, t("done.tagPush", { tag }));
|
||||
};
|
||||
const remove = async (tag: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("tagDelete"),
|
||||
message: t("tagDeleteConfirm", { tag }),
|
||||
confirmLabel: t("tagDelete"),
|
||||
destructive: true,
|
||||
});
|
||||
if (ok && (await run("tag-delete", { tag, confirm: true }, t("done.tagDelete", { tag }))))
|
||||
setSelected(null);
|
||||
};
|
||||
const branchFrom = (tag: string) => {
|
||||
const branch = (branchName ?? "").trim();
|
||||
if (!branch) return;
|
||||
void run(
|
||||
"branch-create",
|
||||
{ branch, from: `refs/tags/${tag}`, ...(switchTo ? { switch: true } : {}) },
|
||||
t("done.create", { branch })
|
||||
).then((ok) => ok && setBranchName(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="px-2">
|
||||
{creating ? (
|
||||
<div className="space-y-1.5 rounded border border-border-light bg-surface-0/40 p-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) void create();
|
||||
}}
|
||||
placeholder={t("tagName")}
|
||||
aria-label={t("tagName")}
|
||||
className={`${smallInput} w-full`}
|
||||
/>
|
||||
<input
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
placeholder={t("tagFrom")}
|
||||
aria-label={t("tagFrom")}
|
||||
className={`${smallInput} w-full`}
|
||||
/>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t("tagMessage")}
|
||||
aria-label={t("tagMessage")}
|
||||
className={`${smallInput} w-full resize-y font-sans`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging || !name.trim()}
|
||||
onClick={() => void create()}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{message.trim() ? t("tagCreateAnnotated") : t("tagCreateLight")}
|
||||
</button>
|
||||
<button type="button" className={smallBtn} onClick={() => setCreating(false)}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging}
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("tagNew")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<p className="px-2 text-xs text-fg-muted">{t("noTags")}</p>
|
||||
) : (
|
||||
<ul>
|
||||
{tags.map((tag) => {
|
||||
const open = selected === tag.name;
|
||||
return (
|
||||
<li key={tag.name} className={open ? "rounded bg-surface-2" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
data-ref-row
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setSelected(open ? null : tag.name);
|
||||
setBranchName(null);
|
||||
}}
|
||||
className="flex w-full min-w-0 items-start gap-2 rounded px-2 py-1 text-left hover:bg-surface-3 focus:outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||
>
|
||||
<Tag className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span title={tag.name} className="truncate font-mono text-xs text-fg-primary">
|
||||
{tag.name}
|
||||
</span>
|
||||
{tag.sha && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{tag.sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
{tag.annotated && (
|
||||
<span className="shrink-0 rounded border border-border-light px-1 text-[9px] uppercase text-fg-muted">
|
||||
{t("annotated")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-fg-muted">
|
||||
{[tag.message, tag.tagger, tag.date ? timeAgo(tag.date) : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-1.5 border-t border-border-light px-2 py-1.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !tag.sha}
|
||||
onClick={() => {
|
||||
setBranchName((v) => (v === null ? "" : null));
|
||||
setSwitchTo(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("newFrom")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || merging}
|
||||
title={t("tagPushHint")}
|
||||
onClick={() => void push(tag.name)}
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{t("tagPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${smallBtn} hover:text-status-danger`}
|
||||
disabled={busy || merging}
|
||||
title={t("tagDeleteHint")}
|
||||
onClick={() => void remove(tag.name)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("tagDelete")}
|
||||
</button>
|
||||
</div>
|
||||
{branchName !== null && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={branchName}
|
||||
onChange={(e) => setBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) branchFrom(tag.name);
|
||||
}}
|
||||
placeholder={t("newBranchName")}
|
||||
aria-label={t("newBranchName")}
|
||||
className={smallInput}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={switchTo}
|
||||
onChange={setSwitchTo}
|
||||
disabled={dirty > 0 || merging}
|
||||
label={t("switchToIt")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={smallBtn}
|
||||
disabled={busy || !branchName.trim()}
|
||||
onClick={() => branchFrom(tag.name)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagManager;
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's history tools: the 3-way merge editor
|
||||
* (three panes, per-block accept/both/edit, "N left", Save only when every
|
||||
* block is chosen and it sends the composed result with the opened sha), the
|
||||
* rebase banner's Skip, the interactive-rebase list (pushed warning + tick,
|
||||
* reorder, squash/reword/drop in the todo, two-click run, Undo after) and the
|
||||
* reset dialog (mode, hard needs a clean tree and the typed branch name).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MergeEditor, composeResult } from "../MergeEditor";
|
||||
import { InteractiveRebase, todoProblem } from "../InteractiveRebase";
|
||||
import { ResetDialog } from "../ResetDialog";
|
||||
import { RebaseSkipButton } from "../GitTools";
|
||||
import { gitRewrite } from "../../../../lib/gitRewrite";
|
||||
import { parseConflicts } from "../../../../lib/conflicts";
|
||||
|
||||
vi.mock("../../../../lib/gitRewrite", async (orig) => {
|
||||
const real = await orig<typeof import("../../../../lib/gitRewrite")>();
|
||||
return {
|
||||
...real,
|
||||
gitRewrite: {
|
||||
conflict: vi.fn(),
|
||||
planInteractive: vi.fn(),
|
||||
planReset: vi.fn(),
|
||||
run: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../../../lib/toast", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const WORKING = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine",
|
||||
"=======",
|
||||
"theirs",
|
||||
">>>>>>> feat",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> feat",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const HEAD = "a".repeat(40);
|
||||
const commit = (n: number, pushed = false) => ({
|
||||
sha: String(n).repeat(40),
|
||||
short: String(n).repeat(7),
|
||||
subject: `c${n}`,
|
||||
message: `c${n}`,
|
||||
pushed,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitRewrite.conflict)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
operation: "merge",
|
||||
branch: "main",
|
||||
incoming: { sha: "f".repeat(40), short: "fffffff", subject: "feat work" },
|
||||
base: "top\nbase\n",
|
||||
ours: "top\nmine\n",
|
||||
theirs: "top\ntheirs\n",
|
||||
working: WORKING,
|
||||
sha: "s1",
|
||||
usable: true,
|
||||
binary: false,
|
||||
tooBig: false,
|
||||
});
|
||||
vi.mocked(gitRewrite.run).mockReset().mockResolvedValue({ action: "x", ok: true, output: "" });
|
||||
});
|
||||
|
||||
describe("MergeEditor", () => {
|
||||
it("shows three panes and saves the composed result once every block is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={onResolved} />);
|
||||
const incoming = await screen.findByTestId("merge-incoming");
|
||||
expect(incoming).toHaveTextContent("Incoming: fffffff feat work");
|
||||
expect(incoming).toHaveTextContent("theirs");
|
||||
expect(screen.getByTestId("merge-current")).toHaveTextContent("Current: main");
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("2 of 2 conflicts left");
|
||||
const save = screen.getByTestId("merge-save");
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
const [first, second] = screen.getAllByTestId("merge-block") as [HTMLElement, HTMLElement];
|
||||
await user.click(within(first).getByTestId("merge-accept-incoming"));
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("1 of 2 conflicts left");
|
||||
await user.click(within(second).getByTestId("merge-both-ic"));
|
||||
expect(screen.getByTestId("merge-left")).toHaveTextContent("Every conflict has a choice");
|
||||
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "conflict-save", {
|
||||
path: "a.txt",
|
||||
content: "top\ntheirs\nmiddle\nb\na\n",
|
||||
baseSha: "s1",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits one block by hand, and the base pane is one click away", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
||||
const [first] = (await screen.findAllByTestId("merge-block")) as [HTMLElement];
|
||||
await user.click(within(first).getByTestId("merge-edit-block"));
|
||||
const box = within(first).getByTestId("merge-block-text");
|
||||
await user.clear(box);
|
||||
await user.type(box, "hand");
|
||||
await user.click(within(first).getByRole("button", { name: "Done" }));
|
||||
expect(first).toHaveTextContent("hand");
|
||||
expect(screen.queryByTestId("merge-base")).toBeNull();
|
||||
await user.click(screen.getByLabelText("Show base"));
|
||||
expect(screen.getByTestId("merge-base")).toHaveTextContent("base");
|
||||
});
|
||||
|
||||
it("labels the sides for a rebase and explains a refused save", async () => {
|
||||
vi.mocked(gitRewrite.conflict).mockResolvedValueOnce({
|
||||
...(await vi.mocked(gitRewrite.conflict).getMockImplementation()!(3, "a.txt")),
|
||||
operation: "rebase",
|
||||
});
|
||||
vi.mocked(gitRewrite.run).mockRejectedValueOnce(
|
||||
Object.assign(new Error("x"), { code: "EMARKERS" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<MergeEditor target={3} path="a.txt" onClose={vi.fn()} onResolved={vi.fn()} />);
|
||||
expect(await screen.findByTestId("merge-current")).toHaveTextContent(
|
||||
"the branch you are rebasing onto"
|
||||
);
|
||||
await user.click(screen.getByTestId("merge-whole-toggle"));
|
||||
await user.click(screen.getByTestId("merge-save"));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("still has conflict markers");
|
||||
});
|
||||
|
||||
it("composeResult keeps markers on an unresolved block", () => {
|
||||
const parts = parseConflicts(WORKING.split("\n"))!;
|
||||
expect(composeResult(parts, [{ kind: "ours" }])).toBe(
|
||||
"top\nmine\nmiddle\n<<<<<<< HEAD\na\n=======\nb\n>>>>>>> feat\n"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RebaseSkipButton", () => {
|
||||
it("skips the stopped commit and re-reads", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<RebaseSkipButton target={3} onChanged={onChanged} />);
|
||||
await user.click(screen.getByTestId("rebase-skip"));
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-skip", {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveRebase", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(gitRewrite.planInteractive)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
kind: "interactive",
|
||||
head: HEAD,
|
||||
branch: "feat",
|
||||
subdir: false,
|
||||
operation: null,
|
||||
dirty: false,
|
||||
base: "b".repeat(40),
|
||||
merges: false,
|
||||
commits: [commit(1, true), commit(2), commit(3)],
|
||||
pushed: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("warns about pushed commits, needs the tick, and sends the edited todo", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
||||
action: "rebase-interactive",
|
||||
ok: true,
|
||||
output: "",
|
||||
before: HEAD,
|
||||
after: "c".repeat(40),
|
||||
conflicts: [],
|
||||
stopped: false,
|
||||
needsForcePush: true,
|
||||
});
|
||||
render(<InteractiveRebase target={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||
expect(await screen.findByTestId("interactive-summary")).toHaveTextContent(
|
||||
"This rewrites 3 commits; 1 are already pushed, so a force-push will be needed."
|
||||
);
|
||||
const run = screen.getByTestId("interactive-run");
|
||||
expect(run).toBeDisabled(); // unchanged + pushed not acknowledged
|
||||
|
||||
const rows = screen.getAllByTestId("interactive-row") as HTMLElement[];
|
||||
await user.selectOptions(within(rows[1]!).getByTestId("interactive-action"), "squash");
|
||||
await user.selectOptions(within(rows[2]!).getByTestId("interactive-action"), "reword");
|
||||
const msg = within(rows[2]!).getByTestId("interactive-message");
|
||||
await user.clear(msg);
|
||||
await user.type(msg, "new words");
|
||||
await user.click(within(rows[2]!).getByRole("button", { name: "Move up" }));
|
||||
expect(run).toBeDisabled();
|
||||
await user.click(screen.getByTestId("interactive-force"));
|
||||
expect(run).toBeEnabled();
|
||||
|
||||
await user.click(run);
|
||||
expect(gitRewrite.run).not.toHaveBeenCalled(); // armed only
|
||||
await user.click(screen.getByRole("button", { name: "Click again to rewrite" }));
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "rebase-interactive", {
|
||||
head: HEAD,
|
||||
confirm: true,
|
||||
force: true,
|
||||
todo: [
|
||||
{ sha: commit(1).sha, action: "pick" },
|
||||
{ sha: commit(3).sha, action: "reword", message: "new words" },
|
||||
{ sha: commit(2).sha, action: "squash" },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("git-rewrite-done")).toHaveTextContent(
|
||||
"Force push (with lease)"
|
||||
);
|
||||
expect(screen.getByTestId("git-rewrite-undo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("todoProblem refuses a squash with nothing above and an empty reword", () => {
|
||||
const c = commit(1);
|
||||
expect(todoProblem([{ commit: c, action: "squash", message: "" }])).toBe("first");
|
||||
expect(todoProblem([{ commit: c, action: "drop", message: "" }])).toBe("allDropped");
|
||||
expect(todoProblem([{ commit: c, action: "reword", message: " " }])).toBe("reword");
|
||||
expect(todoProblem([{ commit: c, action: "pick", message: "" }])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResetDialog", () => {
|
||||
const plan = (over = {}) => ({
|
||||
available: true,
|
||||
kind: "reset" as const,
|
||||
head: HEAD,
|
||||
branch: "feat",
|
||||
subdir: false,
|
||||
operation: null,
|
||||
dirty: false,
|
||||
target: "d".repeat(40),
|
||||
related: true,
|
||||
dropped: 2,
|
||||
added: 0,
|
||||
pushed: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("hard needs the branch name typed back", async () => {
|
||||
vi.mocked(gitRewrite.planReset).mockReset().mockResolvedValue(plan());
|
||||
vi.mocked(gitRewrite.run).mockResolvedValueOnce({
|
||||
action: "reset",
|
||||
ok: true,
|
||||
output: "",
|
||||
before: HEAD,
|
||||
after: "d".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResetDialog
|
||||
target={3}
|
||||
commit={{ sha: "d".repeat(40), short: "ddddddd", subject: "old" }}
|
||||
onClose={vi.fn()}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByTestId("reset-summary")).toHaveTextContent("2 commit(s) leave feat");
|
||||
await user.click(screen.getByTestId("reset-mode-hard"));
|
||||
const run = screen.getByTestId("reset-run");
|
||||
expect(run).toBeDisabled();
|
||||
await user.type(screen.getByTestId("reset-confirm-branch"), "feat");
|
||||
expect(run).toBeEnabled();
|
||||
await user.click(run);
|
||||
await waitFor(() =>
|
||||
expect(gitRewrite.run).toHaveBeenCalledWith(3, "reset", {
|
||||
target: "d".repeat(40),
|
||||
mode: "hard",
|
||||
head: HEAD,
|
||||
confirm: true,
|
||||
confirmBranch: "feat",
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("git-rewrite-undo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses hard on a dirty tree and asks for the tick on pushed commits", async () => {
|
||||
vi.mocked(gitRewrite.planReset)
|
||||
.mockReset()
|
||||
.mockResolvedValue(plan({ dirty: true, pushed: 1 }));
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResetDialog
|
||||
target={3}
|
||||
commit={{ sha: "d".repeat(40) }}
|
||||
onClose={vi.fn()}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await screen.findByTestId("reset-pushed");
|
||||
const run = screen.getByTestId("reset-run");
|
||||
expect(run).toBeDisabled();
|
||||
await user.click(screen.getByTestId("reset-force"));
|
||||
expect(run).toBeEnabled(); // mixed works on a dirty tree
|
||||
await user.click(screen.getByTestId("reset-mode-hard"));
|
||||
expect(screen.getByTestId("reset-hard-dirty")).toBeInTheDocument();
|
||||
expect(run).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @file Pins the History tab's graph view: rows with their graph cell and ref
|
||||
* badges, the search box turning `author:`/`path:` into the log's filters,
|
||||
* all branches, multi-select (checkbox and Shift-click) into a cherry-pick
|
||||
* preview that lists the commits in the server's order and runs only on
|
||||
* Confirm, a paused result handed to the overview, and Undo after a clean
|
||||
* pick (asked first, sent with the from/to the pick answered). Also the pure
|
||||
* `parseSearch` helper, and the commit menu's rewrite tools (Reset branch to
|
||||
* here, Edit commits after this one) and Create tag here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HistoryView } from "../HistoryView";
|
||||
import { api } from "../../../../lib/api";
|
||||
import { confirmDialog } from "../../../../lib/confirm";
|
||||
import { parseSearch } from "../../../../lib/gitHistory";
|
||||
import type { LaneCommit } from "../../../../lib/types";
|
||||
|
||||
vi.mock("../../../../lib/confirm", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
gitLog: vi.fn(),
|
||||
changes: vi.fn(),
|
||||
gitRefAction: vi.fn(),
|
||||
gitHistory: {
|
||||
commit: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
pickPlan: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
act: vi.fn(),
|
||||
compare: vi.fn(),
|
||||
treeAt: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const sha = (c: string) => c.repeat(40);
|
||||
const commit = (c: string, over: Partial<LaneCommit> = {}): LaneCommit => ({
|
||||
sha: sha(c),
|
||||
short: c.repeat(7),
|
||||
subject: `commit ${c}`,
|
||||
author: "Ann",
|
||||
date: "2026-10-01T00:00:00Z",
|
||||
refs: [],
|
||||
parents: [],
|
||||
badges: [],
|
||||
...over,
|
||||
});
|
||||
// other: c -> b (feature branch), main: a (HEAD)
|
||||
const LOG = [
|
||||
commit("c", { parents: [sha("b")], badges: [{ kind: "local", name: "feature" }] }),
|
||||
commit("b", { parents: [sha("a")] }),
|
||||
commit("a", {
|
||||
parents: [],
|
||||
badges: [
|
||||
{ kind: "local", name: "main", current: true },
|
||||
{ kind: "tag", name: "v1" },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const props = () => ({
|
||||
target: 7,
|
||||
branch: "main",
|
||||
branches: ["main", "feature"],
|
||||
historyRef: null,
|
||||
onHistoryRef: vi.fn(),
|
||||
reread: 0,
|
||||
disabled: false,
|
||||
headPushed: false,
|
||||
onViewCommit: vi.fn(),
|
||||
onOpenFileAtCommit: vi.fn(),
|
||||
onUndoCommit: vi.fn(),
|
||||
onResetHere: vi.fn(),
|
||||
onEditAfter: vi.fn(),
|
||||
onPaused: vi.fn(),
|
||||
onChanged: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitLog).mockReset().mockResolvedValue({ available: true, commits: LOG });
|
||||
vi.mocked(api.lanes.changes)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
range: "commit",
|
||||
base: null,
|
||||
baseRef: null,
|
||||
sha: sha("c"),
|
||||
context: 3,
|
||||
truncated: false,
|
||||
files: [],
|
||||
});
|
||||
vi.mocked(api.lanes.gitHistory.commit).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockReset();
|
||||
vi.mocked(api.lanes.gitHistory.act).mockReset();
|
||||
vi.mocked(api.lanes.gitHistory.pickPlan)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_t, op, shas) => ({
|
||||
available: true,
|
||||
op,
|
||||
// The server's order: oldest first for a pick.
|
||||
commits: [...shas].reverse().map((s) => ({
|
||||
sha: s,
|
||||
short: s.slice(0, 7),
|
||||
subject: `commit ${s[0]}`,
|
||||
author: "Ann",
|
||||
date: "",
|
||||
})),
|
||||
branch: "main",
|
||||
clean: true,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
}));
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
});
|
||||
|
||||
describe("parseSearch", () => {
|
||||
it("splits author: and path: from the message text", () => {
|
||||
expect(parseSearch('fix login author:ann path:"src/a b.ts"')).toEqual({
|
||||
q: "fix login",
|
||||
author: "ann",
|
||||
path: "src/a b.ts",
|
||||
});
|
||||
expect(parseSearch(" ")).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryView", () => {
|
||||
it("draws one graph cell per commit with its ref badges", async () => {
|
||||
render(<HistoryView {...props()} />);
|
||||
await screen.findByText("commit c");
|
||||
expect(screen.getAllByTestId("lane-changes-graph")).toHaveLength(3);
|
||||
const badges = screen.getAllByTestId("ref-badge");
|
||||
expect(badges.map((b) => b.textContent)).toEqual(["feature", "main", "v1"]);
|
||||
expect(badges[1]).toHaveAttribute("title", expect.stringMatching(/branch you are on/));
|
||||
});
|
||||
|
||||
it("searches with filters and reads every branch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HistoryView {...props()} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.type(screen.getByTestId("history-search"), "fix author:ann");
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 100,
|
||||
q: "fix",
|
||||
author: "ann",
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
await user.click(screen.getByRole("checkbox", { name: "All branches" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||
limit: 100,
|
||||
all: true,
|
||||
q: "fix",
|
||||
author: "ann",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("multi-selects, previews the cherry-pick in order, and runs it only on Confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = { ...props(), historyRef: "feature" };
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
from: sha("a"),
|
||||
to: sha("e"),
|
||||
commits: [
|
||||
{ sha: sha("d"), short: "ddddddd", subject: "commit b" },
|
||||
{ sha: sha("e"), short: "eeeeeee", subject: "commit c" },
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitHistory.act).mockResolvedValue({
|
||||
action: "undo-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
|
||||
// Shift-click extends from the anchor (c) to b.
|
||||
fireEvent.click(screen.getByText("commit b"), { shiftKey: true });
|
||||
expect(screen.getByTestId("history-selection")).toHaveTextContent("2 commits selected");
|
||||
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
const plan = await screen.findByTestId("pick-plan");
|
||||
expect(
|
||||
within(plan)
|
||||
.getAllByRole("listitem")
|
||||
.map((li) => li.textContent)
|
||||
).toEqual(["1.bbbbbbbcommit b", "2.ccccccccommit c"]);
|
||||
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent("clean");
|
||||
expect(api.lanes.gitHistory.pick).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByTestId("pick-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [sha("b"), sha("c")])
|
||||
);
|
||||
expect(p.onChanged).toHaveBeenCalled();
|
||||
|
||||
// Undo is offered, asked first, and sent with what the pick answered.
|
||||
await user.click(await screen.findByTestId("pick-undo"));
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "undo-pick", {
|
||||
from: sha("a"),
|
||||
to: sha("e"),
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("pick-result")).toBeNull());
|
||||
});
|
||||
|
||||
it("refuses to start when the tree is dirty, and hands a paused pick to the overview", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = { ...props(), historyRef: "feature" };
|
||||
vi.mocked(api.lanes.gitHistory.pickPlan).mockResolvedValueOnce({
|
||||
available: true,
|
||||
op: "cherry-pick",
|
||||
commits: [{ sha: sha("c"), short: "ccccccc", subject: "commit c", author: "", date: "" }],
|
||||
branch: "main",
|
||||
clean: false,
|
||||
repoRoot: true,
|
||||
busy: false,
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
await screen.findByText("commit c");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Select ccccccc" }));
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
await screen.findByTestId("pick-plan");
|
||||
expect(screen.getByTestId("pick-confirm")).toBeDisabled();
|
||||
expect(screen.getByTestId("pick-tree-state")).toHaveTextContent(/uncommitted changes/);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
vi.mocked(api.lanes.gitHistory.pick).mockResolvedValue({
|
||||
action: "cherry-pick",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: ["a.txt"],
|
||||
paused: true,
|
||||
});
|
||||
await user.click(screen.getByTestId("history-pick-selected"));
|
||||
await user.click(await screen.findByTestId("pick-confirm"));
|
||||
await waitFor(() => expect(p.onPaused).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("pick-result")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the details pane on a click and the full change on a double-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = props();
|
||||
render(<HistoryView {...p} />);
|
||||
await user.click(await screen.findByText("commit b"));
|
||||
expect(await screen.findByTestId("commit-details")).toBeInTheDocument();
|
||||
expect(api.lanes.gitHistory.commit).toHaveBeenCalledWith(7, sha("b"));
|
||||
await user.dblClick(screen.getByText("commit c"));
|
||||
expect(p.onViewCommit).toHaveBeenCalledWith(sha("c"));
|
||||
});
|
||||
|
||||
it("keeps the rewrite tools in the commit menu and creates a tag at a commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const p = props();
|
||||
vi.mocked(api.lanes.gitRefAction).mockReset().mockResolvedValue({
|
||||
action: "tag-create",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
render(<HistoryView {...p} />);
|
||||
const menuOn = async (text: string) => {
|
||||
await user.pointer({ keys: "[MouseRight]", target: await screen.findByText(text) });
|
||||
return screen.getByTestId("lane-changes-commit-menu");
|
||||
};
|
||||
// HEAD's own commit (main, the current branch): nothing after it to edit.
|
||||
expect(
|
||||
within(await menuOn("commit a")).getByTestId("lane-changes-menu-interactive")
|
||||
).toBeDisabled();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(within(await menuOn("commit b")).getByText("Reset branch to here…"));
|
||||
expect(p.onResetHere).toHaveBeenCalledWith(expect.objectContaining({ sha: sha("b") }));
|
||||
await user.click(within(await menuOn("commit b")).getByText("Edit commits after this one…"));
|
||||
expect(p.onEditAfter).toHaveBeenCalledWith(sha("b"));
|
||||
|
||||
await user.click(within(await menuOn("commit a")).getByText("Create tag here…"));
|
||||
await user.type(screen.getByTestId("create-ref-name"), "v2");
|
||||
await user.click(screen.getByTestId("create-ref-confirm"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitRefAction).toHaveBeenCalledWith(7, "tag-create", {
|
||||
tag: "v2",
|
||||
from: sha("a"),
|
||||
})
|
||||
);
|
||||
expect(p.onChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file Pins the paused cherry-pick/revert banner: it names the commit it
|
||||
* stopped at and the step ("2 of 3"), Continue waits for every conflict to be
|
||||
* resolved, Skip and Abort each need a second click and only then send
|
||||
* `confirm: true`, and it draws nothing when no pick is open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PickBanner } from "../PickBanner";
|
||||
import { api } from "../../../../lib/api";
|
||||
|
||||
vi.mock("../../../../lib/api", () => ({
|
||||
api: { lanes: { gitHistory: { sequence: vi.fn(), act: vi.fn() } } },
|
||||
}));
|
||||
|
||||
const paused = (conflicts: string[]) => ({
|
||||
available: true as const,
|
||||
active: true as const,
|
||||
operation: "cherry-pick" as const,
|
||||
current: { sha: "b".repeat(40), short: "bbbbbbb", subject: "edit a" },
|
||||
remaining: [{ sha: "c".repeat(40), short: "ccccccc", subject: "add c" }],
|
||||
done: 1,
|
||||
startedFrom: "a".repeat(40),
|
||||
conflicts,
|
||||
});
|
||||
|
||||
const props = () => ({
|
||||
target: 7,
|
||||
conflicts: 1,
|
||||
reread: 0,
|
||||
busy: false,
|
||||
onBusy: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitHistory.act).mockReset().mockResolvedValue({
|
||||
action: "pick-continue",
|
||||
ok: true,
|
||||
output: "",
|
||||
conflicts: [],
|
||||
paused: false,
|
||||
commits: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("PickBanner", () => {
|
||||
it("names the stop and its step, and Continue waits for the conflicts", async () => {
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused(["a.txt"]));
|
||||
render(<PickBanner {...props()} />);
|
||||
const banner = await screen.findByTestId("pick-banner");
|
||||
expect(banner).toHaveTextContent('cherry-pick paused at bbbbbbb "edit a" (2 of 3)');
|
||||
expect(banner).toHaveTextContent("Still to apply: ccccccc");
|
||||
expect(screen.getByTestId("pick-continue")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("continues once resolved, and skips/aborts only on a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue(paused([]));
|
||||
const p = props();
|
||||
render(<PickBanner {...p} />);
|
||||
await user.click(await screen.findByTestId("pick-continue"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledWith(7, "pick-continue", {})
|
||||
);
|
||||
expect(p.onDone).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("pick-skip"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByTestId("pick-skip"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-skip", { confirm: true });
|
||||
|
||||
await user.click(screen.getByTestId("pick-abort"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenCalledTimes(2);
|
||||
await user.click(screen.getByTestId("pick-abort"));
|
||||
expect(api.lanes.gitHistory.act).toHaveBeenLastCalledWith(7, "pick-abort", { confirm: true });
|
||||
});
|
||||
|
||||
it("draws nothing when no pick is open", async () => {
|
||||
vi.mocked(api.lanes.gitHistory.sequence).mockResolvedValue({ available: true, active: false });
|
||||
const { container } = render(<PickBanner {...props()} />);
|
||||
await waitFor(() => expect(api.lanes.gitHistory.sequence).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Shared bits of the Branches panel (`BranchManager`, `TagManager`): the
|
||||
* small button and input classes, the write runner's type, and the arrow-key
|
||||
* walk over a list's rows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { GitRefAction, GitRefBody } from "../../../lib/api";
|
||||
|
||||
/** Runs one write; resolves true when it succeeded (the list is then re-read). */
|
||||
export type RunRefAction = (
|
||||
action: GitRefAction,
|
||||
body: GitRefBody,
|
||||
done?: string
|
||||
) => Promise<boolean>;
|
||||
|
||||
export const smallBtn =
|
||||
"inline-flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40";
|
||||
export const smallInput =
|
||||
"min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
|
||||
/** Arrow-key walk over the rows of a list (`[data-ref-row]`), Home/End too. */
|
||||
export function onListKeys(e: KeyboardEvent<HTMLElement>) {
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
|
||||
// Inside a text field the arrows and Home/End move the caret.
|
||||
if ((e.target as HTMLElement).closest("input, textarea, select")) return;
|
||||
const rows = [...e.currentTarget.querySelectorAll<HTMLElement>("[data-ref-row]")];
|
||||
if (!rows.length) return;
|
||||
const at = rows.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? rows.length - 1
|
||||
: e.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, at + 1)
|
||||
: Math.max(0, at - 1);
|
||||
e.preventDefault();
|
||||
rows[next]?.focus();
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||
* and Manage opens the Claude config page on that tab.
|
||||
* and Manage opens the Claude config page on that tab. The menu opens
|
||||
* downward when the box sits too near the top to fit it above.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
/** Room the menu and its tallest submenu (Project files: search + list) need. */
|
||||
const MENU_ROOM = 380;
|
||||
|
||||
/** The visible band the button sits in: the viewport, narrowed by any
|
||||
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
|
||||
function clipBand(el: HTMLElement): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
const o = getComputedStyle(p);
|
||||
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
|
||||
const r = p.getBoundingClientRect();
|
||||
top = Math.max(top, r.top);
|
||||
bottom = Math.min(bottom, r.bottom);
|
||||
}
|
||||
}
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
/** Open downward when there is not room above but more below. */
|
||||
function opensDown(el: HTMLElement | null): boolean {
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const band = clipBand(el);
|
||||
const above = r.top - band.top;
|
||||
const below = band.bottom - r.bottom;
|
||||
return above < MENU_ROOM && below > above;
|
||||
}
|
||||
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
|
||||
@@ -195,6 +225,7 @@ export function ChatAddMenu({
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [down, setDown] = useState(false);
|
||||
const [sub, setSub] = useState<Sub | null>(null);
|
||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||
@@ -286,6 +317,8 @@ export function ChatAddMenu({
|
||||
/>
|
||||
))
|
||||
);
|
||||
// A submenu lines up with the menu's edge nearest the button.
|
||||
const edge = down ? "top-0" : "bottom-0";
|
||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||
|
||||
return (
|
||||
@@ -296,13 +329,17 @@ export function ChatAddMenu({
|
||||
aria-label={t("chat.add")}
|
||||
title={t("chat.add")}
|
||||
aria-expanded={open}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onClick={() => {
|
||||
if (open) return close();
|
||||
setDown(opensDown(ref.current));
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
|
||||
<Row
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
label={t("chat.upload")}
|
||||
@@ -376,13 +413,13 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "project" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
|
||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sub === "context" || sub === "recent") && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{(sub === "context" ? context : recent)?.length ? (
|
||||
(sub === "context" ? context! : recent).map((it) => (
|
||||
@@ -405,7 +442,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "skills" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
@@ -417,7 +454,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "plugins" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{lists && lists.plugins.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||
@@ -443,7 +480,7 @@ export function ChatAddMenu({
|
||||
onClick={() => manage("plugins")}
|
||||
/>
|
||||
{shownPlugin && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{entries(
|
||||
shownPlugin.items,
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* text file it read, wrote or named (a generated report) anywhere else through
|
||||
* `GET /api/sessions/:id/file`.
|
||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in
|
||||
* a modal over the whole screen.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
* or the project's); a built-in skill has no file and shows by name only.
|
||||
@@ -39,6 +41,7 @@ import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { HtmlFrame, fetchVia } from "../HtmlFrame";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -85,6 +88,18 @@ export const extOf = (p: string) => {
|
||||
export const displayName = (p: string) =>
|
||||
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||
|
||||
/** A file inside the pane's folder: the git target that reads it and its path there. */
|
||||
function workspaceFile(path: string, scope: PreviewScope) {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
|
||||
const target =
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
return { target, rel: file.slice(base.length + 1) };
|
||||
}
|
||||
|
||||
type Content =
|
||||
| { kind: "loading" }
|
||||
| { kind: "image"; src: string }
|
||||
@@ -121,8 +136,8 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
};
|
||||
}
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.cwd).replace(/\/+$/, "");
|
||||
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) {
|
||||
const inside = workspaceFile(path, scope);
|
||||
if (!inside) {
|
||||
// Claude Code's own files (memory, a skill): the config pages' guarded read.
|
||||
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
|
||||
show(
|
||||
@@ -147,11 +162,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
live = false;
|
||||
};
|
||||
}
|
||||
const rel = file.slice(base.length + 1);
|
||||
const target =
|
||||
base === slashed(scope.laneCwd).replace(/\/+$/, "")
|
||||
? scope.laneId
|
||||
: gitTarget(scope.laneId, scope.sessionId);
|
||||
const { target, rel } = inside;
|
||||
if (IMAGE_RE.test(path)) {
|
||||
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||
return;
|
||||
@@ -235,9 +246,28 @@ function defaultView(path: string): View {
|
||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||
}
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
function Body({
|
||||
path,
|
||||
content,
|
||||
view,
|
||||
scope,
|
||||
}: {
|
||||
path: string;
|
||||
content: Content;
|
||||
view: View;
|
||||
scope: PreviewScope;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
// A page in the workspace brings its relative CSS, scripts and images along.
|
||||
const inside = kind === "html" ? workspaceFile(path, scope) : null;
|
||||
const insideTarget = inside ? JSON.stringify(inside.target) : null;
|
||||
const page = useMemo(() => {
|
||||
if (!inside) return null;
|
||||
const { target, rel } = inside;
|
||||
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [insideTarget, inside?.rel]);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const shown: Content =
|
||||
@@ -290,6 +320,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
if (page && !shown.truncated) {
|
||||
return (
|
||||
<HtmlFrame
|
||||
key={path}
|
||||
path={page.rel}
|
||||
html={shown.text}
|
||||
fetchFile={page.fetchFile}
|
||||
title={baseName(path)}
|
||||
testId="artifact-frame"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
@@ -317,6 +359,7 @@ export function FilePreview({
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
modal = false,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
@@ -324,12 +367,16 @@ export function FilePreview({
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
/** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */
|
||||
modal?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(modal, modalRef, onClose);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
@@ -384,7 +431,9 @@ export function FilePreview({
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={
|
||||
full
|
||||
modal
|
||||
? "flex h-[92vh] w-[96vw] max-w-7xl min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
: full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||
}
|
||||
@@ -482,16 +531,18 @@ export function FilePreview({
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{!modal && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -503,9 +554,35 @@ export function FilePreview({
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} content={content} view={view} />
|
||||
<Body path={path} content={content} view={view} scope={scope} />
|
||||
</section>
|
||||
);
|
||||
if (modal) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
// Nothing reaches the view underneath — so Esc, which useDialog's
|
||||
// document listener would never see, closes here.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={displayName(path)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{panel}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* or an installed, enabled AI tool started bare on its default model and
|
||||
* the account's Session defaults (permission mode, Codex sandbox/approval/
|
||||
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type GroupsUpdate,
|
||||
} from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** The start body of a bare tool on its default model, no prompt. Only a
|
||||
* different tool is named, as the home does: the workspace's own keeps
|
||||
* starting even once disabled. */
|
||||
/** The start body of a bare tool on its default model, no prompt, in the
|
||||
* account's Session defaults (Claude's permission mode, Codex's sandbox,
|
||||
* approval and bypass) — the same ones the workspace home's prompt box starts
|
||||
* from. Only a different tool is named, as the home does: the workspace's own
|
||||
* keeps starting even once disabled. */
|
||||
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||
const model = aiTools.byId(id)?.defaultModel;
|
||||
const codex = id === "codex" ? readCodexDefaults() : null;
|
||||
return {
|
||||
prompt: "",
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
|
||||
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
* of files or the used skills a list with a preview (`FileListModal`,
|
||||
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
|
||||
* "Edited N files" card opens in the Workspace's Changes panel
|
||||
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and
|
||||
* (`openInChanges`) when it lies in the pane's folder. An HTML file — a card,
|
||||
* an Outputs row, a path in a reply — opens instead as the page, the same
|
||||
* preview in a modal over the whole screen. The unsent prompt and
|
||||
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload
|
||||
* leaves them as typed; switching to the terminal pastes them into its input
|
||||
* box and back brings that box's text here (`ChatToggle`). A send scrolls the
|
||||
@@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({
|
||||
setPreview(null);
|
||||
};
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
// An HTML file shown as the page, in a modal over the screen.
|
||||
const [htmlView, setHtmlView] = useState<string | null>(null);
|
||||
const show = useCallback(
|
||||
(path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)),
|
||||
[]
|
||||
);
|
||||
// The side panel's width as a fraction of the pane: the dragged one, else
|
||||
// a default per content (a preview wants more room than the panel).
|
||||
const [dragged, setDragged] = useState(storedSide);
|
||||
@@ -681,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working || waiting ? 5000 : undefined,
|
||||
// The transcript growing re-reads it too: a hook that reached another
|
||||
// dashboard on the same database sends this one no agent_updated, and
|
||||
// without a poll Stop never showed for a turn the chat calls Thinking….
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
@@ -757,20 +768,21 @@ export const PaneChat = memo(function PaneChat({
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
show(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
[cwd, show]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
const path = resolveIn(cwd, named);
|
||||
if (/\.html?$/i.test(path)) return show(path);
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
setPreview(path);
|
||||
},
|
||||
[laneId, cwd]
|
||||
[laneId, cwd, show]
|
||||
);
|
||||
|
||||
// Before the session starts the CLI may be asking in the terminal (trust
|
||||
@@ -984,7 +996,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<ChatOutline
|
||||
outline={activity}
|
||||
onClose={() => openOutline(false)}
|
||||
onOpenFile={setPreview}
|
||||
onOpenFile={show}
|
||||
onOpenList={setList}
|
||||
/>
|
||||
)
|
||||
@@ -1006,6 +1018,15 @@ export const PaneChat = memo(function PaneChat({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{htmlView && (
|
||||
<FilePreview
|
||||
path={htmlView}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setHtmlView(null)}
|
||||
modal
|
||||
/>
|
||||
)}
|
||||
{list === "skills" && (
|
||||
<SkillListModal
|
||||
title={t("chat.outline.skills")}
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Divider({
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||
* own machine drops or pastes with its `file://` path, or pastes from a
|
||||
* file manager's Copy its OS clipboard holds, never uploaded —
|
||||
* a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
claudeMention,
|
||||
copiedFilePaths,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
@@ -77,6 +79,8 @@ import {
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
fileManagerPaths,
|
||||
isClipboardBitmap,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -452,23 +456,36 @@ export function PromptEditor({
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** A file manager's copy pasted with no file beside it, only `file://`
|
||||
* text: attached by those paths, as the terminal pastes them. */
|
||||
const pastedLocalFiles = (dt: DataTransfer | null | undefined) => {
|
||||
const paths = isLocalBrowser() ? copiedFilePaths(dt) : null;
|
||||
if (!paths) return false;
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
return true;
|
||||
};
|
||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||
* dashboard's machine and the server finds the same files there (a file
|
||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||
* manager hands over `file://` paths beside the bytes) or its file manager
|
||||
* holds them (a paste: its clipboard; a drop: its selection — no upload,
|
||||
* no size limit), else uploaded. */
|
||||
const attachDropped = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
list: FileList | File[] | null | undefined,
|
||||
from: "paste" | "drop"
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
if (!isLocalBrowser()) return attachImages(files);
|
||||
const candidates = localCandidates(dt, files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
void (async () => {
|
||||
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
|
||||
paths ??= await fileManagerPaths(files, from);
|
||||
if (!paths) return void attachImages(files);
|
||||
setUpload(null);
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
};
|
||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||
@@ -509,7 +526,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
@@ -674,16 +691,21 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
const pasted = Array.from(e.clipboardData?.files ?? []);
|
||||
const text = e.clipboardData?.getData?.("text/plain") ?? "";
|
||||
// Cells from a spreadsheet come with a picture of them: the text
|
||||
// is what was copied (isClipboardBitmap).
|
||||
const textWithPicture =
|
||||
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
|
||||
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
|
||||
e.preventDefault();
|
||||
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||
else if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
// argument and a tmux message both have a size limit).
|
||||
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
e.preventDefault();
|
||||
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
@@ -863,7 +885,7 @@ export function CwdAutocomplete({
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (isSubmitEnter(e)) {
|
||||
if (open && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user