code-graph-cli 3.0.3

Code intelligence engine for TypeScript/JavaScript/Rust/Python/Go — query the dependency graph instead of reading source files.
<script lang="ts">
  import type { GraphNode } from '../types';

  interface Props {
    graphNodes: GraphNode[];
    onFileSelect: (path: string) => void;
    selectedFile: string | null;
  }

  let { graphNodes, onFileSelect, selectedFile }: Props = $props();

  // Tree node representation
  interface TreeNode {
    name: string;
    path: string; // full path to this node
    isFile: boolean;
    children: Map<string, TreeNode>;
    expanded: boolean;
  }

  function makeTreeNode(name: string, path: string, isFile: boolean): TreeNode {
    return { name, path, isFile, children: new Map(), expanded: true };
  }

  // Build a tree from flat file paths
  function buildTree(nodes: GraphNode[]): Map<string, TreeNode> {
    const root = new Map<string, TreeNode>();

    for (const node of nodes) {
      const path = node.attributes.path;
      if (!path) continue;

      // Normalize separators
      const parts = path.replace(/\\/g, '/').split('/').filter(Boolean);
      if (parts.length === 0) continue;

      let currentMap = root;
      let builtPath = '';

      for (let i = 0; i < parts.length; i++) {
        const part = parts[i];
        builtPath = builtPath ? `${builtPath}/${part}` : part;
        const isLast = i === parts.length - 1;

        if (!currentMap.has(part)) {
          currentMap.set(part, makeTreeNode(part, builtPath, isLast));
        } else if (isLast) {
          // Mark existing node as a file
          const existing = currentMap.get(part)!;
          existing.isFile = true;
        }

        const child = currentMap.get(part)!;
        currentMap = child.children;
      }
    }

    return root;
  }

  // Collect unique file paths from graph nodes (filter duplicates for file-granularity)
  function collectFilePaths(nodes: GraphNode[]): GraphNode[] {
    const seen = new Set<string>();
    const result: GraphNode[] = [];
    for (const node of nodes) {
      const path = node.attributes.path;
      if (path && !seen.has(path)) {
        seen.add(path);
        result.push(node);
      }
    }
    return result;
  }

  // Language icon by extension
  function getLangIcon(filename: string): string {
    const ext = filename.split('.').pop()?.toLowerCase() ?? '';
    const icons: Record<string, string> = {
      ts: 'TS',
      tsx: 'TSX',
      js: 'JS',
      jsx: 'JSX',
      rs: 'RS',
      py: 'PY',
      go: 'GO',
      json: 'JS',
      toml: 'TM',
      yaml: 'YM',
      yml: 'YM',
      css: 'CS',
      html: 'HT',
      svelte: 'SV',
      md: 'MD',
    };
    return icons[ext] ?? 'FI';
  }

  // Get color for language
  function getLangColor(filename: string): string {
    const ext = filename.split('.').pop()?.toLowerCase() ?? '';
    const colors: Record<string, string> = {
      ts: '#3B82F6',
      tsx: '#3B82F6',
      js: '#F59E0B',
      jsx: '#F59E0B',
      rs: '#F97316',
      py: '#10B981',
      go: '#06B6D4',
      json: '#6B7280',
      toml: '#6B7280',
      yaml: '#6B7280',
      yml: '#6B7280',
      css: '#EC4899',
      html: '#EF4444',
      svelte: '#EC4899',
    };
    return colors[ext] ?? '#6B7280';
  }

  // Toggle folder expand/collapse
  const expandState = new Map<string, boolean>();

  function isExpanded(path: string): boolean {
    if (!expandState.has(path)) return true; // default expanded
    return expandState.get(path)!;
  }

  function toggleFolder(path: string) {
    expandState.set(path, !isExpanded(path));
    // Force Svelte to re-render
    treeVersion++;
  }

  let treeVersion = $state(0);

  // Build tree reactively
  let tree = $derived.by(() => {
    void treeVersion; // track for forced re-renders
    const filePaths = collectFilePaths(graphNodes);
    return buildTree(filePaths);
  });

  // Render tree nodes recursively — returns flat array of render items
  interface RenderItem {
    type: 'folder' | 'file';
    name: string;
    path: string;
    depth: number;
    hasChildren: boolean;
    isExpanded: boolean;
  }

  function flattenTree(map: Map<string, TreeNode>, depth: number): RenderItem[] {
    const items: RenderItem[] = [];
    // Sort: folders first, then files, alphabetically
    const sorted = Array.from(map.values()).sort((a, b) => {
      if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
      return a.name.localeCompare(b.name);
    });

    for (const node of sorted) {
      const hasChildren = node.children.size > 0;
      const expanded = isExpanded(node.path);

      if (!node.isFile || hasChildren) {
        // It's a folder (or folder+file hybrid)
        items.push({
          type: 'folder',
          name: node.name,
          path: node.path,
          depth,
          hasChildren,
          isExpanded: expanded,
        });
        if (expanded && hasChildren) {
          items.push(...flattenTree(node.children, depth + 1));
        }
      } else {
        items.push({
          type: 'file',
          name: node.name,
          path: node.path,
          depth,
          hasChildren: false,
          isExpanded: false,
        });
      }
    }
    return items;
  }

  let renderItems = $derived.by(() => {
    void treeVersion;
    return flattenTree(tree, 0);
  });
