import React from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
RiFolderAddLine,
RiSettings3Line,
RiQuestionLine,
RiDownloadLine,
RiInformationLine,
RiPencilLine,
RiCloseLine,
RiMenuFoldLine,
RiMenuUnfoldLine,
} from '@remixicon/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { useLongPress } from '@/hooks/useLongPress';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { sessionEvents } from '@/lib/sessionEvents';
import type { ProjectEntry } from '@/lib/api/types';
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
const NAV_RAIL_WIDTH = 56;
const NAV_RAIL_EXPANDED_WIDTH = 200;
const NAV_RAIL_TEXT_FADE_MS = 180;
const PROJECT_TEXT_FADE_IN_DELAY_MS = 24;
const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
colorVar,
children,
}) => (
{colorVar && (
)}
{children}
);
/** First-letter avatar fallback */
const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({
label,
color,
}) => {
const letter = label.charAt(0).toUpperCase() || '?';
const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
return (
{letter}
);
};
const ProjectStatusDots: React.FC<{
color: string;
variant?: 'streaming' | 'attention' | 'none';
size?: 'sm' | 'md';
}> = ({ color, variant = 'none', size = 'md' }) => (
{Array.from({ length: 3 }).map((_, index) => (
))}
);
/** Single project tile in the nav rail — right-click for context menu (no visible 3-dot) */
const ProjectTile: React.FC<{
project: ProjectEntry;
isActive: boolean;
hasStreaming: boolean;
hasUnread: boolean;
label: string;
expanded: boolean;
projectTextVisible: boolean;
onClick: () => void;
onEdit: () => void;
onClose: () => void;
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
const [menuOpen, setMenuOpen] = React.useState(false);
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
const showStreamingDots = hasStreaming;
const showAttentionDots = !hasStreaming && hasUnread;
const longPressHandlers = useLongPress({
onLongPress: () => setMenuOpen(true),
onTap: onClick,
});
const iconElement = (
{ProjectIcon ? (
) : (
)}
{showStreamingDots && (
)}
{showAttentionDots && (
)}
);
const tileButton = (
);
return (
<>
{expanded ? (
{
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
e.preventDefault();
setMenuOpen(true);
}
}}
>
{tileButton}
) : (
{
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
e.preventDefault();
setMenuOpen(true);
}
}}
>
{tileButton}
{label}
)}
Project options
Edit project
Close project
>
);
};
/** Constrain drag to Y axis only */
const restrictToYAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
/** Sortable wrapper for ProjectTile */
const SortableProjectTile: React.FC<{
id: string;
children: React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
{children}
);
};
interface NavRailProps {
className?: string;
mobile?: boolean;
}
export const NavRail: React.FC = ({ className, mobile }) => {
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((s) => s.setActiveProjectIdOnly);
const addProject = useProjectsStore((s) => s.addProject);
const removeProject = useProjectsStore((s) => s.removeProject);
const reorderProjects = useProjectsStore((s) => s.reorderProjects);
const updateProjectMeta = useProjectsStore((s) => s.updateProjectMeta);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const expanded = !mobile && isNavRailExpanded;
const [showExpandedContent, setShowExpandedContent] = React.useState(expanded);
const [projectTextVisible, setProjectTextVisible] = React.useState(expanded);
const [actionTextVisible, setActionTextVisible] = React.useState(expanded);
React.useEffect(() => {
if (expanded) {
setShowExpandedContent(true);
setProjectTextVisible(false);
setActionTextVisible(false);
const projectTimer = window.setTimeout(() => {
setProjectTextVisible(true);
}, PROJECT_TEXT_FADE_IN_DELAY_MS);
const actionTimer = window.setTimeout(() => {
setActionTextVisible(true);
}, ACTION_TEXT_FADE_IN_DELAY_MS);
return () => {
window.clearTimeout(projectTimer);
window.clearTimeout(actionTimer);
};
}
setProjectTextVisible(false);
setActionTextVisible(false);
const timer = window.setTimeout(() => {
setShowExpandedContent(false);
}, NAV_RAIL_TEXT_FADE_MS);
return () => {
window.clearTimeout(timer);
};
}, [expanded]);
const shortcutLabel = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
const sessionStatus = useSessionStore((s) => s.sessionStatus);
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
const sessionsByDirectory = useSessionStore((s) => s.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((s) => s.getSessionsByDirectory);
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
const updateStore = useUpdateStore();
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const [editingProject, setEditingProject] = React.useState<{
id: string;
name: string;
path: string;
icon?: string | null;
color?: string | null;
} | null>(null);
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const formatLabel = React.useCallback(
(project: ProjectEntry): string => {
return (
project.label?.trim() ||
formatDirectoryName(project.path, homeDirectory) ||
project.path
);
},
[homeDirectory],
);
const projectIndicators = React.useMemo(() => {
const result = new Map();
for (const project of projects) {
const projectRoot = normalize(project.path);
if (!projectRoot) {
result.set(project.id, { hasStreaming: false, hasUnread: false });
continue;
}
const dirs: string[] = [projectRoot];
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
for (const meta of worktrees) {
const p =
meta && typeof meta === 'object' && 'path' in meta
? (meta as { path?: unknown }).path
: null;
if (typeof p === 'string' && p.trim()) {
const normalized = normalize(p);
if (normalized && normalized !== projectRoot) {
dirs.push(normalized);
}
}
}
const seen = new Set();
let hasStreaming = false;
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) continue;
seen.add(session.id);
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
hasStreaming = true;
}
const isCurrentVisible =
session.id === currentSessionId && project.id === activeProjectId;
if (
!isCurrentVisible &&
sessionAttentionStates.get(session.id)?.needsAttention === true
) {
hasUnread = true;
}
if (hasStreaming && hasUnread) break;
}
if (hasStreaming && hasUnread) break;
}
result.set(project.id, { hasStreaming, hasUnread });
}
return result;
}, [
activeProjectId,
availableWorktreesByProject,
currentSessionId,
getSessionsByDirectory,
projects,
sessionAttentionStates,
sessionStatus,
sessionsByDirectory,
]);
const handleAddProject = React.useCallback(() => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
sessionEvents.requestDirectoryDialog();
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
.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.',
});
}
} 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, tauriIpcAvailable]);
const handleEditProject = React.useCallback(
(projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (!project) return;
setEditingProject({
id: project.id,
name: formatLabel(project),
path: project.path,
icon: project.icon,
color: project.color,
});
},
[projects, formatLabel],
);
const handleSaveProjectEdit = React.useCallback(
(data: { label: string; icon: string | null; color: string | null }) => {
if (!editingProject) return;
updateProjectMeta(editingProject.id, data);
setEditingProject(null);
},
[editingProject, updateProjectMeta],
);
const handleCloseProject = React.useCallback(
(projectId: string) => {
removeProject(projectId);
},
[removeProject],
);
// Cmd/Ctrl+number to switch projects
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= projects.length) {
e.preventDefault();
const target = projects[num - 1];
if (target && target.id !== activeProjectId) {
setActiveProjectIdOnly(target.id);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, activeProjectId, setActiveProjectIdOnly]);
// Drag-to-reorder
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const projectIds = React.useMemo(() => projects.map((p) => p.id), [projects]);
const handleDragEnd = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const fromIndex = projects.findIndex((p) => p.id === active.id);
const toIndex = projects.findIndex((p) => p.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
reorderProjects(fromIndex, toIndex);
}
},
[projects, reorderProjects],
);
const navRailActionButtonClass = cn(
'group relative flex h-8 items-center rounded-lg',
showExpandedContent ? 'w-full justify-start gap-2.5 pr-2 pl-2' : 'w-8 justify-center',
showExpandedContent
? 'text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)]'
: 'text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)]/50 hover:text-[var(--surface-foreground)]',
'transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
);
const navRailActionIconClass = 'h-4.5 w-4.5 shrink-0';
const ActionButton: React.FC<{
onClick: () => void;
ariaLabel: string;
icon: React.ReactNode;
tooltipLabel: string;
shortcutHint?: string;
showExpandedShortcutHint?: boolean;
}> = ({ onClick, ariaLabel, icon, tooltipLabel, shortcutHint, showExpandedShortcutHint = true }) => {
const btn = (
);
return (
{btn}
{!showExpandedContent && (
{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}
)}
);
};
return (
<>
{/* Dialogs */}
{editingProject && (
{
if (!open) setEditingProject(null);
}}
projectName={editingProject.name}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
onSave={handleSaveProjectEdit}
/>
)}
>
);
};
export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };