Features/extend git features #20

Merged
Christoph merged 6 commits from features/extend_git_features into main 2026-07-06 19:07:49 +00:00
3 changed files with 127 additions and 33 deletions
Showing only changes of commit aa61199b43 - Show all commits
+38 -26
View File
@@ -395,6 +395,22 @@
void backgroundRepoStatusTick(true);
}
async function backgroundFetchRepo(path: string) {
if (!autoRefreshEnabled || !path || backgroundFetchInFlight) return;
backgroundFetchInFlight = true;
try {
await fetchRemote(path);
const nextStatus = await getStatus(path);
if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus);
else updateRepoManagementStatus(path, nextStatus);
} catch {
// ignore; manual Fetch/Pull surfaces auth or network problems
} finally {
backgroundFetchInFlight = false;
}
}
// Keeps the repo list's branch/ahead/behind up to date for every *other* open
// tab, not just the active one, so switching to the management view (or just
// glancing at the tab bar) shows current data without an explicit fetch.
@@ -408,6 +424,25 @@
.filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath)));
}
function updateRepoManagementStatus(path: string, nextStatus: GitStatus) {
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const cached = repoStatusCache[repoKey(path)];
const row: RepoTab = {
path,
name: repoNameFromPath(path),
branch: nextStatus.current_branch,
ahead: nextStatus.ahead,
behind: nextStatus.behind,
changed: nextStatus.files.length,
lastOpened: openTab?.lastOpened ?? cached?.lastOpened ?? 0,
};
if (openTab) {
repoTabs = repoTabs.map((tab) => sameRepoPath(tab.path, path) ? row : tab);
}
cacheRepoStatus(row);
}
async function backgroundRepoStatusTick(fetchFirst: boolean) {
if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return;
const others = knownRepoPathsForBackground();
@@ -427,22 +462,7 @@
try {
if (fetchFirst) await fetchRemote(path);
const nextStatus = await getStatus(path);
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const cached = repoStatusCache[repoKey(path)];
const row: RepoTab = {
path,
name: repoNameFromPath(path),
branch: nextStatus.current_branch,
ahead: nextStatus.ahead,
behind: nextStatus.behind,
changed: nextStatus.files.length,
lastOpened: openTab?.lastOpened ?? cached?.lastOpened ?? 0,
};
if (openTab) {
repoTabs = repoTabs.map((tab) => sameRepoPath(tab.path, path) ? row : tab);
}
cacheRepoStatus(row);
updateRepoManagementStatus(path, nextStatus);
} catch {
// ignore this repo — same rationale as the active-repo background fetch above
}
@@ -1169,16 +1189,6 @@
// and the overlay appears to "come late".
await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Fetch first so the bundle's ahead/behind reflects the remote's current
// state instead of whatever the local remote-tracking ref last saw. Best
// effort: no credentials are passed and failures are swallowed, same as
// the periodic background fetch — auth/network issues surface via the
// manual Fetch/Pull buttons instead of blocking (or erroring) repo open.
try {
await fetchRemote(path);
} catch {
// ignore
}
// Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100);
@@ -1192,6 +1202,7 @@
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
void backgroundFetchRepo(activeRepoPath);
});
}
@@ -2817,6 +2828,7 @@
{commits}
{localBranchNames}
activeBranch={status?.current_branch ?? ""}
activeUpstream={status?.upstream ?? ""}
repositoryKey={activeRepoPath}
{hasRepository}
{isBusy}
+39
View File
@@ -2241,6 +2241,15 @@
.graph-svg path.hidden-branch {
opacity: 0.08;
}
.graph-svg path.graph-segment-ahead {
stroke: #e0a040;
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
}
.graph-svg path.graph-segment-behind {
stroke: #7aacff;
stroke-dasharray: 4 4;
filter: drop-shadow(0 0 3px rgba(122,172,255,0.24));
}
.graph-dot {
position: absolute;
z-index: 2;
@@ -2258,6 +2267,14 @@
opacity: 0.16;
box-shadow: none;
}
.graph-dot.ahead {
background: #e0a040;
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
}
.graph-dot.behind {
background: #7aacff;
box-shadow: 0 0 0 1px rgba(122,172,255,0.46), 0 0 10px rgba(122,172,255,0.18);
}
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge {
width: 15px;
@@ -2303,6 +2320,22 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-hover-branches span.remote,
.branch-filter-option.remote {
border-color: rgba(122,172,255,0.32);
color: #bcd2ff;
background: rgba(31,43,72,0.88);
}
.graph-hover-branches span.ahead {
border-color: rgba(224,160,64,0.42);
color: #ffd99a;
background: rgba(58,42,20,0.94);
}
.graph-hover-branches span.behind {
border-color: rgba(122,172,255,0.46);
color: #c7dbff;
background: rgba(25,39,70,0.94);
}
.graph-hover-branches svg {
flex: 0 0 auto;
color: #76d995;
@@ -2316,6 +2349,12 @@
transition: background 120ms ease;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
.graph-row.graph-ahead-row .commit-body {
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
}
.graph-row.graph-behind-row .commit-body {
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72);
}
.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; }
+50 -7
View File
@@ -32,6 +32,7 @@
commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
activeUpstream: string;
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
@@ -47,6 +48,7 @@
commits = [],
localBranchNames = [],
activeBranch = "",
activeUpstream = "",
repositoryKey = "",
hasRepository = false,
isBusy = false,
@@ -201,7 +203,13 @@
return [...new Set(values.filter(Boolean))];
}
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
let graphBranchNameSet = $derived(new Set(graphBranchNames));
function branchIsVisible(branch: string): boolean {
if (branch === activeUpstream && activeUpstream) {
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
}
return !hiddenGraphBranches.has(branch);
}
@@ -210,7 +218,7 @@
}
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
const directBranches = localBranchRefs(commit);
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return directBranches;
const containingBranches = row?.branchLabels ?? [];
@@ -219,7 +227,7 @@
}
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = localBranchRefs(commit);
const directBranches = graphBranchRefs(commit);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
const containingBranches = row?.branchLabels ?? [];
@@ -231,8 +239,17 @@
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
}
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
if (!activeBranch || !activeUpstream) return "";
const hasLocal = labels.includes(activeBranch);
const hasRemote = labels.includes(activeUpstream);
if (hasLocal && !hasRemote) return "ahead";
if (hasRemote && !hasLocal) return "behind";
return "";
}
function branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.length === 0) return true;
if (graphBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
@@ -264,7 +281,7 @@
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of localBranchRefs(commit)) {
for (const branch of graphBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
@@ -289,7 +306,7 @@
return new Map(
items.map((commit) => [
commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
@@ -407,6 +424,18 @@
return labels;
}
function graphBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!graphBranchNameSet.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)));
}
@@ -440,7 +469,7 @@
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (localBranchNames[0] ?? "");
: (graphBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
@@ -512,8 +541,11 @@
{@const row = graphRows[rowIndex]}
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
{@const otherRefs = visibleRefs(item)}
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
<article
class="commit-row graph-row"
class:graph-ahead-row={rowSyncClass === "ahead"}
class:graph-behind-row={rowSyncClass === "behind"}
class:merge-row={item.parents.length > 1}
class:root-row={item.parents.length === 0}
class:tip-row={item.refs.length > 0}
@@ -524,6 +556,8 @@
{#each row.top as seg}
<path
class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 0, 50)}
stroke={seg.color}
stroke-width="2.2"
@@ -533,6 +567,8 @@
{#each row.bottom as seg}
<path
class:hidden-branch={!segmentIsVisible(seg)}
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
d={graphPath(seg, 50, 100)}
stroke={seg.color}
stroke-width="2.2"
@@ -545,13 +581,20 @@
class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
class:ahead={rowSyncClass === "ahead"}
class:behind={rowSyncClass === "behind"}
title={commitHoverTitle(item, row)}
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}>
<span
class:remote={branch === activeUpstream}
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
title={branch}
>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>