diff --git a/src/ui/components/modals/CloneModal.tsx b/src/ui/components/modals/CloneModal.tsx new file mode 100644 index 0000000..e72afd5 --- /dev/null +++ b/src/ui/components/modals/CloneModal.tsx @@ -0,0 +1,257 @@ +import React, { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import chalk from 'chalk'; +import type { RepoNode } from '../../../types'; +import { SlowSpinner } from '../common'; + +export type CloneType = 'simple' | 'bare'; + +interface CloneModalProps { + repos: RepoNode[]; + terminalWidth: number; + onClose: () => void; + onClone: (repos: RepoNode[], cloneType: CloneType, targetDir: string) => Promise; +} + +export function CloneModal({ repos, terminalWidth, onClose, onClone }: CloneModalProps) { + const [cloneType, setCloneType] = useState('simple'); + const [targetDir, setTargetDir] = useState('.'); + const [editingDir, setEditingDir] = useState(false); + const [cloning, setCloning] = useState(false); + const [cloneError, setCloneError] = useState(null); + const [focus, setFocus] = useState<'type' | 'dir' | 'clone' | 'cancel'>('type'); + + // Handle keyboard input + useInput((input, key) => { + if (cloning) return; + + // Handle directory editing mode + if (editingDir) { + if (key.escape) { + setEditingDir(false); + return; + } + if (key.return) { + setEditingDir(false); + setFocus('clone'); + return; + } + return; // Let TextInput handle the input + } + + const ch = input?.toLowerCase(); + + if (key.escape || ch === 'q') { + onClose(); + return; + } + + // Quick shortcuts + if (ch === 's' && !key.ctrl) { + setCloneType('simple'); + return; + } + if (ch === 'b' && !key.ctrl) { + setCloneType('bare'); + return; + } + + // Navigation + if (key.upArrow || key.downArrow) { + const focusOrder: typeof focus[] = ['type', 'dir', 'clone', 'cancel']; + const currentIndex = focusOrder.indexOf(focus); + let newIndex; + + if (key.upArrow) { + newIndex = currentIndex === 0 ? focusOrder.length - 1 : currentIndex - 1; + } else { + newIndex = currentIndex === focusOrder.length - 1 ? 0 : currentIndex + 1; + } + + setFocus(focusOrder[newIndex]); + return; + } + + if (key.leftArrow || key.rightArrow) { + if (focus === 'type') { + setCloneType(prev => prev === 'simple' ? 'bare' : 'simple'); + } else if (focus === 'clone' || focus === 'cancel') { + setFocus(prev => prev === 'clone' ? 'cancel' : 'clone'); + } + return; + } + + // Enter to activate focused element + if (key.return) { + if (focus === 'type') { + setCloneType(prev => prev === 'simple' ? 'bare' : 'simple'); + } else if (focus === 'dir') { + setEditingDir(true); + } else if (focus === 'clone') { + handleClone(); + } else if (focus === 'cancel') { + onClose(); + } + return; + } + + // Y to confirm + if (ch === 'y') { + handleClone(); + return; + } + + // C to cancel + if (ch === 'c') { + onClose(); + return; + } + }); + + const handleClone = async () => { + if (cloning || repos.length === 0) return; + + try { + setCloning(true); + setCloneError(null); + await onClone(repos, cloneType, targetDir); + } catch (e: any) { + setCloneError(e.message || 'Failed to clone repositories'); + setCloning(false); + } + }; + + if (repos.length === 0) { + return No repositories selected for cloning.; + } + + const modalWidth = Math.min(terminalWidth - 8, 80); + + return ( + + Clone {repos.length === 1 ? 'Repository' : `${repos.length} Repositories`} + + + {/* Repository list */} + + {repos.slice(0, 5).map((repo, i) => ( + + {chalk.cyan(`${i + 1}.`)} {repo.nameWithOwner} + + ))} + {repos.length > 5 && ( + ... and {repos.length - 5} more + )} + + + + {/* Clone type selection */} + Clone Type: + + + + {cloneType === 'simple' ? '● ' : '○ '}Simple Clone + + + + + {cloneType === 'bare' ? '● ' : '○ '}Bare Repository + + + + + + + {cloneType === 'simple' + ? 'Standard clone with working directory' + : 'Bare clone for git worktrees (no working directory)'} + + + + + {/* Target directory */} + Target Directory: + + {editingDir ? ( + + ) : ( + + {focus === 'dir' ? '▶ ' : ' '}{targetDir || '.'} + + )} + + + + {/* Action buttons */} + {cloning ? ( + + + + + + Cloning {repos.length === 1 ? 'repository' : 'repositories'}... + + + ) : ( + <> + + + + {focus === 'clone' + ? chalk.bgGreen.white.bold(' Clone ') + : chalk.green.bold('Clone')} + + + + + {focus === 'cancel' + ? chalk.bgGray.white.bold(' Cancel ') + : chalk.gray.bold('Cancel')} + + + + + ↑↓ Navigate • ←→ Toggle • S Simple • B Bare • Y Clone • Esc/Q Cancel + + + )} + + {cloneError && ( + + {cloneError} + + )} + + ); +} + +export default CloneModal; diff --git a/src/ui/components/modals/index.ts b/src/ui/components/modals/index.ts index 62dd3b0..3db4568 100644 --- a/src/ui/components/modals/index.ts +++ b/src/ui/components/modals/index.ts @@ -10,4 +10,6 @@ export { ChangeVisibilityModal } from './ChangeVisibilityModal'; export { default as CopyUrlModal } from './CopyUrlModal'; export { default as RenameModal } from './RenameModal'; export { StarModal } from './StarModal'; +export { CloneModal } from './CloneModal'; +export type { CloneType } from './CloneModal'; diff --git a/src/ui/components/repo/RepoRow.tsx b/src/ui/components/repo/RepoRow.tsx index 5aec761..9296b90 100644 --- a/src/ui/components/repo/RepoRow.tsx +++ b/src/ui/components/repo/RepoRow.tsx @@ -13,17 +13,21 @@ interface RepoRowProps { dim?: boolean; forkTracking: boolean; starsMode?: boolean; + multiSelectMode?: boolean; + isMultiSelected?: boolean; } -export default function RepoRow({ - repo, - selected, - index, - maxWidth, - spacingLines, - dim, +export default function RepoRow({ + repo, + selected, + index, + maxWidth, + spacingLines, + dim, forkTracking, - starsMode = false + starsMode = false, + multiSelectMode = false, + isMultiSelected = false }: RepoRowProps) { const langName = repo.primaryLanguage?.name || ''; const langColor = repo.primaryLanguage?.color || '#666666'; @@ -42,6 +46,13 @@ export default function RepoRow({ let line1 = ''; const numColor = selected ? chalk.cyan : chalk.gray; const nameColor = selected ? chalk.cyan.bold : chalk.white; + + // Show multi-select checkbox if in multi-select mode + if (multiSelectMode) { + const checkbox = isMultiSelected ? chalk.green('☑') : chalk.gray('☐'); + line1 += checkbox + ' '; + } + line1 += numColor(`${String(index).padStart(3, ' ')}.`); // Show star icon if the repo is starred if (repo.viewerHasStarred) { diff --git a/src/ui/views/RepoList.tsx b/src/ui/views/RepoList.tsx index 7a6df3e..47108f3 100644 --- a/src/ui/views/RepoList.tsx +++ b/src/ui/views/RepoList.tsx @@ -9,7 +9,8 @@ import type { RepoNode, RateLimitInfo, RestRateLimitInfo } from '../../types'; import { exec } from 'child_process'; import OrgSwitcher from '../OrgSwitcher'; import { logger } from '../../lib/logger'; -import { DeleteModal, ArchiveModal, SyncModal, InfoModal, LogoutModal, VisibilityModal, SortModal, SortDirectionModal, ChangeVisibilityModal, CopyUrlModal, RenameModal, StarModal } from '../components/modals'; +import { DeleteModal, ArchiveModal, SyncModal, InfoModal, LogoutModal, VisibilityModal, SortModal, SortDirectionModal, ChangeVisibilityModal, CopyUrlModal, RenameModal, StarModal, CloneModal } from '../components/modals'; +import type { CloneType } from '../components/modals'; import { UnstarModal } from '../components/modals/UnstarModal'; import { RepoRow, FilterInput, RepoListHeader } from '../components/repo'; import { SlowSpinner } from '../components/common'; @@ -182,6 +183,16 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, const [starring, setStarring] = useState(false); const [starError, setStarError] = useState(null); + // Multi-select mode state + const [multiSelectMode, setMultiSelectMode] = useState(false); + const [selectedRepos, setSelectedRepos] = useState>(new Set()); + + // Clone modal state + const [cloneMode, setCloneMode] = useState(false); + const [cloning, setCloning] = useState(false); + const [cloneError, setCloneError] = useState(null); + const [cloneToast, setCloneToast] = useState(null); + // Apply initial --org flag once (if provided) const appliedInitialOrg = useRef(false); useEffect(() => { @@ -403,6 +414,113 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, setStarring(false); } + // Close clone modal + function closeCloneModal() { + setCloneMode(false); + setCloning(false); + setCloneError(null); + } + + // Toggle multi-select for a repo + function toggleRepoSelection(repoId: string) { + setSelectedRepos(prev => { + const newSet = new Set(prev); + if (newSet.has(repoId)) { + newSet.delete(repoId); + } else { + newSet.add(repoId); + } + return newSet; + }); + } + + // Get selected repos as array + function getSelectedReposArray(): RepoNode[] { + if (selectedRepos.size === 0) { + // If no repos selected, use the current cursor position + const repo = visibleItems[cursor]; + return repo ? [repo] : []; + } + return visibleItems.filter((r: any) => selectedRepos.has(r.id)); + } + + // Timer ref for clone toast + const cloneToastTimerRef = useRef(null); + + // Execute clone operation + async function executeClone(repos: RepoNode[], cloneType: CloneType, targetDir: string): Promise { + if (cloning || repos.length === 0) return; + + try { + setCloning(true); + setCloneError(null); + + // Clear any existing timer before cloning + if (cloneToastTimerRef.current) { + clearTimeout(cloneToastTimerRef.current); + cloneToastTimerRef.current = null; + } + + // Build and execute clone commands + const results: { repo: string; success: boolean; error?: string }[] = []; + + for (const repo of repos) { + const sshUrl = `git@github.com:${repo.nameWithOwner}.git`; + const repoName = repo.nameWithOwner.split('/')[1]; + const clonePath = targetDir === '.' ? repoName : `${targetDir}/${repoName}`; + + const cloneCmd = cloneType === 'bare' + ? `git clone --bare "${sshUrl}" "${clonePath}.git"` + : `git clone "${sshUrl}" "${clonePath}"`; + + try { + await new Promise((resolve, reject) => { + exec(cloneCmd, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + } else { + resolve(); + } + }); + }); + results.push({ repo: repo.nameWithOwner, success: true }); + } catch (e: any) { + results.push({ repo: repo.nameWithOwner, success: false, error: e.message }); + } + } + + // Show results + const successCount = results.filter(r => r.success).length; + const failCount = results.filter(r => !r.success).length; + + if (failCount === 0) { + setCloneToast(`Successfully cloned ${successCount} ${successCount === 1 ? 'repository' : 'repositories'}`); + trackSuccessfulOperation(); + closeCloneModal(); + // Clear multi-select after successful clone + setSelectedRepos(new Set()); + setMultiSelectMode(false); + } else if (successCount === 0) { + throw new Error(`Failed to clone: ${results[0].error}`); + } else { + setCloneToast(`Cloned ${successCount}/${repos.length} repositories (${failCount} failed)`); + closeCloneModal(); + setSelectedRepos(new Set()); + setMultiSelectMode(false); + } + + // Set timer for toast + cloneToastTimerRef.current = setTimeout(() => { + setCloneToast(null); + cloneToastTimerRef.current = null; + }, 5000); + + } catch (e: any) { + setCloning(false); + setCloneError(e.message || 'Failed to clone repositories'); + } + } + async function executeSync() { if (!syncTarget || syncing) return; @@ -549,12 +667,15 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, } } - // Clear timer on unmount + // Clear timers on unmount useEffect(() => { return () => { if (copyToastTimerRef.current) { clearTimeout(copyToastTimerRef.current); } + if (cloneToastTimerRef.current) { + clearTimeout(cloneToastTimerRef.current); + } }; }, []); @@ -1291,6 +1412,11 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, return; // SortDirectionModal component handles its own keyboard input } + // When clone modal is open, trap inputs for modal + if (cloneMode) { + return; // CloneModal component handles its own keyboard input + } + // When in filter mode, only handle input for the TextInput if (filterMode) { if (key.escape) { @@ -1588,14 +1714,56 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, } // Fork tracking is now always on - removed toggle - + // Open visibility filter modal (V) - disabled in stars mode - if (input && input.toUpperCase() === 'V') { + if (input && input.toUpperCase() === 'V' && !key.ctrl) { if (!starsMode) { setVisibilityMode(true); } return; } + + // Clone modal (Shift+C) + if (key.shift && input === 'C') { + const reposToClone = getSelectedReposArray(); + if (reposToClone.length > 0) { + setCloneMode(true); + setCloneError(null); + } + return; + } + + // Toggle multi-select mode (M) + if (input && input.toUpperCase() === 'M' && !key.ctrl && !key.shift) { + setMultiSelectMode(prev => { + if (prev) { + // Exiting multi-select mode - clear selections + setSelectedRepos(new Set()); + } + return !prev; + }); + return; + } + + // Space to toggle selection in multi-select mode + if (input === ' ' && multiSelectMode) { + const repo = visibleItems[cursor]; + if (repo) { + toggleRepoSelection((repo as any).id); + } + return; + } + + // Select all in multi-select mode (Ctrl+A when in multi-select) + if (key.ctrl && (input === 'a' || input === 'A') && multiSelectMode) { + // Toggle between select all and deselect all + if (selectedRepos.size === visibleItems.length) { + setSelectedRepos(new Set()); + } else { + setSelectedRepos(new Set(visibleItems.map((r: any) => r.id))); + } + return; + } }); // (moved below visibleItems definition) @@ -1750,7 +1918,7 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, const lowRate = (rateLimit && rateLimit.remaining <= Math.ceil(rateLimit.limit * 0.1)) || (restRateLimit && restRateLimit.core.remaining <= Math.ceil(restRateLimit.core.limit * 0.1)); - const modalOpen = deleteMode || archiveMode || syncMode || logoutMode || infoMode || visibilityMode || sortMode || sortDirectionMode || changeVisibilityMode || copyUrlMode || renameMode; + const modalOpen = deleteMode || archiveMode || syncMode || logoutMode || infoMode || visibilityMode || sortMode || sortDirectionMode || changeVisibilityMode || copyUrlMode || renameMode || cloneMode; // Memoize header to prevent re-renders - must be before any returns const headerBar = useMemo(() => ( @@ -2356,6 +2524,15 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, error={starError} /> + ) : cloneMode ? ( + + + ) : ( <> {/* Context/Filter/sort status */} @@ -2435,6 +2612,8 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, spacingLines={spacingLines} forkTracking={forkTracking} starsMode={starsMode} + multiSelectMode={multiSelectMode} + isMultiSelected={selectedRepos.has((repo as any).id)} /> ); }) @@ -2466,39 +2645,47 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, )} - {/* Help footer - 5 lines */} + {/* Help footer - condensed, aligned shortcuts */} - {/* Line 1: Basic navigation */} - - - ↑↓ Navigate • Ctrl+G Top • G Bottom • ⏎/O Open • R Refresh - - - {/* Line 2: Search and filtering */} - - - / Search • S Sort • D Direction • T Density{!starsMode && ' • V Visibility'}{ownerContext === 'personal' && ' • Shift+S Stars'} - - - {/* Line 3: Repository actions */} + {/* Multi-select indicator */} + {multiSelectMode && ( + + + Multi-Select: {selectedRepos.size} selected • Space Toggle • Ctrl+A All • M Exit • Shift+C Clone + + + )} + {/* Condensed shortcuts in aligned columns */} - {starsMode ? - 'I Info • C Copy URL • U Unstar Repository' : - 'I Info • C Copy URL • Ctrl+S Un/Star • Ctrl+R Rename • Ctrl+A Un/Archive • Ctrl+V Change Visibility • Ctrl+F Sync Fork' - } + {starsMode ? ( + // Stars mode shortcuts - condensed + '↑↓ Nav / Search S Sort D Dir T Dense I Info C Copy U Unstar W Org R Refresh Q Quit' + ) : multiSelectMode ? ( + // Multi-select mode shortcuts - condensed + '↑↓ Nav Space Select Ctrl+A All M Exit Shift+C Clone Q Quit' + ) : ( + // Normal mode shortcuts - condensed into logical groups + `↑↓/G Nav / Search S Sort D Dir T Dense ${ownerContext === 'personal' ? 'Shift+S Stars ' : ''}V Vis M Multi Shift+C Clone ⏎ Open` + )} - {/* Line 4: System controls */} + {!multiSelectMode && !starsMode && ( + + + I Info C Copy Ctrl+S Star Ctrl+R Rename Ctrl+A Archive Ctrl+V ChangeVis Ctrl+F Sync Del Delete + + + )} - K Cache Info • W Org Switch • Del/Backspace Delete • Ctrl+L Logout • Q Quit + {multiSelectMode || starsMode ? '' : 'K Cache W Org R Refresh Ctrl+L Logout Q Quit'} - {/* Line 5: Sponsorship */} + {/* Sponsorship */} - 💖 Sponsor on GitHub: github.com/sponsors/wiiiimm + 💖 github.com/sponsors/wiiiimm @@ -2525,6 +2712,15 @@ export default function RepoList({ token, maxVisibleRows, onLogout, viewerLogin, )} + + {/* Clone toast notification */} + {cloneToast && ( + + + {cloneToast} + + + )} ); }