Skip to content

Commit f642d60

Browse files
committed
add merge pr to interactive mode
1 parent e49ae5b commit f642d60

File tree

9 files changed

+536
-10
lines changed

9 files changed

+536
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. This format
55
## [0.5.3] - 2025-09-28
66

77
### Added
8-
- Improve interactive ui/ux.
8+
- Improve interactive UI/UX with a merge PR action and configurable cleanup prompts.
99

1010
## [0.5.1] - 2025-09-27
1111

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
- Open a terminal UI for browsing worktrees, focusing actions, and inspecting details without memorizing subcommands.
2323
- Launch it with the `interactive` command: `rsworktree interactive` (shortcut: `rsworktree i`).
24+
- Available actions include opening worktrees, removing them, creating PRs, and merging PRs without leaving the TUI.
25+
- The merge flow lets you decide whether to keep the local branch, delete the remote branch, and clean up the worktree before exiting.
2426
- ![Interactive mode screenshot](tapes/gifs/interactive-mode.gif)
2527

2628
## CLI commands

src/commands/interactive/command.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use ratatui::{
1515

1616
use super::{
1717
Action, EventSource, Focus, Selection, StatusMessage, WorktreeEntry,
18-
dialog::{CreateDialog, CreateDialogFocus, Dialog},
18+
dialog::{CreateDialog, CreateDialogFocus, Dialog, MergeDialog, MergeDialogFocus},
1919
view::{DetailData, DialogView, Snapshot},
2020
};
2121

@@ -152,6 +152,14 @@ where
152152
}
153153
return Ok(LoopControl::Continue);
154154
}
155+
Dialog::Merge(_) => {
156+
if let Event::Key(key) = event {
157+
if key.kind == KeyEventKind::Press {
158+
return self.handle_merge_dialog_key(key);
159+
}
160+
}
161+
return Ok(LoopControl::Continue);
162+
}
155163
}
156164
}
157165

@@ -286,6 +294,13 @@ where
286294
}
287295
self.status = Some(StatusMessage::info("No worktree selected."));
288296
}
297+
Action::MergePrGithub => {
298+
if let Some(index) = self.selected {
299+
self.dialog = Some(Dialog::Merge(MergeDialog::new(index)));
300+
} else {
301+
self.status = Some(StatusMessage::info("No worktree selected."));
302+
}
303+
}
289304
}
290305
}
291306
Focus::GlobalActions => match self.global_action_selected {
@@ -474,6 +489,89 @@ where
474489
Ok(())
475490
}
476491

492+
fn handle_merge_dialog_key(&mut self, key: KeyEvent) -> Result<LoopControl> {
493+
let dialog_option = self.dialog.take();
494+
let Some(Dialog::Merge(mut dialog)) = dialog_option else {
495+
self.dialog = dialog_option;
496+
return Ok(LoopControl::Continue);
497+
};
498+
499+
let mut reinstate = true;
500+
let mut outcome = LoopControl::Continue;
501+
502+
match key.code {
503+
KeyCode::Esc => {
504+
reinstate = false;
505+
self.status = Some(StatusMessage::info("Merge cancelled."));
506+
}
507+
KeyCode::Tab => dialog.focus_next(),
508+
KeyCode::BackTab => dialog.focus_prev(),
509+
KeyCode::Up | KeyCode::Char('k') => match dialog.focus {
510+
MergeDialogFocus::Options => dialog.move_option(-1),
511+
MergeDialogFocus::Buttons => dialog.focus = MergeDialogFocus::Options,
512+
},
513+
KeyCode::Down | KeyCode::Char('j') => match dialog.focus {
514+
MergeDialogFocus::Options => dialog.move_option(1),
515+
MergeDialogFocus::Buttons => {}
516+
},
517+
KeyCode::Left => {
518+
if dialog.focus == MergeDialogFocus::Buttons {
519+
dialog.move_button(-1);
520+
}
521+
}
522+
KeyCode::Right => {
523+
if dialog.focus == MergeDialogFocus::Buttons {
524+
dialog.move_button(1);
525+
}
526+
}
527+
KeyCode::Char(' ') => {
528+
if dialog.focus == MergeDialogFocus::Options {
529+
dialog.toggle_selected_option();
530+
}
531+
}
532+
KeyCode::Enter => match dialog.focus {
533+
MergeDialogFocus::Options => dialog.toggle_selected_option(),
534+
MergeDialogFocus::Buttons => {
535+
if dialog.buttons_selected == 0 {
536+
reinstate = false;
537+
self.status = Some(StatusMessage::info("Merge cancelled."));
538+
} else {
539+
match self.build_merge_selection(&dialog) {
540+
Some(selection) => {
541+
reinstate = false;
542+
outcome = LoopControl::Exit(Some(selection));
543+
}
544+
None => {
545+
reinstate = false;
546+
self.status = Some(StatusMessage::error(
547+
"Selected worktree no longer exists.",
548+
));
549+
}
550+
}
551+
}
552+
}
553+
},
554+
_ => {}
555+
}
556+
557+
if reinstate {
558+
self.dialog = Some(Dialog::Merge(dialog));
559+
}
560+
561+
Ok(outcome)
562+
}
563+
564+
fn build_merge_selection(&self, dialog: &MergeDialog) -> Option<Selection> {
565+
self.worktrees
566+
.get(dialog.index)
567+
.map(|entry| Selection::MergePrGithub {
568+
name: entry.name.clone(),
569+
remove_local_branch: dialog.remove_local_branch(),
570+
remove_remote_branch: dialog.remove_remote_branch(),
571+
remove_worktree: dialog.remove_worktree(),
572+
})
573+
}
574+
477575
fn submit_create<G>(
478576
&mut self,
479577
dialog: &mut CreateDialog,
@@ -680,6 +778,14 @@ where
680778
}
681779
Some(Dialog::Info { message }) => Some(DialogView::Info { message }),
682780
Some(Dialog::Create(dialog)) => Some(DialogView::Create(dialog.into())),
781+
Some(Dialog::Merge(dialog)) => {
782+
self.worktrees
783+
.get(dialog.index)
784+
.map(|entry| DialogView::Merge {
785+
name: entry.name.clone(),
786+
dialog: dialog.into(),
787+
})
788+
}
683789
None => None,
684790
};
685791

