Add Git Worktree Explorer canvas (#2344)

* Add Git Worktree Explorer canvas

Add an interactive repository, worktree, branch, and commit graph with GitHub PR enrichment, safe inspection actions, and shared lane visualization.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 69dd5824-7094-4b82-8cfa-83c2fbe53307

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review feedback for Git Worktree Explorer

Security
- Quote copied shell commands (git -C / git log) with shell-safe single quoting and --end-of-options via new public/shell-quote.mjs
- Move repository path into the untrusted data block of the commit Ask Copilot prompt

Performance
- Compute branch divergence with a single `git for-each-ref %(ahead-behind:...)` query (Git 2.41+), falling back to rev-list with bounded concurrency (8) instead of one process per branch
- Drop the unused `git show --stat` summary from commit details

Correctness
- Only attach same-repository pull requests to local branches (filter isCrossRepository / headRepositoryOwner)
- Resolve the default branch by full name or upstream instead of a suffix match (server + client)
- Preserve already-loaded commit pages when a load-more request fails; only reset requests show the error state
- Size the lane graph from the widest row (lanes + branch badges + text) and collapse >3 branch tips into a "+N more" badge

Accessibility / UX
- Add aria-pressed to view toggles and graph nodes; replace the unimplemented ARIA tree with group/button semantics
- preventDefault on Space/Enter for branch badges so keyboard activation does not scroll
- Use a lane palette that meets 3:1 contrast in both light and dark themes (tested)
- Mobile inspector no longer auto-opens on snapshot load and gains a close button

Tests: 19 -> 29

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

* Migrate git-worktree-explorer to plugins/ manifest layout

Move the manifest from extensions/git-worktree-explorer/.github/plugin/plugin.json to plugins/git-worktree-explorer/plugin.json with README and copilot-extension.json, matching the extensions-container migration (#2334). Regenerated marketplace.json and docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

* Address follow-up review: shell-specific quoting and sibling row controls

- quoteShellArg/formatShellCommand take a target shell; PowerShell doubles apostrophes while POSIX uses '\\''. app.js detects the platform and reports which syntax was copied.
- Commit rows no longer nest branch badge buttons inside the row button; the row button and badges are sibling controls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

* Make the closed mobile inspector inert and restore focus to the graph

When the inspector overlay is dismissed on narrow viewports it is now marked inert/aria-hidden so its controls leave the tab order and accessibility tree, focus returns to the selected graph control, Escape closes it, and viewport changes re-sync the state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Aaron Powell <me@aaron-powell.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot-Session: 69dd5824-7094-4b82-8cfa-83c2fbe53307
Copilot-Session: 18350039-5e2b-40f0-b537-bc22cabcbb1b
This commit is contained in:
James Montemagno
2026-08-25 17:25:00 -07:00
committed by GitHub
parent 0976c94103
commit ca87445cb2
20 changed files with 3571 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
// Each color keeps at least 3:1 contrast against both the light (#ffffff)
// and dark (#0d1117) canvas backgrounds so lanes stay traceable in either theme.
const COLORS = [
"#0969da",
"#bf3989",
"#bf8700",
"#1a7f37",
"#8250df",
"#bc4c00",
"#1b7c83",
"#cf222e",
];
export const LANE_COLORS = COLORS;
function nextColor(index) {
return COLORS[index % COLORS.length];
}
export function layoutCommitGraph(commits) {
let lanes = [];
let colorIndex = 0;
let maxLanes = 1;
const rows = commits.map((commit) => {
let laneIndex = lanes.findIndex((lane) => lane.sha === commit.sha);
if (laneIndex === -1) {
lanes.push({ sha: commit.sha, color: nextColor(colorIndex++) });
laneIndex = lanes.length - 1;
}
const before = lanes.map((lane) => ({ ...lane }));
const current = before[laneIndex];
const after = lanes.map((lane) => ({ ...lane }));
const firstParent = commit.parents[0] || null;
if (!firstParent) {
after.splice(laneIndex, 1);
} else {
const existingFirstParent = after.findIndex((lane, index) =>
index !== laneIndex && lane.sha === firstParent
);
if (existingFirstParent >= 0) {
after.splice(laneIndex, 1);
} else {
after[laneIndex] = { sha: firstParent, color: current.color };
}
}
for (const parent of commit.parents.slice(1)) {
if (after.some((lane) => lane.sha === parent)) continue;
const insertAt = Math.min(laneIndex + 1, after.length);
after.splice(insertAt, 0, { sha: parent, color: nextColor(colorIndex++) });
}
const transitions = [];
before.forEach((lane, index) => {
if (index === laneIndex) return;
const target = after.findIndex((candidate) => candidate.sha === lane.sha);
if (target >= 0) {
transitions.push({ from: index, to: target, color: lane.color, kind: "pass" });
}
});
commit.parents.forEach((parent, parentIndex) => {
const target = after.findIndex((lane) => lane.sha === parent);
if (target >= 0) {
transitions.push({
from: laneIndex,
to: target,
color: parentIndex === 0 ? current.color : after[target].color,
kind: parentIndex === 0 ? "first-parent" : "merge-parent",
});
}
});
maxLanes = Math.max(maxLanes, before.length, after.length);
lanes = after;
return {
commit,
laneIndex,
color: current.color,
transitions,
lanesBefore: before.length,
lanesAfter: after.length,
};
});
return { rows, maxLanes };
}
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { LANE_COLORS, layoutCommitGraph } from "./graph-layout.mjs";
function luminance(hex) {
const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255)
.map((value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4));
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrast(a, b) {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
test("lane colors meet 3:1 non-text contrast on light and dark backgrounds", () => {
for (const color of LANE_COLORS) {
assert.ok(contrast(color, "#ffffff") >= 3, `${color} on light: ${contrast(color, "#ffffff").toFixed(2)}`);
assert.ok(contrast(color, "#0d1117") >= 3, `${color} on dark: ${contrast(color, "#0d1117").toFixed(2)}`);
}
});
function commit(sha, parents = []) {
return { sha, parents };
}
test("lays out a linear history in one lane", () => {
const graph = layoutCommitGraph([
commit("c", ["b"]),
commit("b", ["a"]),
commit("a"),
]);
assert.equal(graph.maxLanes, 1);
assert.deepEqual(graph.rows.map((row) => row.laneIndex), [0, 0, 0]);
});
test("creates and rejoins a lane for merge parents", () => {
const graph = layoutCommitGraph([
commit("merge", ["main", "topic"]),
commit("topic", ["base"]),
commit("main", ["base"]),
commit("base"),
]);
assert.ok(graph.maxLanes >= 2);
assert.equal(graph.rows[0].transitions.filter((line) => line.kind === "merge-parent").length, 1);
assert.equal(graph.rows.at(-1).commit.sha, "base");
});
test("keeps independent branch tips in separate lanes", () => {
const graph = layoutCommitGraph([
commit("tip-a", ["base"]),
commit("tip-b", ["base"]),
commit("base"),
]);
assert.ok(graph.maxLanes >= 2);
assert.notEqual(graph.rows[0].color, graph.rows[1].color);
});
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Git Worktree Explorer</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="app-header">
<div class="title-block">
<span class="mark" aria-hidden="true"></span>
<div>
<h1>Git Worktree Explorer</h1>
<p id="repo-path">Loading repository…</p>
</div>
</div>
<div class="header-actions">
<span id="github-status" class="status-pill">Local Git</span>
<button id="refresh-button" class="icon-button" type="button" title="Refresh repository" aria-label="Refresh repository"></button>
</div>
</header>
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Topology breadcrumbs"></nav>
<main class="workspace">
<section class="graph-panel" aria-label="Git repository topology">
<div class="graph-toolbar" aria-label="Graph controls">
<button id="view-worktrees" class="view-button active" type="button" aria-pressed="true">Worktrees</button>
<button id="view-branches" class="view-button" type="button" aria-pressed="false">Branches</button>
<span class="toolbar-divider" aria-hidden="true"></span>
<button id="zoom-out" type="button" aria-label="Zoom out"></button>
<button id="zoom-reset" type="button">100%</button>
<button id="zoom-in" type="button" aria-label="Zoom in">+</button>
</div>
<div id="loading" class="loading">
<span class="spinner" aria-hidden="true"></span>
<span>Reading worktrees and branches…</span>
</div>
<div id="empty-state" class="empty-state" hidden></div>
<div id="graph-scroll" class="graph-scroll">
<svg id="graph" role="group" aria-label="Git topology graph"></svg>
</div>
</section>
<aside id="inspector" class="inspector" aria-live="polite">
<div class="inspector-empty">
<span class="inspector-icon" aria-hidden="true"></span>
<h2>Select a node</h2>
<p>Details and safe actions appear here.</p>
</div>
</aside>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script type="module" src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,19 @@
// Single-quoted arguments are literal in both POSIX shells and PowerShell; only the
// escape for an embedded apostrophe differs, so callers pick the target shell.
const SAFE_ARGUMENT = /^[A-Za-z0-9_\-./:@+=,]+$/;
export function detectShell(platform = "") {
return /^win/i.test(String(platform)) ? "powershell" : "posix";
}
export function quoteShellArg(value, shell = "posix") {
const text = String(value ?? "");
if (text === "") return "''";
if (SAFE_ARGUMENT.test(text)) return text;
const escaped = shell === "powershell" ? text.replace(/'/g, "''") : text.replace(/'/g, "'\\''");
return `'${escaped}'`;
}
export function formatShellCommand(parts, shell = "posix") {
return parts.map((part) => quoteShellArg(part, shell)).join(" ");
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import { detectShell, formatShellCommand, quoteShellArg } from "./shell-quote.mjs";
test("plain arguments are left unquoted", () => {
assert.equal(quoteShellArg("main"), "main");
assert.equal(quoteShellArg("feature/x-1.2"), "feature/x-1.2");
assert.equal(quoteShellArg("C:/repos/app"), "C:/repos/app");
});
test("shell metacharacters are neutralized with single quotes", () => {
assert.equal(quoteShellArg("$(rm -rf ~)"), "'$(rm -rf ~)'");
assert.equal(quoteShellArg("`id`"), "'`id`'");
assert.equal(quoteShellArg('a"b'), "'a\"b'");
assert.equal(quoteShellArg("C:\\repos\\my app"), "'C:\\repos\\my app'");
assert.equal(quoteShellArg(""), "''");
});
test("embedded single quotes are escaped for the target shell", () => {
assert.equal(quoteShellArg("it's"), "'it'\\''s'");
assert.equal(quoteShellArg("it's", "posix"), "'it'\\''s'");
assert.equal(quoteShellArg("O'Brien", "powershell"), "'O''Brien'");
assert.equal(quoteShellArg("C:\\Users\\O'Brien\\repo", "powershell"), "'C:\\Users\\O''Brien\\repo'");
assert.equal(quoteShellArg("$(whoami)", "powershell"), "'$(whoami)'");
});
test("detects PowerShell on Windows platforms and POSIX elsewhere", () => {
assert.equal(detectShell("Win32"), "powershell");
assert.equal(detectShell("Windows"), "powershell");
assert.equal(detectShell("MacIntel"), "posix");
assert.equal(detectShell("Linux x86_64"), "posix");
assert.equal(detectShell(""), "posix");
});
test("commands are assembled from individually quoted parts", () => {
assert.equal(
formatShellCommand(["git", "-C", "/tmp/$(whoami)", "status"]),
"git -C '/tmp/$(whoami)' status",
);
assert.equal(
formatShellCommand(["git", "log", "--oneline", "-50", "--end-of-options", "-evil", "--"]),
"git log --oneline -50 --end-of-options -evil --",
);
});
@@ -0,0 +1,715 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--surface-raised: color-mix(in srgb, var(--background-color-default, #fff) 92%, var(--true-color-blue, #0969da) 8%);
--surface-muted: color-mix(in srgb, var(--background-color-default, #fff) 96%, var(--text-color-default, #1f2328) 4%);
--accent: var(--true-color-blue, #0969da);
--accent-muted: var(--true-color-blue-muted, #ddf4ff);
--success: #1a7f37;
--warning: #9a6700;
--danger: var(--true-color-red, #cf222e);
--radius: 10px;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--background-color-default, #fff);
color: var(--text-color-default, #1f2328);
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
font-size: var(--text-body-medium, 14px);
line-height: var(--leading-body-medium, 20px);
}
button {
color: inherit;
font: inherit;
}
button:focus-visible,
[tabindex="0"]:focus-visible {
outline: 2px solid var(--color-focus-outline, #0969da);
outline-offset: 2px;
}
.app-header {
height: 66px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border-color-default, #d0d7de);
background: var(--background-color-default, #fff);
}
.title-block,
.header-actions {
display: flex;
align-items: center;
min-width: 0;
}
.title-block {
gap: 10px;
}
.mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 9px;
background: var(--accent-muted);
color: var(--accent);
font-family: var(--font-mono, Consolas, monospace);
font-weight: var(--font-weight-semibold, 600);
}
h1,
h2,
p {
margin: 0;
}
h1 {
overflow: hidden;
font-size: var(--text-title-medium, 17px);
font-weight: var(--font-weight-semibold, 600);
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
#repo-path {
overflow: hidden;
max-width: min(52vw, 680px);
color: var(--text-color-muted, #656d76);
font-family: var(--font-mono, Consolas, monospace);
font-size: var(--text-code-inline, 12px);
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
gap: 8px;
}
.status-pill,
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--border-color-default, #d0d7de);
border-radius: 999px;
padding: 3px 9px;
color: var(--text-color-muted, #656d76);
background: var(--surface-muted);
font-size: 11px;
font-weight: var(--font-weight-semibold, 600);
white-space: nowrap;
}
.status-pill.ready {
border-color: color-mix(in srgb, var(--success) 45%, transparent);
color: var(--success);
}
.icon-button,
.graph-toolbar button,
.action-button,
.load-more {
border: 1px solid var(--border-color-default, #d0d7de);
border-radius: 7px;
background: var(--background-color-default, #fff);
cursor: pointer;
}
.icon-button {
width: 32px;
height: 32px;
font-size: 18px;
}
.icon-button:hover,
.graph-toolbar button:hover,
.action-button:hover,
.load-more:hover {
border-color: var(--accent);
color: var(--accent);
}
.icon-button.busy {
animation: spin 0.8s linear infinite;
}
.breadcrumbs {
height: 38px;
display: flex;
align-items: center;
gap: 5px;
overflow-x: auto;
padding: 6px 16px;
border-bottom: 1px solid var(--border-color-default, #d0d7de);
background: var(--surface-muted);
scrollbar-width: thin;
}
.crumb {
max-width: 220px;
overflow: hidden;
border: 0;
background: transparent;
color: var(--text-color-muted, #656d76);
cursor: pointer;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.crumb:last-of-type {
color: var(--text-color-default, #1f2328);
font-weight: var(--font-weight-semibold, 600);
}
.crumb-separator {
color: var(--border-color-default, #d0d7de);
}
.workspace {
height: calc(100% - 104px);
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
}
.graph-panel {
position: relative;
min-width: 0;
overflow: hidden;
background:
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--border-color-default, #d0d7de) 65%, transparent) 1px, transparent 0);
background-size: 20px 20px;
}
.graph-scroll {
width: 100%;
height: 100%;
overflow: auto;
}
#graph {
display: block;
min-width: 100%;
min-height: 100%;
transform-origin: 50% 0;
transition: transform 120ms ease;
}
.graph-toolbar {
position: absolute;
z-index: 2;
top: 12px;
right: 12px;
display: flex;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 2px 8px color-mix(in srgb, var(--text-color-default, #1f2328) 12%, transparent);
}
.graph-toolbar button {
min-width: 32px;
height: 30px;
border-radius: 0;
border-right-width: 0;
font-size: 12px;
}
.graph-toolbar button:first-child {
border-radius: 7px 0 0 7px;
}
.graph-toolbar button:last-child {
border-right-width: 1px;
border-radius: 0 7px 7px 0;
}
.graph-toolbar .view-button {
min-width: 74px;
padding: 0 10px;
}
.graph-toolbar .view-button.active {
border-color: var(--accent);
background: var(--accent-muted);
color: var(--accent);
font-weight: var(--font-weight-semibold, 600);
}
.graph-toolbar .toolbar-divider {
width: 7px;
border-right: 1px solid var(--border-color-default, #d0d7de);
background: var(--background-color-default, #fff);
}
.loading,
.empty-state {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-color-muted, #656d76);
}
.loading[hidden],
.empty-state[hidden] {
display: none;
}
.empty-state.has-action {
flex-direction: column;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border-color-default, #d0d7de);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.edge {
fill: none;
stroke: var(--border-color-default, #d0d7de);
stroke-width: 2;
}
.node {
cursor: pointer;
}
.node-card {
fill: var(--background-color-default, #fff);
stroke: var(--border-color-default, #d0d7de);
stroke-width: 1.5;
filter: drop-shadow(0 2px 3px color-mix(in srgb, var(--text-color-default, #1f2328) 10%, transparent));
transition: stroke 120ms ease, stroke-width 120ms ease;
}
.node:hover .node-card,
.node.selected .node-card {
stroke: var(--accent);
stroke-width: 2.5;
}
.node-type {
fill: var(--text-color-muted, #656d76);
font-family: var(--font-sans, sans-serif);
font-size: 10px;
font-weight: var(--font-weight-semibold, 600);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.node-label {
fill: var(--text-color-default, #1f2328);
font-family: var(--font-sans, sans-serif);
font-size: 13px;
font-weight: var(--font-weight-semibold, 600);
}
.node-meta {
fill: var(--text-color-muted, #656d76);
font-family: var(--font-mono, Consolas, monospace);
font-size: 10px;
}
.node-accent {
fill: var(--accent);
}
.node.worktree .node-accent {
fill: #8250df;
}
.node.branch .node-accent {
fill: #1a7f37;
}
.node.commit .node-accent {
fill: #bf8700;
}
.node.dirty .node-card {
stroke: var(--warning);
}
.commit-lane {
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.commit-row {
cursor: pointer;
}
.commit-row-hit {
fill: transparent;
stroke: none;
}
.commit-row:hover .commit-row-hit,
.commit-row.selected .commit-row-hit {
fill: color-mix(in srgb, var(--accent) 8%, transparent);
}
.commit-dot {
stroke: var(--background-color-default, #fff);
stroke-width: 2;
}
.commit-row:hover .commit-dot,
.commit-row.selected .commit-dot {
stroke: var(--accent);
stroke-width: 3;
}
.commit-subject {
fill: var(--text-color-default, #1f2328);
font-family: var(--font-sans, sans-serif);
font-size: 13px;
font-weight: var(--font-weight-semibold, 600);
}
.commit-author,
.commit-time {
fill: var(--text-color-muted, #656d76);
font-family: var(--font-sans, sans-serif);
font-size: 10px;
}
.commit-time {
text-anchor: end;
}
.ref-badge {
cursor: pointer;
}
.ref-badge rect {
fill: var(--accent-muted);
stroke: color-mix(in srgb, var(--accent) 55%, transparent);
}
.ref-badge.default rect {
fill: color-mix(in srgb, #8250df 16%, var(--background-color-default, #fff));
stroke: #8250df;
}
.ref-badge-text {
fill: var(--accent);
font-family: var(--font-mono, Consolas, monospace);
font-size: 10px;
font-weight: var(--font-weight-semibold, 600);
}
.ref-badge.default .ref-badge-text {
fill: #8250df;
}
.ref-badge.overflow {
cursor: default;
}
.ref-badge.overflow rect {
fill: var(--surface-muted);
stroke: var(--border-color-default, #d0d7de);
}
.ref-badge.overflow .ref-badge-text {
fill: var(--text-color-muted, #656d76);
}
.inspector {
min-width: 0;
overflow-y: auto;
border-left: 1px solid var(--border-color-default, #d0d7de);
background: var(--background-color-default, #fff);
}
.inspector-empty {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px;
color: var(--text-color-muted, #656d76);
text-align: center;
}
.inspector-icon {
margin-bottom: 8px;
color: var(--border-color-default, #d0d7de);
font-size: 42px;
}
.inspector-content {
position: relative;
padding: 18px;
}
.inspector-close {
display: none;
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border-color-default, #d0d7de);
border-radius: 8px;
background: var(--surface-muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.eyebrow {
color: var(--accent);
font-size: 10px;
font-weight: var(--font-weight-semibold, 600);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.inspector h2 {
margin-top: 3px;
font-size: var(--text-title-medium, 17px);
line-height: 24px;
overflow-wrap: anywhere;
}
.summary {
margin-top: 7px;
color: var(--text-color-muted, #656d76);
font-size: 12px;
}
.detail-list {
margin: 18px 0;
display: grid;
gap: 11px;
}
.detail-row {
display: grid;
gap: 2px;
}
.detail-row dt {
color: var(--text-color-muted, #656d76);
font-size: 10px;
font-weight: var(--font-weight-semibold, 600);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.detail-row dd {
margin: 0;
overflow-wrap: anywhere;
font-size: 12px;
}
.mono {
font-family: var(--font-mono, Consolas, monospace);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 16px 0;
}
.action-button {
min-height: 30px;
padding: 5px 10px;
font-size: 12px;
}
.action-button.primary {
border-color: var(--accent);
background: var(--accent);
color: var(--color-white, #fff);
}
.action-button.primary:hover {
filter: brightness(1.08);
color: var(--color-white, #fff);
}
.action-button:disabled {
cursor: wait;
opacity: 0.7;
}
.action-status {
min-height: 18px;
margin: -8px 0 14px;
color: var(--text-color-muted, #656d76);
font-size: 11px;
}
.action-status:empty {
min-height: 0;
margin: 0;
}
.action-status.pending {
color: var(--accent);
}
.action-status.success {
color: var(--success);
}
.action-status.error {
color: var(--danger);
}
.section-title {
margin: 20px 0 7px;
font-size: 11px;
font-weight: var(--font-weight-semibold, 600);
}
.file-list,
.pr-list {
margin: 0;
padding: 0;
list-style: none;
}
.file-list li,
.pr-list li {
padding: 7px 0;
border-bottom: 1px solid var(--border-color-default, #d0d7de);
font-size: 11px;
overflow-wrap: anywhere;
}
.file-status {
display: inline-block;
min-width: 28px;
margin-right: 5px;
color: var(--warning);
font-family: var(--font-mono, Consolas, monospace);
font-weight: var(--font-weight-semibold, 600);
}
.pr-link {
color: var(--accent);
text-decoration: none;
}
.pr-link:hover {
text-decoration: underline;
}
.load-more {
display: block;
margin: 18px auto 36px;
padding: 7px 14px;
}
.toast {
position: fixed;
z-index: 10;
right: 18px;
bottom: 18px;
max-width: 320px;
padding: 9px 12px;
border: 1px solid var(--border-color-default, #d0d7de);
border-radius: 8px;
background: var(--surface-raised);
box-shadow: 0 4px 16px color-mix(in srgb, var(--text-color-default, #1f2328) 18%, transparent);
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: 160ms ease;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 760px) {
.workspace {
grid-template-columns: minmax(0, 1fr) 280px;
}
.status-pill {
display: none;
}
}
@media (max-width: 560px) {
.workspace {
grid-template-columns: 1fr;
}
.inspector {
position: absolute;
z-index: 4;
right: 0;
bottom: 0;
width: min(88%, 340px);
height: calc(100% - 104px);
box-shadow: -8px 0 20px color-mix(in srgb, var(--text-color-default, #1f2328) 18%, transparent);
transform: translateX(100%);
transition: transform 160ms ease;
}
.inspector.has-selection {
transform: translateX(0);
}
.inspector-close {
display: inline-flex;
align-items: center;
justify-content: center;
}
.inspector-content {
padding-right: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}