Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Agent Instructions

## Pull Requests

Always use the `pr-writer` skill when writing PR descriptions unless explicitly instructed otherwise.

## Build Commands

- Build: `cargo build`
- Lint: `cargo clippy`
- Format: `cargo fmt`
- Run: `cargo run`
- Run (dry-run mode): `cargo run -- --dry-run`
46 changes: 44 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ impl App {
match self.state.mode {
AppMode::Search => self.handle_search_key(key),
AppMode::Staging => self.handle_staging_key(key),
AppMode::ConfirmDeletion => self.handle_confirm_key(key),
AppMode::Deleting => Action::None,
}
}
Expand Down Expand Up @@ -273,11 +274,12 @@ impl App {

(KeyCode::Enter, KeyModifiers::NONE) => {
if !self.state.staged_for_deletion.is_empty() {
Action::ExecuteDeletion
self.state.confirmation_input.clear();
self.state.mode = AppMode::ConfirmDeletion;
} else {
self.state.mode = AppMode::Search;
Action::None
}
Action::None
}

(KeyCode::Tab, KeyModifiers::NONE) | (KeyCode::Esc, _) => {
Expand All @@ -289,6 +291,46 @@ impl App {
}
}

fn handle_confirm_key(&mut self, key: KeyEvent) -> Action {
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Quit,

(KeyCode::Esc, _) => {
self.state.confirmation_input.clear();
self.state.mode = AppMode::Staging;
Action::None
}

(KeyCode::Enter, KeyModifiers::NONE) => {
let expected = self.state.staged_for_deletion.len().to_string();
if self.state.confirmation_input == expected {
self.state.confirmation_input.clear();
Action::ExecuteDeletion
} else {
self.state.set_status(
format!("Type '{}' to confirm deletion", expected),
StatusLevel::Warning,
);
Action::None
}
}

(KeyCode::Backspace, _) => {
self.state.confirmation_input.pop();
Action::None
}

(KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
if c.is_ascii_digit() {
self.state.confirmation_input.push(c);
}
Action::None
}

_ => Action::None,
}
}

async fn execute_deletion(&mut self, terminal: &mut tui::terminal::Terminal) {
self.state.mode = AppMode::Deleting;
let repos_to_delete: Vec<String> = self.state.staged_for_deletion.iter().cloned().collect();
Expand Down
5 changes: 4 additions & 1 deletion src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::github::types::Repository;
pub enum AppMode {
Search,
Staging,
ConfirmDeletion,
Deleting,
}

Expand Down Expand Up @@ -62,6 +63,7 @@ pub struct AppState {
pub deleting_repo: Option<String>,
pub spinner_frame: usize,
pub dry_run: bool,
pub confirmation_input: String,
pub filter_private: bool,
pub filter_forks: bool,
pub sort_mode: SortMode,
Expand All @@ -83,6 +85,7 @@ impl AppState {
deleting_repo: None,
spinner_frame: 0,
dry_run,
confirmation_input: String::new(),
filter_private: false,
filter_forks: false,
sort_mode: SortMode::default(),
Expand Down Expand Up @@ -127,7 +130,7 @@ impl AppState {
let new_idx = (self.staged_selected_index as i32 + delta).rem_euclid(len);
self.staged_selected_index = new_idx as usize;
}
AppMode::Deleting => {}
AppMode::ConfirmDeletion | AppMode::Deleting => {}
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/tui/widgets/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub fn render_header(frame: &mut Frame, area: Rect, state: &AppState) {
AppMode::Search => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
AppMode::Staging => Style::default()
AppMode::Staging | AppMode::ConfirmDeletion => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
AppMode::Deleting => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
Expand All @@ -22,6 +22,7 @@ pub fn render_header(frame: &mut Frame, area: Rect, state: &AppState) {
let mode_text = match state.mode {
AppMode::Search => "SEARCH",
AppMode::Staging => "STAGING",
AppMode::ConfirmDeletion => "CONFIRM",
AppMode::Deleting => "DELETING",
};

Expand Down
84 changes: 79 additions & 5 deletions src/tui/widgets/staged.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
use ratatui::{
layout::Rect,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame,
};

use crate::state::{AppMode, AppState};

pub fn render_staged(frame: &mut Frame, area: Rect, state: &AppState) {
let is_active = state.mode == AppMode::Staging;
let is_active = state.mode == AppMode::Staging || state.mode == AppMode::ConfirmDeletion;

let border_style = if is_active {
Style::default().fg(Color::Yellow)
Expand Down Expand Up @@ -43,7 +43,7 @@ pub fn render_staged(frame: &mut Frame, area: Rect, state: &AppState) {
.iter()
.enumerate()
.map(|(idx, name)| {
let is_selected = idx == state.staged_selected_index && is_active;
let is_selected = idx == state.staged_selected_index && state.mode == AppMode::Staging;

let repo = state.repositories.iter().find(|r| &r.full_name == name);

Expand Down Expand Up @@ -76,9 +76,83 @@ pub fn render_staged(frame: &mut Frame, area: Rect, state: &AppState) {
let list = List::new(items).block(block);

let mut list_state = ListState::default();
if is_active && !staged_names.is_empty() {
if state.mode == AppMode::Staging && !staged_names.is_empty() {
list_state.select(Some(state.staged_selected_index));
}

frame.render_stateful_widget(list, area, &mut list_state);

if state.mode == AppMode::ConfirmDeletion {
render_confirmation_dialog(frame, area, state);
}
}

fn render_confirmation_dialog(frame: &mut Frame, area: Rect, state: &AppState) {
let dialog_width = 45u16;
let dialog_height = 7u16;

let x = area.x + area.width.saturating_sub(dialog_width) / 2;
let y = area.y + area.height.saturating_sub(dialog_height) / 2;
let dialog_area = Rect::new(
x,
y,
dialog_width.min(area.width),
dialog_height.min(area.height),
);

frame.render_widget(Clear, dialog_area);

let count = state.staged_for_deletion.len();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(Span::styled(
" Confirm Deletion ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));

let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);

let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);

let prompt = Line::from(vec![
Span::raw("Type "),
Span::styled(
count.to_string(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw(" to delete "),
Span::styled(
format!("{} repo{}", count, if count == 1 { "" } else { "s" }),
Style::default().fg(Color::Red),
),
Span::raw(":"),
]);
frame.render_widget(Paragraph::new(prompt), chunks[0]);

let input_line = Line::from(vec![
Span::raw("> "),
Span::styled(
&state.confirmation_input,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled("_", Style::default().fg(Color::Gray)),
]);
frame.render_widget(Paragraph::new(input_line), chunks[1]);

let hint = Line::from(Span::styled(
"Press Esc to cancel",
Style::default().fg(Color::DarkGray),
));
frame.render_widget(Paragraph::new(hint), chunks[2]);
}
1 change: 1 addition & 0 deletions src/tui/widgets/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub fn render_status(frame: &mut Frame, area: Rect, state: &AppState) {
("Tab/Esc", "Back"),
("C-c/q", "Quit"),
],
AppMode::ConfirmDeletion => vec![("Enter", "Confirm"), ("Esc", "Cancel"), ("C-c", "Quit")],
AppMode::Deleting => vec![("", "Deleting repositories...")],
};

Expand Down