fix(extensions): resolve project path from both projects and repositoryGroups

This commit is contained in:
Diego Serrano 2026-04-14 07:37:04 -04:00 committed by 777genius
parent a0c8db4771
commit 43ae8ae6bc
5 changed files with 247 additions and 11 deletions

View file

@ -18,6 +18,7 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -45,6 +46,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
openDashboard,
sessions,
projects,
repositoryGroups,
} = useStore(
useShallow((s) => ({
fetchPluginCatalog: s.fetchPluginCatalog,
@ -61,6 +63,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
openDashboard: s.openDashboard,
sessions: s.sessions,
projects: s.projects,
repositoryGroups: s.repositoryGroups,
}))
);
const cliInstalled = cliStatus?.installed ?? true;
@ -74,14 +77,12 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const tabState = useExtensionsTabState();
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
const projectPath = useMemo(
() => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null,
[extensionsTabProjectId, projects]
);
const projectLabel = useMemo(
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
[extensionsTabProjectId, projects]
const resolvedProject = useMemo(
() => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups),
[extensionsTabProjectId, projects, repositoryGroups]
);
const projectPath = resolvedProject?.path ?? null;
const projectLabel = resolvedProject?.name ?? null;
const subTabs = useMemo(
() => [
{

View file

@ -7,6 +7,7 @@ import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import type { AppState } from '../types';
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
import type {
ApiKeyEntry,
ApiKeySaveRequest,
@ -889,9 +890,24 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── Tab opener ──
openExtensionsTab: () => {
const state = get();
const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined;
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
if (existingTab) {
// Update projectId to reflect the currently selected project
if (existingTab.projectId !== currentProjectId) {
const pane = findPaneByTabId(state.paneLayout, existingTab.id);
if (pane) {
set({
paneLayout: updatePane(state.paneLayout, {
...pane,
tabs: pane.tabs.map((t) =>
t.id === existingTab.id ? { ...t, projectId: currentProjectId } : t
),
}),
});
}
}
state.setActiveTab(existingTab.id);
return;
}
@ -899,7 +915,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
state.openTab({
type: 'extensions',
label: 'Extensions',
projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined,
projectId: currentProjectId,
});
},

View file

@ -35,3 +35,30 @@ export function resolveProjectIdByPath(
return null;
}
/**
* Resolve a filesystem path from an encoded project ID.
*
* Inverse of `resolveProjectIdByPath`: given a project ID (e.g. `-home-diego-DEV-monorepo-rCAPI`),
* returns the decoded path (e.g. `/home/diego/DEV/monorepo/rCAPI`).
*
* Checks both `projects[]` and `repositoryGroups[].worktrees[]` so the result
* is correct regardless of whether the sidebar is in flat or grouped view mode.
*/
export function resolveProjectPathById(
projectId: string | undefined | null,
projects: readonly Pick<Project, 'id' | 'path' | 'name'>[],
repositoryGroups: readonly Pick<RepositoryGroup, 'worktrees'>[]
): { path: string; name: string } | null {
if (!projectId) return null;
const fromProjects = projects.find((p) => p.id === projectId);
if (fromProjects) return { path: fromProjects.path, name: fromProjects.name };
for (const group of repositoryGroups) {
const worktree = group.worktrees.find((w) => w.id === projectId);
if (worktree) return { path: worktree.path, name: worktree.name };
}
return null;
}

View file

@ -258,6 +258,42 @@ describe('extensionsSlice', () => {
expect(count1).toBe(1);
expect(count2).toBe(1); // no duplicate
});
it('updates projectId on existing tab when selected project changes', () => {
// Open Extensions with project-A
store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
store.getState().openExtensionsTab();
const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const extTabBefore = tabsBefore.find((t) => t.type === 'extensions');
expect(extTabBefore?.projectId).toBe('project-A');
// Switch to project-B and reopen Extensions
store.setState({ selectedProjectId: 'project-B' });
store.getState().openExtensionsTab();
const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const extTabAfter = tabsAfter.find((t) => t.type === 'extensions');
expect(extTabAfter?.projectId).toBe('project-B');
// Still only one extensions tab
expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1);
});
it('does not update projectId when it already matches', () => {
store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
store.getState().openExtensionsTab();
const layoutBefore = store.getState().paneLayout;
// Reopen with same project — layout should be referentially stable (no set() call)
store.getState().openExtensionsTab();
const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs);
const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
const extBefore = tabsBefore.find((t) => t.type === 'extensions');
const extAfter = tabsAfter.find((t) => t.type === 'extensions');
expect(extAfter?.projectId).toBe(extBefore?.projectId);
});
});
describe('installPlugin', () => {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { resolveProjectIdByPath, resolveProjectPathById } from '@renderer/utils/projectLookup';
import type { Project, RepositoryGroup } from '@renderer/types/data';
@ -9,16 +9,19 @@ import type { Project, RepositoryGroup } from '@renderer/types/data';
// ---------------------------------------------------------------------------
type ProjectLike = Pick<Project, 'id' | 'path'>;
type ProjectWithName = Pick<Project, 'id' | 'path' | 'name'>;
type RepoGroupLike = Pick<RepositoryGroup, 'worktrees'>;
const CRYPTO_PROJECT: ProjectLike = {
const CRYPTO_PROJECT: ProjectWithName = {
id: '-Users-belief-dev-projects-crypto-research',
path: '/Users/belief/dev/projects/crypto_research',
name: 'crypto_research',
};
const CLAUDE_PROJECT: ProjectLike = {
const CLAUDE_PROJECT: ProjectWithName = {
id: '-Users-belief-dev-projects-claude-claude-team',
path: '/Users/belief/dev/projects/claude/claude_team',
name: 'claude_team',
};
function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike {
@ -290,3 +293,156 @@ describe('resolveProjectIdByPath', () => {
});
});
});
// ===========================================================================
// resolveProjectPathById — inverse lookup (ID → path + name)
// ===========================================================================
describe('resolveProjectPathById', () => {
// -----------------------------------------------------------------------
// Null / undefined / empty input
// -----------------------------------------------------------------------
describe('null/undefined/empty projectId', () => {
it('returns null for undefined projectId', () => {
expect(resolveProjectPathById(undefined, [CRYPTO_PROJECT], [])).toBeNull();
});
it('returns null for null projectId', () => {
expect(resolveProjectPathById(null, [CRYPTO_PROJECT], [])).toBeNull();
});
it('returns null for empty string projectId', () => {
expect(resolveProjectPathById('', [CRYPTO_PROJECT], [])).toBeNull();
});
});
// -----------------------------------------------------------------------
// Lookup from projects (flat view mode)
// -----------------------------------------------------------------------
describe('lookup from projects (flat mode)', () => {
it('finds project by exact id match', () => {
const result = resolveProjectPathById(
'-Users-belief-dev-projects-crypto-research',
[CRYPTO_PROJECT, CLAUDE_PROJECT],
[]
);
expect(result).toEqual({
path: '/Users/belief/dev/projects/crypto_research',
name: 'crypto_research',
});
});
it('returns null when id not in projects', () => {
expect(
resolveProjectPathById('-Users-belief-dev-projects-unknown', [CRYPTO_PROJECT], [])
).toBeNull();
});
it('returns null when projects list is empty', () => {
expect(
resolveProjectPathById('-Users-belief-dev-projects-crypto-research', [], [])
).toBeNull();
});
});
// -----------------------------------------------------------------------
// Lookup from repositoryGroups (grouped view mode)
// -----------------------------------------------------------------------
describe('lookup from repositoryGroups (grouped mode)', () => {
it('finds project in worktrees when projects is empty', () => {
const result = resolveProjectPathById(
'-Users-belief-dev-projects-crypto-research',
[],
[CRYPTO_REPO_GROUP]
);
expect(result).toEqual({
path: '/Users/belief/dev/projects/crypto_research',
name: '-Users-belief-dev-projects-crypto-research',
});
});
it('finds project across multiple repo groups', () => {
const result = resolveProjectPathById(
'-Users-belief-dev-projects-claude-claude-team',
[],
[CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP]
);
expect(result).toEqual({
path: '/Users/belief/dev/projects/claude/claude_team',
name: '-Users-belief-dev-projects-claude-claude-team',
});
});
it('finds correct worktree in multi-worktree group', () => {
const result = resolveProjectPathById(
'-Users-belief-dev-projects-app-wt-feature',
[],
[MULTI_WORKTREE_GROUP]
);
expect(result).toEqual({
path: '/Users/belief/dev/projects/app-wt-feature',
name: '-Users-belief-dev-projects-app-wt-feature',
});
});
it('returns null when id not in any worktree', () => {
expect(
resolveProjectPathById('-Users-belief-dev-projects-unknown', [], [CRYPTO_REPO_GROUP])
).toBeNull();
});
});
// -----------------------------------------------------------------------
// Priority: projects takes precedence over repositoryGroups
// -----------------------------------------------------------------------
describe('priority order', () => {
it('prefers projects match over repositoryGroups match', () => {
const projectEntry: ProjectWithName = {
id: 'shared-id',
path: '/from/projects',
name: 'from-projects',
};
const repoGroupEntry = makeRepoGroup([
{ id: 'shared-id', path: '/from/repo-group' },
]);
const result = resolveProjectPathById('shared-id', [projectEntry], [repoGroupEntry]);
expect(result).toEqual({ path: '/from/projects', name: 'from-projects' });
});
it('falls back to repositoryGroups when projects has no match', () => {
const result = resolveProjectPathById(
'-Users-belief-dev-projects-crypto-research',
[CLAUDE_PROJECT],
[CRYPTO_REPO_GROUP]
);
expect(result).toEqual({
path: '/Users/belief/dev/projects/crypto_research',
name: '-Users-belief-dev-projects-crypto-research',
});
});
});
// -----------------------------------------------------------------------
// Regression: Extensions tab with grouped view mode
// -----------------------------------------------------------------------
describe('regression: Extensions tab skills in grouped view mode', () => {
it('resolves projectPath from id when only repositoryGroups is populated', () => {
// This is the exact scenario that caused skills not to show:
// viewMode=grouped → projects=[] but repositoryGroups has the data
// ExtensionStoreView used projects.find(p => p.id === tabProjectId)
// which returned null, so projectPath was null and no project skills loaded
const emptyProjects: ProjectWithName[] = [];
const populatedGroups: RepoGroupLike[] = [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP];
const result = resolveProjectPathById(
'-Users-belief-dev-projects-crypto-research',
emptyProjects,
populatedGroups
);
expect(result).not.toBeNull();
expect(result!.path).toBe('/Users/belief/dev/projects/crypto_research');
});
});
});