fix(extensions): resolve project path from both projects and repositoryGroups
This commit is contained in:
parent
a0c8db4771
commit
43ae8ae6bc
5 changed files with 247 additions and 11 deletions
|
|
@ -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(
|
||||
() => [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue