Auto-derived project labels were title-cased, turning .ssh into .Ssh and opencode-claude into Opencode Claude. Show the folder name verbatim in the sidebar, window title, settings selector and notification templates, and migrate persisted legacy labels back to the folder name (manual renames are preserved).
90 lines
3.6 KiB
TypeScript
90 lines
3.6 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 { 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);
|
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
|
|
|
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.id === activeProjectId) ?? sortedProjects[0];
|
|
}, [activeProjectId, 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;
|
|
setActiveProject(value);
|
|
}}
|
|
>
|
|
{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>
|
|
);
|
|
};
|