import { Box, Text, useStdout } from "ink"; import { StatusBar } from "./StatusBar.tsx"; import { FilterBar } from "./FilterBar.tsx"; import { ProjectList } from "./ProjectList.tsx"; import { HelpOverlay } from "./HelpOverlay.tsx"; import { ConfirmDialog } from "./ConfirmDialog.tsx"; import { CloneDialog } from "./CloneDialog.tsx"; import { RepoDetailModal } from "./RepoDetailModal.tsx"; import { CommandPalette } from "./CommandPalette.tsx"; import { AddDirectoryDialog } from "./AddDirectoryDialog.tsx"; import { FilterOptionsOverlay } from "./FilterOptionsOverlay.tsx"; import { useStore, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx"; import { useConfirmDialogActions } from "../hooks/useConfirmDialogActions.ts"; import { startAction, endAction, setMessage } from "../state/actions.ts"; import { executeCommand } from "../operations/commands.ts"; import { batchPull, batchPush, batchFetch } from "../operations/batch.ts"; import { errorToString } from "../utils/errors.ts"; import { openInEditor, openInBrowser } from "../services/editor.ts"; import type { Project } from "../types/index.ts"; import { UI } from "../constants.ts"; import type { GitforestConfig, CommandConfig, ConfirmDialogState, UnifiedRepo } from "../types/index.ts"; interface LayoutProps { config: GitforestConfig; onRefresh: (options?: { forceRefresh?: boolean }) => Promise; onClone?: (repos: UnifiedRepo[], targetDir: string, useSSH: boolean) => Promise; onAddDirectory?: (path: string, maxDepth: number, label: string) => Promise<{ success: boolean; error?: string }>; } export function Layout({ config, onRefresh, onClone, onAddDirectory }: LayoutProps) { const { state, dispatch } = useStore(); const { mode, modal } = state; const confirmDialog = modal.kind === "confirm" ? modal.data : null; const cloneDialog = modal.kind === "clone" ? modal.data : null; const detailModal = modal.kind === "detail" ? modal.data : null; const addDirectoryDialog = modal.kind === "add-directory" ? modal.data : null; const filteredRepos = useFilteredUnifiedRepos(); const selectedRepos = useSelectedUnifiedRepos(); const { stdout } = useStdout(); const { handleConfirm, handleCancel } = useConfirmDialogActions({ config, onRefresh, }); // Calculate available height const terminalHeight = stdout?.rows ?? UI.DEFAULT_TERMINAL_HEIGHT; const listHeight = terminalHeight - UI.LAYOUT_OVERHEAD; // Calculate stats for status bar const dirtyCount = filteredRepos.filter((r) => r.local?.status?.isDirty).length; const unpushedCount = filteredRepos.filter((r) => r.local?.status?.isAhead).length; const localOnlyCount = filteredRepos.filter((r) => r.source === "local").length; const githubOnlyCount = filteredRepos.filter((r) => r.source === "github").length; const syncedCount = filteredRepos.filter((r) => r.source === "both").length; // Clone dialog handlers const handleCloneConfirm = async (targetDir: string, useSSH: boolean) => { const repos = cloneDialog?.repos; dispatch({ type: "CLOSE_MODAL" }); if (onClone && repos) { await onClone(repos, targetDir, useSSH); } }; const handleCloneCancel = () => { dispatch({ type: "CLOSE_MODAL" }); }; const handleCloneSelectDir = (index: number) => { dispatch({ type: "UPDATE_MODAL", payload: { kind: "clone", data: { selectedDirIndex: index } } }); }; const handleCloneToggleSSH = () => { if (cloneDialog) { dispatch({ type: "UPDATE_MODAL", payload: { kind: "clone", data: { useSSH: !cloneDialog.useSSH } } }); } }; // Show help overlay if (mode === "help") { return ( ); } // Show command palette if (mode === "command-palette") { return ( dispatch({ type: "SET_MODE", payload: "normal" })} /> ); } // Show confirm dialog if (confirmDialog) { return ( ); } // Show clone dialog if (cloneDialog) { return ( ); } // Show detail modal if (detailModal) { const handleDetailClose = () => { dispatch({ type: "CLOSE_MODAL" }); }; const handleDetailAction = async (action: string) => { const repo = detailModal.repo; if (!repo) return; switch (action) { case "clone": { dispatch({ type: "OPEN_MODAL", payload: { kind: "clone", data: { repos: [repo], directories: config.directories, selectedDirIndex: 0, useSSH: true, }, }, }); return; } case "primary": { if (repo.localPath) { // Fallthrough to editor logic await handleDetailAction("editor"); } else if (repo.github?.htmlUrl) { await handleDetailAction("browser"); } return; } case "push": if (repo.local && repo.local.status?.isAhead) { dispatch(startAction("Pushing")); const result = await batchPush([repo.local satisfies Project]); dispatch(endAction()); dispatch(setMessage(result.failed > 0 ? `Push failed: ${result.results[0]?.error ?? "unknown"}` : `Pushed ${repo.name}`)); await onRefresh({ forceRefresh: true }); } break; case "pull": if (repo.local && repo.local.status?.hasRemote) { dispatch(startAction("Pulling")); const result = await batchPull([repo.local satisfies Project]); dispatch(endAction()); dispatch(setMessage(result.failed > 0 ? `Pull failed: ${result.results[0]?.error ?? "unknown"}` : `Pulled ${repo.name}`)); await onRefresh({ forceRefresh: true }); } break; case "fetch": if (repo.local && repo.local.status?.hasRemote) { dispatch(startAction("Fetching")); const result = await batchFetch([repo.local satisfies Project]); dispatch(endAction()); dispatch(setMessage(result.failed > 0 ? `Fetch failed: ${result.results[0]?.error ?? "unknown"}` : `Fetched ${repo.name}`)); await onRefresh({ forceRefresh: true }); } break; case "browser": if (repo.github?.htmlUrl) { const browserResult = await openInBrowser(repo.github.htmlUrl); if (!browserResult.success) { dispatch(setMessage(`Failed to open browser: ${browserResult.error}`)); } } else { dispatch(setMessage("No GitHub URL available for this repository")); } break; case "editor": if (repo.localPath) { const editorResult = await openInEditor(repo.localPath, config); if (!editorResult.success) { dispatch(setMessage(`Failed to open editor: ${editorResult.error}`)); } } else { dispatch(setMessage("No local path available for this repository")); } break; } }; const handleDetailScroll = (offset: number) => { dispatch({ type: "UPDATE_MODAL", payload: { kind: "detail", data: { readmeScrollOffset: offset } } }); }; const handleDetailCommand = async (command: CommandConfig) => { const repo = detailModal.repo; if (!repo) return; const projectPath = repo.localPath || repo.local?.path; if (!projectPath) { dispatch(setMessage("No local path for this repo")); return; } if (command.confirm) { const dialogState: ConfirmDialogState = { operation: "command", title: `Run "${command.name}"`, message: `Run \`${command.command}\` on:`, items: [repo.name], projectPaths: [projectPath], showVisibilityToggle: false, command, }; // CLOSE_MODAL then OPEN_MODAL (the detail modal is replaced by the // confirm modal — one modal slot, sequential dispatches). dispatch({ type: "OPEN_MODAL", payload: { kind: "confirm", data: dialogState } }); return; } dispatch(startAction(`Running: ${command.name}`)); try { const result = await executeCommand(command, projectPath); dispatch(endAction()); if (result.success) { const shortOutput = result.output && result.output.length > UI.OUTPUT_TRUNCATION_LENGTH ? result.output.slice(0, UI.OUTPUT_TRUNCATION_LENGTH) + "..." : result.output || "Done"; dispatch(setMessage(`${command.name}: ${shortOutput}`)); } else { dispatch(setMessage(`${command.name} failed: ${result.error}`)); } } catch (error) { dispatch(endAction()); dispatch(setMessage(`${command.name} failed: ${errorToString(error)}`)); } }; return ( ); } // Show add directory dialog if (addDirectoryDialog) { const handleAddDirConfirm = async (path: string, maxDepth: number, label: string) => { if (onAddDirectory) { const result = await onAddDirectory(path, maxDepth, label); if (result.success) { dispatch({ type: "CLOSE_MODAL" }); dispatch(setMessage(`Added directory: ${path}`)); await onRefresh({ forceRefresh: true }); } else { // The hook owns the rich flow state, so we report failures through // the message bar rather than re-injecting an error into modal data. dispatch(setMessage(`Failed to add directory: ${result.error ?? "unknown error"}`)); } } }; return ( dispatch({ type: "CLOSE_MODAL" })} /> ); } // Show filter options overlay if (mode === "filter-options") { return ( ); } return ( {/* Header */} gitforest - Git Repository Manager {/* Filter bar */} {/* Project list */} {/* Status bar */} ); }