feat(history-ui): add resizable commit history aside
The commit history panel is now resizable with a draggable handle and keyboard support. The chosen width is persisted in local storage so the layout stays consistent across sessions. Git commit loading was also adjusted to include all branch tips for a more complete graph. - Add width persistence and pointer/keyboard resizing for history panel - Update commit graph query to use topo-order across all refs - Extend tests to ensure branch tips are included in commit results
This commit is contained in:
@@ -996,6 +996,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
|
||||
repo,
|
||||
[
|
||||
"log",
|
||||
"--all",
|
||||
"--topo-order",
|
||||
"--decorate=short",
|
||||
"--name-status",
|
||||
"-M",
|
||||
@@ -3869,6 +3871,33 @@ mod tests {
|
||||
assert!(comparison.patch.contains("+original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_for_repo_includes_all_branch_tips_for_graph() {
|
||||
let repo = init_temp_repo("commits_all_branches");
|
||||
commit_initial_file(&repo.path);
|
||||
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
|
||||
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/graph"]);
|
||||
fs::write(repo.path.join("feature.txt"), "feature\n")
|
||||
.expect("feature file should be written");
|
||||
run_git_test(&repo.path, ["add", "feature.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "feature graph"]);
|
||||
let feature_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
|
||||
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
|
||||
run_git_test(&repo.path, ["add", "main.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "main graph"]);
|
||||
let main_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
let commits = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
|
||||
|
||||
assert!(commits.iter().any(|commit| commit.hash == main_commit));
|
||||
assert!(commits.iter().any(|commit| {
|
||||
commit.hash == feature_commit && commit.refs.iter().any(|r| r.contains("feature/graph"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(
|
||||
windows,
|
||||
|
||||
+81
-1
@@ -117,9 +117,13 @@
|
||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
|
||||
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
|
||||
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
|
||||
const COMMIT_PANEL_MAX_HEIGHT = 640;
|
||||
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
||||
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
||||
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -207,6 +211,10 @@
|
||||
let resizingCommitPanel = false;
|
||||
let resizeStartY = 0;
|
||||
let resizeStartHeight = 0;
|
||||
let historyAsideWidth = loadHistoryAsideWidth();
|
||||
let resizingHistoryAside = false;
|
||||
let historyResizeStartX = 0;
|
||||
let historyResizeStartWidth = 0;
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -226,6 +234,7 @@
|
||||
: "";
|
||||
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
|
||||
$: localBranches = branches.filter((b) => !b.remote);
|
||||
$: localBranchNames = localBranches.map((b) => b.name);
|
||||
$: remoteBranches = branches.filter((b) => b.remote);
|
||||
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
||||
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
|
||||
@@ -625,6 +634,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clampHistoryAsideWidth(value: number): number {
|
||||
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
|
||||
}
|
||||
|
||||
function loadHistoryAsideWidth(): number {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(HISTORY_ASIDE_WIDTH_KEY));
|
||||
if (Number.isFinite(stored) && stored > 0) return clampHistoryAsideWidth(stored);
|
||||
} catch {
|
||||
// Fall through to the default below.
|
||||
}
|
||||
return HISTORY_ASIDE_DEFAULT_WIDTH;
|
||||
}
|
||||
|
||||
function persistHistoryAsideWidth(value: number) {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_ASIDE_WIDTH_KEY, String(value));
|
||||
} catch {
|
||||
// Local storage is best-effort only; resizing must keep working without it.
|
||||
}
|
||||
}
|
||||
|
||||
function startCommitPanelResize(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
resizingCommitPanel = true;
|
||||
@@ -653,6 +684,34 @@
|
||||
persistCommitPanelHeight(commitPanelHeight);
|
||||
}
|
||||
|
||||
function startHistoryAsideResize(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
resizingHistoryAside = true;
|
||||
historyResizeStartX = event.clientX;
|
||||
historyResizeStartWidth = historyAsideWidth;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onHistoryAsideResizeMove(event: PointerEvent) {
|
||||
if (!resizingHistoryAside) return;
|
||||
historyAsideWidth = clampHistoryAsideWidth(historyResizeStartWidth + (historyResizeStartX - event.clientX));
|
||||
}
|
||||
|
||||
function endHistoryAsideResize(event: PointerEvent) {
|
||||
if (!resizingHistoryAside) return;
|
||||
resizingHistoryAside = false;
|
||||
persistHistoryAsideWidth(historyAsideWidth);
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onHistoryAsideResizeKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
historyAsideWidth = clampHistoryAsideWidth(historyAsideWidth + (event.key === "ArrowLeft" ? 24 : -24));
|
||||
persistHistoryAsideWidth(historyAsideWidth);
|
||||
}
|
||||
|
||||
function rememberRecentRepo(path: string) {
|
||||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||
persistRepoLists();
|
||||
@@ -1945,7 +2004,7 @@
|
||||
</section>
|
||||
{:else}
|
||||
<!-- Workspace -->
|
||||
<section class="workspace" aria-label="Git workspace">
|
||||
<section class="workspace" aria-label="Git workspace" style="--history-aside-width: {historyAsideWidth}px;">
|
||||
|
||||
<!-- Left sidebar: branches + explorer -->
|
||||
<aside class="left-sidebar" aria-label="Repository navigation">
|
||||
@@ -2049,8 +2108,29 @@
|
||||
|
||||
<!-- Right sidebar: commit graph + file history -->
|
||||
<aside class="history-aside" aria-label="Commit history">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="history-resize-handle"
|
||||
class:resizing={resizingHistoryAside}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize history panel width"
|
||||
aria-valuenow={historyAsideWidth}
|
||||
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
|
||||
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
|
||||
tabindex="0"
|
||||
onpointerdown={startHistoryAsideResize}
|
||||
onpointermove={onHistoryAsideResizeMove}
|
||||
onpointerup={endHistoryAsideResize}
|
||||
onpointercancel={endHistoryAsideResize}
|
||||
onkeydown={onHistoryAsideResizeKeydown}
|
||||
></div>
|
||||
<HistoryPanel
|
||||
{commits}
|
||||
{localBranchNames}
|
||||
activeBranch={status?.current_branch ?? ""}
|
||||
repositoryKey={activeRepoPath}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
{expandedCommitHashes}
|
||||
|
||||
+384
-79
@@ -187,6 +187,199 @@
|
||||
}
|
||||
.section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; }
|
||||
|
||||
.section-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.graph-branch-dialog-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
padding: 0 9px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(91,209,138,0.24);
|
||||
border-radius: 999px;
|
||||
color: #b6f1c4;
|
||||
background: rgba(34,68,48,0.28);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.graph-branch-dialog-button:hover {
|
||||
border-color: rgba(91,209,138,0.42);
|
||||
background: rgba(34,68,48,0.42);
|
||||
}
|
||||
|
||||
.graph-branch-dialog-button span {
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
color: #061021;
|
||||
background: #6ce18f;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.branch-filter-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(4,8,18,0.58);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.branch-filter-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
width: min(520px, 100%);
|
||||
max-height: min(680px, calc(100vh - 48px));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(94,110,156,0.24);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
|
||||
var(--color-surface);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.branch-filter-dialog-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 14px 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: linear-gradient(90deg, rgba(100,108,255,0.12), rgba(65,209,255,0.04));
|
||||
}
|
||||
|
||||
.branch-filter-dialog-head h3 {
|
||||
margin: 1px 0 0;
|
||||
color: var(--color-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.dialog-icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(94,110,156,0.18);
|
||||
border-radius: 7px;
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,0.035);
|
||||
}
|
||||
|
||||
.dialog-icon-button:hover {
|
||||
color: var(--color-ink);
|
||||
border-color: rgba(65,209,255,0.28);
|
||||
background: rgba(65,209,255,0.08);
|
||||
}
|
||||
|
||||
.branch-filter-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
color: var(--color-ink-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.branch-filter-actions {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.branch-filter-actions button {
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-color: rgba(94,110,156,0.16);
|
||||
border-radius: 999px;
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,0.035);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.branch-filter-actions button:hover:not(:disabled) {
|
||||
border-color: rgba(65,209,255,0.28);
|
||||
color: var(--color-ink);
|
||||
background: rgba(65,209,255,0.08);
|
||||
}
|
||||
|
||||
.branch-filter-actions button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.branch-filter-dialog-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.branch-filter-option {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 16px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: #a8eeba;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.branch-filter-option:hover {
|
||||
border-color: rgba(91,209,138,0.18);
|
||||
background: rgba(34,68,48,0.22);
|
||||
}
|
||||
|
||||
.branch-filter-option.muted {
|
||||
color: var(--color-ink-faint);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.branch-filter-option input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #6ce18f;
|
||||
}
|
||||
|
||||
.branch-filter-option svg {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.branch-filter-option span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
color: var(--color-ink-faint);
|
||||
@@ -862,7 +1055,7 @@
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) clamp(400px, 40vw, 620px);
|
||||
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) minmax(560px, var(--history-aside-width, 620px));
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -870,13 +1063,45 @@
|
||||
}
|
||||
|
||||
.history-aside {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -7px;
|
||||
width: 12px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
.history-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
left: 5px;
|
||||
width: 2px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transition: background 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.history-resize-handle:hover::before,
|
||||
.history-resize-handle.resizing::before {
|
||||
background: rgba(65,209,255,0.62);
|
||||
box-shadow: 0 0 14px rgba(65,209,255,0.3);
|
||||
}
|
||||
.history-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.left-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr);
|
||||
@@ -1431,97 +1656,126 @@
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.commit-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(122,172,255,0.28);
|
||||
border-radius: 10px;
|
||||
color: #dce8ff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(122,172,255,0.2), rgba(65,209,255,0.08)),
|
||||
rgba(18, 22, 38, 0.84);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 8px 20px rgba(0,0,0,0.16);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.02em;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid rgba(122,172,255,0.18);
|
||||
border-radius: 999px;
|
||||
color: #b8c5df;
|
||||
background: rgba(18, 22, 38, 0.72);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.commit-card-main { display: grid; gap: 4px; min-width: 0; }
|
||||
.commit-card-main { display: grid; gap: 2px; min-width: 0; }
|
||||
.commit-title-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.commit-summary {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #f6f8ff;
|
||||
font-size: 13.5px;
|
||||
font-weight: 800;
|
||||
line-height: 1.28;
|
||||
color: #edf2ff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.commit-kind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(94,110,156,0.24);
|
||||
padding: 1px 5px;
|
||||
border: 1px solid rgba(94,110,156,0.18);
|
||||
border-radius: 999px;
|
||||
color: var(--color-ink-dim);
|
||||
background: rgba(255,255,255,0.035);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
background: rgba(255,255,255,0.025);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.commit-kind.merge { color: #f09de2; border-color: rgba(208,96,192,0.32); background: rgba(208,96,192,0.12); }
|
||||
.commit-kind.root { color: #e0b45c; border-color: rgba(224,180,92,0.3); background: rgba(224,180,92,0.11); }
|
||||
.commit-kind.merge { color: #dca6da; border-color: rgba(208,96,192,0.24); background: rgba(208,96,192,0.07); }
|
||||
.commit-kind.root { color: #d7b66b; border-color: rgba(224,180,92,0.22); background: rgba(224,180,92,0.07); }
|
||||
.commit-meta-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 11px;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.commit-meta-line > * { min-width: 0; }
|
||||
.commit-hash {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(65,209,255,0.16);
|
||||
border-radius: 6px;
|
||||
color: var(--color-accent);
|
||||
background: rgba(65,209,255,0.075);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #8db8ff;
|
||||
background: transparent;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
}
|
||||
.commit-local-branches {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: min(100%, 260px);
|
||||
}
|
||||
.commit-branch-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 17px;
|
||||
padding: 0 6px 0 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(91,209,138,0.22);
|
||||
border-radius: 999px;
|
||||
color: #a8eeba;
|
||||
background: rgba(34,68,48,0.42);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.commit-branch-chip svg {
|
||||
flex: 0 0 auto;
|
||||
color: #76d995;
|
||||
}
|
||||
.commit-author {
|
||||
flex: 1 1 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ref-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ref-list { display: flex; flex-wrap: wrap; gap: 3px; }
|
||||
.ref-list .ref-chip {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 2px 7px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
color: var(--color-accent);
|
||||
background: rgba(106,154,255,0.13);
|
||||
border: 1px solid rgba(106,154,255,0.22);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
background: rgba(106,154,255,0.09);
|
||||
border: 1px solid rgba(106,154,255,0.16);
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1532,9 +1786,9 @@
|
||||
background: linear-gradient(135deg, #41d1ff, #7c6cff);
|
||||
box-shadow: 0 0 14px rgba(65,209,255,0.2);
|
||||
}
|
||||
.ref-list .ref-chip.branch { color: #55d886; background: rgba(78,202,118,0.12); border-color: rgba(78,202,118,0.26); }
|
||||
.ref-list .ref-chip.remote { color: #9aa7ff; background: rgba(124,108,255,0.12); border-color: rgba(124,108,255,0.26); }
|
||||
.ref-list .ref-chip.tag { color: #e0b45c; background: rgba(224,180,92,0.12); border-color: rgba(224,180,92,0.28); }
|
||||
.ref-list .ref-chip.branch { color: #7ddf9c; background: rgba(78,202,118,0.08); border-color: rgba(78,202,118,0.18); }
|
||||
.ref-list .ref-chip.remote { color: #aeb6ff; background: rgba(124,108,255,0.08); border-color: rgba(124,108,255,0.18); }
|
||||
.ref-list .ref-chip.tag { color: #dbc078; background: rgba(224,180,92,0.08); border-color: rgba(224,180,92,0.2); }
|
||||
|
||||
.commit-files {
|
||||
display: grid;
|
||||
@@ -1695,16 +1949,14 @@
|
||||
|
||||
.graph-list {
|
||||
padding: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(65,209,255,0.04), transparent 22%),
|
||||
var(--color-surface-dim);
|
||||
background: var(--color-surface-dim);
|
||||
}
|
||||
|
||||
.graph-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: 86px;
|
||||
min-height: 58px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
@@ -1716,57 +1968,109 @@
|
||||
.graph-gutter {
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
border-right: 1px solid rgba(94,110,156,0.18);
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.055), transparent 34%),
|
||||
linear-gradient(90deg, rgba(8,10,18,0.48), rgba(22,22,36,0.9));
|
||||
box-shadow: inset -10px 0 16px rgba(0,0,0,0.12);
|
||||
min-width: 42px;
|
||||
border-right: 1px solid rgba(94,110,156,0.12);
|
||||
background: rgba(7,8,16,0.2);
|
||||
}
|
||||
.graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
|
||||
.graph-svg path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.78;
|
||||
opacity: 0.76;
|
||||
transition: opacity 120ms ease, stroke-width 120ms ease;
|
||||
}
|
||||
.graph-svg path.hidden-branch {
|
||||
opacity: 0.08;
|
||||
}
|
||||
.graph-dot {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 50%;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--dot-color, #5a8cf8);
|
||||
border: 2px solid #111321;
|
||||
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8), 0 0 14px rgba(65,209,255,0.14);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.07);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.graph-dot.merge { width: 16px; height: 16px; background: var(--color-surface-alt); border-color: var(--dot-color, #5a8cf8); box-shadow: 0 0 0 2px rgba(255,255,255,0.08), 0 0 16px rgba(208,96,192,0.24); }
|
||||
|
||||
.graph-dot.hidden-branch {
|
||||
opacity: 0.16;
|
||||
box-shadow: none;
|
||||
}
|
||||
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
||||
.graph-dot.merge {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: var(--color-surface-alt);
|
||||
border-color: var(--dot-color, #5a8cf8);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
|
||||
}
|
||||
.graph-hover-branches {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
max-width: 190px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%) translateX(-4px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.graph-gutter:hover .graph-hover-branches,
|
||||
.graph-row:hover .graph-hover-branches {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
.graph-hover-branches span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
max-width: 180px;
|
||||
height: 18px;
|
||||
padding: 0 6px 0 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(91,209,138,0.28);
|
||||
border-radius: 999px;
|
||||
color: #b2f0c2;
|
||||
background: rgba(20,35,29,0.94);
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,0.3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.graph-hover-branches svg {
|
||||
flex: 0 0 auto;
|
||||
color: #76d995;
|
||||
}
|
||||
.commit-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.035), transparent 34%),
|
||||
rgba(28,29,48,0.55);
|
||||
padding: 8px 10px;
|
||||
background: rgba(28,29,48,0.34);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
|
||||
.graph-row:hover .commit-body {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(65,209,255,0.075), transparent 36%),
|
||||
var(--color-surface-hover);
|
||||
}
|
||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.6; }
|
||||
.graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.08); }
|
||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
|
||||
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
|
||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
||||
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
|
||||
.graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); }
|
||||
.graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); }
|
||||
.graph-row.merge-row .commit-body {
|
||||
background: rgba(36,31,54,0.46);
|
||||
}
|
||||
.graph-row.tip-row .commit-body {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(208,96,192,0.095), transparent 34%),
|
||||
rgba(28,29,48,0.62);
|
||||
linear-gradient(90deg, rgba(105,167,255,0.055), transparent 32%),
|
||||
rgba(28,29,48,0.38);
|
||||
}
|
||||
|
||||
/* --- Compare panel --- */
|
||||
@@ -3241,16 +3545,16 @@
|
||||
/* --- Responsive breakpoints --- */
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
|
||||
.workspace { grid-template-columns: 320px minmax(0, 1fr) minmax(560px, var(--history-aside-width, 680px)); }
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.workspace { grid-template-columns: clamp(200px, 17vw, 265px) minmax(0, 1fr) clamp(400px, 40vw, 580px); }
|
||||
.workspace { grid-template-columns: clamp(200px, 17vw, 265px) minmax(0, 1fr) minmax(540px, var(--history-aside-width, 580px)); }
|
||||
}
|
||||
|
||||
/* Stack CommitPanel below StatusPanel; history panels stay side by side */
|
||||
@media (max-width: 1100px) {
|
||||
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) clamp(380px, 38vw, 500px); }
|
||||
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) minmax(500px, var(--history-aside-width, 560px)); }
|
||||
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
|
||||
}
|
||||
|
||||
@@ -3258,6 +3562,7 @@
|
||||
@media (max-width: 960px) {
|
||||
.workspace { grid-template-columns: 180px minmax(0, 1fr) 300px; gap: 6px; }
|
||||
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); }
|
||||
.history-resize-handle { display: none; }
|
||||
.shell-body { gap: 6px; }
|
||||
.left-sidebar { gap: 6px; grid-template-rows: minmax(180px, 0.8fr) minmax(200px, 1.2fr); }
|
||||
.section-head { min-height: 40px; padding: 6px 10px; }
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw } from "@lucide/svelte";
|
||||
import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
fromCol: number;
|
||||
toCol: number;
|
||||
color: string;
|
||||
branches: string[];
|
||||
}
|
||||
|
||||
interface GraphRow {
|
||||
dotCol: number;
|
||||
dotColor: string;
|
||||
branchLabels: string[];
|
||||
top: GraphSegment[];
|
||||
bottom: GraphSegment[];
|
||||
}
|
||||
|
||||
interface VisibleCommitEntry {
|
||||
commit: GitCommit;
|
||||
graphCommit: GitCommit;
|
||||
}
|
||||
|
||||
const GRAPH_COLORS = [
|
||||
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
|
||||
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
|
||||
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||
];
|
||||
const GRAPH_LANE = 16;
|
||||
const GRAPH_LANE = 18;
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
localBranchNames: string[];
|
||||
activeBranch: string;
|
||||
repositoryKey: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
expandedCommitHashes: Set<string>;
|
||||
@@ -34,6 +44,9 @@
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
localBranchNames = [],
|
||||
activeBranch = "",
|
||||
repositoryKey = "",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
expandedCommitHashes = new Set(),
|
||||
@@ -43,6 +56,11 @@
|
||||
onCreateBranchFromCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
let branchDialogOpen = $state(false);
|
||||
let userAdjustedBranchFilter = $state(false);
|
||||
let lastDefaultFilterKey = $state("");
|
||||
|
||||
function laneColor(col: number): string {
|
||||
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
||||
}
|
||||
@@ -59,13 +77,15 @@
|
||||
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
|
||||
}
|
||||
|
||||
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
|
||||
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
|
||||
const rows: GraphRow[] = [];
|
||||
let lanes: (string | null)[] = [];
|
||||
let laneBranches: string[][] = [];
|
||||
let maxColumns = 1;
|
||||
|
||||
for (const commit of items) {
|
||||
const before = lanes.slice();
|
||||
const beforeBranches = laneBranches.map((branches) => branches.slice());
|
||||
|
||||
let col = before.indexOf(commit.hash);
|
||||
if (col === -1) {
|
||||
@@ -75,18 +95,27 @@
|
||||
|
||||
const after = before.slice();
|
||||
while (after.length <= col) after.push(null);
|
||||
const afterBranches = beforeBranches.map((branches) => branches.slice());
|
||||
while (afterBranches.length <= col) afterBranches.push([]);
|
||||
|
||||
for (let k = 0; k < after.length; k++) {
|
||||
if (after[k] === commit.hash) after[k] = null;
|
||||
if (after[k] === commit.hash) {
|
||||
after[k] = null;
|
||||
afterBranches[k] = [];
|
||||
}
|
||||
}
|
||||
|
||||
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
||||
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
||||
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
||||
afterBranches[col] = after[col] ? commitBranches.slice() : [];
|
||||
|
||||
const fromCommit = new Set<number>([col]);
|
||||
for (let p = 1; p < commit.parents.length; p++) {
|
||||
let slot = after.indexOf(null);
|
||||
if (slot === -1) { slot = after.length; after.push(null); }
|
||||
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
||||
after[slot] = commit.parents[p];
|
||||
afterBranches[slot] = [];
|
||||
fromCommit.add(slot);
|
||||
}
|
||||
|
||||
@@ -94,19 +123,28 @@
|
||||
for (let k = 0; k < before.length; k++) {
|
||||
const target = before[k];
|
||||
if (target == null) continue;
|
||||
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k) });
|
||||
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
|
||||
}
|
||||
|
||||
const bottom: GraphSegment[] = [];
|
||||
for (let k = 0; k < after.length; k++) {
|
||||
if (after[k] == null) continue;
|
||||
bottom.push({ fromCol: fromCommit.has(k) ? col : k, toCol: k, color: laneColor(k) });
|
||||
bottom.push({
|
||||
fromCol: fromCommit.has(k) ? col : k,
|
||||
toCol: k,
|
||||
color: laneColor(k),
|
||||
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
|
||||
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
|
||||
|
||||
lanes = after.slice();
|
||||
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop();
|
||||
laneBranches = afterBranches.map((branches) => branches.slice());
|
||||
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
|
||||
lanes.pop();
|
||||
laneBranches.pop();
|
||||
}
|
||||
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
|
||||
}
|
||||
|
||||
@@ -151,9 +189,159 @@
|
||||
return "commit";
|
||||
}
|
||||
|
||||
let localBranchNameSet = $derived(new Set(localBranchNames));
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function branchIsVisible(branch: string): boolean {
|
||||
return !hiddenGraphBranches.has(branch);
|
||||
}
|
||||
|
||||
function visibleBranchLabels(labels: string[]): string[] {
|
||||
return labels.filter(branchIsVisible);
|
||||
}
|
||||
|
||||
function segmentIsVisible(segment: GraphSegment): boolean {
|
||||
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
||||
}
|
||||
|
||||
function branchesAreVisible(branches: string[]): boolean {
|
||||
if (localBranchNames.length === 0) return true;
|
||||
return branches.some(branchIsVisible);
|
||||
}
|
||||
|
||||
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
||||
return branchesAreVisible(row?.branchLabels ?? []);
|
||||
}
|
||||
|
||||
function nearestVisibleGraphParents(
|
||||
hash: string,
|
||||
visibleHashes: Set<string>,
|
||||
commitByHash: Map<string, GitCommit>,
|
||||
seen: Set<string>,
|
||||
): string[] {
|
||||
if (visibleHashes.has(hash)) return [hash];
|
||||
if (seen.has(hash)) return [];
|
||||
seen.add(hash);
|
||||
|
||||
const commit = commitByHash.get(hash);
|
||||
if (!commit) return [];
|
||||
return uniqueStrings(
|
||||
commit.parents.flatMap((parentHash) => (
|
||||
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
|
||||
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
|
||||
const membership = new Map<string, Set<string>>();
|
||||
|
||||
for (const commit of items) {
|
||||
for (const branch of localBranchRefs(commit)) {
|
||||
const stack = [commit.hash];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const hash = stack.pop();
|
||||
if (!hash || seen.has(hash)) continue;
|
||||
seen.add(hash);
|
||||
|
||||
let branches = membership.get(hash);
|
||||
if (!branches) {
|
||||
branches = new Set<string>();
|
||||
membership.set(hash, branches);
|
||||
}
|
||||
branches.add(branch);
|
||||
|
||||
const parentCommit = commitByHash.get(hash);
|
||||
if (parentCommit) stack.push(...parentCommit.parents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(
|
||||
items.map((commit) => [
|
||||
commit.hash,
|
||||
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
|
||||
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
|
||||
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
|
||||
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
|
||||
|
||||
return visibleItems.map((commit) => {
|
||||
const parents = uniqueStrings(
|
||||
commit.parents.flatMap((parentHash) => (
|
||||
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
|
||||
)),
|
||||
);
|
||||
return { commit, graphCommit: { ...commit, parents } };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleGraphBranch(branch: string) {
|
||||
const next = new Set(hiddenGraphBranches);
|
||||
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
||||
hiddenGraphBranches = next;
|
||||
userAdjustedBranchFilter = true;
|
||||
}
|
||||
|
||||
function showAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = true;
|
||||
}
|
||||
|
||||
function hideAllGraphBranches() {
|
||||
hiddenGraphBranches = new Set(localBranchNames);
|
||||
userAdjustedBranchFilter = true;
|
||||
}
|
||||
|
||||
function openBranchDialog() {
|
||||
branchDialogOpen = true;
|
||||
}
|
||||
|
||||
function closeBranchDialog() {
|
||||
branchDialogOpen = false;
|
||||
}
|
||||
|
||||
function handleBranchDialogKeydown(event: KeyboardEvent) {
|
||||
if (branchDialogOpen && event.key === "Escape") {
|
||||
closeBranchDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBranchDialogBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeBranchDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function localBranchRefs(commit: GitCommit): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const ref of commit.refs) {
|
||||
const label = refLabel(ref);
|
||||
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function visibleRefs(commit: GitCommit): string[] {
|
||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||
}
|
||||
|
||||
function refClass(ref: string): string {
|
||||
if (ref.startsWith("HEAD")) return "head";
|
||||
if (ref.startsWith("tag:")) return "tag";
|
||||
if (localBranchNameSet.has(refLabel(ref))) return "branch";
|
||||
if (ref.includes("/")) return "remote";
|
||||
return "branch";
|
||||
}
|
||||
@@ -168,45 +356,108 @@
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
let graph = $derived(computeGraph(commits));
|
||||
$effect(() => {
|
||||
const available = new Set(localBranchNames);
|
||||
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
|
||||
if (nextHidden.size !== hiddenGraphBranches.size) {
|
||||
hiddenGraphBranches = nextHidden;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||
? activeBranch
|
||||
: (localBranchNames[0] ?? "");
|
||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||
|
||||
if (!defaultBranch) {
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
hiddenGraphBranches = new Set();
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastDefaultFilterKey !== defaultFilterKey) {
|
||||
userAdjustedBranchFilter = false;
|
||||
lastDefaultFilterKey = defaultFilterKey;
|
||||
}
|
||||
|
||||
if (!userAdjustedBranchFilter) {
|
||||
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
|
||||
}
|
||||
});
|
||||
|
||||
let branchMembership = $derived(branchMembershipByHash(commits));
|
||||
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
||||
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
||||
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
||||
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
|
||||
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
||||
let graphRows = $derived(graph.rows);
|
||||
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE);
|
||||
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleBranchDialogKeydown} />
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">History</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||
</div>
|
||||
<span class="pill pill-count">{commits.length}</span>
|
||||
<div class="section-head-actions">
|
||||
{#if localBranchNames.length > 0}
|
||||
<button
|
||||
class="graph-branch-dialog-button"
|
||||
type="button"
|
||||
onclick={openBranchDialog}
|
||||
title="Select branches shown in the graph"
|
||||
>
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
Branches
|
||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
|
||||
{visibleCommits.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if commits.length === 0}
|
||||
<div class="blank-state">No commits returned.</div>
|
||||
{:else if visibleCommits.length === 0}
|
||||
<div class="blank-state">No commits match the selected branches.</div>
|
||||
{:else}
|
||||
<div class="history-list graph-list overflow-auto">
|
||||
{#each commits as item, rowIndex (item.hash)}
|
||||
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||
{@const item = entry.commit}
|
||||
{@const row = graphRows[rowIndex]}
|
||||
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0}>
|
||||
{@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
|
||||
{@const otherRefs = visibleRefs(item)}
|
||||
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
|
||||
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
||||
{#if row}
|
||||
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
||||
{#each row.top as seg}
|
||||
<path
|
||||
class:hidden-branch={!segmentIsVisible(seg)}
|
||||
d={graphPath(seg, 0, 50)}
|
||||
stroke={seg.color}
|
||||
stroke-width="2"
|
||||
stroke-width="2.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
{#each row.bottom as seg}
|
||||
<path
|
||||
class:hidden-branch={!segmentIsVisible(seg)}
|
||||
d={graphPath(seg, 50, 100)}
|
||||
stroke={seg.color}
|
||||
stroke-width="2"
|
||||
stroke-width="2.2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
@@ -214,8 +465,21 @@
|
||||
<span
|
||||
class="graph-dot"
|
||||
class:merge={item.parents.length > 1}
|
||||
class:tip={item.refs.length > 0}
|
||||
class:hidden-branch={!rowGraphIsVisible(row)}
|
||||
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
|
||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||
></span>
|
||||
{#if hoverBranchRefs.length > 0}
|
||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||
{#each hoverBranchRefs as branch}
|
||||
<span title={branch}>
|
||||
<GitBranch size={10} aria-hidden="true" />
|
||||
{branch}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -241,9 +505,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if item.refs.length > 0}
|
||||
{#if otherRefs.length > 0}
|
||||
<div class="ref-list" aria-label="Commit refs">
|
||||
{#each item.refs as ref}
|
||||
{#each otherRefs as ref}
|
||||
<span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -303,3 +567,46 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if branchDialogOpen}
|
||||
<div class="branch-filter-backdrop" role="presentation" onclick={handleBranchDialogBackdropClick}>
|
||||
<div
|
||||
class="branch-filter-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select visible branches"
|
||||
>
|
||||
<header class="branch-filter-dialog-head">
|
||||
<div>
|
||||
<span class="eyebrow">Git graph</span>
|
||||
<h3>Visible branches</h3>
|
||||
</div>
|
||||
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="branch-filter-summary">
|
||||
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
|
||||
<div class="branch-filter-actions">
|
||||
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
|
||||
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="branch-filter-dialog-list">
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user