fix(team): dedupe project path options

This commit is contained in:
777genius 2026-04-19 21:49:40 +03:00
parent 358496c353
commit 83748673af
3 changed files with 238 additions and 120 deletions

View file

@ -8,6 +8,8 @@ import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Check, FolderOpen } from 'lucide-react'; import { Check, FolderOpen } from 'lucide-react';
import { buildProjectPathOptions } from './projectPathOptions';
import type { Project } from '@shared/types'; import type { Project } from '@shared/types';
function escapeRegExp(value: string): string { function escapeRegExp(value: string): string {
@ -69,131 +71,134 @@ export const ProjectPathSelector = ({
projectsLoading, projectsLoading,
projectsError, projectsError,
fieldError, fieldError,
}: ProjectPathSelectorProps): React.JSX.Element => ( }: ProjectPathSelectorProps): React.JSX.Element => {
<div className="space-y-1.5"> const projectOptions = React.useMemo(
<Label>Project</Label> () => buildProjectPathOptions(projects, selectedProjectPath),
<div className="space-y-2"> [projects, selectedProjectPath]
<div className="flex flex-col gap-2 md:flex-row md:items-start"> );
<div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('project')}
>
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onCwdModeChange('custom')}
>
Custom path
</button>
</div>
<div className="min-w-0 flex-1"> return (
{cwdMode === 'project' ? ( <div className="space-y-1.5">
<div className="space-y-1.5"> <Label>Project</Label>
<div className="flex items-center gap-2"> <div className="space-y-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" /> <div className="flex flex-col gap-2 md:flex-row md:items-start">
<div className="min-w-0 flex-1"> <div className="inline-flex shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<Combobox <button
options={projects.map((project) => ({ type="button"
value: project.path, className={cn(
label: project.name, 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
description: project.path, cwdMode === 'project'
}))} ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
value={selectedProjectPath} : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
onValueChange={onSelectedProjectPathChange} )}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} onClick={() => onCwdModeChange('project')}
searchPlaceholder="Search project by name or path" >
emptyMessage="Nothing found" From project list
disabled={projectsLoading || projects.length === 0} </button>
renderOption={(option, isSelected, query) => ( <button
<> type="button"
<Check className={cn(
className={cn( 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
'mr-2 size-3.5 shrink-0', cwdMode === 'custom'
isSelected ? 'opacity-100' : 'opacity-0' ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
)} : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
/> )}
<div className="min-w-0 flex-1"> onClick={() => onCwdModeChange('custom')}
<p className="truncate font-medium text-[var(--color-text)]"> >
{renderHighlightedText(option.label, query)} Custom path
</p> </button>
<p className="truncate text-[var(--color-text-muted)]"> </div>
{renderHighlightedText(option.description ?? '', query)}
</p> <div className="min-w-0 flex-1">
</div> {cwdMode === 'project' ? (
</> <div className="space-y-1.5">
)} <div className="flex items-center gap-2">
/> <FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="min-w-0 flex-1">
<Combobox
options={projectOptions}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projectOptions.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
</div>
</div> </div>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
{!projectsLoading && projectOptions.length === 0 ? (
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
No projects found, switch to custom path.
</p>
) : null}
</div> </div>
{!selectedProjectPath ? ( ) : (
<p className="text-[11px] text-[var(--color-text-muted)]"> <div className="space-y-1.5">
Select a project from the list <div className="flex items-center gap-2">
</p> <FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
) : null} <Input
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null} className="h-8 flex-1 text-xs"
{!projectsLoading && projects.length === 0 ? ( value={customCwd}
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}> aria-label="Custom working directory"
No projects found, switch to custom path. onChange={(event) => onCustomCwdChange(event.target.value)}
</p> placeholder="/absolute/path/to/project"
) : null} />
</div> <Button
) : ( variant="outline"
<div className="space-y-1.5"> size="sm"
<div className="flex items-center gap-2"> onClick={() => {
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" /> void (async () => {
<Input try {
className="h-8 flex-1 text-xs" const paths = await api.config.selectFolders();
value={customCwd} if (paths.length > 0) {
aria-label="Custom working directory" onCustomCwdChange(paths[0]);
onChange={(event) => onCustomCwdChange(event.target.value)} }
placeholder="/absolute/path/to/project" } catch {
/> // IPC error - dialog may have been cancelled or failed
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
try {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
onCustomCwdChange(paths[0]);
} }
} catch { })();
// IPC error — dialog may have been cancelled or failed }}
} >
})(); Browse
}} </Button>
> </div>
Browse <p className="text-[11px] text-[var(--color-text-muted)]">
</Button> If the directory does not exist, it will be created automatically.
</p>
</div> </div>
<p className="text-[11px] text-[var(--color-text-muted)]"> )}
If the directory does not exist, it will be created automatically. </div>
</p>
</div>
)}
</div> </div>
</div> </div>
{fieldError ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{fieldError}
</p>
) : null}
</div> </div>
{fieldError ? ( );
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}> };
{fieldError}
</p>
) : null}
</div>
);

