feat(dashboard): scrollable, autocomplete, bookmarkable project selector

- Fix: ProjectSelector dropdown now scrolls (max-height + overflow-y)
  when project list exceeds visible area, with scrollIntoView on
  keyboard navigation
- Feat: Always-visible search input with type-ahead filtering,
  HighlightMatch text highlighting, exact match detection + auto-select
  on Enter
- Feat: Project bookmarking via localStorage (useProjectBookmarks hook),
  star toggle on each item, bookmarked section shown at top of dropdown
- Refactor: Header.tsx now imports standalone ProjectSelector instead
  of using an inline copy that lacked these features
- Tests: 131 tests passing across ProjectSelector + useProjectBookmarks
This commit is contained in:
buihongduc132
2026-06-07 02:26:00 +07:00
parent 1afdbbbb47
commit cb455bf757
6 changed files with 1000 additions and 197 deletions

View File

@@ -2,8 +2,9 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent a
import { useTranslation } from "react-i18next";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react";
import "./Header.css";
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
@@ -30,125 +31,8 @@ const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
initializing: { color: "var(--info)" },
};
/**
* ProjectSelector - A component for project navigation.
* Shows project dropdown for switching projects and navigating to project management.
*/
function ProjectSelector({
projects,
currentProject,
onViewAll,
onSelectProject,
}: {
projects: ProjectInfo[];
currentProject: ProjectInfo | null;
onViewAll: () => void;
onSelectProject?: (project: ProjectInfo) => void;
}) {
const { t } = useTranslation("app");
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
const handleSelectProject = useCallback(
(project: ProjectInfo) => {
onSelectProject?.(project);
setIsOpen(false);
},
[onSelectProject]
);
return (
<div className="project-selector" ref={dropdownRef}>
{projects.length > 0 && (
<>
<button
className={`project-selector-trigger${isOpen ? " project-selector-trigger--open" : ""}`}
onClick={() => setIsOpen((prev) => !prev)}
title={currentProject?.name ? t("header.switchProjectCurrent", "Switch project (current: {{name}})", { name: currentProject.name }) : t("header.switchProject", "Switch project")}
aria-label={t("header.switchProject", "Switch project")}
aria-expanded={isOpen}
aria-haspopup="listbox"
data-testid="project-selector-trigger"
>
<span className="project-selector-trigger-label">
{currentProject?.name ?? t("header.projects", "Projects")}
</span>
<ChevronDown size={12} className={`project-selector-chevron${isOpen ? " project-selector-chevron--open" : ""}`} />
</button>
{isOpen && (
<div
className="project-selector-dropdown"
role="listbox"
aria-label={t("header.selectProject", "Select project")}
data-testid="project-selector-dropdown"
>
{projects.map((project) => {
const isCurrent = currentProject?.id === project.id;
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
return (
<button
key={project.id}
className={`project-selector-item${isCurrent ? " project-selector-item--current" : ""}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={isCurrent}
>
<span
className="project-selector-dot"
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
/>
<div className="project-selector-info">
<span className="project-selector-name">{project.name}</span>
<span className="project-selector-path">
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="project-selector-check" />}
</button>
);
})}
<div className="project-selector-divider" role="presentation" />
<button
className="project-selector-manage"
onClick={() => {
onViewAll();
setIsOpen(false);
}}
data-testid="manage-projects-action"
>
{t("header.manageProjects", "Manage Projects")}
</button>
</div>
)}
</>
)}
</div>
);
}
// Inline ProjectSelector removed — now imports StandaloneProjectSelector from ./ProjectSelector
// which has scroll fix, autocomplete, and bookmarking features.
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
function GitHubLogo({ size = 16 }: { size?: number }) {
@@ -953,11 +837,11 @@ export function Header({
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
{!isMobile && projects.length >= 1 && onViewAllProjects && (
<ProjectSelector
<StandaloneProjectSelector
projects={projects}
currentProject={currentProject ?? null}
onViewAll={onViewAllProjects}
onSelectProject={onSelectProject}
onSelect={onSelectProject}
/>
)}

View File

@@ -71,11 +71,29 @@
z-index: 100;
min-width: 240px;
max-width: 360px;
max-height: min(480px, calc(100vh - 120px));
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-lg);
scrollbar-width: thin;
scrollbar-color: var(--text-dim) transparent;
}
.project-selector__dropdown::-webkit-scrollbar {
width: 6px;
}
.project-selector__dropdown::-webkit-scrollbar-track {
background: transparent;
}
.project-selector__dropdown::-webkit-scrollbar-thumb {
background-color: var(--text-dim);
border-radius: 3px;
}
.project-selector-item,
@@ -199,11 +217,59 @@
}
.project-selector__no-results {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
font-size: 13px;
}
.project-selector__no-results-icon {
flex-shrink: 0;
opacity: 0.5;
}
/* Autocomplete highlight — marks the matched text substring */
.project-selector__highlight {
background: transparent;
color: inherit;
font-weight: 700;
text-decoration: underline;
text-decoration-color: var(--todo);
text-underline-offset: 2px;
text-decoration-thickness: 2px;
}
/* Exact match indicator banner */
.project-selector__exact-match {
padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs));
margin-bottom: var(--space-xs);
font-size: 12px;
color: var(--todo);
background: color-mix(in srgb, var(--todo) 8%, transparent);
border-radius: var(--radius-md);
text-align: center;
}
/* Exact match badge shown on item */
.project-selector__exact-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--todo) 15%, transparent);
color: var(--todo);
flex-shrink: 0;
}
/* Exact match item subtle highlight */
.project-selector__item.exact-match {
background: color-mix(in srgb, var(--todo) 5%, transparent);
}
.project-selector__footer {
margin-top: var(--space-xs);
padding-top: var(--space-xs);
@@ -279,6 +345,44 @@
color: var(--todo);
}
/* Bookmark star toggle */
.project-selector__bookmark {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
user-select: none;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
/* Show star on row hover or when bookmarked */
.project-selector__item:hover .project-selector__bookmark,
.project-selector__item.highlighted .project-selector__bookmark,
.project-selector__bookmark.bookmarked {
opacity: 1;
}
.project-selector__bookmark:hover {
color: var(--todo);
background: var(--card-hover);
}
.project-selector__bookmark.bookmarked {
color: var(--todo);
}
.project-selector__bookmark:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
/* Light theme overrides for project selector */
[data-theme="light"] .project-selector-trigger:hover {
background: var(--card-hover);

View File

@@ -1,5 +1,5 @@
import "./ProjectSelector.css";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
@@ -8,12 +8,14 @@ import {
Grid3X3,
Search,
Clock,
Star,
X,
} from "lucide-react";
import type { ProjectInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { getTrailingPath } from "../utils/pathDisplay";
import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig";
import { useProjectBookmarks } from "../hooks/useProjectBookmarks";
export interface ProjectSelectorProps {
projects: ProjectInfo[];
@@ -24,14 +26,50 @@ export interface ProjectSelectorProps {
}
/**
* ProjectSelector - Project switcher dropdown with keyboard navigation
* HighlightMatch — Renders text with matching substring highlighted (bold + accent underline).
* Used to show which part of a project name/path matches the autocomplete query.
*/
function HighlightMatch({
text,
query,
}: {
text: string;
query: string;
}): ReactNode {
if (!query.trim()) return <>{text}</>;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const matchIndex = lowerText.indexOf(lowerQuery);
if (matchIndex === -1) return <>{text}</>;
const before = text.slice(0, matchIndex);
const match = text.slice(matchIndex, matchIndex + query.length);
const after = text.slice(matchIndex + query.length);
return (
<>
{before}
<mark className="project-selector__highlight">{match}</mark>
{after}
</>
);
}
/**
* ProjectSelector - Project switcher dropdown with autocomplete/type-ahead
*
* Features:
* - Dropdown trigger showing current project name + chevron
* - Always-visible search input with type-ahead filtering
* - Text highlighting showing matched portions of project names/paths
* - Dropdown menu with project list, status icons, "View All Projects" option
* - Keyboard navigation: arrow keys, enter to select, escape to close
* - Search/filter when 5+ projects
* - Recent projects section at top (last 3 accessed)
* - Recent projects section (last 3 accessed)
* - Bookmarked projects section (star toggle, persisted in localStorage)
* - Exact match detection: auto-highlights and Enter-selects the exact match
* - No matches state with clear messaging
*/
export function ProjectSelector({
projects,
@@ -47,6 +85,8 @@ export function ProjectSelector({
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks();
// Close dropdown on outside click
useEffect(() => {
@@ -60,6 +100,7 @@ export function ProjectSelector({
!triggerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
setSearchQuery("");
}
};
@@ -74,6 +115,7 @@ export function ProjectSelector({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
setSearchQuery("");
triggerRef.current?.focus();
}
};
@@ -82,12 +124,12 @@ export function ProjectSelector({
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
// Focus search input when dropdown opens (if search is visible)
// Focus search input when dropdown opens (always visible for autocomplete)
useEffect(() => {
if (isOpen && projects.length >= 5) {
if (isOpen) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen, projects.length]);
}, [isOpen]);
// Get recent projects
const recentProjects = useMemo(() => {
@@ -108,28 +150,53 @@ export function ProjectSelector({
);
}, [projects, searchQuery]);
// Organize projects for display: recent first, then others
// Detect exact match (case-insensitive name match)
const exactMatch = useMemo((): ProjectInfo | null => {
if (!searchQuery.trim()) return null;
const query = searchQuery.toLowerCase();
const nameMatches = filteredProjects.filter(
(p) => p.name.toLowerCase() === query
);
if (nameMatches.length === 1) return nameMatches[0];
return null;
}, [filteredProjects, searchQuery]);
// Organize projects for display: bookmarked first, then recent, then others
const displayProjects = useMemo(() => {
const recentIds = new Set(recentProjects.map((p) => p.id));
const currentId = currentProject?.id;
// Exclude current project from list
// Bookmarked projects (excluding current)
const bookmarked = filteredProjects.filter(
(p) =>
p.id !== currentId &&
bookmarkedIds.has(p.id) &&
!recentIds.has(p.id)
);
// Exclude current, bookmarked, and recent from "others"
const bookmarkedAndRecentIds = new Set([
...bookmarked.map((p) => p.id),
...recentIds,
]);
const others = filteredProjects.filter(
(p) => p.id !== currentId && !recentIds.has(p.id)
(p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id)
);
return {
bookmarked: searchQuery.trim() ? [] : bookmarked,
recent: searchQuery.trim() ? [] : recentProjects,
others,
};
}, [filteredProjects, recentProjects, currentProject, searchQuery]);
}, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]);
// Calculate total items for keyboard navigation
const totalItems = useMemo(() => {
const bookmarkedCount = displayProjects.bookmarked.length;
const recentCount = displayProjects.recent.length;
const othersCount = displayProjects.others.length;
const viewAllCount = 1;
return recentCount + othersCount + viewAllCount;
return bookmarkedCount + recentCount + othersCount + viewAllCount;
}, [displayProjects]);
// Handle keyboard navigation within dropdown
@@ -151,21 +218,30 @@ export function ProjectSelector({
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0) {
const bookmarkedCount = displayProjects.bookmarked.length;
const recentCount = displayProjects.recent.length;
const othersCount = displayProjects.others.length;
if (highlightedIndex < recentCount) {
if (highlightedIndex < bookmarkedCount) {
// Select bookmarked project
onSelect(displayProjects.bookmarked[highlightedIndex]);
} else if (highlightedIndex < bookmarkedCount + recentCount) {
// Select recent project
onSelect(displayProjects.recent[highlightedIndex]);
} else if (highlightedIndex < recentCount + othersCount) {
onSelect(displayProjects.recent[highlightedIndex - bookmarkedCount]);
} else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) {
// Select other project
onSelect(displayProjects.others[highlightedIndex - recentCount]);
onSelect(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]);
} else {
// View All
onViewAll();
}
setIsOpen(false);
setSearchQuery("");
} else if (exactMatch) {
// Auto-select exact match on Enter when nothing is highlighted
onSelect(exactMatch);
setIsOpen(false);
setSearchQuery("");
}
break;
case "Home":
@@ -178,15 +254,41 @@ export function ProjectSelector({
break;
}
},
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll]
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll, exactMatch]
);
// Reset highlight when dropdown opens or search changes
// Auto-highlight first result when filtering (type-ahead behavior)
useEffect(() => {
if (isOpen) {
if (isOpen && searchQuery.trim()) {
if (exactMatch) {
// Auto-highlight the exact match item
const bookmarkedCount = displayProjects.bookmarked.length;
const recentCount = displayProjects.recent.length;
const matchIdx = displayProjects.others.findIndex(
(p) => p.id === exactMatch.id
);
if (matchIdx >= 0) {
setHighlightedIndex(bookmarkedCount + recentCount + matchIdx);
}
} else if (displayProjects.others.length > 0) {
// Highlight first item in others section
setHighlightedIndex(displayProjects.bookmarked.length + displayProjects.recent.length);
} else {
setHighlightedIndex(-1);
}
} else if (isOpen && !searchQuery.trim()) {
setHighlightedIndex(-1);
}
}, [isOpen, searchQuery]);
}, [isOpen, searchQuery, exactMatch, displayProjects]);
// Scroll highlighted item into view for keyboard navigation
useEffect(() => {
if (highlightedIndex < 0) return;
const el = itemRefs.current.get(highlightedIndex);
if (el) {
el.scrollIntoView({ block: "nearest" });
}
}, [highlightedIndex]);
// Handle project selection
const handleSelectProject = useCallback(
@@ -207,11 +309,14 @@ export function ProjectSelector({
// Toggle dropdown
const toggleDropdown = useCallback(() => {
setIsOpen((prev) => !prev);
if (isOpen) {
setSearchQuery("");
}
}, [isOpen]);
setIsOpen((prev) => {
if (!prev) {
// Opening — always clear search for a fresh type-ahead
setSearchQuery("");
}
return !prev;
});
}, []);
// Render status icon
const renderStatusIcon = (status: ProjectStatus) => {
@@ -226,6 +331,36 @@ export function ProjectSelector({
);
};
// Render bookmark star toggle (span to avoid nested <button> inside listbox items)
const renderBookmarkToggle = (projectId: string) => {
const bookmarked = isBookmarked(projectId);
return (
<span
role="button"
tabIndex={0}
className={`project-selector__bookmark ${bookmarked ? "bookmarked" : ""}`}
onClick={(e) => {
e.stopPropagation();
toggleBookmark(projectId);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
toggleBookmark(projectId);
}
}}
aria-label={bookmarked ? t("projectSelector.removeBookmark", "Remove bookmark") : t("projectSelector.addBookmark", "Bookmark project")}
data-testid={`bookmark-toggle-${projectId}`}
>
<Star
size={14}
fill={bookmarked ? "currentColor" : "none"}
/>
</span>
);
};
// Don't render if only one project (single-project mode)
if (projects.length <= 1) {
return null;
@@ -262,40 +397,51 @@ export function ProjectSelector({
onKeyDown={handleDropdownKeyDown}
data-testid="project-selector-dropdown"
>
{/* Search input (shown when 5+ projects) */}
{projects.length >= 5 && (
<div className="project-selector__search">
<Search size={14} className="project-selector__search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={t("projectSelector.searchPlaceholder", "Search projects...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="project-selector__search-input"
/>
{searchQuery && (
<button
className="project-selector__search-clear"
onClick={() => setSearchQuery("")}
aria-label={t("projectSelector.clearSearch", "Clear search")}
>
<X size={12} />
</button>
)}
{/* Search input — always visible for autocomplete/type-ahead */}
<div className="project-selector__search">
<Search size={14} className="project-selector__search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={t("projectSelector.searchPlaceholder", "Search projects...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="project-selector__search-input"
data-testid="project-selector-search-input"
aria-label={t("projectSelector.searchAriaLabel", "Type to search projects")}
/>
{searchQuery && (
<button
className="project-selector__search-clear"
onClick={() => setSearchQuery("")}
aria-label={t("projectSelector.clearSearch", "Clear search")}
>
<X size={12} />
</button>
)}
</div>
{/* Exact match indicator */}
{exactMatch && (
<div className="project-selector__exact-match" data-testid="project-selector-exact-match">
{t("projectSelector.exactMatch", "Exact match — press Enter to select")}
</div>
)}
{/* Recent projects section */}
{displayProjects.recent.length > 0 && (
{/* Bookmarked projects section */}
{displayProjects.bookmarked.length > 0 && (
<div className="project-selector__section">
<div className="project-selector__section-header">
<Clock size={12} />
<span>{t("projectSelector.recent", "Recent")}</span>
<Star size={12} fill="currentColor" />
<span>{t("projectSelector.bookmarked", "Bookmarked")}</span>
</div>
{displayProjects.recent.map((project, index) => (
{displayProjects.bookmarked.map((project, index) => (
<button
key={project.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
className={`project-selector__item ${
highlightedIndex === index ? "highlighted" : ""
}`}
@@ -307,6 +453,7 @@ export function ProjectSelector({
<span className="project-selector__item-name">
{project.name}
</span>
{renderBookmarkToggle(project.id)}
{currentProject?.id === project.id && (
<Check size={14} className="project-selector__item-check" />
)}
@@ -315,41 +462,98 @@ export function ProjectSelector({
</div>
)}
{/* All projects section */}
<div className="project-selector__section">
{displayProjects.recent.length > 0 && (
{/* Recent projects section */}
{displayProjects.recent.length > 0 && (
<div className="project-selector__section">
<div className="project-selector__section-header">
<Folder size={12} />
<span>{t("projectSelector.allProjects", "All Projects")}</span>
<Clock size={12} />
<span>{t("projectSelector.recent", "Recent")}</span>
</div>
)}
{displayProjects.others.length === 0 && searchQuery ? (
<div className="project-selector__no-results">
{t("projectSelector.noResults", "No projects match your search")}
</div>
) : (
displayProjects.others.map((project, index) => {
const actualIndex = displayProjects.recent.length + index;
{displayProjects.recent.map((project, index) => {
const actualIndex = displayProjects.bookmarked.length + index;
return (
<button
key={project.id}
ref={(el) => {
if (el) itemRefs.current.set(actualIndex, el);
else itemRefs.current.delete(actualIndex);
}}
className={`project-selector__item ${
highlightedIndex === actualIndex ? "highlighted" : ""
}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={currentProject?.id === project.id}
>
{renderStatusIcon(project.status)}
<span className="project-selector__item-name">
{project.name}
</span>
{renderBookmarkToggle(project.id)}
{currentProject?.id === project.id && (
<Check size={14} className="project-selector__item-check" />
)}
</button>
);
})}
</div>
)}
{/* All projects section */}
<div className="project-selector__section">
{(displayProjects.bookmarked.length > 0 || displayProjects.recent.length > 0) && (
<div className="project-selector__section-header">
<Folder size={12} />
<span>{t("projectSelector.allProjects", "All Projects")}</span>
</div>
)}
{displayProjects.others.length === 0 && searchQuery ? (
<div className="project-selector__no-results" data-testid="project-selector-no-results">
<Search size={14} className="project-selector__no-results-icon" />
<span>
{t("projectSelector.noResults", "No projects match \"{{query}}\"", { query: searchQuery })}
</span>
</div>
) : (
displayProjects.others.map((project, index) => {
const actualIndex = displayProjects.bookmarked.length + displayProjects.recent.length + index;
const isExactMatch = exactMatch?.id === project.id;
return (
<button
key={project.id}
ref={(el) => {
if (el) itemRefs.current.set(actualIndex, el);
else itemRefs.current.delete(actualIndex);
}}
className={`project-selector__item ${
highlightedIndex === actualIndex ? "highlighted" : ""
} ${isExactMatch ? "exact-match" : ""}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={currentProject?.id === project.id}
data-testid={`project-selector-item-${project.id}`}
>
{renderStatusIcon(project.status)}
<div className="project-selector__item-info">
<span className="project-selector__item-name">
{project.name}
</span>
<span className="project-selector__item-path">
{getTrailingPath(project.path, 2)}
<HighlightMatch text={project.name} query={searchQuery} />
</span>
{project.path && (
<span className="project-selector__item-path">
<HighlightMatch
text={getTrailingPath(project.path, 2)}
query={searchQuery}
/>
</span>
)}
</div>
{isExactMatch && (
<span className="project-selector__exact-badge">
{t("projectSelector.exact", "Exact")}
</span>
)}
{renderBookmarkToggle(project.id)}
{currentProject?.id === project.id && (
<Check size={14} className="project-selector__item-check" />
)}
@@ -362,6 +566,11 @@ export function ProjectSelector({
{/* View All option */}
<div className="project-selector__footer">
<button
ref={(el) => {
const viewAllIndex = totalItems - 1;
if (el) itemRefs.current.set(viewAllIndex, el);
else itemRefs.current.delete(viewAllIndex);
}}
className={`project-selector__view-all ${
highlightedIndex === totalItems - 1 ? "highlighted" : ""
}`}

View File

@@ -3,6 +3,18 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ProjectSelector } from "../ProjectSelector";
import type { ProjectInfo, ProjectStatus } from "@fusion/core";
// Mock useProjectBookmarks
const mockToggleBookmark = vi.fn();
let mockBookmarkedIds: Set<string> = new Set();
vi.mock("../../hooks/useProjectBookmarks", () => ({
useProjectBookmarks: () => ({
bookmarkedIds: mockBookmarkedIds,
toggleBookmark: mockToggleBookmark,
isBookmarked: (id: string) => mockBookmarkedIds.has(id),
}),
}));
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
@@ -14,6 +26,9 @@ vi.mock("lucide-react", async () => {
Grid3X3: () => <span data-testid="grid-icon">⊞</span>,
Search: () => <span data-testid="search-icon">🔍</span>,
Clock: () => <span data-testid="clock-icon">🕐</span>,
Star: ({ fill }: { fill?: string }) => (
<span data-testid="star-icon" data-fill={fill ?? "none"}>★</span>
),
X: () => <span data-testid="x-icon">✕</span>,
Play: () => <span data-testid="play-icon">▶</span>,
Pause: () => <span data-testid="pause-icon">⏸</span>,
@@ -41,6 +56,9 @@ const noop = () => {};
describe("ProjectSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBookmarkedIds = new Set();
// JSDOM does not implement scrollIntoView — mock it for itemRefs
Element.prototype.scrollIntoView = vi.fn();
});
it("renders without crashing", () => {
@@ -231,14 +249,12 @@ describe("ProjectSelector", () => {
expect(screen.getByPlaceholderText("Search projects...")).toBeDefined();
});
it("does not show search input when fewer than 5 projects", () => {
it("shows search input even with only 2 projects (autocomplete always available)", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1" }),
makeProject({ id: "proj_2" }),
makeProject({ id: "proj_3" }),
makeProject({ id: "proj_4" }),
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
@@ -247,7 +263,8 @@ describe("ProjectSelector", () => {
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.queryByPlaceholderText("Search projects...")).toBeNull();
// Search input should always be present for autocomplete
expect(screen.getByTestId("project-selector-search-input")).toBeDefined();
});
it("filters projects based on search query", () => {
@@ -370,4 +387,421 @@ describe("ProjectSelector", () => {
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// Should have checkmark for current project (though in dropdown it might not be visible due to filtering)
});
// === Bookmark-specific tests ===
describe("bookmarks", () => {
it("shows bookmark toggle on each project item", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
expect(bookmarkToggle).toBeDefined();
});
it("calls toggleBookmark when star is clicked", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
fireEvent.click(bookmarkToggle);
expect(mockToggleBookmark).toHaveBeenCalledWith("proj_2");
});
it("does not trigger onSelect when star is clicked", () => {
const onSelect = vi.fn();
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={onSelect}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
fireEvent.click(bookmarkToggle);
expect(onSelect).not.toHaveBeenCalled();
});
it("shows Bookmarked section when projects are bookmarked", () => {
mockBookmarkedIds = new Set(["proj_2"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
makeProject({ id: "proj_3", name: "Project Three" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.getByText("Bookmarked")).toBeDefined();
expect(screen.getByText("Project Two")).toBeDefined();
});
it("displays bookmarked projects at top of list", () => {
mockBookmarkedIds = new Set(["proj_3"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
makeProject({ id: "proj_3", name: "Project Three" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// "Bookmarked" section header should exist
expect(screen.getByText("Bookmarked")).toBeDefined();
// The "All Projects" section should also exist since we have non-bookmarked items
expect(screen.getByText("All Projects")).toBeDefined();
});
it("bookmark toggle shows filled star for bookmarked projects", () => {
mockBookmarkedIds = new Set(["proj_2"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkedStar = screen.getByTestId("bookmark-toggle-proj_2");
// Star inside should have data-fill="currentColor" for bookmarked
const starIcon = bookmarkedStar.querySelector('[data-testid="star-icon"]');
expect(starIcon?.getAttribute("data-fill")).toBe("currentColor");
});
it("does not show Bookmarked section when no projects are bookmarked", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.queryByText("Bookmarked")).toBeNull();
});
it("does not show current project in bookmarked section", () => {
mockBookmarkedIds = new Set(["proj_1"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// Current project should not appear in Bookmarked section
// (it's excluded from all lists)
expect(screen.queryByText("Bookmarked")).toBeNull();
});
it("bookmark toggle has correct aria-label", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const toggle = screen.getByTestId("bookmark-toggle-proj_2");
expect(toggle.getAttribute("aria-label")).toBe("Bookmark project");
});
it("bookmarked project toggle shows remove aria-label", () => {
mockBookmarkedIds = new Set(["proj_2"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const toggle = screen.getByTestId("bookmark-toggle-proj_2");
expect(toggle.getAttribute("aria-label")).toBe("Remove bookmark");
});
it("dropdown stays open when bookmark is toggled", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
fireEvent.click(bookmarkToggle);
// Dropdown should still be open
expect(screen.getByTestId("project-selector-dropdown")).toBeDefined();
});
});
// === Autocomplete-specific tests ===
describe("autocomplete", () => {
it("highlights matching text in project names", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project Alpha" }),
makeProject({ id: "proj_2", name: "Project Beta" }),
makeProject({ id: "proj_3", name: "Unrelated" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Beta" } });
// Should show the matched project with highlighted text
const mark = screen.getByText("Beta");
expect(mark.tagName).toBe("MARK");
expect(mark.closest(".project-selector__item")).toBeDefined();
});
it("detects exact match and shows indicator", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "My App" }),
makeProject({ id: "proj_2", name: "My Application" }),
makeProject({ id: "proj_3", name: "Other" }),
]}
currentProject={makeProject({ id: "proj_3" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "My App" } });
// Should show exact match indicator
expect(screen.getByTestId("project-selector-exact-match")).toBeDefined();
// Should show "Exact" badge on the matching item
expect(screen.getByText("Exact")).toBeDefined();
});
it("does not show exact match indicator for partial match", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "My App" }),
makeProject({ id: "proj_2", name: "My Application" }),
makeProject({ id: "proj_3", name: "Other" }),
]}
currentProject={makeProject({ id: "proj_3" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
// "My" matches multiple projects — not an exact match
fireEvent.change(searchInput, { target: { value: "My" } });
expect(screen.queryByTestId("project-selector-exact-match")).toBeNull();
});
it("auto-selects exact match on Enter when nothing is highlighted", () => {
const onSelect = vi.fn();
const exactProject = makeProject({ id: "proj_1", name: "Unique Project" });
render(
<ProjectSelector
projects={[
exactProject,
makeProject({ id: "proj_2", name: "Other Project" }),
]}
currentProject={makeProject({ id: "proj_2" })}
onSelect={onSelect}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Unique Project" } });
// Press Enter without highlighting anything first
fireEvent.keyDown(screen.getByTestId("project-selector-dropdown"), { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith(exactProject);
});
it("shows no results message when search matches nothing", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "zzznonexistent" } });
expect(screen.getByTestId("project-selector-no-results")).toBeDefined();
});
it("clear button clears search query", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Alpha" } });
// Clear button should appear
const clearButton = screen.getByLabelText("Clear search");
expect(clearButton).toBeDefined();
fireEvent.click(clearButton);
expect(searchInput).toHaveValue("");
});
it("filters by project path as well as name", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha", path: "/home/user/projects/frontend" }),
makeProject({ id: "proj_2", name: "Beta", path: "/home/user/projects/backend" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "backend" } });
// Beta project should be visible (matched by path)
expect(screen.getByText("Beta")).toBeDefined();
// Alpha should be filtered out
expect(screen.queryByText("Alpha")).toBeNull();
});
it("resets search query when dropdown is closed and reopened", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
// Open, type something, close with escape
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Alpha" } });
fireEvent.keyDown(document, { key: "Escape" });
// Reopen — search should be cleared
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const reopenedSearchInput = screen.getByPlaceholderText("Search projects...");
expect(reopenedSearchInput).toHaveValue("");
});
});
});

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useProjectBookmarks } from "../useProjectBookmarks";
describe("useProjectBookmarks", () => {
beforeEach(() => {
localStorage.clear();
});
it("starts with empty bookmarks when localStorage is empty", () => {
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(0);
expect(result.current.isBookmarked("any-id")).toBe(false);
});
it("loads bookmarks from localStorage", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", "proj_2"]));
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(2);
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.isBookmarked("proj_2")).toBe(true);
expect(result.current.isBookmarked("proj_3")).toBe(false);
});
it("adds a bookmark", () => {
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.bookmarkedIds.has("proj_1")).toBe(true);
});
it("removes a bookmark", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1"]));
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
expect(result.current.isBookmarked("proj_1")).toBe(false);
expect(result.current.bookmarkedIds.size).toBe(0);
});
it("persists bookmarks to localStorage", () => {
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
// Check that localStorage was updated
const stored = JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]");
expect(stored).toContain("proj_1");
});
it("persists removal to localStorage", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", "proj_2"]));
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
const stored = JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]");
expect(stored).toEqual(["proj_2"]);
});
it("handles corrupted localStorage gracefully", () => {
localStorage.setItem("fusion_project_bookmarks", "not-json{{{");
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(0);
});
it("handles non-array localStorage value gracefully", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify({ foo: "bar" }));
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(0);
});
it("filters non-string items from stored array", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", 42, null, "proj_2"]));
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(2);
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.isBookmarked("proj_2")).toBe(true);
});
it("toggles multiple bookmarks independently", () => {
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
act(() => {
result.current.toggleBookmark("proj_2");
});
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.isBookmarked("proj_2")).toBe(true);
expect(result.current.bookmarkedIds.size).toBe(2);
act(() => {
result.current.toggleBookmark("proj_1");
});
expect(result.current.isBookmarked("proj_1")).toBe(false);
expect(result.current.isBookmarked("proj_2")).toBe(true);
expect(result.current.bookmarkedIds.size).toBe(1);
});
});

View File

@@ -0,0 +1,52 @@
import { useCallback, useState, useEffect } from "react";
const STORAGE_KEY = "fusion_project_bookmarks";
/**
* Manages project bookmark IDs persisted in localStorage.
* Bookmarks are stored as a JSON array of project ID strings.
*/
export function useProjectBookmarks() {
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((id: unknown) => typeof id === "string"));
}
}
} catch {
// Corrupted or missing — start empty
}
return new Set<string>();
});
// Sync to localStorage whenever the set changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...bookmarkedIds]));
} catch {
// localStorage full or unavailable — non-critical, ignore
}
}, [bookmarkedIds]);
const toggleBookmark = useCallback((projectId: string) => {
setBookmarkedIds((prev) => {
const next = new Set(prev);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
return next;
});
}, []);
const isBookmarked = useCallback(
(projectId: string) => bookmarkedIds.has(projectId),
[bookmarkedIds],
);
return { bookmarkedIds, toggleBookmark, isBookmarked };
}