Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/features/app/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(),
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<HTMLElement>(),
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(<Sidebar {...baseProps} />);

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();
});
});
133 changes: 122 additions & 11 deletions src/features/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -126,6 +127,9 @@ export function Sidebar({
const [expandedWorkspaces, setExpandedWorkspaces] = useState(
new Set<string>(),
);
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [addMenuAnchor, setAddMenuAnchor] = useState<{
workspaceId: string;
top: number;
Expand All @@ -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({
Expand All @@ -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(
<span key={`${matchIndex}-${cursor}`} className="workspace-name-match">
{name.slice(matchIndex, matchIndex + normalizedQuery.length)}
</span>,
);
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 };
Expand All @@ -173,6 +214,9 @@ export function Sidebar({
}> = [];

workspaces.forEach((workspace) => {
if (!isWorkspaceMatch(workspace)) {
return;
}
const threads = threadsByWorkspace[workspace.id] ?? [];
if (!threads.length) {
return;
Expand Down Expand Up @@ -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<string, WorkspaceInfo[]>();
workspaces
Expand Down Expand Up @@ -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 (
<aside
className="sidebar"
className={`sidebar${isSearchOpen ? " search-open" : ""}`}
ref={workspaceDropTargetRef}
onDragOver={onWorkspaceDragOver}
onDragEnter={onWorkspaceDragEnter}
onDragLeave={onWorkspaceDragLeave}
onDrop={onWorkspaceDrop}
>
<SidebarHeader onSelectHome={onSelectHome} onAddWorkspace={onAddWorkspace} />
<SidebarHeader
onSelectHome={onSelectHome}
onAddWorkspace={onAddWorkspace}
onToggleSearch={() => setIsSearchOpen((prev) => !prev)}
isSearchOpen={isSearchOpen}
/>
<div className={`sidebar-search${isSearchOpen ? " is-open" : ""}`}>
{isSearchOpen && (
<input
className="sidebar-search-input"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search projects"
aria-label="Search projects"
data-tauri-drag-region="false"
autoFocus
/>
)}
{isSearchOpen && searchQuery.length > 0 && (
<button
type="button"
className="sidebar-search-clear"
onClick={() => setSearchQuery("")}
aria-label="Clear search"
data-tauri-drag-region="false"
>
<X size={12} aria-hidden />
</button>
)}
</div>
<div
className={`workspace-drop-overlay${
isWorkspaceDropActive ? " is-active" : ""
Expand Down Expand Up @@ -331,7 +437,7 @@ export function Sidebar({
/>
</div>
)}
{groupedWorkspaces.map((group) => {
{filteredGroupedWorkspaces.map((group) => {
const groupId = group.id;
const showGroupHeader = Boolean(groupId) || hasWorkspaceGroups;
const toggleId = groupId ?? (showGroupHeader ? UNGROUPED_COLLAPSE_ID : null);
Expand Down Expand Up @@ -377,6 +483,7 @@ export function Sidebar({
<WorkspaceCard
key={entry.id}
workspace={entry}
workspaceName={renderHighlightedName(entry.name)}
isActive={entry.id === activeWorkspaceId}
isCollapsed={isCollapsed}
addMenuOpen={addMenuOpen}
Expand Down Expand Up @@ -484,8 +591,12 @@ export function Sidebar({
</WorkspaceGroup>
);
})}
{!groupedWorkspaces.length && (
<div className="empty">Add a workspace to start.</div>
{!filteredGroupedWorkspaces.length && (
<div className="empty">
{isSearchActive
? "No projects match your search."
: "Add a workspace to start."}
</div>
)}
</div>
</div>
Expand Down
54 changes: 38 additions & 16 deletions src/features/app/components/SidebarHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,54 @@
import FolderKanban from "lucide-react/dist/esm/icons/folder-kanban";
import FolderPlus from "lucide-react/dist/esm/icons/folder-plus";
import Search from "lucide-react/dist/esm/icons/search";

type SidebarHeaderProps = {
onSelectHome: () => void;
onAddWorkspace: () => void;
onToggleSearch: () => void;
isSearchOpen: boolean;
};

export function SidebarHeader({ onSelectHome, onAddWorkspace }: SidebarHeaderProps) {
export function SidebarHeader({
onSelectHome,
onAddWorkspace,
onToggleSearch,
isSearchOpen,
}: SidebarHeaderProps) {
return (
<div className="sidebar-header">
<div>
<div className="sidebar-header-title">
<div className="sidebar-title-group">
<button
className="subtitle subtitle-button sidebar-title-button"
onClick={onSelectHome}
data-tauri-drag-region="false"
aria-label="Open home"
>
Projects
</button>
<button
className="sidebar-title-add"
onClick={onAddWorkspace}
data-tauri-drag-region="false"
aria-label="Add workspace"
type="button"
>
<FolderPlus aria-hidden />
</button>
</div>
</div>
<div className="sidebar-header-actions">
<button
className="subtitle subtitle-button"
onClick={onSelectHome}
className={`ghost sidebar-search-toggle${isSearchOpen ? " is-active" : ""}`}
onClick={onToggleSearch}
data-tauri-drag-region="false"
aria-label="Open home"
aria-label="Toggle search"
aria-pressed={isSearchOpen}
type="button"
>
<FolderKanban className="sidebar-nav-icon" />
Projects
<Search aria-hidden />
</button>
</div>
<button
className="ghost workspace-add"
onClick={onAddWorkspace}
data-tauri-drag-region="false"
aria-label="Add workspace"
>
+
</button>
</div>
);
}
4 changes: 3 additions & 1 deletion src/features/app/components/WorkspaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { WorkspaceInfo } from "../../../types";

type WorkspaceCardProps = {
workspace: WorkspaceInfo;
workspaceName?: React.ReactNode;
isActive: boolean;
isCollapsed: boolean;
addMenuOpen: boolean;
Expand All @@ -23,6 +24,7 @@ type WorkspaceCardProps = {

export function WorkspaceCard({
workspace,
workspaceName,
isActive,
isCollapsed,
addMenuOpen,
Expand Down Expand Up @@ -52,7 +54,7 @@ export function WorkspaceCard({
<div>
<div className="workspace-name-row">
<div className="workspace-title">
<span className="workspace-name">{workspace.name}</span>
<span className="workspace-name">{workspaceName ?? workspace.name}</span>
<button
className={`workspace-toggle ${isCollapsed ? "" : "expanded"}`}
onClick={(event) => {
Expand Down
Loading