Skip to content

feat: add clone shortcut with multi-select and condensed TUI footer#41

Open
cameronsjo wants to merge 1 commit intowiiiimm:mainfrom
cameronsjo:claude/condensed-tui-shortcuts-012SXD18KNny9Nxsme9Hhn5V
Open

feat: add clone shortcut with multi-select and condensed TUI footer#41
cameronsjo wants to merge 1 commit intowiiiimm:mainfrom
cameronsjo:claude/condensed-tui-shortcuts-012SXD18KNny9Nxsme9Hhn5V

Conversation

@cameronsjo
Copy link

@cameronsjo cameronsjo commented Nov 28, 2025

  • 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)

Summary by CodeRabbit

  • New Features
    • Multi-select mode for repositories with keyboard shortcuts (M to toggle mode, Space to select, Ctrl+A for select all)
    • Clone modal enabling batch cloning of multiple repositories with choice of clone type (simple or bare)
    • Configurable target directory for clone operations
    • Real-time clone progress feedback and results summary via toast notifications
    • Enhanced repository list with visual selection indicators for multi-select

✏️ Tip: You can customize this high-level summary in your review settings.

- 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)
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 28, 2025

Walkthrough

Introduces 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

Cohort / File(s) Summary
New Clone Modal Component
src/ui/components/modals/CloneModal.tsx
New React Ink component for cloning repositories with state management for clone type selection, target directory input, and clone progress. Supports keyboard shortcuts (S/B for type, Up/Down/Left/Right for navigation, Enter to activate, Y to confirm, Esc/Q/C to cancel). Displays inline repository list, clone type selection UI, and editable directory input with error handling.
Modal Exports
src/ui/components/modals/index.ts
Exports new CloneModal component and CloneType type definition to public API.
Multi-Select UI Support
src/ui/components/repo/RepoRow.tsx
Adds multiSelectMode and isMultiSelected optional props. Renders checkbox prefix (☐ or ☑) in line1 when multiSelectMode is enabled to indicate selection state.
Multi-Select and Clone Integration
src/ui/views/RepoList.tsx
Integrates multi-select mode state and clone workflow. Adds keyboard shortcuts (M to toggle multi-select, Space to toggle item selection, Ctrl+A to select all, Shift+C to open clone). Implements executeClone function to batch clone repositories via git commands. Wires CloneModal with onClose and onClone handlers. Updates RepoRow rendering with multi-select props. Adds clone result toast feedback and cleanup logic.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20–25 minutes

  • CloneModal.tsx: Dense state management with keyboard handling, focus logic, and multi-stage UI rendering requiring careful validation of interaction flows and error paths.
  • RepoList.tsx: Substantial additions of multi-select state, clone execution logic, and modal integration woven into existing component; verify keyboard shortcut conflicts, git command construction, and timer cleanup.
  • RepoRow.tsx: Straightforward prop additions, but verify checkbox rendering integrates cleanly without affecting existing layout.
  • index.ts: Simple re-export additions; low risk.

Poem

🐰 Cloning in bunches, a hop and a bound,
Multi-select magic spreads repos around,
No bare directory left behind,
A keyboard-driven workflow, thoughtfully designed—
Selection and clone, now working as one,
Batch operations made easy and fun!

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarises the main changes: adding clone functionality with multi-select support and a condensed TUI footer, which aligns with the core objectives and file changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Avoid any type in catch block.

Per coding guidelines requiring strict TypeScript, consider using unknown type:

-    } 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: Avoid any type 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).id casts suggest the RepoNode type should include id. Looking at the relevant code snippets, RepoNode in src/types.ts already has id: 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 RepoNode includes id: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 527237a and 66d88eb.

📒 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.ts
  • src/ui/components/repo/RepoRow.tsx
  • src/ui/components/modals/CloneModal.tsx
  • src/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.tsx
  • src/ui/components/modals/CloneModal.tsx
  • src/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.tsx
  • src/ui/components/modals/CloneModal.tsx
  • src/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.

Comment on lines +467 to +490
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 });
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +1757 to +1766
// 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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants