attn 0.1.14

A beautiful markdown viewer that launches from the CLI
<script lang="ts">
  import type { TreeNode } from './types';
  import FileTree from './FileTree.svelte';
  import { openExternal } from './ipc';
  import {
    SidebarMenuItem,
    SidebarMenuButton,
  } from '$lib/components/ui/sidebar';
  import {
    ContextMenu,
    ContextMenuContent,
    ContextMenuItem,
    ContextMenuSeparator,
    ContextMenuTrigger,
  } from '$lib/components/ui/context-menu';
  import {
    Collapsible,
    CollapsibleContent,
    CollapsibleTrigger,
  } from '$lib/components/ui/collapsible';
  import ChevronRight from '@lucide/svelte/icons/chevron-right';
  import { resolveFileIcon, resolveFolderIcon } from '$lib/icon-resolver';

  interface Props {
    nodes: TreeNode[];
    activePath?: string;
    depth?: number;
    rootPath?: string;
    onNavigate?: (path: string, newTab: boolean) => void;
    onExpand?: (path: string) => void;
  }

  let { nodes, activePath = '', depth = 0, rootPath = '', onNavigate, onExpand }: Props = $props();

  let expanded: Record<string, boolean> = $state({});

  function isExpanded(path: string): boolean {
    if (expanded[path] !== undefined) return expanded[path];
    return depth === 0;
  }

  function setExpanded(path: string, value: boolean): void {
    expanded[path] = value;
  }

  function handleDirOpenChange(path: string, value: boolean): void {
    setExpanded(path, value);
    if (value) {
      onExpand?.(path);
    }
  }

  $effect(() => {
    for (const node of nodes) {
      if (!node.isDir) continue;
      if (!isExpanded(node.path)) continue;
      onExpand?.(node.path);
    }
  });

  function handleFileClick(e: MouseEvent, node: TreeNode): void {
    if (node.isDir) return;
    const newTab = e.metaKey || e.ctrlKey;
    if (onNavigate) {
      onNavigate(node.path, newTab);
    }
  }

  function handleFileAuxClick(e: MouseEvent, node: TreeNode): void {
    if (node.isDir || e.button !== 1) return;
    e.preventDefault();
    onNavigate?.(node.path, true);
  }

  function handleFileKeydown(e: KeyboardEvent, node: TreeNode): void {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      if (!node.isDir && onNavigate) {
        onNavigate(node.path, e.metaKey || e.ctrlKey);
      }
    }
  }

  function getFileIcon(node: TreeNode): string | null {
    return resolveFileIcon(node.name);
  }

  function getFolderIcon(name: string, path: string): string {
    return resolveFolderIcon(name, isExpanded(path));
  }

  function normalizePath(path: string): string {
    const normalized = path.replace(/\\/g, '/');
    if (normalized === '/') return '/';
    return normalized.replace(/\/+$/, '');
  }

  function toRelativePath(path: string, basePath: string): string {
    const normalizedPath = normalizePath(path);
    const normalizedBase = normalizePath(basePath);
    if (!normalizedBase) return normalizedPath;
    if (normalizedBase === '/') {
      if (normalizedPath === '/') return '.';
      return normalizedPath.startsWith('/') ? normalizedPath.slice(1) : normalizedPath;
    }
    if (normalizedPath === normalizedBase) return '.';
    const prefix = `${normalizedBase}/`;
    if (normalizedPath.startsWith(prefix)) {
      return normalizedPath.slice(prefix.length);
    }
    return normalizedPath;
  }

  async function copyText(value: string): Promise<void> {
    if (!value) return;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(value);
        return;
      }
    } catch {
      // Fall back to document.execCommand below.
    }

    const textarea = document.createElement('textarea');
    textarea.value = value;
    textarea.style.position = 'fixed';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();
    document.execCommand('copy');
    document.body.removeChild(textarea);
  }

  function handleContextOpen(path: string, newTab: boolean): void {
    onNavigate?.(path, newTab);
  }

  function handleCopyRelativePath(path: string): void {
    void copyText(toRelativePath(path, rootPath));
  }

  function handleCopyAbsolutePath(path: string): void {
    void copyText(path);
  }

  function handleOpenExternal(path: string): void {
    openExternal(path);
  }
</script>

{#each nodes as node (node.path)}
  {#if node.isDir}
    {@const exp = isExpanded(node.path)}
    <Collapsible
      open={exp}
      onOpenChange={(v) => handleDirOpenChange(node.path, v)}
      class="group/collapsible"
    >
      <SidebarMenuItem>
        <CollapsibleTrigger>
          {#snippet child({ props: triggerProps })}
            <SidebarMenuButton
              {...triggerProps}
              size="sm"
              class="sidebar-tree-row sidebar-tree-row--dir"
              style={`--tree-depth: ${depth};`}
            >
              <ChevronRight class="sidebar-tree-chevron size-3.5 shrink-0 transition-transform duration-150 group-data-[state=open]/collapsible:rotate-90" />
              <img src={getFolderIcon(node.name, node.path)} alt="" aria-hidden="true" class="sidebar-tree-icon-image size-3.5 shrink-0" />
              <span class="sidebar-tree-name truncate">{node.name}</span>
            </SidebarMenuButton>
          {/snippet}
        </CollapsibleTrigger>
        <CollapsibleContent>
          {#if node.children}
            <div class="sidebar-tree-sub" style={`--tree-depth: ${depth};`}>
              <FileTree nodes={node.children} {activePath} depth={depth + 1} {rootPath} {onNavigate} {onExpand} />
            </div>
          {/if}
        </CollapsibleContent>
      </SidebarMenuItem>
    </Collapsible>
  {:else}
    {@const icon = getFileIcon(node)}
    <SidebarMenuItem>
      <ContextMenu>
        <ContextMenuTrigger>
          {#snippet child({ props: triggerProps })}
            <SidebarMenuButton
              {...triggerProps}
              size="sm"
              isActive={node.path === activePath}
              class="sidebar-tree-row sidebar-tree-row--file"
              onclick={(e: MouseEvent) => handleFileClick(e, node)}
              onauxclick={(e: MouseEvent) => handleFileAuxClick(e, node)}
              onkeydown={(e: KeyboardEvent) => handleFileKeydown(e, node)}
              style={`--tree-depth: ${depth};`}
            >
              {#if icon}
                <img src={icon} alt="" aria-hidden="true" class="sidebar-tree-icon-image size-3.5 shrink-0" />
              {:else}
                <span aria-hidden="true" class="sidebar-tree-icon-placeholder size-3.5 shrink-0"></span>
              {/if}
              <span class="sidebar-tree-name truncate">{node.name}</span>
            </SidebarMenuButton>
          {/snippet}
        </ContextMenuTrigger>
        <ContextMenuContent class="w-56">
          <ContextMenuItem onSelect={() => handleContextOpen(node.path, false)}>
            Open
          </ContextMenuItem>
          <ContextMenuItem onSelect={() => handleContextOpen(node.path, true)}>
            Open in new tab
          </ContextMenuItem>
          <ContextMenuSeparator />
          <ContextMenuItem onSelect={() => handleCopyRelativePath(node.path)}>
            Copy relative path
          </ContextMenuItem>
          <ContextMenuItem onSelect={() => handleCopyAbsolutePath(node.path)}>
            Copy absolute path
          </ContextMenuItem>
          <ContextMenuSeparator />
          <ContextMenuItem onSelect={() => handleOpenExternal(node.path)}>
            Open in external (open)
          </ContextMenuItem>
        </ContextMenuContent>
      </ContextMenu>
    </SidebarMenuItem>
  {/if}
{/each}