diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -1,4 +1,4 @@
-import type { MouseEventHandler} from "react";
+import type { MouseEventHandler } from "react";
import { useCallback, useMemo, useState } from "react";
import type { SaveStatus } from "../ports";
import { CheckIcon, EyeIcon, FocusIcon, RefreshIcon, SaveIcon, SettingsIcon, SplitViewIcon } from "./icons";
@@ -75,28 +75,28 @@
);
}
-function SaveStatusIndicator({ status }: { status: SaveStatus }) {
- const getStatusDisplay = () => {
- switch (status) {
- case "Saving": {
- return { icon:
, text: "Saving...", color: "text-accent-cyan" };
- }
- case "Saved": {
- return { icon:
, text: "Saved", color: "text-accent-green" };
- }
- case "Dirty": {
- return { icon: null, text: "Unsaved", color: "text-accent-yellow" };
- }
- case "Error": {
- return { icon: null, text: "Error", color: "text-support-error" };
- }
- default: {
- return { icon: null, text: "Ready", color: "text-text-placeholder" };
- }
+const getStatusDisplay = (status: SaveStatus) => {
+ switch (status) {
+ case "Saving": {
+ return { icon:
, text: "Saving...", color: "text-accent-cyan" };
}
- };
+ case "Saved": {
+ return { icon:
, text: "Saved", color: "text-accent-green" };
+ }
+ case "Dirty": {
+ return { icon: null, text: "Unsaved", color: "text-accent-yellow" };
+ }
+ case "Error": {
+ return { icon: null, text: "Error", color: "text-support-error" };
+ }
+ default: {
+ return { icon: null, text: "Ready", color: "text-text-placeholder" };
+ }
+ }
+};
- const { icon, text, color } = getStatusDisplay();
+function SaveStatusIndicator({ status }: { status: SaveStatus }) {
+ const { icon, text, color } = getStatusDisplay(status);
return (
-
-
- );
-}
+export const FolderIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function FileTextIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const FileTextIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function SearchIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const SearchIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function PlusIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const PlusIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function XIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const XIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function ChevronRightIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const ChevronRightIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function ChevronDownIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const ChevronDownIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function SaveIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const SaveIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function CheckIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const CheckIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function MoreVerticalIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const MoreVerticalIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function SettingsIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const SettingsIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function SplitViewIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const SplitViewIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function EyeIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const EyeIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function FocusIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const FocusIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function LibraryIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const LibraryIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function TrashIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const TrashIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
-export function RefreshIcon({ size = 16, className = "", style }: IconProps) {
- return (
-
- );
-}
+export const RefreshIcon = ({ size = 16, className = "", style }: IconProps) => (
+
+);
diff --git a/src/state/appStore.ts b/src/state/appStore.ts
--- a/src/state/appStore.ts
+++ b/src/state/appStore.ts
@@ -1,4 +1,4 @@
-import { type StateCreator, create } from "zustand";
+import { create, type StateCreator } from "zustand";
import { useShallow } from "zustand/react/shallow";
import type { Tab } from "../components/DocumentTabs";
import type { DocMeta, DocRef, LocationDescriptor } from "../ports";
@@ -57,10 +57,7 @@
setLoadingDocuments: (value: boolean) => void;
};
-export type TabsState = {
- tabs: Tab[];
- activeTabId: string | null;
-};
+export type TabsState = { tabs: Tab[]; activeTabId: string | null };
export type TabsActions = {
openDocumentTab: (docRef: DocRef, title: string) => OpenDocumentTabResult;
@@ -96,10 +93,7 @@
}
function getInitialTabsState(): TabsState {
- return {
- tabs: [],
- activeTabId: null,
- };
+ return { tabs: [], activeTabId: null };
}
const createLayoutSlice: StateCreator
= (set) => ({
@@ -315,12 +309,7 @@
}
export function useTabsState() {
- return useAppStore(
- useShallow((state) => ({
- tabs: state.tabs,
- activeTabId: state.activeTabId,
- })),
- );
+ return useAppStore(useShallow((state) => ({ tabs: state.tabs, activeTabId: state.activeTabId })));
}
export function useTabsActions() {
@@ -338,9 +327,5 @@
export function resetAppStore(): void {
nextTabId = 1;
- useAppStore.setState({
- ...getInitialLayoutState(),
- ...getInitialWorkspaceState(),
- ...getInitialTabsState(),
- });
+ useAppStore.setState({ ...getInitialLayoutState(), ...getInitialWorkspaceState(), ...getInitialTabsState() });
}
diff --git a/src/state/searchAtoms.ts b/src/state/searchAtoms.ts
--- a/src/state/searchAtoms.ts
+++ b/src/state/searchAtoms.ts
@@ -8,7 +8,6 @@
export const activeSearchFilterCountAtom = atom((get) => {
const filters = get(searchFiltersAtom);
-
return (filters.locations?.length ?? 0) + (filters.fileTypes?.length ?? 0) + (filters.dateRange ? 1 : 0);
});
diff --git a/src/components/Sidebar/AddButton.tsx b/src/components/Sidebar/AddButton.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/AddButton.tsx
@@ -0,0 +1,19 @@
+import type { MouseEventHandler } from "react";
+import { PlusIcon } from "../icons";
+
+export const AddButton = (
+ { onAddLocation, handleMouseEnter, handleMouseLeave }: {
+ onAddLocation: () => void;
+ handleMouseEnter: MouseEventHandler;
+ handleMouseLeave: MouseEventHandler;
+ },
+) => (
+
+);
diff --git a/src/components/Sidebar/DocumentItem.tsx b/src/components/Sidebar/DocumentItem.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/DocumentItem.tsx
@@ -0,0 +1,26 @@
+import { useCallback, useMemo } from "react";
+import type { DocMeta } from "../../ports";
+import { FileTextIcon } from "../icons";
+import { TreeItem } from "./TreeItem";
+
+export function DocumentItem(
+ { doc, isSelected, selectedDocPath, onSelectDocument, id }: {
+ doc: DocMeta;
+ isSelected: boolean;
+ selectedDocPath?: string;
+ onSelectDocument: (id: number, path: string) => void;
+ id: number;
+ },
+) {
+ const fileTextIcon = useMemo(() => ({ Component: FileTextIcon, size: 14 }), []);
+ const handleClick = useCallback(() => onSelectDocument(id, doc.rel_path), [id, onSelectDocument]);
+ return (
+
+ );
+}
diff --git a/src/components/Sidebar/EmptyDocuments.tsx b/src/components/Sidebar/EmptyDocuments.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/EmptyDocuments.tsx
@@ -0,0 +1,5 @@
+export const EmptyDocuments = ({ filterText }: { filterText?: string }) => (
+
+ {filterText ? "No matching documents" : "No documents found"}
+
+);
diff --git a/src/components/Sidebar/EmptyLocations.tsx b/src/components/Sidebar/EmptyLocations.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/EmptyLocations.tsx
@@ -0,0 +1,13 @@
+import { LibraryIcon } from "../icons";
+
+export const EmptyLocations = ({ onAddLocation }: { onAddLocation: () => void }) => (
+
+
+
No locations added
+
+
+);
diff --git a/src/components/Sidebar/RemoveButton.tsx b/src/components/Sidebar/RemoveButton.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/RemoveButton.tsx
@@ -0,0 +1,28 @@
+import type { MouseEventHandler } from "react";
+import { TrashIcon } from "../icons";
+
+export function RemoveButton(
+ { isMenuOpen, handleRemoveClick, handleMouseEnter, handleMouseLeave }: {
+ isMenuOpen: boolean;
+ handleRemoveClick: () => void;
+ handleMouseEnter: MouseEventHandler;
+ handleMouseLeave: MouseEventHandler;
+ },
+) {
+ if (isMenuOpen) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/Sidebar/SearchInput.tsx b/src/components/Sidebar/SearchInput.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/SearchInput.tsx
@@ -0,0 +1,20 @@
+import { SearchIcon } from "../icons";
+
+export const SearchInput = (
+ { filterText, handleInputChange }: {
+ filterText: string;
+ handleInputChange: (e: React.ChangeEvent) => void;
+ },
+) => (
+
+);
diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/Sidebar.tsx
@@ -0,0 +1,129 @@
+import type { ChangeEventHandler, MouseEventHandler } from "react";
+import { useCallback, useMemo, useState } from "react";
+import type { DocMeta, LocationDescriptor } from "../../ports";
+import { LibraryIcon } from "../icons";
+import { AddButton } from "./AddButton";
+import { EmptyLocations } from "./EmptyLocations";
+import { SearchInput } from "./SearchInput";
+import { SidebarLocationItem } from "./SidebarLocationItem";
+import { Title } from "./Title";
+
+export type SidebarProps = {
+ locations: LocationDescriptor[];
+ selectedLocationId?: number;
+ selectedDocPath?: string;
+ documents: DocMeta[];
+ isCollapsed?: boolean;
+ isLoading?: boolean;
+ onAddLocation: () => void;
+ onRemoveLocation: (locationId: number) => void;
+ onSelectLocation: (locationId: number) => void;
+ onSelectDocument: (locationId: number, path: string) => void;
+ filterText?: string;
+ onFilterChange?: (text: string) => void;
+};
+
+export function Sidebar(
+ {
+ locations,
+ selectedLocationId,
+ selectedDocPath,
+ documents,
+ isCollapsed = false,
+ isLoading = false,
+ onAddLocation,
+ onRemoveLocation,
+ onSelectLocation,
+ onSelectDocument,
+ filterText = "",
+ onFilterChange,
+ }: SidebarProps,
+) {
+ const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id)));
+ const [showLocationMenu, setShowLocationMenu] = useState(null);
+
+ const toggleLocation = useCallback((locationId: number) => {
+ setExpandedLocations((prev) => {
+ const next = new Set(prev);
+ if (next.has(locationId)) {
+ next.delete(locationId);
+ } else {
+ next.add(locationId);
+ }
+ return next;
+ });
+ }, []);
+
+ const filteredDocuments = useMemo(
+ () =>
+ filterText
+ ? documents.filter((doc) =>
+ doc.title.toLowerCase().includes(filterText.toLowerCase())
+ || doc.rel_path.toLowerCase().includes(filterText.toLowerCase())
+ )
+ : documents,
+ [documents, filterText],
+ );
+
+ const handleMouseEnter: MouseEventHandler = useCallback((e) => {
+ (e.currentTarget as HTMLButtonElement).classList.add("bg-layer-hover-01", "text-icon-primary");
+ }, []);
+
+ const handleMouseLeave: MouseEventHandler = useCallback((e) => {
+ (e.currentTarget as HTMLButtonElement).classList.remove("bg-layer-hover-01", "text-icon-primary");
+ }, []);
+
+ const handleInputChange: ChangeEventHandler = useCallback((e) => {
+ onFilterChange?.(e.currentTarget.value);
+ }, []);
+
+ if (isCollapsed) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/SidebarLocationItem.tsx
@@ -0,0 +1,115 @@
+import { MouseEventHandler, useCallback, useMemo } from "react";
+import { DocMeta, LocationDescriptor } from "../../ports";
+import { FolderIcon, MoreVerticalIcon } from "../icons";
+import { DocumentItem } from "./DocumentItem";
+import { EmptyDocuments } from "./EmptyDocuments";
+import { RemoveButton } from "./RemoveButton";
+import { TreeItem } from "./TreeItem";
+
+type SidebarLocationItemProps = {
+ location: LocationDescriptor;
+ isSelected: boolean;
+ selectedDocPath?: string;
+ isExpanded: boolean;
+ onSelect: (id: number) => void;
+ onToggle: (id: number) => void;
+ onRemove: (id: number) => void;
+ onSelectDocument: (id: number, path: string) => void;
+ setShowLocationMenu: (id: number | null) => void;
+ documents: DocMeta[];
+ filterText: string;
+ isMenuOpen: boolean;
+};
+
+export function SidebarLocationItem(
+ {
+ location,
+ isSelected,
+ selectedDocPath,
+ isExpanded,
+ onSelect,
+ onToggle,
+ onRemove,
+ onSelectDocument,
+ setShowLocationMenu,
+ documents,
+ filterText,
+ isMenuOpen,
+ }: SidebarLocationItemProps,
+) {
+ const handleRemoveClick = useCallback(() => {
+ onRemove(location.id);
+ setShowLocationMenu(null);
+ }, [location.id, setShowLocationMenu]);
+
+ const handleMenuClick = useCallback(() => {
+ setShowLocationMenu(location.id);
+ }, [location.id, setShowLocationMenu]);
+
+ const handleMouseEnter: MouseEventHandler = useCallback((e) => {
+ (e.currentTarget as HTMLButtonElement).classList.add("bg-support-error", "text-white");
+ (e.currentTarget as HTMLButtonElement).classList.remove("text-support-error");
+ }, []);
+
+ const handleMouseLeave: MouseEventHandler = useCallback((e) => {
+ (e.currentTarget as HTMLButtonElement).classList.remove("bg-support-error", "text-white");
+ (e.currentTarget as HTMLButtonElement).classList.add("text-support-error");
+ }, []);
+
+ const onItemClick = useCallback(() => {
+ onSelect(location.id);
+ }, [location.id, onSelect]);
+
+ const onToggleClick = useCallback(() => {
+ onToggle(location.id);
+ }, [location.id, onToggle]);
+
+ const folderIcon = useMemo(() => ({ Component: FolderIcon, size: 16 }), []);
+
+ const LocationActions = useCallback(() => (
+
+
+
+
+ ), [isMenuOpen, handleMenuClick, handleRemoveClick, handleMouseEnter, handleMouseLeave]);
+
+ return (
+
+
+
+
+
+ {isExpanded && isSelected && (
+
+ {documents.length === 0
+ ?
+ : (documents.map((doc) => (
+
+ )))}
+
+ )}
+
+ );
+}
diff --git a/src/components/Sidebar/Title.tsx b/src/components/Sidebar/Title.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/Title.tsx
@@ -0,0 +1,6 @@
+export const Title = ({ isLoading }: { isLoading: boolean }) => (
+
+ Library
+ {isLoading && (loading...)}
+
+);
diff --git a/src/components/Sidebar/TreeItem.tsx b/src/components/Sidebar/TreeItem.tsx
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/TreeItem.tsx
@@ -0,0 +1,102 @@
+import type { CSSProperties, MouseEventHandler } from "react";
+import { useCallback, useMemo } from "react";
+import { ChevronRightIcon } from "../icons";
+
+type TreeItemProps = {
+ icon: { Component: (props: { size: number }) => React.ReactNode; size: number };
+ label: string;
+ isSelected?: boolean;
+ isExpanded?: boolean;
+ hasChildren?: boolean;
+ level?: number;
+ onClick?: () => void;
+ onToggle?: () => void;
+ Actions?: React.ComponentType;
+};
+
+export function TreeItem(
+ {
+ icon,
+ label,
+ isSelected = false,
+ isExpanded = false,
+ hasChildren = false,
+ level = 0,
+ onClick,
+ onToggle,
+ Actions = () => null,
+ }: TreeItemProps,
+) {
+ const paddingLeft = level * 16 + 12;
+
+ const handleMouseEnter: MouseEventHandler = useCallback((e) => {
+ if (!isSelected) {
+ (e.currentTarget as HTMLDivElement).classList.add("bg-layer-hover-01");
+ }
+ }, []);
+
+ const handleMouseLeave: MouseEventHandler = useCallback((e) => {
+ if (!isSelected) {
+ (e.currentTarget as HTMLDivElement).classList.remove("bg-layer-hover-01");
+ }
+ }, []);
+
+ const handleButtonClick: MouseEventHandler = useCallback((e) => {
+ e.stopPropagation();
+ onToggle?.();
+ }, []);
+
+ const containerStyle: CSSProperties = useMemo(
+ () => ({ paddingLeft: `${paddingLeft}px`, paddingRight: "8px", paddingTop: "6px", paddingBottom: "6px" }),
+ [paddingLeft],
+ );
+
+ const containerClasses = useMemo(() => {
+ const base = [
+ "sidebar-item group flex items-center gap-2",
+ "cursor-pointer rounded mx-2 mb-0.5 text-[0.8125rem]",
+ "transition-colors duration-150",
+ ];
+
+ if (isSelected) {
+ base.push("bg-layer-accent-01 text-text-primary");
+ } else {
+ base.push("bg-transparent text-text-secondary");
+ }
+
+ return base.join(" ");
+ }, [isSelected]);
+
+ const buttonStyle: CSSProperties = useMemo(() => ({ transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)" }), [
+ isExpanded,
+ ]);
+
+ const labelStyle: CSSProperties = useMemo(() => ({ fontWeight: isSelected ? 500 : 400 }), [isSelected]);
+
+ return (
+
+ {hasChildren
+ ? (
+
+ )
+ :
}
+
+
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/src/components/Sidebar/index.ts b/src/components/Sidebar/index.ts
new file mode 100644
--- /dev/null
+++ b/src/components/Sidebar/index.ts
@@ -0,0 +1,1 @@
+export { Sidebar, type SidebarProps } from "./Sidebar";