fix(team): dedupe project path options
This commit is contained in:
parent
358496c353
commit
83748673af
3 changed files with 238 additions and 120 deletions
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
|
||||||
45
src/renderer/components/team/dialogs/projectPathOptions.ts
Normal file
45
src/renderer/components/team/dialogs/projectPathOptions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue