leankg 0.19.31

Lightweight Knowledge Graph for AI-Assisted Development
import { useEffect, useMemo, useState } from 'react';
import { fetchFileSnippet } from '../lib/api';
import type { GraphNode } from '../lib/types';

/**
 * FR-E03 + FR-E30 — node detail panel: qualified name, element type, file
 * path, relationship counts by type, and a code context snippet fetched from
 * /api/file (falls back gracefully when the file is not readable).
 */
export interface RelationshipCounts {
  total: number;
  byType: Record<string, number>;
}

export function countRelationships(
  relationships: { sourceId: string; targetId: string; type: string }[],
  nodeId: string,
): RelationshipCounts {
  const byType: Record<string, number> = {};
  let total = 0;
  for (const r of relationships) {
    if (r.sourceId === nodeId || r.targetId === nodeId) {
      total += 1;
      byType[r.type] = (byType[r.type] ?? 0) + 1;
    }
  }
  return { total, byType };
}

export default function DetailPanel({
  node,
  relationships,
  onClose,
}: {
  node: GraphNode | null;
  relationships: { sourceId: string; targetId: string; type: string }[];
  onClose: () => void;
}) {
  const [snippet, setSnippet] = useState<string | null>(null);
  const [snippetState, setSnippetState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');

  const counts = useMemo(
    () => (node ? countRelationships(relationships, node.id) : { total: 0, byType: {} }),
    [node, relationships],
  );

  useEffect(() => {
    setSnippet(null);
    setSnippetState('idle');
    if (!node || !node.properties.filePath) return;
    let cancelled = false;
    setSnippetState('loading');
    fetchFileSnippet(node.properties.filePath)
      .then((text) => {
        if (!cancelled) {
          setSnippet(text);
          setSnippetState('ready');
        }
      })
      .catch(() => {
        if (!cancelled) setSnippetState('error');
      });
    return () => {
      cancelled = true;
    };
  }, [node]);

  if (!node) {
    return (
      <aside className="detail-panel" data-testid="detail-panel">
        <div className="detail-empty">
          Select a node to inspect element details.
        </div>
      </aside>
    );
  }
  return (
    <aside className="detail-panel" data-testid="detail-panel">
      <div className="detail-header">
        <span className="detail-title" data-testid="detail-title">
          {node.properties.name || node.id}
        </span>
        <button className="detail-close" onClick={onClose} aria-label="Close detail">
          ×
        </button>
      </div>
      <dl className="detail-body">
        <dt>Element</dt>
        <dd>{node.properties.elementType || '—'}</dd>
        <dt>File</dt>
        <dd className="mono">{node.properties.filePath || '—'}</dd>
        <dt>Connections</dt>
        <dd>{counts.total}</dd>
        <dt>ID</dt>
        <dd className="mono">{node.id}</dd>
      </dl>
      {Object.keys(counts.byType).length > 0 && (
        <div className="detail-rels">
          <h4>By type</h4>
          <ul>
            {Object.entries(counts.byType)
              .sort((a, b) => b[1] - a[1])
              .map(([type, count]) => (
                <li key={type}>
                  <span className="mono">{type}</span>
                  <span className="breakdown-count">{count}</span>
                </li>
              ))}
          </ul>
        </div>
      )}
      {snippetState === 'loading' && <p className="panel-muted">Loading source…</p>}
      {snippetState === 'ready' && snippet && (
        <pre className="detail-snippet">{snippet}</pre>
      )}
      {snippetState === 'error' && (
        <p className="panel-muted">Source not readable.</p>
      )}
    </aside>
  );
}