src/commands/interactive/dialog.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,119 @@ impl CreateDialogView {
189189
}
190190
}
191191

192+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193+
pub(crate) enum MergeDialogFocus {
194+
Options,
195+
Buttons,
196+
}
197+
198+
#[derive(Clone, Debug)]
199+
pub(crate) struct MergeDialog {
200+
pub(crate) index: usize,
201+
pub(crate) focus: MergeDialogFocus,
202+
pub(crate) options_selected: usize,
203+
pub(crate) buttons_selected: usize,
204+
pub(crate) remove_local_branch: bool,
205+
pub(crate) remove_remote_branch: bool,
206+
pub(crate) remove_worktree: bool,
207+
}
208+
209+
impl MergeDialog {
210+
const OPTION_COUNT: usize = 3;
211+
const BUTTON_COUNT: usize = 2;
212+
213+
pub(crate) fn new(index: usize) -> Self {
214+
Self {
215+
index,
216+
focus: MergeDialogFocus::Options,
217+
options_selected: 0,
218+
buttons_selected: 1,
219+
remove_local_branch: true,
220+
remove_remote_branch: false,
221+
remove_worktree: false,
222+
}
223+
}
224+
225+
pub(crate) fn focus_next(&mut self) {
226+
self.focus = match self.focus {
227+
MergeDialogFocus::Options => MergeDialogFocus::Buttons,
228+
MergeDialogFocus::Buttons => MergeDialogFocus::Options,
229+
};
230+
}
231+
232+
pub(crate) fn focus_prev(&mut self) {
233+
self.focus_next();
234+
}
235+
236+
pub(crate) fn move_option(&mut self, delta: isize) {
237+
let len = Self::OPTION_COUNT as isize;
238+
let current = self.options_selected as isize;
239+
let next = (current + delta).rem_euclid(len);
240+
self.options_selected = next as usize;
241+
}
242+
243+
pub(crate) fn move_button(&mut self, delta: isize) {
244+
let len = Self::BUTTON_COUNT as isize;
245+
let current = self.buttons_selected as isize;
246+
let next = (current + delta).rem_euclid(len);
247+
self.buttons_selected = next as usize;
248+
}
249+
250+
pub(crate) fn toggle_selected_option(&mut self) {
251+
match self.options_selected {
252+
0 => self.remove_local_branch = !self.remove_local_branch,
253+
1 => self.remove_remote_branch = !self.remove_remote_branch,
254+
2 => self.remove_worktree = !self.remove_worktree,
255+
_ => {}
256+
}
257+
}
258+
259+
pub(crate) fn remove_local_branch(&self) -> bool {
260+
self.remove_local_branch
261+
}
262+
263+
pub(crate) fn remove_remote_branch(&self) -> bool {
264+
self.remove_remote_branch
265+
}
266+
267+
pub(crate) fn remove_worktree(&self) -> bool {
268+
self.remove_worktree
269+
}
270+
}
271+
272+
#[derive(Clone, Debug)]
273+
pub(crate) struct MergeDialogView {
274+
pub(crate) focus: MergeDialogFocus,
275+
pub(crate) options_selected: usize,
276+
pub(crate) buttons_selected: usize,
277+
pub(crate) remove_local_branch: bool,
278+
pub(crate) remove_remote_branch: bool,
279+
pub(crate) remove_worktree: bool,
280+
}
281+
282+
impl From<&MergeDialog> for MergeDialogView {
283+
fn from(dialog: &MergeDialog) -> Self {
284+
Self {
285+
focus: dialog.focus,
286+
options_selected: dialog.options_selected,
287+
buttons_selected: dialog.buttons_selected,
288+
remove_local_branch: dialog.remove_local_branch,
289+
remove_remote_branch: dialog.remove_remote_branch,
290+
remove_worktree: dialog.remove_worktree,
291+
}
292+
}
293+
}
294+
295+
impl From<MergeDialog> for MergeDialogView {
296+
fn from(dialog: MergeDialog) -> Self {
297+
Self::from(&dialog)
298+
}
299+
}
300+
192301
#[derive(Clone, Debug)]
193302
pub(crate) enum Dialog {
194303
ConfirmRemove { index: usize },
195304
Info { message: String },
196305
Create(CreateDialog),
306+
Merge(MergeDialog),
197307
}

