cflx 0.6.11

Conflux – a spec-driven parallel coding orchestrator that runs AI agents on git worktrees
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Folder, FolderOpen, File, ChevronRight, ChevronDown } from 'lucide-react';
import { FileBrowseContext, FileTreeEntry, FileContentResponse } from '../api/types';
import { fetchFileTree, fetchFileContent } from '../api/restClient';
import { TerminalPanel } from './TerminalPanel';

interface FileViewPanelProps {
  projectId: string | null;
  context: FileBrowseContext | null;
}

interface TreeNodeProps {
  entry: FileTreeEntry;
  expandedPaths: Set<string>;
  selectedFilePath: string | null;
  onToggleDir: (path: string) => void;
  onSelectFile: (path: string) => void;
  depth: number;
}

function TreeNode({ entry, expandedPaths, selectedFilePath, onToggleDir, onSelectFile, depth }: TreeNodeProps) {
  const isDir = entry.type === 'directory';
  const isExpanded = expandedPaths.has(entry.path);
  const isSelected = selectedFilePath === entry.path;

  const handleClick = useCallback(() => {
    if (isDir) {
      onToggleDir(entry.path);
    } else {
      onSelectFile(entry.path);
    }
  }, [isDir, entry.path, onToggleDir, onSelectFile]);

  return (
    <>
      <button
        onClick={handleClick}
        className={`flex w-full items-center gap-1 py-0.5 text-left text-xs transition-colors hover:bg-[#27272a]/50 ${
          isSelected ? 'bg-[#1e1b4b]/50 text-[#a5b4fc]' : 'text-[#a1a1aa]'
        }`}
        style={{ paddingLeft: `${depth * 12 + 4}px` }}
      >
        {isDir ? (
          <>
            {isExpanded ? (
              <ChevronDown className="size-3 shrink-0 text-[#52525b]" />
            ) : (
              <ChevronRight className="size-3 shrink-0 text-[#52525b]" />
            )}
            {isExpanded ? (
              <FolderOpen className="size-3.5 shrink-0 text-[#6366f1]" />
            ) : (
              <Folder className="size-3.5 shrink-0 text-[#6366f1]" />
            )}
          </>
        ) : (
          <>
            <span className="size-3 shrink-0" />
            <File className="size-3.5 shrink-0 text-[#52525b]" />
          </>
        )}
        <span className="truncate">{entry.name}</span>
      </button>
      {isDir && isExpanded && entry.children && (
        <>
          {entry.children.map((child) => (
            <TreeNode
              key={child.path}
              entry={child}
              expandedPaths={expandedPaths}
              selectedFilePath={selectedFilePath}
              onToggleDir={onToggleDir}
              onSelectFile={onSelectFile}
              depth={depth + 1}
            />
          ))}
        </>
      )}
    </>
  );
}

/** Collect all ancestor directory paths for a given file path */
function getAncestorPaths(filePath: string): string[] {
  const parts = filePath.split('/');
  const paths: string[] = [];
  for (let i = 1; i < parts.length; i++) {
    paths.push(parts.slice(0, i).join('/'));
  }
  return paths;
}

/** Find the first file at a given directory path in the tree */
function findFileInTree(tree: FileTreeEntry[], dirPath: string, fileName: string): string | null {
  for (const entry of tree) {
    if (entry.type === 'file' && entry.path === `${dirPath}/${fileName}`) {
      return entry.path;
    }
    if (entry.type === 'directory' && entry.children) {
      const found = findFileInTree(entry.children, dirPath, fileName);
      if (found) return found;
    }
  }
  return null;
}

