leankg 0.16.7

Lightweight Knowledge Graph for AI-Assisted Development
Documentation
import { useState, useEffect } from 'react';
import { X, ChevronRight, ArrowUpRight, ArrowDownLeft, FileCode, FolderOpen } from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import type { KGNode, KGEdge } from '../lib/graph-adapter';
import { getFileFunctions, getNodeRelationships } from '../lib/graph-adapter';

interface FileDetailPanelProps {
  selectedFileId: string | null;
  graphData: { nodes: KGNode[]; relationships: KGEdge[] } | null;
  onClose: () => void;
  onFunctionSelect?: (functionId: string) => void;
  onNavigateToFile?: (fileId: string) => void;
}

const FILE_DETAIL_STYLES = `
  @keyframes slide-in-right {
    from { transform: translateX(100%); opacity: 0; }
    to { transform: translateX(0); opacity: 1; }
  }
  .file-detail-panel {
    animation: slide-in-right 200ms ease-out;
  }
  .function-item:hover {
    background: rgba(59, 130, 246, 0.1);
  }
  .relationship-item:hover {
    background: rgba(124, 58, 237, 0.1);
  }
`;

const codeTheme = {
  ...vscDarkPlus,
  'pre[class*="language-"]': {
    ...vscDarkPlus['pre[class*="language-"]'],
    background: '#0a0a10',
    margin: 0,
    padding: '12px 0',
    fontSize: '12px',
    lineHeight: '1.6',
  },
  'code[class*="language-"]': {
    ...vscDarkPlus['code[class*="language-"]'],
    background: 'transparent',
    fontFamily: '"JetBrains Mono", "Fira Code", monospace',
  },
};

