diff --git a/src/App.svelte b/src/App.svelte index ed43f8b..ce13e70 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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((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} diff --git a/src/app.css b/src/app.css index a5aa4c6..ce7abb8 100644 --- a/src/app.css +++ b/src/app.css @@ -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; } diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index 4808fff..67204b7 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -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>(); 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(); @@ -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(); + 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 ?? [])}
1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0} @@ -524,6 +556,8 @@ {#each row.top as seg} 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}`} > {#if hoverBranchRefs.length > 0}
{#each hoverBranchRefs as branch} - +