View file

@ -0,0 +1,45 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import type { ComboboxOption } from '@renderer/components/ui/combobox';
import type { Project } from '@shared/types';
function toProjectOption(project: Project): ComboboxOption {
return {
value: project.path,
label: project.name,
description: project.path,
};
}
/**
* Collapse duplicate project entries that resolve to the same filesystem path.
* This keeps combobox item values unique even when scanner sources overlap.
*/
export function buildProjectPathOptions(
projects: Project[],
preferredPath?: string
): ComboboxOption[] {
const options: ComboboxOption[] = [];
const optionIndexByNormalizedPath = new Map<string, number>();
const normalizedPreferredPath = preferredPath ? normalizePath(preferredPath) : null;
for (const project of projects) {
const normalizedProjectPath = normalizePath(project.path);
const existingIndex = optionIndexByNormalizedPath.get(normalizedProjectPath);
if (existingIndex === undefined) {
optionIndexByNormalizedPath.set(normalizedProjectPath, options.length);
options.push(toProjectOption(project));
continue;
}
const shouldPreferCurrentOption =
normalizedPreferredPath === normalizedProjectPath && project.path === preferredPath;
if (shouldPreferCurrentOption) {
options[existingIndex] = toProjectOption(project);
}
}
return options;
}

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { buildProjectPathOptions } from '@renderer/components/team/dialogs/projectPathOptions';
import type { Project } from '@shared/types';
function createProject(overrides: Partial<Project>): Project {
return {
id: 'project-id',
name: 'project',
path: '/Users/test/project',
sessions: [],
totalSessions: 0,
createdAt: 1,
...overrides,
};
}
describe('buildProjectPathOptions', () => {
it('removes duplicate projects that point to the same path', () => {
const options = buildProjectPathOptions([
createProject({
id: 'project-1',
name: 'lintai',
path: '/Users/belief/dev/projects/lintai',
}),
createProject({
id: 'project-2',
name: 'lintai duplicate',
path: '/Users/belief/dev/projects/lintai',
}),
]);
expect(options).toEqual([
{
value: '/Users/belief/dev/projects/lintai',
label: 'lintai',
description: '/Users/belief/dev/projects/lintai',
},
]);
});
it('prefers the currently selected variant when duplicate paths normalize equally', () => {
const options = buildProjectPathOptions(
[
createProject({
id: 'project-1',
name: 'LintAI',
path: '/Users/Belief/dev/projects/lintai',
}),
createProject({
id: 'project-2',
name: 'lintai',
path: '/Users/belief/dev/projects/lintai/',
}),
],
'/Users/belief/dev/projects/lintai/'
);
expect(options).toEqual([
{
value: '/Users/belief/dev/projects/lintai/',
label: 'lintai',
description: '/Users/belief/dev/projects/lintai/',
},
]);
});
});