2026-02-24 03:28:30 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
|
|
|
|
|
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
|
2026-02-27 20:03:42 +02:00
|
|
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
2026-02-24 03:28:30 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { RiAddLine, RiFolderLine } from '@remixicon/react';
|
2026-02-27 20:03:42 +02:00
|
|
|
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime, requestDirectoryAccess } from '@/lib/desktop';
|
2026-02-24 03:28:30 +02:00
|
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
|
|
|
|
import { toast } from '@/components/ui';
|
|
|
|
|
|
|
|
|
|
export const ProjectsSidebar: React.FC<{ onItemSelect?: () => void }> = ({ onItemSelect }) => {
|
|
|
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
|
|
|
const addProject = useProjectsStore((state) => state.addProject);
|
|
|
|
|
const selectedId = useUIStore((state) => state.settingsProjectsSelectedId);
|
|
|
|
|
const setSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
2026-02-27 20:03:42 +02:00
|
|
|
const [brokenIconIds, setBrokenIconIds] = React.useState<Set<string>>(new Set());
|
2026-02-24 03:28:30 +02:00
|
|
|
|
|
|
|
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
|
|
|
|
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
|
|
|
|
|
|
|
|
|
const handleAddProject = React.useCallback(() => {
|
|
|
|
|
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
|
|
|
|
|
sessionEvents.requestDirectoryDialog();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 20:03:42 +02:00
|
|
|
requestDirectoryAccess('')
|
2026-02-24 03:28:30 +02:00
|
|
|
.then((result) => {
|
|
|
|
|
if (result.success && result.path) {
|
|
|
|
|
const added = addProject(result.path, { id: result.projectId });
|
|
|
|
|
if (!added) {
|
|
|
|
|
toast.error('Failed to add project', {
|
|
|
|
|
description: 'Please select a valid directory.',
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSelectedId(added.id);
|
|
|
|
|
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
|
|
|
|
toast.error('Failed to select directory', {
|
|
|
|
|
description: result.error,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
console.error('Failed to select directory:', error);
|
|
|
|
|
toast.error('Failed to select directory');
|
|
|
|
|
});
|
|
|
|
|
}, [addProject, setSelectedId, tauriIpcAvailable]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (projects.length === 0) {
|
|
|
|
|
if (selectedId !== null) {
|
|
|
|
|
setSelectedId(null);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (selectedId && projects.some((p) => p.id === selectedId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSelectedId(projects[0].id);
|
|
|
|
|
}, [projects, selectedId, setSelectedId]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<SettingsSidebarLayout
|
|
|
|
|
variant="background"
|
|
|
|
|
header={
|
|
|
|
|
<div className={cn('border-b px-3', 'pt-4 pb-3')}>
|
|
|
|
|
<h2 className="text-base font-semibold text-foreground mb-3">Projects</h2>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="typography-meta text-muted-foreground">Total {projects.length}</span>
|
|
|
|
|
{!isVSCode && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-7 w-7 -my-1 text-muted-foreground"
|
|
|
|
|
onClick={handleAddProject}
|
|
|
|
|
aria-label="Add project"
|
|
|
|
|
>
|
|
|
|
|
<RiAddLine className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{projects.map((project) => {
|
|
|
|
|
const selected = project.id === selectedId;
|
|
|
|
|
const Icon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
2026-02-27 20:03:42 +02:00
|
|
|
const imageFailureKey = `${project.id}:${project.iconImage?.updatedAt ?? 0}`;
|
|
|
|
|
const imageUrl = brokenIconIds.has(imageFailureKey) ? null : getProjectIconImageUrl(project);
|
2026-02-24 03:28:30 +02:00
|
|
|
const color = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
2026-02-27 20:03:42 +02:00
|
|
|
const icon = imageUrl
|
|
|
|
|
? (
|
|
|
|
|
<span
|
|
|
|
|
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
|
|
|
|
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src={imageUrl}
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-full w-full object-contain"
|
|
|
|
|
draggable={false}
|
|
|
|
|
onError={() => {
|
|
|
|
|
setBrokenIconIds((prev) => {
|
|
|
|
|
if (prev.has(imageFailureKey)) {
|
|
|
|
|
return prev;
|
|
|
|
|
}
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
next.add(imageFailureKey);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
: Icon
|
2026-02-24 03:28:30 +02:00
|
|
|
? (
|
|
|
|
|
<Icon className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
|
|
|
|
|
)
|
|
|
|
|
: (
|
|
|
|
|
<RiFolderLine className={cn('h-4 w-4', selected ? 'text-foreground' : 'text-muted-foreground/70')} style={color ? { color } : undefined} />
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<SettingsSidebarItem
|
|
|
|
|
key={project.id}
|
|
|
|
|
title={project.label || project.path}
|
|
|
|
|
icon={icon}
|
|
|
|
|
selected={selected}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedId(project.id);
|
|
|
|
|
onItemSelect?.();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</SettingsSidebarLayout>
|
|
|
|
|
);
|
|
|
|
|
};
|