Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
13 changes: 13 additions & 0 deletions src/panels/__tests__/MainPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 22 additions & 0 deletions webview-ui/src/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
14 changes: 12 additions & 2 deletions webview-ui/src/components/layout/Toolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions webview-ui/src/components/layout/__tests__/Toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
25 changes: 24 additions & 1 deletion webview-ui/src/lib/utils/__tests__/path.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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('');
});
});
13 changes: 13 additions & 0 deletions webview-ui/src/lib/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}