diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx new file mode 100644 index 000000000..05d833866 --- /dev/null +++ b/src/features/app/components/Sidebar.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from "@testing-library/react"; +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRef } from "react"; +import { Sidebar } from "./Sidebar"; + +const baseProps = { + workspaces: [], + groupedWorkspaces: [], + hasWorkspaceGroups: false, + deletingWorktreeIds: new Set(), + threadsByWorkspace: {}, + threadParentById: {}, + threadStatusById: {}, + threadListLoadingByWorkspace: {}, + threadListPagingByWorkspace: {}, + threadListCursorByWorkspace: {}, + activeWorkspaceId: null, + activeThreadId: null, + accountRateLimits: null, + onOpenSettings: vi.fn(), + onOpenDebug: vi.fn(), + showDebugButton: false, + onAddWorkspace: vi.fn(), + onSelectHome: vi.fn(), + onSelectWorkspace: vi.fn(), + onConnectWorkspace: vi.fn(), + onAddAgent: vi.fn(), + onAddWorktreeAgent: vi.fn(), + onAddCloneAgent: vi.fn(), + onToggleWorkspaceCollapse: vi.fn(), + onSelectThread: vi.fn(), + onDeleteThread: vi.fn(), + onSyncThread: vi.fn(), + pinThread: vi.fn(() => false), + unpinThread: vi.fn(), + isThreadPinned: vi.fn(() => false), + getPinTimestamp: vi.fn(() => null), + onRenameThread: vi.fn(), + onDeleteWorkspace: vi.fn(), + onDeleteWorktree: vi.fn(), + onLoadOlderThreads: vi.fn(), + onReloadWorkspaceThreads: vi.fn(), + workspaceDropTargetRef: createRef(), + isWorkspaceDropActive: false, + workspaceDropText: "Drop Project Here", + onWorkspaceDragOver: vi.fn(), + onWorkspaceDragEnter: vi.fn(), + onWorkspaceDragLeave: vi.fn(), + onWorkspaceDrop: vi.fn(), +}; + +describe("Sidebar", () => { + it("toggles the search bar from the header icon", () => { + vi.useFakeTimers(); + render(); + + const toggleButton = screen.getByRole("button", { name: "Toggle search" }); + expect(screen.queryByLabelText("Search projects")).toBeNull(); + + act(() => { + fireEvent.click(toggleButton); + }); + const input = screen.getByLabelText("Search projects") as HTMLInputElement; + expect(input).toBeTruthy(); + + act(() => { + fireEvent.change(input, { target: { value: "alpha" } }); + vi.runOnlyPendingTimers(); + }); + expect(input.value).toBe("alpha"); + + act(() => { + fireEvent.click(toggleButton); + vi.runOnlyPendingTimers(); + }); + expect(screen.queryByLabelText("Search projects")).toBeNull(); + + act(() => { + fireEvent.click(toggleButton); + vi.runOnlyPendingTimers(); + }); + const reopened = screen.getByLabelText("Search projects") as HTMLInputElement; + expect(reopened.value).toBe(""); + vi.useRealTimers(); + }); +}); diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 946411ce3..c3e6d0aed 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { RefObject } from "react"; import { FolderOpen } from "lucide-react"; +import X from "lucide-react/dist/esm/icons/x"; import { SidebarCornerActions } from "./SidebarCornerActions"; import { SidebarFooter } from "./SidebarFooter"; import { SidebarHeader } from "./SidebarHeader"; @@ -126,6 +127,9 @@ export function Sidebar({ const [expandedWorkspaces, setExpandedWorkspaces] = useState( new Set(), ); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); const [addMenuAnchor, setAddMenuAnchor] = useState<{ workspaceId: string; top: number; @@ -136,12 +140,6 @@ export function Sidebar({ const { collapsedGroups, toggleGroupCollapse } = useCollapsedGroups( COLLAPSED_GROUPS_STORAGE_KEY, ); - const scrollFadeDeps = useMemo( - () => [groupedWorkspaces, threadsByWorkspace, expandedWorkspaces], - [groupedWorkspaces, threadsByWorkspace, expandedWorkspaces], - ); - const { sidebarBodyRef, scrollFade, updateScrollFade } = - useSidebarScrollFade(scrollFadeDeps); const { getThreadRows } = useThreadRows(threadParentById); const { showThreadMenu, showWorkspaceMenu, showWorktreeMenu } = useSidebarMenus({ @@ -163,6 +161,49 @@ export function Sidebar({ creditsLabel, showWeekly, } = getUsageLabels(accountRateLimits); + const normalizedQuery = debouncedQuery.trim().toLowerCase(); + + const isWorkspaceMatch = useCallback( + (workspace: WorkspaceInfo) => { + if (!normalizedQuery) { + return true; + } + return workspace.name.toLowerCase().includes(normalizedQuery); + }, + [normalizedQuery], + ); + + const renderHighlightedName = useCallback( + (name: string) => { + if (!normalizedQuery) { + return name; + } + const lower = name.toLowerCase(); + const parts: React.ReactNode[] = []; + let cursor = 0; + let matchIndex = lower.indexOf(normalizedQuery, cursor); + + while (matchIndex !== -1) { + if (matchIndex > cursor) { + parts.push(name.slice(cursor, matchIndex)); + } + parts.push( + + {name.slice(matchIndex, matchIndex + normalizedQuery.length)} + , + ); + cursor = matchIndex + normalizedQuery.length; + matchIndex = lower.indexOf(normalizedQuery, cursor); + } + + if (cursor < name.length) { + parts.push(name.slice(cursor)); + } + + return parts.length ? parts : name; + }, + [normalizedQuery], + ); const pinnedThreadRows = (() => { type ThreadRow = { thread: ThreadSummary; depth: number }; @@ -173,6 +214,9 @@ export function Sidebar({ }> = []; workspaces.forEach((workspace) => { + if (!isWorkspaceMatch(workspace)) { + return; + } const threads = threadsByWorkspace[workspace.id] ?? []; if (!threads.length) { return; @@ -224,6 +268,26 @@ export function Sidebar({ ); })(); + const scrollFadeDeps = useMemo( + () => [groupedWorkspaces, threadsByWorkspace, expandedWorkspaces, normalizedQuery], + [groupedWorkspaces, threadsByWorkspace, expandedWorkspaces, normalizedQuery], + ); + const { sidebarBodyRef, scrollFade, updateScrollFade } = + useSidebarScrollFade(scrollFadeDeps); + + const filteredGroupedWorkspaces = useMemo( + () => + groupedWorkspaces + .map((group) => ({ + ...group, + workspaces: group.workspaces.filter(isWorkspaceMatch), + })) + .filter((group) => group.workspaces.length > 0), + [groupedWorkspaces, isWorkspaceMatch], + ); + + const isSearchActive = Boolean(normalizedQuery); + const worktreesByParent = useMemo(() => { const worktrees = new Map(); workspaces @@ -279,16 +343,58 @@ export function Sidebar({ }; }, [addMenuAnchor]); + useEffect(() => { + if (!isSearchOpen && searchQuery) { + setSearchQuery(""); + } + }, [isSearchOpen, searchQuery]); + + useEffect(() => { + const handle = window.setTimeout(() => { + setDebouncedQuery(searchQuery); + }, 150); + return () => window.clearTimeout(handle); + }, [searchQuery]); + return (