fix(extensions): resolve project path from both projects and repositoryGroups
This commit is contained in:
parent
bda2e160f7
commit
7d98956dad
5 changed files with 247 additions and 11 deletions
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -45,6 +46,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
openDashboard,
|
openDashboard,
|
||||||
sessions,
|
sessions,
|
||||||
projects,
|
projects,
|
||||||
|
repositoryGroups,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
fetchPluginCatalog: s.fetchPluginCatalog,
|
fetchPluginCatalog: s.fetchPluginCatalog,
|
||||||
|
|
@ -61,6 +63,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
openDashboard: s.openDashboard,
|
openDashboard: s.openDashboard,
|
||||||
sessions: s.sessions,
|
sessions: s.sessions,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
|
repositoryGroups: s.repositoryGroups,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const cliInstalled = cliStatus?.installed ?? true;
|
const cliInstalled = cliStatus?.installed ?? true;
|
||||||
|
|
@ -74,14 +77,12 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
const tabState = useExtensionsTabState();
|
const tabState = useExtensionsTabState();
|
||||||
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
|
||||||
const projectPath = useMemo(
|
const resolvedProject = useMemo(
|
||||||
() => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null,
|
() => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups),
|
||||||
[extensionsTabProjectId, projects]
|
[extensionsTabProjectId, projects, repositoryGroups]
|
||||||
);
|
|
||||||
const projectLabel = useMemo(
|
|
||||||
() => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
|
|
||||||
[extensionsTabProjectId, projects]
|
|
||||||
);
|
);
|
||||||
|
const projectPath = resolvedProject?.path ?? null;
|
||||||
|
const projectLabel = resolvedProject?.name ?? null;
|
||||||
const subTabs = useMemo(
|
const subTabs = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { api } from '@renderer/api';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
|
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||||
import type {
|
import type {
|
||||||
ApiKeyEntry,
|
ApiKeyEntry,
|
||||||
ApiKeySaveRequest,
|
ApiKeySaveRequest,
|
||||||
|
|
@ -889,9 +890,24 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// ── Tab opener ──
|
// ── Tab opener ──
|
||||||
openExtensionsTab: () => {
|
openExtensionsTab: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined;
|
||||||
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
||||||
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
|
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
|
||||||
if (existingTab) {
|
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);
|
state.setActiveTab(existingTab.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -899,7 +915,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
state.openTab({
|
state.openTab({
|
||||||
type: 'extensions',
|
type: 'extensions',
|
||||||
label: 'Extensions',
|
label: 'Extensions',
|
||||||
projectId: state.selectedProjectId ?? state.activeProjectId ?? undefined,
|
projectId: currentProjectId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,30 @@ export function resolveProjectIdByPath(
|
||||||
|
|
||||||
return null;
|
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(count1).toBe(1);
|
||||||
expect(count2).toBe(1); // no duplicate
|
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', () => {
|
describe('installPlugin', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
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 ProjectLike = Pick<Project, 'id' | 'path'>;
|
||||||
|
type ProjectWithName = Pick<Project, 'id' | 'path' | 'name'>;
|
||||||
type RepoGroupLike = Pick<RepositoryGroup, 'worktrees'>;
|
type RepoGroupLike = Pick<RepositoryGroup, 'worktrees'>;
|
||||||
|
|
||||||
const CRYPTO_PROJECT: ProjectLike = {
|
const CRYPTO_PROJECT: ProjectWithName = {
|
||||||
id: '-Users-belief-dev-projects-crypto-research',
|
id: '-Users-belief-dev-projects-crypto-research',
|
||||||
path: '/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',
|
id: '-Users-belief-dev-projects-claude-claude-team',
|
||||||
path: '/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 {
|
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