export const FileDetailPanel = ({
  selectedFileId,
  graphData,
  onClose,
  onFunctionSelect,
  onNavigateToFile,
}: FileDetailPanelProps) => {
  const [activeTab, setActiveTab] = useState<'functions' | 'relationships' | 'content'>('functions');
  const [fileContent, setFileContent] = useState<string | null>(null);
  const [contentLoading, setContentLoading] = useState(false);

  if (!selectedFileId || !graphData) return null;

  const fileNode = graphData.nodes.find(n => n.id === selectedFileId);
  const fileProps = fileNode?.properties as Record<string, unknown> | undefined;
  const filePath = (fileProps?.filePath || fileProps?.file_path) as string | undefined;

  const functions = getFileFunctions(graphData.nodes, graphData.relationships, selectedFileId);
  const { callsFrom, callsTo, imports } = getNodeRelationships(graphData.nodes, graphData.relationships, selectedFileId);

  const uniqueCallTargets = [...new Set(callsFrom.map(c => c.target_id || c.targetId).filter((id): id is string => Boolean(id)))];
  const uniqueCallers = [...new Set(callsTo.map(c => c.source_id || c.sourceId).filter((id): id is string => Boolean(id)))];

  useEffect(() => {
    if (!filePath) {
      setFileContent(null);
      return;
    }
    let cancelled = false;
    setContentLoading(true);
    fetch(`/api/file?path=${encodeURIComponent(filePath)}`)
      .then(res => res.json())
      .then(response => {
        if (!cancelled) {
          if (response.success && response.data?.content) {
            setFileContent(response.data.content);
          } else {
            setFileContent(`/* ${response.error || 'Failed to read file'}: ${filePath} */`);
          }
        }
      })
      .catch((err) => {
        if (!cancelled) setFileContent(`/* Error fetching file: ${err.message} */`);
      })
      .finally(() => {
        if (!cancelled) setContentLoading(false);
      });
    return () => { cancelled = true; };
  }, [filePath]);

  return (
    <>
      <style>{FILE_DETAIL_STYLES}</style>
      <div className="file-detail-panel absolute right-0 top-0 bottom-0 w-[400px] bg-[#0A0F24]/95 backdrop-blur-md shadow-2xl border-l border-slate-700/50 flex flex-col z-30">
        {/* Header */}
        <div className="flex items-center justify-between border-b border-slate-700/50 px-4 py-3 bg-gradient-to-r from-blue-500/10 to-purple-500/5">
          <div className="flex items-center gap-2 min-w-0 flex-1">
            <FolderOpen className="h-4 w-4 text-blue-400 shrink-0" />
            <span className="truncate font-mono text-xs text-slate-200">
              {filePath?.split('/').pop() || fileNode?.label || selectedFileId.split('::').pop()}
            </span>
          </div>
          <button
            onClick={onClose}
            className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-200 ml-2 shrink-0"
          >
            <X className="h-4 w-4" />
          </button>
        </div>

        {/* File path */}
        {filePath && (
          <div className="px-4 py-2 border-b border-slate-800 bg-slate-900/30">
            <p className="text-[10px] font-mono text-slate-500 truncate" title={filePath}>
              {filePath}
            </p>
          </div>
        )}

        {/* Tabs */}
        <div className="flex border-b border-slate-700/50">
          <button
            onClick={() => setActiveTab('functions')}
            className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
              activeTab === 'functions'
                ? 'text-blue-400 border-b-2 border-blue-400 bg-blue-500/5'
                : 'text-slate-400 hover:text-slate-200'
            }`}
          >
            Functions ({functions.length})
          </button>
          <button
            onClick={() => setActiveTab('relationships')}
            className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
              activeTab === 'relationships'
                ? 'text-purple-400 border-b-2 border-purple-400 bg-purple-500/5'
                : 'text-slate-400 hover:text-slate-200'
            }`}
          >
            Relationships ({callsFrom.length + callsTo.length + imports.length})
          </button>
          <button
            onClick={() => setActiveTab('content')}
            className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
              activeTab === 'content'
                ? 'text-emerald-400 border-b-2 border-emerald-400 bg-emerald-500/5'
                : 'text-slate-400 hover:text-slate-200'
            }`}
          >
            Content
          </button>
        </div>

        {/* Content */}
        <div className="flex-1 overflow-y-auto">
          {activeTab === 'functions' ? (
            <div className="p-2">
              {functions.length === 0 ? (
                <p className="text-xs text-slate-500 text-center py-8">No functions found in this file</p>
              ) : (
                <div className="flex flex-col gap-1">
                  {functions.map((func) => {
                    const funcProps = func.properties as Record<string, unknown> | undefined;
                    const funcPath = (funcProps?.filePath || funcProps?.file_path) as string | undefined;
                    const startLine = ((funcProps?.startLine || funcProps?.start_line) as number | undefined) ?? 0;
                    const endLine = ((funcProps?.endLine || funcProps?.end_line) as number | undefined) ?? startLine;

                    return (
                      <button
                        key={func.id}
                        onClick={() => onFunctionSelect?.(func.id)}
                        className="function-item w-full text-left px-3 py-2 rounded-lg border border-transparent hover:border-blue-500/30 transition-colors"
                      >
                        <div className="flex items-center gap-2">
                          <FileCode className="h-3.5 w-3.5 text-emerald-400 shrink-0" />
                          <span className="text-xs font-mono text-slate-200 truncate">
                            {func.label || func.id.split('::').pop()}
                          </span>
                        </div>
                        {funcPath && (
                          <p className="text-[10px] text-slate-500 mt-1 pl-5 font-mono">
                            L{startLine}-{endLine}
                          </p>
                        )}
                      </button>
                    );
                  })}
                </div>
              )}
            </div>
          ) : activeTab === 'content' ? (
            <div className="h-full overflow-auto bg-[#0a0a10]">
              {contentLoading ? (
                <div className="flex items-center justify-center h-full text-slate-400 text-sm">
                  Loading content...
                </div>
              ) : fileContent ? (
                <SyntaxHighlighter
                  language={(filePath?.split('.').pop()?.toLowerCase() || 'text') as any}
                  style={codeTheme as any}
                  showLineNumbers
                  startingLineNumber={1}
                  lineNumberStyle={{
                    minWidth: '3em',
                    paddingRight: '1em',
                    color: '#5a5a70',
                    textAlign: 'right',
                    userSelect: 'none',
                  }}
                  wrapLines
                >
                  {fileContent}
                </SyntaxHighlighter>
              ) : (
                <p className="text-xs text-slate-500 text-center py-8">No content available</p>
              )}
            </div>
          ) : (
            <div className="p-2">
              {/* Calls from this file */}
              {uniqueCallTargets.length > 0 && (
                <div className="mb-4">
                  <h4 className="flex items-center gap-1.5 px-2 py-1.5 text-[10px] font-semibold text-slate-400 uppercase tracking-wider">
                    <ArrowUpRight className="h-3 w-3 text-purple-400" />
                    Calls ({uniqueCallTargets.length})
                  </h4>
                  <div className="flex flex-col gap-1">
                    {uniqueCallTargets.map((targetId) => {
                      const targetNode = graphData.nodes.find(n => n.id === targetId);
                      return (
                        <button
                          key={targetId}
                          onClick={() => {
                            if (targetNode?.properties?.filePath) {
                              onNavigateToFile?.(targetId);
                            }
                          }}
                          className="relationship-item w-full text-left px-3 py-2 rounded-lg border border-transparent hover:border-purple-500/30 transition-colors"
                        >
                          <span className="text-xs font-mono text-slate-300 truncate block">
                            {targetNode?.label || targetId.split('::').pop() || targetId}
                          </span>
                        </button>
                      );
                    })}
                  </div>
                </div>
              )}

              {/* Callers of this file */}
              {uniqueCallers.length > 0 && (
                <div className="mb-4">
                  <h4 className="flex items-center gap-1.5 px-2 py-1.5 text-[10px] font-semibold text-slate-400 uppercase tracking-wider">
                    <ArrowDownLeft className="h-3 w-3 text-amber-400" />
                    Called By ({uniqueCallers.length})
                  </h4>
                  <div className="flex flex-col gap-1">
                    {uniqueCallers.map((callerId) => {
                      const callerNode = graphData.nodes.find(n => n.id === callerId);
                      return (
                        <button
                          key={callerId}
                          onClick={() => {
                            if (callerNode?.properties?.filePath) {
                              onNavigateToFile?.(callerId);
                            }
                          }}
                          className="relationship-item w-full text-left px-3 py-2 rounded-lg border border-transparent hover:border-amber-500/30 transition-colors"
                        >
                          <span className="text-xs font-mono text-slate-300 truncate block">
                            {callerNode?.label || callerId.split('::').pop() || callerId}
                          </span>
                        </button>
                      );
                    })}
                  </div>
                </div>
              )}

              {/* Imports */}
              {imports.length > 0 && (
                <div className="mb-4">
                  <h4 className="flex items-center gap-1.5 px-2 py-1.5 text-[10px] font-semibold text-slate-400 uppercase tracking-wider">
                    <ChevronRight className="h-3 w-3 text-blue-400" />
                    Imports ({imports.length})
                  </h4>
                  <div className="flex flex-col gap-1">
                    {[...new Set(imports.map(i => i.target_id || i.targetId).filter((id): id is string => Boolean(id)))].map((importId) => {
                      const importNode = graphData.nodes.find(n => n.id === importId);
                      return (
                        <button
                          key={importId}
                          onClick={() => {
                            if (importNode?.properties?.filePath) {
                              onNavigateToFile?.(importId);
                            }
                          }}
                          className="relationship-item w-full text-left px-3 py-2 rounded-lg border border-transparent hover:border-blue-500/30 transition-colors"
                        >
                          <span className="text-xs font-mono text-slate-300 truncate block">
                            {importNode?.label || importId?.split('::').pop() || importId}
                          </span>
                        </button>
                      );
                    })}
                  </div>
                </div>
              )}

              {uniqueCallTargets.length === 0 && uniqueCallers.length === 0 && imports.length === 0 && (
                <p className="text-xs text-slate-500 text-center py-8">No relationships found</p>
              )}
            </div>
          )}
        </div>
      </div>
    </>
  );
};