diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905d..d653856e 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -365,6 +365,14 @@ export class MainPanel { if (samePath(newPath, this.repoPath)) { return; } this.swapRepo(newPath); + // The discovered list can lag behind the repo VS Code's SCM switched to + // (e.g. it sits deeper than discovery scans). Add the active repo so the + // webview can resolve its header/dropdown entry instead of falling back to + // the first repo while the graph already shows the new one. See issue #96. + if (!this.cachedRepos.some(r => samePath(r.path, this.repoPath))) { + this.cachedRepos = [...this.cachedRepos, { path: this.repoPath, name: path.basename(this.repoPath), type: 'root' }]; + } + this.post({ type: 'repoList', payload: { repos: this.cachedRepos, active: this.repoPath }, diff --git a/src/panels/__tests__/MainPanel.test.ts b/src/panels/__tests__/MainPanel.test.ts index 76051ee3..f16e5c41 100644 --- a/src/panels/__tests__/MainPanel.test.ts +++ b/src/panels/__tests__/MainPanel.test.ts @@ -287,6 +287,19 @@ describe('MainPanel message routing', () => { await dispatch({ type: 'switchRepo', payload: { path: '/somewhere/else' } }); expect(postedOfType('error').length).toBeGreaterThan(0); }); + + it('posts a repoList that includes the active repo when SCM switches to one discovery missed (issue #96)', async () => { + await new Promise(r => setTimeout(r, 0)); // let sendRepoList populate cachedRepos + // The extension-driven switch (VS Code SCM focus change) bypasses the + // webview allow-list, so the target can be absent from the discovered list. + await MainPanel.currentPanel!.switchRepo('/deep/repo-b'); + + const list = postedOfType('repoList').at(-1)!; + const repos = list.payload!.repos as Array<{ path: string; name: string }>; + expect(list.payload!.active).toBe('/deep/repo-b'); + expect(repos.some(r => r.path === '/deep/repo-b')).toBe(true); + expect(repos.find(r => r.path === '/deep/repo-b')!.name).toBe('repo-b'); + }); }); describe('MainPanel error handling', () => { diff --git a/webview-ui/src/__tests__/App.test.ts b/webview-ui/src/__tests__/App.test.ts index 69b013b7..52902df6 100644 --- a/webview-ui/src/__tests__/App.test.ts +++ b/webview-ui/src/__tests__/App.test.ts @@ -115,6 +115,28 @@ describe('App — message handling', () => { }); }); + it('repoList keeps the header on the active repo when the list is stale (issue #96)', async () => { + // Reproduce the SCM repo-switch path: the extension posts repoList with the + // new active path while the discovered list still only holds the old repo. + const { container } = render(App); + postMsg('repoList', { + repos: [{ path: 'C:\\repo\\alpha', name: 'alpha', type: 'root' }], + active: 'C:\\repo\\alpha', + }); + await waitFor(() => { + expect(container.querySelector('.repo-name')?.textContent?.trim()).toBe('alpha'); + }); + + postMsg('repoList', { + repos: [{ path: 'C:\\repo\\alpha', name: 'alpha', type: 'root' }], + active: 'C:\\repo\\beta', + }); + await waitFor(() => { + expect(uiStore.activeRepo).toBe('C:\\repo\\beta'); + expect(container.querySelector('.repo-name')?.textContent?.trim()).toBe('beta'); + }); + }); + it('notGitRepo flips commitStore.notGitRepo', async () => { render(App); postMsg('notGitRepo'); diff --git a/webview-ui/src/components/layout/Toolbar.svelte b/webview-ui/src/components/layout/Toolbar.svelte index 6ceb0910..0e58849f 100644 --- a/webview-ui/src/components/layout/Toolbar.svelte +++ b/webview-ui/src/components/layout/Toolbar.svelte @@ -9,7 +9,7 @@ import { tooltip } from '../../lib/actions/tooltip'; import { modalStore } from '../../lib/stores/modals.svelte'; import { commitStore } from '../../lib/stores/commits.svelte'; - import { samePath } from '../../lib/utils/path'; + import { samePath, repoNameFromPath } from '../../lib/utils/path'; import type { FlowStatus, FlowBranches } from '../../lib/types'; const vscode = getVsCodeApi(); @@ -96,7 +96,17 @@ const hasUpstream = $derived(!!branchStore.currentBranch?.upstream && !branchStore.currentBranch?.upstreamGone); let ahead = $derived(branchStore.currentBranch?.ahead ?? 0); let behind = $derived(branchStore.currentBranch?.behind ?? 0); - let activeRepoInfo = $derived(uiStore.repos.find(r => samePath(r.path, uiStore.activeRepo)) ?? uiStore.repos[0]); + // The repo list can lag behind the active repo — e.g. VS Code's SCM switches + // focus to a repo the extension's discovery did not surface. Resolve the + // active path directly in that case: falling back to `repos[0]` would keep + // showing the previously active (main) repo while the graph already switched. + // See issue #96. + let activeRepoInfo = $derived( + uiStore.repos.find(r => samePath(r.path, uiStore.activeRepo)) + ?? (uiStore.activeRepo + ? { path: uiStore.activeRepo, name: repoNameFromPath(uiStore.activeRepo), type: 'root' as const } + : uiStore.repos[0]), + ); const currentBranchName = $derived(branchStore.currentBranch?.name ?? null); // git reports non-branch HEAD states as parenthesized pseudo-labels diff --git a/webview-ui/src/components/layout/__tests__/Toolbar.test.ts b/webview-ui/src/components/layout/__tests__/Toolbar.test.ts index 8151e1ef..a73d2a6e 100644 --- a/webview-ui/src/components/layout/__tests__/Toolbar.test.ts +++ b/webview-ui/src/components/layout/__tests__/Toolbar.test.ts @@ -236,6 +236,23 @@ describe('Toolbar — repo dropdown', () => { expect(container.querySelector('.repo-name')?.textContent?.trim()).toBe('main-repo'); }); + it('shows the active repo even when it is missing from the repo list (issue #96)', () => { + // VS Code's SCM can switch to a repo the extension's discovery missed, so + // the active path has no entry in the list. The header must follow the + // active repo instead of falling back to the first (previously active) one. + uiStore.repos = [{ path: 'C:\\repo\\alpha', name: 'alpha', type: 'root' }]; + uiStore.activeRepo = 'C:\\repo\\beta'; + const { container } = render(Toolbar); + expect(container.querySelector('.repo-name')?.textContent?.trim()).toBe('beta'); + }); + + it('falls back to the first repo before an active repo is known', () => { + uiStore.repos = [{ path: '/repo/main', name: 'main-repo', type: 'root' }]; + uiStore.activeRepo = ''; + const { container } = render(Toolbar); + expect(container.querySelector('.repo-name')?.textContent?.trim()).toBe('main-repo'); + }); + it('chevron and dropdown appear only with multiple repos', async () => { uiStore.repos = [ { path: '/r/a', name: 'a', type: 'root' }, diff --git a/webview-ui/src/lib/utils/__tests__/path.test.ts b/webview-ui/src/lib/utils/__tests__/path.test.ts index c1edf766..562c3ba1 100644 --- a/webview-ui/src/lib/utils/__tests__/path.test.ts +++ b/webview-ui/src/lib/utils/__tests__/path.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { samePath } from '../path'; +import { samePath, repoNameFromPath } from '../path'; describe('samePath', () => { it('matches identical paths', () => { @@ -28,3 +28,26 @@ describe('samePath', () => { expect(samePath('/Users/me/proj', '')).toBe(false); }); }); + +describe('repoNameFromPath', () => { + it('returns the last segment of a POSIX path', () => { + expect(repoNameFromPath('/Users/me/projB')).toBe('projB'); + }); + + it('returns the last segment of a Windows path', () => { + expect(repoNameFromPath('C:\\Users\\me\\projB')).toBe('projB'); + }); + + it('ignores trailing separators', () => { + expect(repoNameFromPath('C:\\Users\\me\\projB\\')).toBe('projB'); + expect(repoNameFromPath('/Users/me/projB/')).toBe('projB'); + }); + + it('returns the input when there is no separator', () => { + expect(repoNameFromPath('projB')).toBe('projB'); + }); + + it('returns an empty string for an empty path', () => { + expect(repoNameFromPath('')).toBe(''); + }); +}); diff --git a/webview-ui/src/lib/utils/path.ts b/webview-ui/src/lib/utils/path.ts index 62d25f9b..5c4305be 100644 --- a/webview-ui/src/lib/utils/path.ts +++ b/webview-ui/src/lib/utils/path.ts @@ -15,3 +15,16 @@ export function samePath(a: string, b: string): boolean { if (!a || !b) { return false; } return normalize(a) === normalize(b); } + +/** + * Last segment of a filesystem path, used to label a repository whose entry is + * missing from the discovered repo list (e.g. VS Code's SCM switched focus to a + * repo the filesystem scan did not surface). Handles both separators so Windows + * backslash paths work. See issue #96. + */ +export function repoNameFromPath(p: string): string { + if (!p) { return ''; } + const trimmed = p.replace(/[\\/]+$/, ''); + const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; +}