From 7d98956dadd4ea9ec0d22e7a2791620ffdd05f30 Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:04 -0400 Subject: [PATCH] fix(extensions): resolve project path from both projects and repositoryGroups --- .../extensions/ExtensionStoreView.tsx | 15 +- src/renderer/store/slices/extensionsSlice.ts | 18 +- src/renderer/utils/projectLookup.ts | 27 +++ test/renderer/store/extensionsSlice.test.ts | 36 ++++ test/renderer/utils/projectLookup.test.ts | 162 +++++++++++++++++- 5 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index cec304fb..ec905ee0 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -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( () => [ { diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index eb44adae..58063575 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -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 { 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[], + repositoryGroups: readonly Pick[] +): { 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; +} diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 623a7b06..a69507fa 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -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', () => { diff --git a/test/renderer/utils/projectLookup.test.ts b/test/renderer/utils/projectLookup.test.ts index e8685d45..c90b2626 100644 --- a/test/renderer/utils/projectLookup.test.ts +++ b/test/renderer/utils/projectLookup.test.ts @@ -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; +type ProjectWithName = Pick; type RepoGroupLike = Pick; -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'); + }); + }); +});