In NavRail's ProjectTile, the expanded active item renders an absolutely positioned background overlay (--interactive-selection) that visually covers the text label. The label span was missing 'relative z-10', unlike NavRailActionButton which correctly uses it. Fixes text being invisible on selected project in dark themes (nord-dark, dracula-dark, monokai-dark, gruvbox-dark, etc.)
920 lines
32 KiB
TypeScript
920 lines
32 KiB
TypeScript
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, getProjectIconImageUrl } from '@/lib/projectMeta';
|
|
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
|
|
import { useLongPress } from '@/hooks/useLongPress';
|
|
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
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;
|
|
|
|
type NavRailActionButtonProps = {
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
ariaLabel: string;
|
|
icon: React.ReactNode;
|
|
tooltipLabel: string;
|
|
shortcutHint?: string;
|
|
showExpandedShortcutHint?: boolean;
|
|
buttonClassName: string;
|
|
showExpandedContent: boolean;
|
|
actionTextVisible: boolean;
|
|
};
|
|
|
|
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
|
onClick,
|
|
disabled = false,
|
|
ariaLabel,
|
|
icon,
|
|
tooltipLabel,
|
|
shortcutHint,
|
|
showExpandedShortcutHint = true,
|
|
buttonClassName,
|
|
showExpandedContent,
|
|
actionTextVisible,
|
|
}) => {
|
|
const pointerTriggeredRef = React.useRef(false);
|
|
const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({
|
|
active: false,
|
|
pointerId: null,
|
|
});
|
|
|
|
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
|
if (disabled || event.button !== 0) {
|
|
pointerPressRef.current = { active: false, pointerId: null };
|
|
return;
|
|
}
|
|
pointerPressRef.current = { active: true, pointerId: event.pointerId };
|
|
}, [disabled]);
|
|
|
|
const clearPointerPress = React.useCallback(() => {
|
|
pointerPressRef.current = { active: false, pointerId: null };
|
|
}, []);
|
|
|
|
const handlePointerUp = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
|
if (disabled) return;
|
|
if (event.button !== 0) return;
|
|
const pointerPress = pointerPressRef.current;
|
|
if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
clearPointerPress();
|
|
pointerTriggeredRef.current = true;
|
|
onClick();
|
|
}, [clearPointerPress, disabled, onClick]);
|
|
|
|
const handleClick = React.useCallback(() => {
|
|
if (disabled) return;
|
|
if (pointerTriggeredRef.current) {
|
|
pointerTriggeredRef.current = false;
|
|
return;
|
|
}
|
|
onClick();
|
|
}, [disabled, onClick]);
|
|
|
|
const btn = (
|
|
<button
|
|
type="button"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={clearPointerPress}
|
|
onPointerLeave={clearPointerPress}
|
|
onClick={handleClick}
|
|
className={buttonClassName}
|
|
aria-label={ariaLabel}
|
|
disabled={disabled}
|
|
>
|
|
{showExpandedContent && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
|
|
/>
|
|
)}
|
|
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
|
|
{icon}
|
|
</span>
|
|
<span
|
|
aria-hidden={!actionTextVisible}
|
|
className={cn(
|
|
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
|
|
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
|
|
actionTextVisible ? 'opacity-100' : 'opacity-0',
|
|
)}
|
|
>
|
|
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
|
|
{shortcutHint && showExpandedShortcutHint && (
|
|
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
|
|
{shortcutHint}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<Tooltip delayDuration={400}>
|
|
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
|
{!showExpandedContent && (
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
/** 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,
|
|
}) => (
|
|
<span
|
|
className="relative flex h-full w-full items-center justify-center rounded-lg overflow-hidden"
|
|
style={{ backgroundColor: 'var(--surface-muted)' }}
|
|
>
|
|
{colorVar && (
|
|
<span
|
|
className="absolute inset-0 opacity-15"
|
|
style={{ backgroundColor: colorVar }}
|
|
/>
|
|
)}
|
|
<span className="relative z-10 flex items-center justify-center">
|
|
{children}
|
|
</span>
|
|
</span>
|
|
);
|
|
|
|
/** 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 (
|
|
<span
|
|
className="flex h-4 w-4 items-center justify-center text-[15px] font-medium leading-none select-none"
|
|
style={{ color: colorVar ?? 'var(--surface-foreground)', fontFamily: 'var(--font-mono, monospace)' }}
|
|
>
|
|
{letter}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const ProjectStatusDots: React.FC<{
|
|
color: string;
|
|
variant?: 'streaming' | 'attention' | 'none';
|
|
size?: 'sm' | 'md';
|
|
}> = ({ color, variant = 'none', size = 'md' }) => (
|
|
<span className="inline-flex items-center justify-center gap-px" aria-hidden="true">
|
|
{Array.from({ length: 3 }).map((_, index) => (
|
|
<span key={index} className="inline-flex h-[3px] w-[3px] items-center justify-center">
|
|
<span
|
|
className={cn(
|
|
size === 'sm' ? 'h-[2.5px] w-[2.5px]' : 'h-[3px] w-[3px]',
|
|
'rounded-full',
|
|
variant === 'streaming' && 'animate-grid-pulse',
|
|
variant === 'attention' && 'animate-attention-diamond-pulse'
|
|
)}
|
|
style={{
|
|
backgroundColor: color,
|
|
animationDelay: variant === 'streaming'
|
|
? `${index * 150}ms`
|
|
: variant === 'attention'
|
|
? (index === 1 ? '0ms' : '130ms')
|
|
: undefined,
|
|
}}
|
|
/>
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
|
|
/** 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 { currentTheme } = useThemeSystem();
|
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
|
const [iconImageFailed, setIconImageFailed] = React.useState(false);
|
|
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
|
const projectIconImageUrl = !iconImageFailed
|
|
? getProjectIconImageUrl(project, {
|
|
themeVariant: currentTheme.metadata.variant,
|
|
iconColor: currentTheme.colors.surface.foreground,
|
|
})
|
|
: null;
|
|
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
|
const showStreamingDots = hasStreaming;
|
|
const showAttentionDots = !hasStreaming && hasUnread;
|
|
|
|
React.useEffect(() => {
|
|
setIconImageFailed(false);
|
|
}, [project.id, project.iconImage?.updatedAt]);
|
|
|
|
const longPressHandlers = useLongPress({
|
|
onLongPress: () => setMenuOpen(true),
|
|
onTap: onClick,
|
|
});
|
|
|
|
const iconElement = (
|
|
<TileBackground colorVar={projectColorVar}>
|
|
<span className="relative h-full w-full leading-none">
|
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
{projectIconImageUrl ? (
|
|
<span
|
|
className="inline-flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]"
|
|
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
|
>
|
|
<img
|
|
src={projectIconImageUrl}
|
|
alt=""
|
|
className="h-full w-full object-contain"
|
|
draggable={false}
|
|
onError={() => setIconImageFailed(true)}
|
|
/>
|
|
</span>
|
|
) : ProjectIcon ? (
|
|
<ProjectIcon
|
|
className="h-4 w-4 shrink-0"
|
|
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
|
|
/>
|
|
) : (
|
|
<LetterAvatar label={label} color={project.color} />
|
|
)}
|
|
</span>
|
|
{showStreamingDots && (
|
|
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
|
|
<ProjectStatusDots color="var(--primary)" variant="streaming" />
|
|
</span>
|
|
)}
|
|
{showAttentionDots && (
|
|
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
|
|
<ProjectStatusDots color="var(--status-info)" variant="attention" />
|
|
</span>
|
|
)}
|
|
</span>
|
|
</TileBackground>
|
|
);
|
|
|
|
const tileButton = (
|
|
<button
|
|
type="button"
|
|
{...longPressHandlers}
|
|
className={cn(
|
|
'group relative flex cursor-pointer items-center rounded-lg overflow-hidden',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
|
expanded ? 'h-9 w-full gap-2.5 pr-1.5 pl-[7px]' : 'h-9 w-9 justify-center',
|
|
!expanded && (
|
|
isActive
|
|
? 'bg-transparent border border-[var(--surface-foreground)]'
|
|
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]'
|
|
),
|
|
!expanded && menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
|
|
)}
|
|
>
|
|
{expanded && (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg border transition-colors',
|
|
isActive
|
|
? 'bg-[var(--interactive-selection)] border-[var(--interactive-border)]'
|
|
: 'bg-transparent border-transparent group-hover:bg-[var(--interactive-hover)]/50 group-hover:border-[var(--interactive-border)]',
|
|
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
|
|
)}
|
|
/>
|
|
)}
|
|
<span className="flex size-[34px] basis-[34px] shrink-0 grow-0 items-center justify-center">
|
|
{iconElement}
|
|
</span>
|
|
<span
|
|
aria-hidden={!projectTextVisible}
|
|
className={cn(
|
|
'relative z-10 min-w-0 truncate text-left text-[13px] leading-tight transition-opacity duration-[180ms] ease-in-out',
|
|
expanded ? 'flex-1' : 'w-0 flex-none',
|
|
projectTextVisible ? 'opacity-100' : 'opacity-0',
|
|
isActive && expanded ? 'font-medium text-[var(--interactive-selection-foreground)]' : 'text-[var(--surface-foreground)]',
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{expanded ? (
|
|
<div
|
|
className="relative w-full"
|
|
onContextMenu={(e) => {
|
|
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
|
e.preventDefault();
|
|
setMenuOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
{tileButton}
|
|
</div>
|
|
) : (
|
|
<Tooltip delayDuration={400}>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className="relative"
|
|
onContextMenu={(e) => {
|
|
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
|
e.preventDefault();
|
|
setMenuOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
{tileButton}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
{label}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<span className="sr-only">Project options</span>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" side="right" sideOffset={4} className="min-w-[160px]">
|
|
<DropdownMenuItem onClick={onEdit} className="gap-2">
|
|
<RiPencilLine className="h-4 w-4" />
|
|
Edit project
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={onClose}
|
|
className="text-destructive focus:text-destructive gap-2"
|
|
>
|
|
<RiCloseLine className="h-4 w-4" />
|
|
Close project
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
);
|
|
};
|
|
|
|
/** 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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
}}
|
|
className={cn(isDragging && 'opacity-30 z-50')}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface NavRailProps {
|
|
className?: string;
|
|
mobile?: boolean;
|
|
}
|
|
|
|
export const NavRail: React.FC<NavRailProps> = ({ 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 isOverlayBlockingNavRailActions = useUIStore((s) => (
|
|
s.isSettingsDialogOpen
|
|
|| s.isHelpDialogOpen
|
|
|| s.isCommandPaletteOpen
|
|
|| s.isSessionSwitcherOpen
|
|
|| s.isAboutDialogOpen
|
|
|| s.isOpenCodeStatusDialogOpen
|
|
|| s.isSessionCreateDialogOpen
|
|
|| s.isModelSelectorOpen
|
|
|| s.isTimelineDialogOpen
|
|
|| s.isMultiRunLauncherOpen
|
|
|| s.isImagePreviewOpen
|
|
));
|
|
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 navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
|
|
|
|
const [editingProject, setEditingProject] = React.useState<{
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
icon?: string | null;
|
|
color?: string | null;
|
|
iconBackground?: 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<string, { hasStreaming: boolean; hasUnread: boolean }>();
|
|
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<string>();
|
|
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;
|
|
}
|
|
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,
|
|
iconBackground: project.iconBackground,
|
|
});
|
|
},
|
|
[projects, formatLabel],
|
|
);
|
|
|
|
const handleSaveProjectEdit = React.useCallback(
|
|
(data: { label: string; icon: string | null; color: string | null; iconBackground: 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 cursor-pointer items-center rounded-lg disabled:cursor-not-allowed',
|
|
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';
|
|
|
|
return (
|
|
<>
|
|
<nav
|
|
className={cn(
|
|
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
|
showExpandedContent ? 'items-stretch' : 'items-center',
|
|
navRailInteractionBlocked && 'pointer-events-none',
|
|
className,
|
|
)}
|
|
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
|
aria-label="Project navigation"
|
|
>
|
|
{/* Projects list */}
|
|
<div className="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
modifiers={[restrictToYAxis]}
|
|
>
|
|
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
|
|
<div className={cn('flex flex-col gap-3 pt-1 pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
|
{projects.map((project) => {
|
|
const isActive = project.id === activeProjectId;
|
|
const indicators = projectIndicators.get(project.id);
|
|
return (
|
|
<SortableProjectTile key={project.id} id={project.id}>
|
|
<ProjectTile
|
|
project={project}
|
|
isActive={isActive}
|
|
hasStreaming={indicators?.hasStreaming ?? false}
|
|
hasUnread={indicators?.hasUnread ?? false}
|
|
label={formatLabel(project)}
|
|
expanded={showExpandedContent}
|
|
projectTextVisible={projectTextVisible}
|
|
onClick={() => {
|
|
if (project.id !== activeProjectId) {
|
|
setActiveProjectIdOnly(project.id);
|
|
}
|
|
}}
|
|
onEdit={() => handleEditProject(project.id)}
|
|
onClose={() => handleCloseProject(project.id)}
|
|
/>
|
|
</SortableProjectTile>
|
|
);
|
|
})}
|
|
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{/* Add project button */}
|
|
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
|
<NavRailActionButton
|
|
onClick={handleAddProject}
|
|
disabled={navRailInteractionBlocked}
|
|
ariaLabel="Add project"
|
|
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
|
tooltipLabel="Add project"
|
|
buttonClassName={navRailActionButtonClass}
|
|
showExpandedContent={showExpandedContent}
|
|
actionTextVisible={actionTextVisible}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom actions */}
|
|
<div className={cn(
|
|
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
|
|
showExpandedContent ? 'items-stretch px-1' : 'items-center',
|
|
)}>
|
|
{(updateAvailable || updateDownloaded) && (
|
|
<NavRailActionButton
|
|
onClick={() => setUpdateDialogOpen(true)}
|
|
disabled={navRailInteractionBlocked}
|
|
ariaLabel="Update available"
|
|
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
|
tooltipLabel="Update available"
|
|
buttonClassName={navRailActionButtonClass}
|
|
showExpandedContent={showExpandedContent}
|
|
actionTextVisible={actionTextVisible}
|
|
/>
|
|
)}
|
|
|
|
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
|
<NavRailActionButton
|
|
onClick={() => setAboutDialogOpen(true)}
|
|
disabled={navRailInteractionBlocked}
|
|
ariaLabel="About"
|
|
icon={<RiInformationLine className={navRailActionIconClass} />}
|
|
tooltipLabel="About OpenChamber"
|
|
buttonClassName={navRailActionButtonClass}
|
|
showExpandedContent={showExpandedContent}
|
|
actionTextVisible={actionTextVisible}
|
|
/>
|
|
)}
|
|
|
|
{!mobile && (
|
|
<NavRailActionButton
|
|
onClick={toggleHelpDialog}
|
|
disabled={navRailInteractionBlocked}
|
|
ariaLabel="Keyboard shortcuts"
|
|
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
|
tooltipLabel="Shortcuts"
|
|
shortcutHint={shortcutLabel('open_help')}
|
|
showExpandedShortcutHint={false}
|
|
buttonClassName={navRailActionButtonClass}
|
|
showExpandedContent={showExpandedContent}
|
|
actionTextVisible={actionTextVisible}
|
|
/>
|
|
)}
|
|
|
|
<NavRailActionButton
|
|
onClick={() => setSettingsDialogOpen(true)}
|
|
disabled={navRailInteractionBlocked}
|
|
ariaLabel="Settings"
|
|
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
|
tooltipLabel="Settings"
|
|
shortcutHint={shortcutLabel('open_settings')}
|
|
showExpandedShortcutHint={false}
|
|
buttonClassName={navRailActionButtonClass}
|
|
showExpandedContent={showExpandedContent}
|
|
actionTextVisible={actionTextVisible}
|
|
/>
|
|
|
|
{/* Toggle expand/collapse (desktop only) */}
|
|
{!mobile && (
|
|
<NavRailActionButton
|
|
onClick={toggleNavRail}
|
|
disabled={navRailInteractionBlocked}
|
|
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
|
icon={expanded
|
|
? <RiMenuFoldLine className={navRailActionIconClass} />
|
|
: <RiMenuUnfoldLine className={navRailActionIconClass} />
|
|
}
|
|
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
|
|
shortcutHint={shortcutLabel('toggle_nav_rail')}
|
|
showExpandedShortcutHint={false}
|
|
buttonClassName={navRailActionButtonClass}
|
|
showExpandedContent={showExpandedContent}
|
|
actionTextVisible={actionTextVisible}
|
|
/>
|
|
)}
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Dialogs */}
|
|
{editingProject && (
|
|
<ProjectEditDialog
|
|
open={!!editingProject}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditingProject(null);
|
|
}}
|
|
projectId={editingProject.id}
|
|
projectName={editingProject.name}
|
|
projectPath={editingProject.path}
|
|
initialIcon={editingProject.icon}
|
|
initialColor={editingProject.color}
|
|
initialIconBackground={editingProject.iconBackground}
|
|
onSave={handleSaveProjectEdit}
|
|
/>
|
|
)}
|
|
|
|
<UpdateDialog
|
|
open={updateDialogOpen}
|
|
onOpenChange={setUpdateDialogOpen}
|
|
info={updateStore.info}
|
|
downloading={updateStore.downloading}
|
|
downloaded={updateStore.downloaded}
|
|
progress={updateStore.progress}
|
|
error={updateStore.error}
|
|
onDownload={updateStore.downloadUpdate}
|
|
onRestart={updateStore.restartToUpdate}
|
|
runtimeType={updateStore.runtimeType}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };
|