Files
openchamber/packages/ui/src/components/sections/shared/SettingsProjectSelector.tsx
T
Bohdan Triapitsyn 9032dfa5c0 fix(ui): show project names exactly as the folder is named
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).
2026-08-15 10:16:31 +03:00

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>
);
};