Picking a project in Settings called setActiveProject, which relocates the chat, the session list, the file tree and the Git surface. Reading another project's MCP servers or agents moved the user's whole app. It had to, because the configuration stores resolved the directory themselves from the active project and held one flat list. Each of them now takes an explicit directory — omitted still means the active project, so every caller outside Settings is unchanged — and keys loaded data by directory next to a flat mirror of the active project. Chat, autocompletes and pickers keep reading that mirror; a load for another directory writes only the map. A failed load restores that directory's previous list. Settings resolves its own directory through useSettingsDirectory, backed by a session-local settingsProjectPath that follows the active project until the user picks something else.
97 lines
4.0 KiB
TypeScript
97 lines
4.0 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
const formatProjectLabel = (label: string): string => label.trim();
|
|
|
|
export const SettingsProjectSelector: React.FC<{ className?: string }> = ({ className }) => {
|
|
const { t } = useI18n();
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
// Settings-only selection. Picking a project here used to call
|
|
// `setActiveProject`, which relocates the chat, the session list and the file
|
|
// tree; reading another project's configuration must not move the app.
|
|
const settingsDirectory = useSettingsDirectory();
|
|
const setSettingsProjectPath = useUIStore((state) => state.setSettingsProjectPath);
|
|
|
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
|
|
|
const sortedProjects = React.useMemo(() => {
|
|
return [...projects].sort((a, b) => (a.label || a.path).localeCompare(b.label || b.path));
|
|
}, [projects]);
|
|
|
|
const activeProject = React.useMemo(() => {
|
|
if (sortedProjects.length === 0) {
|
|
return null;
|
|
}
|
|
return sortedProjects.find((p) => p.path === settingsDirectory) ?? sortedProjects[0];
|
|
}, [settingsDirectory, sortedProjects]);
|
|
|
|
if (isVSCode || sortedProjects.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const rawLabel = activeProject?.label && activeProject.label.trim().length > 0
|
|
? activeProject.label
|
|
: (activeProject?.path.split('/').filter(Boolean).pop() || activeProject?.path || t('settings.shared.projectSelector.fallbackProject'));
|
|
const label = formatProjectLabel(rawLabel);
|
|
|
|
return (
|
|
<div className={cn(className)}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={t('settings.shared.projectSelector.switchProjectAria')}
|
|
title={t('settings.shared.projectSelector.switchProjectTitle')}
|
|
className={cn(
|
|
// Mirror Input sizing so headers align visually.
|
|
'text-foreground border border-border/80 appearance-none flex h-8 w-full min-w-0 rounded-lg bg-transparent px-3 py-1 outline-none',
|
|
'hover:border-input focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary/70',
|
|
'flex items-center gap-1.5 text-left'
|
|
)}
|
|
>
|
|
<Icon name="folder" className="h-4 w-4 opacity-70" />
|
|
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium">{label}</span>
|
|
<Icon name="arrow-down-s" className="size-4 opacity-50" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-auto">
|
|
<DropdownMenuRadioGroup
|
|
value={activeProject?.id ?? ''}
|
|
onValueChange={(value) => {
|
|
if (!value) return;
|
|
const project = sortedProjects.find((entry) => entry.id === value);
|
|
if (!project) return;
|
|
setSettingsProjectPath(project.path);
|
|
}}
|
|
>
|
|
{sortedProjects.map((project) => {
|
|
const raw = project.label?.trim()
|
|
? project.label.trim()
|
|
: (project.path.split('/').filter(Boolean).pop() || project.path);
|
|
const itemLabel = formatProjectLabel(raw);
|
|
return (
|
|
<DropdownMenuRadioItem key={project.id} value={project.id}>
|
|
<span className="min-w-0 truncate typography-ui">{itemLabel}</span>
|
|
</DropdownMenuRadioItem>
|
|
);
|
|
})}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
};
|