feat: add clone shortcut with multi-select and condensed TUI footer#41
Conversation
- Add CloneModal component with support for: - Multiple repository selection - Simple clone (regular git clone) - Bare repository clone (for git worktrees) - Configurable target directory - Add multi-select mode (M key): - Space to toggle individual selection - Ctrl+A to select/deselect all - Visual checkbox indicators in repo list - Selection count in status bar - Add clone shortcut (Shift+C): - Clone selected repos or current repo if none selected - Progress indicator and result toast - Redesign help footer: - Condensed shortcuts for easier scanning - Logical grouping by function - Shorter sponsorship line - Context-aware display (multi-select, stars mode)
WalkthroughIntroduces a multi-select and batch clone feature to the repository list UI. Adds a new CloneModal component for configuring clone operations with selectable clone types (simple/bare) and target directory. Updates RepoRow to support multi-select checkboxes. Integrates multi-select state management and clone workflow into RepoList with keyboard shortcuts and result feedback. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant RepoList
participant CloneModal
participant Git
participant Toast
User->>RepoList: Press M to toggle multi-select mode
RepoList->>RepoList: Set multiSelectMode = true
User->>RepoList: Press Space to select repos (multiple)
RepoList->>RepoList: Toggle selectedRepos state
RepoList->>RepoRow: Render with isMultiSelected = true
User->>RepoList: Press Shift+C to open clone
RepoList->>CloneModal: Open with selected repos list
CloneModal->>User: Display repos, clone type options, target dir
User->>CloneModal: Select clone type (S for simple/B for bare)
CloneModal->>CloneModal: Update cloneType state
User->>CloneModal: Edit target directory
CloneModal->>CloneModal: Toggle editingDir, show TextInput
User->>CloneModal: Press Y to confirm/clone
CloneModal->>RepoList: Call onClone(repos, cloneType, targetDir)
RepoList->>RepoList: Set cloning = true, show spinner
RepoList->>Git: Execute git clone for each repo
Git-->>RepoList: Success/failure per repo
RepoList->>RepoList: Aggregate results
RepoList->>RepoList: Set cloning = false, clear selectedRepos
RepoList->>Toast: Display clone results summary
Toast->>User: Show toast with successes/failures
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20–25 minutes
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Tip 📝 Customizable high-level summaries are now available in beta!You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.
Example instruction:
Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (7)
src/ui/components/modals/index.ts (1)
13-14: Minor export pattern inconsistency.Other modals use
export { default as X }pattern, whilst CloneModal uses named export. This works since CloneModal.tsx exports both ways, but consider aligning for consistency:-export { CloneModal } from './CloneModal'; +export { default as CloneModal } from './CloneModal';src/ui/components/modals/CloneModal.tsx (2)
119-122: Avoidanytype in catch block.Per coding guidelines requiring strict TypeScript, consider using
unknowntype:- } catch (e: any) { - setCloneError(e.message || 'Failed to clone repositories'); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Failed to clone repositories'; + setCloneError(message); setCloning(false); }
159-179: Redundant borderColor ternary expressions.The borderColor logic on lines 163 and 174 evaluates to the same value in both branches of the outer ternary:
borderColor={focus === 'type' ? (cloneType === 'simple' ? 'green' : 'gray') : (cloneType === 'simple' ? 'green' : 'gray')}This simplifies to:
- borderColor={focus === 'type' ? (cloneType === 'simple' ? 'green' : 'gray') : (cloneType === 'simple' ? 'green' : 'gray')} + borderColor={cloneType === 'simple' ? 'green' : 'gray'}Apply same fix to line 174 for the 'bare' option.
src/ui/views/RepoList.tsx (4)
468-469: SSH URL is hardcoded; consider user preference.The clone URL is hardcoded to SSH format (
git@github.com:). Users who prefer HTTPS or have SSH issues won't be able to clone. Consider adding a clone URL type option in the CloneModal, similar to the CopyUrlModal pattern.
518-521: Avoidanytype in catch block.Per coding guidelines requiring strict TypeScript:
- } catch (e: any) { + } catch (e: unknown) { setCloning(false); - setCloneError(e.message || 'Failed to clone repositories'); + setCloneError(e instanceof Error ? e.message : 'Failed to clone repositories'); }
438-445: Type assertion can be avoided.The
(repo as any).idcasts suggest theRepoNodetype should includeid. Looking at the relevant code snippets,RepoNodeinsrc/types.tsalready hasid: string. Consider removing the cast:function getSelectedReposArray(): RepoNode[] { if (selectedRepos.size === 0) { const repo = visibleItems[cursor]; return repo ? [repo] : []; } - return visibleItems.filter((r: any) => selectedRepos.has(r.id)); + return visibleItems.filter(r => selectedRepos.has(r.id)); }
2615-2617: Type assertion for repo.id can be removed.Since
RepoNodeincludesid: string, the cast is unnecessary:multiSelectMode={multiSelectMode} - isMultiSelected={selectedRepos.has((repo as any).id)} + isMultiSelected={selectedRepos.has(repo.id)}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/ui/components/modals/CloneModal.tsx(1 hunks)src/ui/components/modals/index.ts(1 hunks)src/ui/components/repo/RepoRow.tsx(2 hunks)src/ui/views/RepoList.tsx(11 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (AGENTS.md)
Use strict TypeScript with comprehensive types; avoid any without clear justification
Files:
src/ui/components/modals/index.tssrc/ui/components/repo/RepoRow.tsxsrc/ui/components/modals/CloneModal.tsxsrc/ui/views/RepoList.tsx
src/ui/**/*.tsx
📄 CodeRabbit inference engine (AGENTS.md)
src/ui/**/*.tsx: Implement React/Ink UI as functional components using hooks
Use chalk for colours instead of Ink colour props to avoid nested Text issues
Pre-colour strings and render within a single to prevent nested Text rendering issues
Use spacers for consistent terminal spacing
Files:
src/ui/components/repo/RepoRow.tsxsrc/ui/components/modals/CloneModal.tsxsrc/ui/views/RepoList.tsx
src/**/*.tsx
📄 CodeRabbit inference engine (AGENTS.md)
Use British English for all user-facing text (e.g., organisation, authorisation, colour)
Files:
src/ui/components/repo/RepoRow.tsxsrc/ui/components/modals/CloneModal.tsxsrc/ui/views/RepoList.tsx
🧠 Learnings (2)
📚 Learning: 2025-09-05T11:52:17.587Z
Learnt from: CR
Repo: wiiiimm/gh-manager-cli PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-09-05T11:52:17.587Z
Learning: Applies to src/github.ts : Select fields: name, nameWithOwner, description, visibility, isPrivate, isFork, isArchived, stargazerCount, forkCount, primaryLanguage, updatedAt, pushedAt, diskUsage
Applied to files:
src/ui/components/repo/RepoRow.tsx
📚 Learning: 2025-09-05T11:52:17.587Z
Learnt from: CR
Repo: wiiiimm/gh-manager-cli PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-09-05T11:52:17.587Z
Learning: Applies to src/ui/**/*.tsx : Use <Box minHeight={...}> spacers for consistent terminal spacing
Applied to files:
src/ui/views/RepoList.tsx
🧬 Code graph analysis (2)
src/ui/components/modals/CloneModal.tsx (3)
src/ui/components/modals/index.ts (2)
CloneType(14-14)CloneModal(13-13)src/types.ts (1)
RepoNode(8-47)src/ui/components/common/SlowSpinner.tsx (1)
SlowSpinner(9-22)
src/ui/views/RepoList.tsx (3)
src/types.ts (1)
RepoNode(8-47)src/ui/components/modals/CloneModal.tsx (2)
CloneType(8-8)CloneModal(17-255)src/ui/components/modals/index.ts (2)
CloneType(14-14)CloneModal(13-13)
🔇 Additional comments (6)
src/ui/components/repo/RepoRow.tsx (2)
16-17: LGTM!The new props are correctly typed as optional booleans with sensible defaults.
49-54: LGTM!The checkbox rendering correctly follows the coding guidelines: using chalk for colours and pre-colouring strings before rendering in a single
<Text>element. The Unicode checkbox characters (☑/☐) provide clear visual feedback.src/ui/components/modals/CloneModal.tsx (1)
1-257: Well-structured modal component.The CloneModal follows established patterns: functional component with hooks, chalk for colours, comprehensive keyboard navigation, and proper loading/error states. The dual export (named + default) on lines 17 and 257 provides flexibility for consumers.
src/ui/views/RepoList.tsx (3)
186-194: LGTM!State variables for multi-select and clone functionality are well-organised and follow established patterns in the file.
2648-2689: LGTM!The condensed footer layout is well-structured with logical groupings. Context-aware display for multi-select and stars modes improves UX by showing only relevant shortcuts.
2527-2535: LGTM!CloneModal integration follows the established modal rendering pattern used by other modals in the file.
| 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<void>((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 }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Clone operations lack timeout and could hang indefinitely.
The exec() calls for git clone have no timeout. On slow networks or large repositories, this could cause the UI to appear frozen.
Consider adding a timeout:
try {
await new Promise<void>((resolve, reject) => {
- exec(cloneCmd, (error, stdout, stderr) => {
+ const child = exec(cloneCmd, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
} else {
resolve();
}
});
+ // Timeout after 5 minutes
+ const timeout = setTimeout(() => {
+ child.kill();
+ reject(new Error('Clone timed out after 5 minutes'));
+ }, 5 * 60 * 1000);
+ child.on('exit', () => clearTimeout(timeout));
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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<void>((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 }); | |
| } | |
| } | |
| 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<void>((resolve, reject) => { | |
| const child = exec(cloneCmd, (error, stdout, stderr) => { | |
| if (error) { | |
| reject(new Error(stderr || error.message)); | |
| } else { | |
| resolve(); | |
| } | |
| }); | |
| // Timeout after 5 minutes | |
| const timeout = setTimeout(() => { | |
| child.kill(); | |
| reject(new Error('Clone timed out after 5 minutes')); | |
| }, 5 * 60 * 1000); | |
| child.on('exit', () => clearTimeout(timeout)); | |
| }); | |
| results.push({ repo: repo.nameWithOwner, success: true }); | |
| } catch (e: any) { | |
| results.push({ repo: repo.nameWithOwner, success: false, error: e.message }); | |
| } | |
| } |
🤖 Prompt for AI Agents
In src/ui/views/RepoList.tsx around lines 467 to 490, the git clone exec() calls
have no timeout and can hang indefinitely; update the Promise wrapper to enforce
a timeout (e.g., pass a timeout option to exec or create a manual timer that
kills the child process and rejects after N ms), increase maxBuffer if needed,
clear the timer on success, and ensure the child is killed and the promise
rejected with a clear timeout error so the UI can recover and report failure.
| // 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; | ||
| } |
There was a problem hiding this comment.
Ctrl+A shortcut conflict with archive modal.
When multiSelectMode is true, Ctrl+A should select/deselect all repositories. However, the archive handler at lines 1523-1534 executes first in the useInput callback and doesn't check for multiSelectMode, so pressing Ctrl+A will open the archive modal instead.
Move this handler before the archive handler, or add !multiSelectMode guard to the archive handler:
// Archive/unarchive modal (Ctrl+A)
- if (key.ctrl && (input === 'a' || input === 'A')) {
+ if (key.ctrl && (input === 'a' || input === 'A') && !multiSelectMode) {
const repo = visibleItems[cursor];Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/ui/views/RepoList.tsx around lines 1757-1766 (and the archive handler at
1523-1534), the Ctrl+A multi-select logic is being bypassed because the archive
handler runs first and doesn't respect multiSelectMode; update the archive
handler to short-circuit when multiSelectMode is true (add a guard like if
(multiSelectMode) return) or move the multi-select Ctrl+A block so it executes
before the archive handler, and ensure each handler returns after handling to
prevent fall-through.
Add CloneModal component with support for:
Add multi-select mode (M key):
Add clone shortcut (Shift+C):
Redesign help footer:
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.