mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 10:45:03 +00:00
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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user