</script>

<div class="file-tree">
  <div class="tree-header">
    <span class="tree-title">Files</span>
    <span class="tree-count">{collectFilePaths(graphNodes).length}</span>
  </div>

  <div class="tree-body">
    {#each renderItems as item (item.path + item.type)}
      {#if item.type === 'folder'}
        <button
          class="tree-item tree-folder"
          style="padding-left: {8 + item.depth * 14}px;"
          onclick={() => toggleFolder(item.path)}
          aria-expanded={item.isExpanded}
        >
          <span class="tree-chevron {item.isExpanded ? 'chevron-open' : ''}">
            <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
              <path d="M3 2l4 3-4 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
          </span>
          <span class="folder-icon">
            <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
              {#if item.isExpanded}
                <path d="M1 4a1 1 0 011-1h2.5L6 4.5H11a1 1 0 011 1V10a1 1 0 01-1 1H2a1 1 0 01-1-1V4z" fill="rgba(100,160,255,0.3)" stroke="#5B9BD5" stroke-width="1"/>
              {:else}
                <path d="M1 3.5a1 1 0 011-1h2.5L6 4H11a1 1 0 011 1v5a1 1 0 01-1 1H2a1 1 0 01-1-1V3.5z" fill="rgba(100,160,255,0.2)" stroke="#5B9BD5" stroke-width="1"/>
              {/if}
            </svg>
          </span>
          <span class="item-name">{item.name}</span>
        </button>
      {:else}
        <button
          class="tree-item tree-file {selectedFile === item.path ? 'tree-item-selected' : ''}"
          style="padding-left: {8 + item.depth * 14 + 20}px;"
          onclick={() => onFileSelect(item.path)}
        >
          <span
            class="file-badge"
            style="background: {getLangColor(item.name)}22; color: {getLangColor(item.name)}; border-color: {getLangColor(item.name)}44;"
          >
            {getLangIcon(item.name)}
          </span>
          <span class="item-name">{item.name}</span>
        </button>
      {/if}
    {/each}

    {#if renderItems.length === 0}
      <div class="tree-empty">No files in graph</div>
    {/if}
  </div>
</div>

<style>
  .file-tree {
    display: flex;
    flex-direction: column;
    height: 100%;
    overflow: hidden;
  }

  .tree-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 8px 12px 6px;
    flex-shrink: 0;
  }

  .tree-title {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: var(--color-text-muted);
  }

  .tree-count {
    font-size: 10px;
    color: var(--color-text-muted);
    background: rgba(255, 255, 255, 0.07);
    padding: 1px 5px;
    border-radius: 10px;
  }

  .tree-body {
    flex: 1;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
  }

  .tree-body::-webkit-scrollbar {
    width: 4px;
  }

  .tree-body::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.1);
    border-radius: 2px;
  }

  .tree-item {
    display: flex;
    align-items: center;
    gap: 5px;
    width: 100%;
    padding-top: 3px;
    padding-bottom: 3px;
    padding-right: 8px;
    background: transparent;
    border: none;
    cursor: pointer;
    text-align: left;
    color: var(--color-text-muted);
    font-size: 12px;
    transition: background 80ms ease, color 80ms ease;
    border-radius: 0;
    white-space: nowrap;
    overflow: hidden;
  }

  .tree-item:hover {
    background: rgba(255, 255, 255, 0.05);
    color: var(--color-text-primary);
  }

  .tree-item-selected {
    background: rgba(59, 130, 246, 0.12) !important;
    color: var(--color-text-primary) !important;
  }

  .tree-chevron {
    display: flex;
    align-items: center;
    flex-shrink: 0;
    color: var(--color-text-muted);
    transition: transform 150ms ease;
    width: 10px;
  }

  .chevron-open {
    transform: rotate(90deg);
  }

  .folder-icon {
    display: flex;
    align-items: center;
    flex-shrink: 0;
  }

  .file-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 8px;
    font-weight: 700;
    width: 20px;
    height: 14px;
    border-radius: 2px;
    border: 1px solid;
    flex-shrink: 0;
    letter-spacing: 0.02em;
    font-family: 'JetBrains Mono', monospace;
  }

  .item-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
  }

  .tree-empty {
    padding: 16px 12px;
    color: var(--color-text-muted);
    font-size: 11px;
    text-align: center;
  }
</style>