export function FileViewPanel({ projectId, context }: FileViewPanelProps) {
  const [tree, setTree] = useState<FileTreeEntry[]>([]);
  const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
  const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
  const [fileContent, setFileContent] = useState<FileContentResponse | null>(null);
  const [isLoadingTree, setIsLoadingTree] = useState(false);
  const [isLoadingContent, setIsLoadingContent] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [terminalExpanded, setTerminalExpanded] = useState(false);

  // Determine root parameter
  const rootParam = useMemo(() => {
    if (!context) return 'base';
    if (context.type === 'change') return 'base';
    if (context.type === 'worktree' && context.worktreeBranch) {
      return `worktree:${context.worktreeBranch}`;
    }
    return 'base';
  }, [context]);

  // Load file tree when project or context changes
  useEffect(() => {
    if (!projectId || !context) {
      setTree([]);
      setSelectedFilePath(null);
      setFileContent(null);
      return;
    }

    let cancelled = false;
    setIsLoadingTree(true);
    setError(null);
    setSelectedFilePath(null);
    setFileContent(null);

    fetchFileTree(projectId, rootParam)
      .then((entries) => {
        if (cancelled) return;
        setTree(entries);
        setIsLoadingTree(false);

        // Auto-expand for change context
        if (context.type === 'change' && context.changeId) {
          const changeDirPath = `openspec/changes/${context.changeId}`;
          const ancestors = getAncestorPaths(changeDirPath + '/proposal.md');
          // Include the change directory itself
          ancestors.push(changeDirPath);
          setExpandedPaths(new Set(ancestors));

          // Auto-select proposal.md
          const proposalPath = `${changeDirPath}/proposal.md`;
          const found = findFileInTree(entries, changeDirPath, 'proposal.md');
          if (found) {
            setSelectedFilePath(proposalPath);
          }
        } else {
          setExpandedPaths(new Set());
        }
      })
      .catch((err) => {
        if (cancelled) return;
        setIsLoadingTree(false);
        setError(err instanceof Error ? err.message : String(err));
      });

    return () => {
      cancelled = true;
    };
  }, [projectId, context, rootParam]);

  // Load file content when selected file changes
  useEffect(() => {
    if (!projectId || !selectedFilePath) {
      setFileContent(null);
      return;
    }

    let cancelled = false;
    setIsLoadingContent(true);

    fetchFileContent(projectId, rootParam, selectedFilePath)
      .then((response) => {
        if (cancelled) return;
        setFileContent(response);
        setIsLoadingContent(false);
      })
      .catch((err) => {
        if (cancelled) return;
        setIsLoadingContent(false);
        setFileContent(null);
        setError(err instanceof Error ? err.message : String(err));
      });

    return () => {
      cancelled = true;
    };
  }, [projectId, rootParam, selectedFilePath]);

  const handleToggleDir = useCallback((path: string) => {
    setExpandedPaths((prev) => {
      const next = new Set(prev);
      if (next.has(path)) {
        next.delete(path);
      } else {
        next.add(path);
      }
      return next;
    });
  }, []);

  const handleSelectFile = useCallback((path: string) => {
    setSelectedFilePath(path);
  }, []);

  // Placeholder when no context
  if (!context) {
    return (
      <div className="flex flex-1 items-center justify-center p-8">
        <p className="text-sm text-[#52525b]">Select a change or worktree to browse files</p>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex flex-1 items-center justify-center p-8">
        <p className="text-sm text-[#ef4444]">{error}</p>
      </div>
    );
  }

  if (isLoadingTree) {
    return (
      <div className="flex flex-1 items-center justify-center p-8">
        <p className="text-sm text-[#52525b]">Loading files...</p>
      </div>
    );
  }

  return (
    <div className="flex flex-1 overflow-hidden">
      {/* File tree (left) */}
      <div className="w-48 shrink-0 overflow-y-auto border-r border-[#27272a] py-1">
        {tree.length === 0 ? (
          <div className="flex items-center justify-center p-4">
            <p className="text-xs text-[#52525b]">No files</p>
          </div>
        ) : (
          tree.map((entry) => (
            <TreeNode
              key={entry.path}
              entry={entry}
              expandedPaths={expandedPaths}
              selectedFilePath={selectedFilePath}
              onToggleDir={handleToggleDir}
              onSelectFile={handleSelectFile}
              depth={0}
            />
          ))
        )}
      </div>

      {/* File content + terminal (right) */}
      <div className="flex flex-1 flex-col overflow-hidden">
        {/* File content area */}
        <div className="flex flex-1 flex-col overflow-hidden">
          {!selectedFilePath && (
            <div className="flex flex-1 items-center justify-center p-8">
              <p className="text-sm text-[#52525b]">Select a file to view its content</p>
            </div>
          )}

          {selectedFilePath && isLoadingContent && (
            <div className="flex flex-1 items-center justify-center p-8">
              <p className="text-sm text-[#52525b]">Loading...</p>
            </div>
          )}

          {selectedFilePath && !isLoadingContent && fileContent && (
            <>
              <div className="flex items-center justify-between border-b border-[#27272a] px-3 py-1.5">
                <span className="truncate font-mono text-xs text-[#71717a]">{fileContent.path}</span>
                <div className="flex items-center gap-2 text-xs text-[#52525b]">
                  {fileContent.truncated && (
                    <span className="text-[#f59e0b]">truncated</span>
                  )}
                  <span>{formatFileSize(fileContent.size)}</span>
                </div>
              </div>
              <div className="flex-1 overflow-auto">
                {fileContent.binary ? (
                  <div className="flex flex-1 items-center justify-center p-8">
                    <p className="text-sm text-[#52525b]">Binary file - cannot display</p>
                  </div>
                ) : fileContent.content !== null ? (
                  <pre className="p-3 font-mono text-xs leading-relaxed text-[#d4d4d8]">
                    <code>
                      {fileContent.content.split('\n').map((line, idx) => (
                        <div key={idx} className="flex">
                          <span className="mr-3 inline-block w-8 shrink-0 select-none text-right text-[#3f3f46]">
                            {idx + 1}
                          </span>
                          <span className="whitespace-pre-wrap break-all">{line}</span>
                        </div>
                      ))}
                    </code>
                  </pre>
                ) : null}
              </div>
            </>
          )}
        </div>

        {/* Terminal panel (below file content) */}
        {projectId && (
          <TerminalPanel
            projectId={projectId}
            root={rootParam}
            isExpanded={terminalExpanded}
            onToggleExpand={() => setTerminalExpanded((prev) => !prev)}
          />
        )}
      </div>
    </div>
  );
}

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}