209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
import React from 'react';
|
|||
|
|
import {
|
||
|
|
DndContext,
|
||
|
|
DragOverlay,
|
||
|
|
KeyboardSensor,
|
||
|
|
PointerSensor,
|
||
|
|
closestCenter,
|
||
|
|
useSensor,
|
||
|
|
useSensors,
|
||
|
|
} from '@dnd-kit/core';
|
||
|
|
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||
|
|
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||
|
|
import type { SessionGroup } from './types';
|
||
|
|
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||
|
|
import { formatProjectLabel } from './utils';
|
||
|
|
|
||
|
|
type ProjectSection = {
|
||
|
|
project: {
|
||
|
|
id: string;
|
||
|
|
label?: string;
|
||
|
|
normalizedPath: string;
|
||
|
|
};
|
||
|
|
groups: SessionGroup[];
|
||
|
|
};
|
||
|
|
|
||
|
|
type Props = {
|
||
|
|
sectionsForRender: ProjectSection[];
|
||
|
|
projectSections: ProjectSection[];
|
||
|
|
activeProjectId: string | null;
|
||
|
|
showOnlyMainWorkspace: boolean;
|
||
|
|
hasSessionSearchQuery: boolean;
|
||
|
|
emptyState: React.ReactNode;
|
||
|
|
searchEmptyState: React.ReactNode;
|
||
|
|
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => React.ReactNode;
|
||
|
|
homeDirectory: string | null;
|
||
|
|
collapsedProjects: Set<string>;
|
||
|
|
hideDirectoryControls: boolean;
|
||
|
|
projectRepoStatus: Map<string, boolean | null>;
|
||
|
|
hoveredProjectId: string | null;
|
||
|
|
setHoveredProjectId: (id: string | null) => void;
|
||
|
|
isDesktopShellRuntime: boolean;
|
||
|
|
stuckProjectHeaders: Set<string>;
|
||
|
|
mobileVariant: boolean;
|
||
|
|
toggleProject: (id: string) => void;
|
||
|
|
setActiveProjectIdOnly: (id: string) => void;
|
||
|
|
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||
|
|
setSessionSwitcherOpen: (open: boolean) => void;
|
||
|
|
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||
|
|
createWorktreeSession: () => void;
|
||
|
|
openMultiRunLauncher: () => void;
|
||
|
|
setEditingProjectId: (id: string | null) => void;
|
||
|
|
setEditProjectTitle: (title: string) => void;
|
||
|
|
editingProjectId: string | null;
|
||
|
|
editProjectTitle: string;
|
||
|
|
handleSaveProjectEdit: () => void;
|
||
|
|
handleCancelProjectEdit: () => void;
|
||
|
|
removeProject: (id: string) => void;
|
||
|
|
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||
|
|
settingsAutoCreateWorktree: boolean;
|
||
|
|
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||
|
|
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function SidebarProjectsList(props: Props): React.ReactNode {
|
||
|
|
const sensors = useSensors(
|
||
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||
|
|
);
|
||
|
|
|
||
|
|
if (props.projectSections.length === 0) {
|
||
|
|
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.emptyState}</ScrollableOverlay>;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (props.sectionsForRender.length === 0) {
|
||
|
|
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>
|
||
|
|
{props.showOnlyMainWorkspace ? (
|
||
|
|
<div className="space-y-[0.6rem] py-1">
|
||
|
|
{(() => {
|
||
|
|
const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0];
|
||
|
|
if (!activeSection) {
|
||
|
|
return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
|
||
|
|
}
|
||
|
|
const group =
|
||
|
|
activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
|
||
|
|
?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
|
||
|
|
?? activeSection.groups.find((candidate) => candidate.isMain)
|
||
|
|
?? activeSection.groups[0];
|
||
|
|
if (!group) {
|
||
|
|
return <div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>;
|
||
|
|
}
|
||
|
|
const groupKey = `${activeSection.project.id}:${group.id}`;
|
||
|
|
return props.renderGroupSessions(group, groupKey, activeSection.project.id, props.showOnlyMainWorkspace);
|
||
|
|
})()}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{props.sectionsForRender.map((section) => {
|
||
|
|
const project = section.project;
|
||
|
|
const projectKey = project.id;
|
||
|
|
const projectLabel = formatProjectLabel(
|
||
|
|
project.label?.trim()
|
||
|
|
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||
|
|
|| project.normalizedPath,
|
||
|
|
);
|
||
|
|
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||
|
|
const isCollapsed = props.collapsedProjects.has(projectKey) && props.hideDirectoryControls;
|
||
|
|
const isActiveProject = projectKey === props.activeProjectId;
|
||
|
|
const isRepo = props.projectRepoStatus.get(projectKey);
|
||
|
|
const isHovered = props.hoveredProjectId === projectKey;
|
||
|
|
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<SortableProjectItem
|
||
|
|
key={projectKey}
|
||
|
|
id={projectKey}
|
||
|
|
projectLabel={projectLabel}
|
||
|
|
projectDescription={projectDescription}
|
||
|
|
isCollapsed={isCollapsed}
|
||
|
|
isActiveProject={isActiveProject}
|
||
|
|
isRepo={Boolean(isRepo)}
|
||
|
|
isHovered={isHovered}
|
||
|
|
isDesktopShell={props.isDesktopShellRuntime}
|
||
|
|
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||
|
|
hideDirectoryControls={props.hideDirectoryControls}
|
||
|
|
mobileVariant={props.mobileVariant}
|
||
|
|
onToggle={() => props.toggleProject(projectKey)}
|
||
|
|
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
|
||
|
|
onNewSession={() => {
|
||
|
|
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||
|
|
props.setActiveMainTab('chat');
|
||
|
|
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||
|
|
props.openNewSessionDraft({ directoryOverride: project.normalizedPath });
|
||
|
|
}}
|
||
|
|
onNewWorktreeSession={() => {
|
||
|
|
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||
|
|
props.setActiveMainTab('chat');
|
||
|
|
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||
|
|
props.createWorktreeSession();
|
||
|
|
}}
|
||
|
|
onOpenMultiRunLauncher={() => {
|
||
|
|
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||
|
|
props.openMultiRunLauncher();
|
||
|
|
}}
|
||
|
|
onRenameStart={() => {
|
||
|
|
props.setEditingProjectId(projectKey);
|
||
|
|
props.setEditProjectTitle(project.label?.trim() || formatDirectoryName(project.normalizedPath, props.homeDirectory) || project.normalizedPath);
|
||
|
|
}}
|
||
|
|
onRenameSave={props.handleSaveProjectEdit}
|
||
|
|
onRenameCancel={props.handleCancelProjectEdit}
|
||
|
|
onRenameValueChange={props.setEditProjectTitle}
|
||
|
|
renameValue={props.editingProjectId === projectKey ? props.editProjectTitle : ''}
|
||
|
|
isRenaming={props.editingProjectId === projectKey}
|
||
|
|
onClose={() => props.removeProject(projectKey)}
|
||
|
|
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||
|
|
settingsAutoCreateWorktree={props.settingsAutoCreateWorktree}
|
||
|
|
showCreateButtons={false}
|
||
|
|
hideHeader
|
||
|
|
>
|
||
|
|
{!isCollapsed ? (
|
||
|
|
<div className="space-y-[0.6rem] py-1">
|
||
|
|
{section.groups.length > 0 ? (
|
||
|
|
<DndContext
|
||
|
|
sensors={sensors}
|
||
|
|
collisionDetection={closestCenter}
|
||
|
|
onDragEnd={(event) => {
|
||
|
|
const { active, over } = event;
|
||
|
|
if (!over || active.id === over.id) return;
|
||
|
|
const oldIndex = orderedGroups.findIndex((item) => item.id === active.id);
|
||
|
|
const newIndex = orderedGroups.findIndex((item) => item.id === over.id);
|
||
|
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||
|
|
const next = arrayMove(orderedGroups, oldIndex, newIndex).map((item) => item.id);
|
||
|
|
props.setGroupOrderByProject((prev) => {
|
||
|
|
const map = new Map(prev);
|
||
|
|
map.set(projectKey, next);
|
||
|
|
return map;
|
||
|
|
});
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SortableContext items={orderedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||
|
|
{orderedGroups.map((group) => {
|
||
|
|
const groupKey = `${projectKey}:${group.id}`;
|
||
|
|
return (
|
||
|
|
<SortableGroupItem key={group.id} id={group.id}>
|
||
|
|
{props.renderGroupSessions(group, groupKey, projectKey)}
|
||
|
|
</SortableGroupItem>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</SortableContext>
|
||
|
|
<DragOverlay dropAnimation={null} />
|
||
|
|
</DndContext>
|
||
|
|
) : (
|
||
|
|
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</SortableProjectItem>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</ScrollableOverlay>
|
||
|
|
);
|
||
|
|
}
|