import React from "react"; import { useInput, useApp } from "ink"; import type { Key } from "ink"; import { useStore, useFilteredProjects, useFilteredUnifiedRepos, useSelectedUnifiedRepos } from "../state/store.tsx"; import { moveCursor, toggleSelection, selectAll, deselectAll, setMode, setFilter, cycleSort, setSort, setMessage, startAction, endAction, showAddDirectoryDialog, } from "../state/actions.ts"; import { SORT_FIELDS } from "../state/reducer.ts"; import { batchPull as defaultBatchPull, batchPush as defaultBatchPush, batchFetch as defaultBatchFetch, runBatchGitOp } from "../operations/batch.ts"; import { bunGitService } from "../services/git.ts"; import { executeCommand as defaultExecuteCommand, findCommandByKey as defaultFindCommandByKey } from "../operations/commands.ts"; import { runSequentialBatch } from "../operations/sequential.ts"; import { fetchReadme } from "../services/readme.ts"; import { UI } from "../constants.ts"; import type { GitforestConfig, ConfirmDialogState, QuickFilter, ViewMode, DetailModalState, UnifiedRepo, UnifiedAppAction, CommandConfig, Project, BatchResult, OperationResult } from "../types/index.ts"; /** * Dependencies that can be injected for testing */ export interface KeyBindingDeps { batchPull: (projects: Project[], options?: { concurrency?: number; onProgress?: (current: number, total: number) => void }) => Promise; batchPush: (projects: Project[], options?: { concurrency?: number; onProgress?: (current: number, total: number) => void }) => Promise; batchFetch: (projects: Project[], options?: { concurrency?: number; onProgress?: (current: number, total: number) => void }) => Promise; initGit: (path: string) => Promise; executeCommand: (command: CommandConfig, projectPath: string) => Promise<{ success: boolean; output?: string; error?: string }>; findCommandByKey: (commands: CommandConfig[], key: string) => CommandConfig | undefined; } interface UseKeyBindingsOptions { config: GitforestConfig; onRefresh: (options?: { forceRefresh?: boolean }) => Promise; deps?: Partial; } /** * Handle input in filter mode. * Returns true if input was handled. */ function handleFilterMode( key: Key, dispatch: React.Dispatch, ): boolean { if (key.escape) { dispatch(setMode("normal")); dispatch(setFilter("")); return true; } if (key.return) { dispatch(setMode("normal")); return true; } return true; // Let TextInput handle other keys } /** * Handle input in detail modal mode. * Returns true if input was fully handled, false if it should fall through to normal mode. */ function handleDetailMode(): boolean { // RepoDetailModal owns all detail-mode keys. The global handler only blocks // normal-list shortcuts from also running against the current selection. return true; } /** * Handle input in filter options mode. * Any key closes the overlay. */ function handleFilterOptionsMode( dispatch: React.Dispatch, ): void { dispatch(setMode("normal")); } /** * Handle input in help mode. * Any key closes the help overlay. */ function handleHelpMode( dispatch: React.Dispatch, ): void { dispatch(setMode("normal")); } /** * Handle input in clone dialog mode. * Returns true if input was handled. */ function handleCloneMode( key: Key, dispatch: React.Dispatch, ): boolean { if (key.escape) { dispatch({ type: "CLOSE_MODAL" }); return true; } // Let CloneDialog handle other keys return true; } /** * Handle input in command palette mode. * Returns true if input was handled. */ async function handleCommandPaletteMode( input: string, key: Key, config: GitforestConfig, dispatch: React.Dispatch, selectedUnifiedRepos: UnifiedRepo[], findCommandByKey: (commands: CommandConfig[], key: string) => CommandConfig | undefined, handleCommandExecution: (command: CommandConfig, repos: UnifiedRepo[]) => Promise, ): Promise { if (key.escape) { dispatch(setMode("normal")); return true; } const command = findCommandByKey(config.commands, input); if (command) { const openedConfirmDialog = await handleCommandExecution(command, selectedUnifiedRepos); if (!openedConfirmDialog) { dispatch(setMode("normal")); } return true; } return true; } export interface OpenConfirmDialogOptions { operation: ConfirmDialogState["operation"]; title: string; message: string; predicate: (p: Project) => boolean; selection: Project[]; showVisibilityToggle: boolean; emptyMessage: string; itemLabel?: (p: Project) => string; command?: CommandConfig; dispatch: React.Dispatch; } /** * Filter the selection by the predicate, then either dispatch OPEN_MODAL * with kind="confirm" or emit a setMessage with `emptyMessage` if nothing * matches. */ export function openConfirmDialog(options: OpenConfirmDialogOptions): void { const matches = options.selection.filter(options.predicate); if (matches.length === 0) { options.dispatch(setMessage(options.emptyMessage)); return; } const label = options.itemLabel ?? ((p: Project) => p.name); const data: ConfirmDialogState = { operation: options.operation, title: options.title, message: options.message, items: matches.map(label), projectPaths: matches.map((p) => p.path), showVisibilityToggle: options.showVisibilityToggle, ...(options.command ? { command: options.command } : {}), }; options.dispatch({ type: "OPEN_MODAL", payload: { kind: "confirm", data } }); } export function useKeyBindings({ config, onRefresh, deps }: UseKeyBindingsOptions) { const { state, dispatch } = useStore(); const { exit } = useApp(); const filteredProjects = useFilteredProjects(); const filteredUnifiedRepos = useFilteredUnifiedRepos(); const selectedUnifiedRepos = useSelectedUnifiedRepos(); // Use injected dependencies or defaults const batchPull = deps?.batchPull ?? defaultBatchPull; const batchPush = deps?.batchPush ?? defaultBatchPush; const batchFetch = deps?.batchFetch ?? defaultBatchFetch; const initGit = deps?.initGit ?? bunGitService.init.bind(bunGitService); const executeCommand = deps?.executeCommand ?? defaultExecuteCommand; const findCommandByKey = deps?.findCommandByKey ?? defaultFindCommandByKey; async function runBatchGitOpSafely( options: Parameters[0], ): Promise { try { return await runBatchGitOp(options); } catch { // runBatchGitOp already dispatches the failure message. Keyboard input // must consume the rejection so a transient git/network error cannot // trigger the process-level unhandledRejection shutdown handler. return null; } } // Helper function to execute a custom command on selected repos async function handleCommandExecution(command: CommandConfig, repos: UnifiedRepo[]): Promise { // If no repos explicitly selected, use the current cursor position let targetRepos = repos; if (targetRepos.length === 0) { const currentRepo = filteredUnifiedRepos[state.cursorIndex]; if (currentRepo) { targetRepos = [currentRepo]; } } // Filter to repos with local paths const localRepos = targetRepos.filter((r) => r.localPath || r.local?.path); if (localRepos.length === 0) { dispatch(setMessage("No local projects selected")); return false; } if (command.confirm) { const dialogState: ConfirmDialogState = { operation: "command", title: `Run "${command.name}"`, message: `Run \`${command.command}\` on:`, items: localRepos.map((r) => r.name), projectPaths: localRepos.map((r) => (r.localPath ?? r.local!.path)), showVisibilityToggle: false, command, }; dispatch({ type: "OPEN_MODAL", payload: { kind: "confirm", data: dialogState } }); return true; } dispatch(startAction(`Running: ${command.name} on ${localRepos.length} repo(s)`)); // Sequential keeps output ordering deterministic. const summary = await runSequentialBatch(localRepos, async (repo) => { const projectPath = repo.localPath ?? repo.local!.path; const result = await executeCommand(command, projectPath); return result.success ? { success: true, data: result.output ?? "" } : { success: false, error: result.error ?? "unknown" }; }); dispatch(endAction()); const lastOutput = summary.results.findLast((r) => r.success)?.data ?? ""; const lastError = summary.firstError ?? "unknown"; if (summary.failures === 0) { const shortOutput = lastOutput && lastOutput.length > UI.OUTPUT_TRUNCATION_LENGTH ? lastOutput.slice(0, UI.OUTPUT_TRUNCATION_LENGTH) + "..." : lastOutput || "Done"; dispatch(setMessage(`${command.name}: ${summary.successes}/${localRepos.length} ${shortOutput ? `- ${shortOutput}` : ""}`)); } else { dispatch(setMessage(`${command.name}: ${summary.successes}/${localRepos.length} succeeded (${summary.failures} failed: ${lastError})`)); } return false; } useInput(async (input, key) => { const { mode, modal, cursorIndex, selectedIndices } = state; // Dispatch to mode-specific handlers if (mode === "filter" && handleFilterMode(key, dispatch)) return; if (modal.kind === "detail" && handleDetailMode()) return; if (mode === "filter-options") { handleFilterOptionsMode(dispatch); return; } if (mode === "help") { handleHelpMode(dispatch); return; } if (modal.kind === "clone" && handleCloneMode(key, dispatch)) return; if (modal.kind === "add-directory") return; // Let AddDirectoryDialog handle all keys if (mode === "command-palette" && await handleCommandPaletteMode(input, key, config, dispatch, selectedUnifiedRepos, findCommandByKey, handleCommandExecution)) return; // Global keys that work in any mode if (input === "q" || (key.ctrl && input === "c")) { exit(); return; } if (input === "?") { dispatch(setMode("help")); return; } // F key for filter options overlay if (input === "F") { dispatch(setMode("filter-options")); return; } // x key for command palette if (input === "x") { if (config.commands.length === 0) { dispatch(setMessage("No commands configured")); return; } dispatch(setMode("command-palette")); return; } // Tab - cycle view mode if (key.tab) { const modes: ViewMode[] = ["local", "github", "combined"]; const currentMode: ViewMode = state.viewMode || "local"; const currentIdx = modes.indexOf(currentMode); if (currentIdx === -1) return; // Safety check const nextIdx = (currentIdx + 1) % modes.length; const nextMode = modes[nextIdx]!; dispatch({ type: "SET_VIEW_MODE", payload: nextMode }); const modeLabels: Record = { local: "Local only", github: "GitHub only", combined: "All repos" }; dispatch(setMessage(`View: ${modeLabels[nextMode]}`)); return; } // Navigation — bound the cursor to the currently visible (filtered) list const visibleCount = (state.viewMode || "combined") === "local" ? filteredProjects.length : filteredUnifiedRepos.length; const visibleMaxIndex = Math.max(0, visibleCount - 1); if (input === "j" || key.downArrow) { dispatch(moveCursor(cursorIndex + 1, visibleMaxIndex)); return; } if (input === "k" || key.upArrow) { dispatch(moveCursor(cursorIndex - 1, visibleMaxIndex)); return; } // Enter - open detail modal if (key.return) { const currentRepo = filteredUnifiedRepos[cursorIndex]; if (currentRepo) { // Fetch README content (async) const detailState: DetailModalState = { repo: currentRepo, readmeContent: null, readmeLoading: true, readmeError: null, readmeScrollOffset: 0, }; dispatch({ type: "OPEN_MODAL", payload: { kind: "detail", data: detailState } }); // Fetch README in background fetchReadme(currentRepo).then(({ content, error }) => { dispatch({ type: "UPDATE_MODAL", payload: { kind: "detail", data: { readmeContent: content, readmeLoading: false, readmeError: error, }, }}); }); } return; } if (input === "g") { dispatch(moveCursor(0, visibleMaxIndex)); return; } if (input === "G") { dispatch(moveCursor(visibleMaxIndex, visibleMaxIndex)); return; } // Selection if (input === " ") { dispatch(toggleSelection(cursorIndex)); return; } if (input === "a") { if (selectedIndices.size === visibleCount) { dispatch(deselectAll()); } else { dispatch(selectAll(visibleCount)); } return; } // Filter if (input === "/") { dispatch(setMode("filter")); return; } // Sort - 's' cycles field, 'S' reverses direction // Cycle order is defined in src/state/reducer.ts SORT_FIELDS: // status → name → branch → sync → language → stars → forks → lastActivity → size if (input === "s") { dispatch(cycleSort()); // Surface the next field in the message bar — without this, pressing s // when many rows share the same sort value (e.g. all 0 stars) looks // like nothing happened. const fields = SORT_FIELDS; const currentIdx = fields.indexOf(state.sortBy); const nextField = fields[(currentIdx + 1) % fields.length]; dispatch(setMessage(`Sort: ${nextField} ${state.sortDirection === 'desc' ? '↓' : '↑'}`)); return; } if (input === "S") { const newDirection = state.sortDirection === 'desc' ? 'asc' : 'desc'; dispatch(setSort(state.sortBy, newDirection)); dispatch(setMessage(`Sort: ${state.sortBy} ${newDirection === 'desc' ? '↓' : '↑'}`)); return; } // Quick filters (1=dirty, 2=unpushed, 3=no-remote, 4=github-only, 5=local-only, 6=private, 7=public, 8=archived, 9=forks, 0=all) const quickFilterMap: Record = { "0": "all", "1": "dirty", "2": "unpushed", "3": "no-remote", "4": "github-only", "5": "local-only", "6": "private", "7": "public", "8": "archived", "9": "forks", }; if (input in quickFilterMap) { const newFilter = quickFilterMap[input]!; dispatch({ type: "SET_QUICK_FILTER", payload: newFilter }); const filterNames: Record = { all: "All projects", dirty: "Dirty projects", unpushed: "Unpushed commits", "no-remote": "No remote", "github-only": "GitHub only", "local-only": "Local only", private: "Private repos", public: "Public repos", archived: "Archived repos", forks: "Forked repos", }; dispatch(setMessage(`Filter: ${filterNames[newFilter]}`)); return; } // Refresh (force rescan to pick up config/filesystem changes) if (input === "r") { dispatch({ type: "SET_REFRESHING", payload: true }); await onRefresh({ forceRefresh: true }); dispatch({ type: "SET_REFRESHING", payload: false }); dispatch(setMessage("Refresh complete")); return; } // Build list of selected local Project records by mapping unified repo // selection back through state.projects by path. This keeps selection // semantics consistent across local/github/combined views. const selectedLocalProjects: Project[] = (() => { const selectedPaths = new Set( selectedUnifiedRepos .map((r) => r.localPath ?? r.local?.path) .filter((p): p is string => !!p) ); return state.projects.filter((p) => selectedPaths.has(p.path)); })(); // Git operations if (input === "p") { const gitProjects = selectedLocalProjects.filter( (p) => p.type === "git" && p.status?.hasRemote && p.status?.isAhead ); if (gitProjects.length === 0) { dispatch(setMessage("No projects to push")); return; } const result = await runBatchGitOpSafely({ label: "Pushing", projects: gitProjects, op: batchPush, concurrency: config.scan.concurrency, dispatch, formatSuccess: (_r, ps) => { const names = ps.slice(0, 3).map((p) => p.name).join(", "); const more = ps.length > 3 ? ` +${ps.length - 3} more` : ""; return `Pushed: ${names}${more}`; }, formatFailure: (r, ps) => { const failedNames = r.results .map((res, i) => (!res.success ? ps[i]?.name : null)) .filter((n): n is string => !!n) .slice(0, 3) .join(", "); const more = r.failed > 3 ? ` +${r.failed - 3} more` : ""; return `Pushed ${r.successful}/${r.total} (failed: ${failedNames}${more})`; }, }); if (!result) return; await onRefresh({ forceRefresh: true }); return; } if (input === "P") { const gitProjects = filteredProjects.filter( (p) => p.type === "git" && p.status?.hasRemote ); if (gitProjects.length === 0) { dispatch(setMessage("No projects with remotes")); return; } const result = await runBatchGitOpSafely({ label: "Pulling", projects: gitProjects, op: batchPull, concurrency: config.scan.concurrency, dispatch, formatSuccess: (r) => `Pulled ${r.successful} projects successfully`, formatFailure: (r) => `Pulled ${r.successful}/${r.total} (${r.failed} failed)`, }); if (!result) return; await onRefresh({ forceRefresh: true }); return; } if (input === "f") { const gitProjects = filteredProjects.filter( (p) => p.type === "git" && p.status?.hasRemote ); if (gitProjects.length === 0) { dispatch(setMessage("No projects with remotes")); return; } const result = await runBatchGitOpSafely({ label: "Fetching", projects: gitProjects, op: batchFetch, concurrency: config.scan.concurrency, dispatch, formatSuccess: (r) => `Fetched ${r.successful} projects`, formatFailure: (r) => `Fetched ${r.successful}/${r.total} (${r.failed} failed)`, }); if (!result) return; await onRefresh({ forceRefresh: true }); return; } if (input === "i") { // Init git in selected non-git projects const nonGitProjects = selectedLocalProjects.filter((p) => p.type === "non-git"); if (nonGitProjects.length === 0) { dispatch(setMessage("No non-git projects selected")); return; } dispatch(startAction(`Initializing ${nonGitProjects.length} projects`)); const summary = await runSequentialBatch(nonGitProjects, async (p) => { const result = await initGit(p.path); return { success: result.success }; }); dispatch(endAction()); dispatch( setMessage(`Initialized ${summary.successes}/${nonGitProjects.length} projects`) ); // Refresh after init await onRefresh({ forceRefresh: true }); return; } // GitHub operations if (input === "c") { openConfirmDialog({ operation: "create", title: "Create GitHub Repos", message: "Create GitHub repositories for:", predicate: (p) => p.type === "git" && !p.status?.hasRemote, selection: selectedLocalProjects, showVisibilityToggle: true, emptyMessage: "No git projects without remotes selected", dispatch, }); return; } if (input === "C") { openConfirmDialog({ operation: "setup", title: "Setup Projects", message: "Init git (if needed) and create GitHub repos for:", predicate: (p) => p.type === "non-git" || (p.type === "git" && !p.status?.hasRemote), selection: selectedLocalProjects, showVisibilityToggle: true, emptyMessage: "No projects need setup (all have remotes)", itemLabel: (p) => `${p.name}${p.type === "non-git" ? " (will init)" : ""}`, dispatch, }); return; } if (input === "A") { openConfirmDialog({ operation: "archive", title: "Archive GitHub Repos", message: "Archive these repositories on GitHub:", predicate: (p) => p.type === "git" && !!p.status?.hasRemote, selection: selectedLocalProjects, showVisibilityToggle: false, emptyMessage: "No git projects with remotes selected", dispatch, }); return; } // D - Clone GitHub repos if (input === "D") { const githubOnlyRepos = selectedUnifiedRepos.filter(r => r.source === "github"); if (githubOnlyRepos.length === 0) { dispatch(setMessage("No GitHub-only repos selected to clone")); return; } dispatch({ type: "OPEN_MODAL", payload: { kind: "clone", data: { repos: githubOnlyRepos, directories: config.directories, selectedDirIndex: 0, useSSH: true, }, }, }); return; } // + key - Add a directory to scan if (input === "+") { dispatch(showAddDirectoryDialog()); return; } // Custom commands - check if input matches a configured command key // This allows executing commands directly from main list without command palette if (config.commands.length > 0) { const command = findCommandByKey(config.commands, input); if (command) { await handleCommandExecution(command, selectedUnifiedRepos); return; } } }); }