src/commands/interactive/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ pub(crate) const GLOBAL_ACTIONS: [&str; 2] = ["Create worktree", "Cd to root dir
4242
pub(crate) enum Selection {
4343
Worktree(String),
4444
PrGithub(String),
45+
MergePrGithub {
46+
name: String,
47+
remove_local_branch: bool,
48+
remove_remote_branch: bool,
49+
remove_worktree: bool,
50+
},
4551
RepoRoot,
4652
}
4753

@@ -50,21 +56,31 @@ pub(crate) enum Action {
5056
Open,
5157
Remove,
5258
PrGithub,
59+
MergePrGithub,
5360
}
5461

5562
impl Action {
56-
pub(crate) const ALL: [Action; 3] = [Action::Open, Action::Remove, Action::PrGithub];
63+
pub(crate) const ALL: [Action; 4] = [
64+
Action::Open,
65+
Action::Remove,
66+
Action::PrGithub,
67+
Action::MergePrGithub,
68+
];
5769

5870
pub(crate) fn label(self) -> &'static str {
5971
match self {
6072
Action::Open => "Open",
6173
Action::Remove => "Remove",
6274
Action::PrGithub => "PR (GitHub)",
75+
Action::MergePrGithub => "Merge PR (GitHub)",
6376
}
6477
}
6578

6679
pub(crate) fn requires_selection(self) -> bool {
67-
matches!(self, Action::Open | Action::Remove | Action::PrGithub)
80+
matches!(
81+
self,
82+
Action::Open | Action::Remove | Action::PrGithub | Action::MergePrGithub
83+
)
6884
}
6985

7086
pub(crate) fn from_index(index: usize) -> Self {

src/commands/interactive/runtime.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::{
1414
cd::{CdCommand, shell_command},
1515
create::{CreateCommand, CreateOutcome},
1616
list::{find_worktrees, format_worktree},
17+
merge_pr_github::MergePrGithubCommand,
1718
pr_github::{PrGithubCommand, PrGithubOptions},
1819
rm::RemoveCommand,
1920
},
@@ -115,6 +116,26 @@ pub fn run(repo: &Repo) -> Result<()> {
115116
let mut command = PrGithubCommand::new(options);
116117
command.execute(repo)?;
117118
}
119+
Selection::MergePrGithub {
120+
name,
121+
remove_local_branch,
122+
remove_remote_branch,
123+
remove_worktree,
124+
} => {
125+
let mut command = MergePrGithubCommand::new(name.clone());
126+
if !remove_local_branch {
127+
command.disable_remove_local();
128+
}
129+
if remove_remote_branch {
130+
command.enable_remove_remote();
131+
}
132+
command.execute(repo)?;
133+
134+
if remove_worktree {
135+
let remove_command = RemoveCommand::new(name, false);
136+
remove_command.execute(repo)?;
137+
}
138+
}
118139
}
119140
}
120141

0 commit comments

Comments
 (0)