<script lang="ts">
import type { TreeNode } from './types';
import FileTree from './FileTree.svelte';
import { dragWindow } from './ipc';
import FolderTree from '@lucide/svelte/icons/folder-tree';
import TextQuote from '@lucide/svelte/icons/text-quote';
import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
import {
Sidebar,
SidebarContent,
SidebarInput,
SidebarMenu,
SidebarRail,
} from '$lib/components/ui/sidebar';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '$lib/components/ui/dropdown-menu';
import * as Command from '$lib/components/ui/command';
interface Props {
entries: TreeNode[];
activePath?: string;
rootPath?: string;
knownProjects?: string[];
activeProjectPath?: string;
onProjectSwitch?: (path: string) => void;
onNavigate?: (path: string, newTab: boolean) => void;
onExpand?: (path: string) => void;
outline?: { id: string; text: string; level: number; line: number }[];
activeOutlineId?: string;
onOutlineNavigate?: (id: string) => void;
}
let {
entries,
activePath = '',
rootPath = '',
knownProjects = [],
activeProjectPath = '',
onProjectSwitch,
onNavigate,
onExpand,
outline = [],
activeOutlineId = '',
onOutlineNavigate,
}: Props = $props();
let sidebarView: 'files' | 'outline' = $state('files');
let query = $state('');
function formatRootLabel(path: string): string {
if (!path) return 'Workspace';
const parts = path.split('/').filter(Boolean);
return parts.at(-1) || path;
}
let projectOptions = $derived(
knownProjects.length > 0 ? knownProjects : rootPath ? [rootPath] : [],
);
let selectedProject = $derived(
activeProjectPath || rootPath || projectOptions[0] || '',
);
let markdownFileCount = $derived(entries.length ? countMarkdownFiles(entries) : 0);
let totalFileCount = $derived(entries.length ? countFiles(entries) : 0);
let outlineCount = $derived(outline.length);
let filteredEntries = $derived(filterTree(entries, query));
let filteredOutline = $derived(filterOutline(outline, query));
let projectPickerOpen = $state(false);
function countFiles(nodes: TreeNode[]): number {
let count = 0;
for (const node of nodes) {
if (node.isDir && node.children) {
count += countFiles(node.children);
} else if (!node.isDir) {
count += 1;
}
}
return count;
}
function countMarkdownFiles(nodes: TreeNode[]): number {
let count = 0;
for (const node of nodes) {
if (node.isDir && node.children) {
count += countMarkdownFiles(node.children);
} else if (node.fileType === 'markdown') {
count += 1;
}
}
return count;
}
function filterTree(nodes: TreeNode[], term: string): TreeNode[] {
const q = term.trim().toLowerCase();
if (!q) return nodes;
const out: TreeNode[] = [];
for (const node of nodes) {
const selfMatch = node.name.toLowerCase().includes(q);
if (node.isDir) {
const kids = filterTree(node.children ?? [], q);
if (selfMatch || kids.length > 0) {
out.push({ ...node, children: kids });
}
} else if (selfMatch) {
out.push(node);
}
}
return out;
}
function filterOutline(
headings: { id: string; text: string; level: number; line: number }[],
term: string,
): { id: string; text: string; level: number; line: number }[] {
const q = term.trim().toLowerCase();
if (!q) return headings;
return headings.filter((heading) => heading.text.toLowerCase().includes(q));
}
function handleSearchKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a') {
// Keep native select-all behavior in the search field and prevent
// global/editor key handlers from intercepting the event.
e.stopPropagation();
}
}
</script>
<Sidebar class="project-sidebar">
<!-- Drag strip: clears traffic lights -->
<div
class="h-[46px] shrink-0"
style="-webkit-user-select: none"
role="button"
aria-label="Drag window"
tabindex="-1"
onmousedown={dragWindow}
></div>
<div
class="sidebar-controls"
data-sidebar-controls="true"
style="
display: grid;
gap: 12px;
margin: 2px 12px 14px;
padding: 12px;
border-radius: 14px;
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
background: color-mix(in oklch, var(--background) 80%, white 20%);
box-shadow:
inset 0 1px 0 color-mix(in oklch, white 48%, transparent),
0 1px 2px color-mix(in oklch, black 4%, transparent);
"
>
<div class="sidebar-header flex items-center justify-between gap-3" style="-webkit-user-select: none">
<div class="sidebar-project-picker min-w-0 flex-1">
<DropdownMenu bind:open={projectPickerOpen}>
<DropdownMenuTrigger
class="sidebar-project-select"
aria-label="Project picker"
role="combobox"
aria-expanded={projectPickerOpen}
>
<span class="sidebar-project-select-label" title={selectedProject}>
{formatRootLabel(selectedProject)}
</span>
<ChevronsUpDown class="sidebar-project-switch-icon size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="sidebar-project-menu p-0">
<Command.Root class="sidebar-project-command">
<Command.Input placeholder="Search projects..." />
<Command.List class="max-h-[240px]">
<Command.Empty class="px-3 py-5 text-xs text-muted-foreground">
No projects found.
</Command.Empty>
<Command.Group>
{#each projectOptions as projectPath (projectPath)}
<Command.Item
value={`${formatRootLabel(projectPath)} ${projectPath}`}
class="sidebar-project-menu-item"
onSelect={() => {
projectPickerOpen = false;
if (projectPath !== selectedProject) {
onProjectSwitch?.(projectPath);
}
}}
>
{formatRootLabel(projectPath)}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="sidebar-mode-toggle" aria-label="Sidebar views">
<button
type="button"
class="sidebar-mode-button"
class:sidebar-mode-button--active={sidebarView === 'files'}
aria-label="Files"
title="Files"
onclick={() => { sidebarView = 'files'; }}
>
<FolderTree class="size-3.5" />
</button>
<button
type="button"
class="sidebar-mode-button"
class:sidebar-mode-button--active={sidebarView === 'outline'}
aria-label="Outline"
title="Outline"
onclick={() => { sidebarView = 'outline'; }}
>
<TextQuote class="size-3.5" />
</button>
</div>
</div>
<div class="sidebar-search-wrap p-0">
<SidebarInput
class="sidebar-search !h-8 !px-3 !py-1.5 !text-[0.82rem] !leading-tight"
bind:value={query}
placeholder={sidebarView === 'outline' ? 'Filter headings' : 'Filter files'}
aria-label={sidebarView === 'outline' ? 'Filter headings' : 'Filter files'}
onkeydown={handleSearchKeydown}
/>
</div>
</div>
<SidebarContent class="p-0">
<div class="sidebar-shell">
<section class="sidebar-pane">
<ScrollArea class="min-h-0 flex-1" scrollbarYClasses="pr-1">
{#if sidebarView === 'files'}
{#if filteredEntries.length > 0}
<SidebarMenu class="sidebar-tree-menu">
<FileTree nodes={filteredEntries} {activePath} {rootPath} {onNavigate} {onExpand} />
</SidebarMenu>
{:else}
<div class="sidebar-outline-empty">
<p class="sidebar-outline-empty-title">No files found</p>
<p class="sidebar-outline-empty-copy">Try a different filter term.</p>
</div>
{/if}
{:else if filteredOutline.length > 0}
<div class="sidebar-outline-wrap">
<nav class="sidebar-outline-list" aria-label="Markdown sections">
{#each filteredOutline as heading (heading.id)}
<button
type="button"
class="sidebar-outline-item"
class:sidebar-outline-item--active={heading.id === activeOutlineId}
style={`--outline-level:${heading.level};`}
onclick={() => onOutlineNavigate?.(heading.id)}
>
<span class="sidebar-outline-title">{heading.text}</span>
<span class="sidebar-outline-line">L{heading.line}</span>
</button>
{/each}
</nav>
</div>
{:else}
<div class="sidebar-outline-wrap">
<div class="sidebar-outline-empty">
<p class="sidebar-outline-empty-title">No sections found</p>
<p class="sidebar-outline-empty-copy">Open a markdown file with headings or clear the filter.</p>
</div>
</div>
{/if}
</ScrollArea>
</section>
</div>
</SidebarContent>
<SidebarRail />
</Sidebar>