feat(history): highlight ahead/behind vs active upstream
Repo status updates for the management view are now refreshed in the background after switching, without blocking the initial open flow. The history graph and hover labels now distinguish local vs upstream branches, visually marking commits that are ahead or behind. - Add background fetch helper and shared status update logic - Pass active upstream into HistoryPanel and render sync styling
This commit is contained in:
+38
-26
@@ -395,6 +395,22 @@
|
|||||||
void backgroundRepoStatusTick(true);
|
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
|
// 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
|
// 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.
|
// glancing at the tab bar) shows current data without an explicit fetch.
|
||||||
@@ -408,6 +424,25 @@
|
|||||||
.filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath)));
|
.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) {
|
async function backgroundRepoStatusTick(fetchFirst: boolean) {
|
||||||
if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return;
|
if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return;
|
||||||
const others = knownRepoPathsForBackground();
|
const others = knownRepoPathsForBackground();
|
||||||
@@ -427,22 +462,7 @@
|
|||||||
try {
|
try {
|
||||||
if (fetchFirst) await fetchRemote(path);
|
if (fetchFirst) await fetchRemote(path);
|
||||||
const nextStatus = await getStatus(path);
|
const nextStatus = await getStatus(path);
|
||||||
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
updateRepoManagementStatus(path, nextStatus);
|
||||||
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);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore this repo — same rationale as the active-repo background fetch above
|
// ignore this repo — same rationale as the active-repo background fetch above
|
||||||
}
|
}
|
||||||
@@ -1169,16 +1189,6 @@
|
|||||||
// and the overlay appears to "come late".
|
// and the overlay appears to "come late".
|
||||||
await tick();
|
await tick();
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
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,
|
// Single backend round-trip: resolves the repo and reads status, branches,
|
||||||
// commits and files in one pass instead of four sequential git calls.
|
// commits and files in one pass instead of four sequential git calls.
|
||||||
const bundle = await openRepositoryBundle(path, 100);
|
const bundle = await openRepositoryBundle(path, 100);
|
||||||
@@ -1192,6 +1202,7 @@
|
|||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
lastRepoSwitchAt = Date.now();
|
lastRepoSwitchAt = Date.now();
|
||||||
|
void backgroundFetchRepo(activeRepoPath);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2817,6 +2828,7 @@
|
|||||||
{commits}
|
{commits}
|
||||||
{localBranchNames}
|
{localBranchNames}
|
||||||
activeBranch={status?.current_branch ?? ""}
|
activeBranch={status?.current_branch ?? ""}
|
||||||
|
activeUpstream={status?.upstream ?? ""}
|
||||||
repositoryKey={activeRepoPath}
|
repositoryKey={activeRepoPath}
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
|
|||||||
+39
@@ -2241,6 +2241,15 @@
|
|||||||
.graph-svg path.hidden-branch {
|
.graph-svg path.hidden-branch {
|
||||||
opacity: 0.08;
|
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 {
|
.graph-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
@@ -2258,6 +2267,14 @@
|
|||||||
opacity: 0.16;
|
opacity: 0.16;
|
||||||
box-shadow: none;
|
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.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
||||||
.graph-dot.merge {
|
.graph-dot.merge {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
@@ -2303,6 +2320,22 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.graph-hover-branches svg {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #76d995;
|
color: #76d995;
|
||||||
@@ -2316,6 +2349,12 @@
|
|||||||
transition: background 120ms ease;
|
transition: background 120ms ease;
|
||||||
}
|
}
|
||||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
|
.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 .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 { 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-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
localBranchNames: string[];
|
localBranchNames: string[];
|
||||||
activeBranch: string;
|
activeBranch: string;
|
||||||
|
activeUpstream: string;
|
||||||
repositoryKey: string;
|
repositoryKey: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
commits = [],
|
commits = [],
|
||||||
localBranchNames = [],
|
localBranchNames = [],
|
||||||
activeBranch = "",
|
activeBranch = "",
|
||||||
|
activeUpstream = "",
|
||||||
repositoryKey = "",
|
repositoryKey = "",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
@@ -201,7 +203,13 @@
|
|||||||
return [...new Set(values.filter(Boolean))];
|
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 {
|
function branchIsVisible(branch: string): boolean {
|
||||||
|
if (branch === activeUpstream && activeUpstream) {
|
||||||
|
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
||||||
|
}
|
||||||
return !hiddenGraphBranches.has(branch);
|
return !hiddenGraphBranches.has(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +218,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||||
const directBranches = localBranchRefs(commit);
|
const directBranches = graphBranchRefs(commit);
|
||||||
if (directBranches.length > 0) return directBranches;
|
if (directBranches.length > 0) return directBranches;
|
||||||
|
|
||||||
const containingBranches = row?.branchLabels ?? [];
|
const containingBranches = row?.branchLabels ?? [];
|
||||||
@@ -219,7 +227,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||||
const directBranches = localBranchRefs(commit);
|
const directBranches = graphBranchRefs(commit);
|
||||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||||
|
|
||||||
const containingBranches = row?.branchLabels ?? [];
|
const containingBranches = row?.branchLabels ?? [];
|
||||||
@@ -231,8 +239,17 @@
|
|||||||
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
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 {
|
function branchesAreVisible(branches: string[]): boolean {
|
||||||
if (localBranchNames.length === 0) return true;
|
if (graphBranchNames.length === 0) return true;
|
||||||
return branches.some(branchIsVisible);
|
return branches.some(branchIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +281,7 @@
|
|||||||
const membership = new Map<string, Set<string>>();
|
const membership = new Map<string, Set<string>>();
|
||||||
|
|
||||||
for (const commit of items) {
|
for (const commit of items) {
|
||||||
for (const branch of localBranchRefs(commit)) {
|
for (const branch of graphBranchRefs(commit)) {
|
||||||
const stack = [commit.hash];
|
const stack = [commit.hash];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -289,7 +306,7 @@
|
|||||||
return new Map(
|
return new Map(
|
||||||
items.map((commit) => [
|
items.map((commit) => [
|
||||||
commit.hash,
|
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;
|
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[] {
|
function visibleRefs(commit: GitCommit): string[] {
|
||||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||||
}
|
}
|
||||||
@@ -440,7 +469,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||||
? activeBranch
|
? activeBranch
|
||||||
: (localBranchNames[0] ?? "");
|
: (graphBranchNames[0] ?? "");
|
||||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||||
|
|
||||||
if (!defaultBranch) {
|
if (!defaultBranch) {
|
||||||
@@ -512,8 +541,11 @@
|
|||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||||
{@const otherRefs = visibleRefs(item)}
|
{@const otherRefs = visibleRefs(item)}
|
||||||
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||||
<article
|
<article
|
||||||
class="commit-row graph-row"
|
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:merge-row={item.parents.length > 1}
|
||||||
class:root-row={item.parents.length === 0}
|
class:root-row={item.parents.length === 0}
|
||||||
class:tip-row={item.refs.length > 0}
|
class:tip-row={item.refs.length > 0}
|
||||||
@@ -524,6 +556,8 @@
|
|||||||
{#each row.top as seg}
|
{#each row.top as seg}
|
||||||
<path
|
<path
|
||||||
class:hidden-branch={!segmentIsVisible(seg)}
|
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)}
|
d={graphPath(seg, 0, 50)}
|
||||||
stroke={seg.color}
|
stroke={seg.color}
|
||||||
stroke-width="2.2"
|
stroke-width="2.2"
|
||||||
@@ -533,6 +567,8 @@
|
|||||||
{#each row.bottom as seg}
|
{#each row.bottom as seg}
|
||||||
<path
|
<path
|
||||||
class:hidden-branch={!segmentIsVisible(seg)}
|
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)}
|
d={graphPath(seg, 50, 100)}
|
||||||
stroke={seg.color}
|
stroke={seg.color}
|
||||||
stroke-width="2.2"
|
stroke-width="2.2"
|
||||||
@@ -545,13 +581,20 @@
|
|||||||
class:merge={item.parents.length > 1}
|
class:merge={item.parents.length > 1}
|
||||||
class:tip={item.refs.length > 0}
|
class:tip={item.refs.length > 0}
|
||||||
class:hidden-branch={!rowGraphIsVisible(row)}
|
class:hidden-branch={!rowGraphIsVisible(row)}
|
||||||
|
class:ahead={rowSyncClass === "ahead"}
|
||||||
|
class:behind={rowSyncClass === "behind"}
|
||||||
title={commitHoverTitle(item, row)}
|
title={commitHoverTitle(item, row)}
|
||||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||||
></span>
|
></span>
|
||||||
{#if hoverBranchRefs.length > 0}
|
{#if hoverBranchRefs.length > 0}
|
||||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||||
{#each hoverBranchRefs as branch}
|
{#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" />
|
<GitBranch size={10} aria-hidden="true" />
|
||||||
{branch}
|
{branch}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user