Skip to content

Commit b13bf07

Browse files
committed
implement open editor command
1 parent d248b68 commit b13bf07

File tree

19 files changed

+1004
-15
lines changed

19 files changed

+1004
-15
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
All notable changes to this project will be documented in this file. This format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [Unreleased]
6+
7+
### Added
8+
- Add an “Open in Editor” action in the interactive UI and a `rsworktree worktree open-editor` CLI command, including guidance when no editor is configured.
9+
510
## [0.6.4] - 2025-10-10
611

712
### Added

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ crossterm = "0.27"
2020
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
2121
serde = { version = "1.0", features = ["derive"] }
2222
serde_json = "1.0"
23+
shell-words = "1.1"
2324

2425
[dev-dependencies]
2526
assert_cmd = "2.0"

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414
- [`rsworktree rm`](#rsworktree-rm)
1515
- [`rsworktree pr-github`](#rsworktree-pr-github)
1616
- [`rsworktree merge-pr-github`](#rsworktree-merge-pr-github)
17+
- [`rsworktree worktree open-editor`](#rsworktree-worktree-open-editor)
1718
- [Installation](#installation)
1819
- [Environment](#environment)
1920

2021
## Interactive mode
2122

2223
- Open a terminal UI for browsing worktrees, focusing actions, and inspecting details without memorizing subcommands.
2324
- 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+
- Available actions include opening worktrees, launching editors, removing worktrees, creating PRs, and merging PRs without leaving the TUI.
26+
- Use the **Open in Editor** action to launch the highlighted worktree in your configured editor (initial support covers `vim`, `cursor`, `webstorm`, and `rider`; see the quickstart for setup guidance).
2527
- The merge flow lets you decide whether to keep the local branch, delete the remote branch, and clean up the worktree before exiting.
2628
- ![Interactive mode screenshot](tapes/gifs/interactive-mode.gif)
2729

@@ -78,6 +80,12 @@
7880
- Options:
7981
- `<name>` — optional explicit worktree to operate on; defaults to the current directory.
8082

83+
### `rsworktree worktree open-editor`
84+
85+
- Open the specified worktree (or the current directory when omitted) in your configured editor.
86+
- Editor resolution checks the rsworktree config first, then falls back to `$EDITOR` / `$VISUAL`. If no editor is configured, the command prints actionable guidance instead of failing.
87+
- Initial support focuses on `vim`, `cursor`, `webstorm`, and `rider`. For setup instructions and troubleshooting, see `specs/002-i-want-to/quickstart.md`.
88+
8189
## Installation
8290

8391
Install from crates.io with:
@@ -98,4 +106,3 @@ After the binary is on your `PATH`, run `rsworktree --help` to explore the avail
98106
## Environment
99107

100108
Set `RSWORKTREE_SHELL` to override the shell used by `rsworktree cd` (falls back to `$SHELL` or `/bin/sh`).
101-

src/cli/mod.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::env;
1+
use std::{env, path::PathBuf};
22

33
use clap::{Parser, Subcommand};
44

@@ -12,6 +12,7 @@ use crate::{
1212
interactive,
1313
list::ListCommand,
1414
merge_pr_github::MergePrGithubCommand,
15+
open_editor::OpenEditorCommand,
1516
pr_github::{PrGithubCommand, PrGithubOptions},
1617
rm::RemoveCommand,
1718
},
@@ -35,6 +36,9 @@ enum Commands {
3536
/// Interactively browse and open worktrees.
3637
#[command(alias = "i")]
3738
Interactive,
39+
/// Worktree scoped commands.
40+
#[command(subcommand)]
41+
Worktree(WorktreeCommands),
3842
/// Remove a worktree tracked in `.rsworktree`.
3943
Rm(RmArgs),
4044
/// Create a GitHub pull request for the worktree's branch using the GitHub CLI.
@@ -43,6 +47,12 @@ enum Commands {
4347
MergePrGithub(MergePrGithubArgs),
4448
}
4549

50+
#[derive(Subcommand, Debug)]
51+
enum WorktreeCommands {
52+
/// Open a worktree in the configured editor.
53+
OpenEditor(OpenEditorArgs),
54+
}
55+
4656
#[derive(Parser, Debug)]
4757
struct CreateArgs {
4858
/// Name of the worktree (also used as the branch name)
@@ -70,6 +80,16 @@ struct RmArgs {
7080
force: bool,
7181
}
7282

83+
#[derive(Parser, Debug)]
84+
struct OpenEditorArgs {
85+
/// Name of the worktree to open
86+
#[arg(required_unless_present = "path")]
87+
name: Option<String>,
88+
/// Open a worktree by absolute path instead of managed name
89+
#[arg(long, value_name = "path", conflicts_with = "name")]
90+
path: Option<PathBuf>,
91+
}
92+
7393
#[derive(Parser, Debug)]
7494
struct PrGithubArgs {
7595
/// Name of the worktree to prepare a PR from (defaults to the current worktree)
@@ -126,6 +146,12 @@ pub fn run() -> color_eyre::Result<()> {
126146
Commands::Interactive => {
127147
interactive::run(&repo)?;
128148
}
149+
Commands::Worktree(command) => match command {
150+
WorktreeCommands::OpenEditor(args) => {
151+
let command = OpenEditorCommand::new(args.name, args.path);
152+
command.execute(&repo)?;
153+
}
154+
},
129155
Commands::Rm(args) => {
130156
let command = RemoveCommand::new(args.name, args.force);
131157
let _ = command.execute(&repo)?;

src/commands/interactive/command.rs

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::path::PathBuf;
1+
use std::path::{Path, PathBuf};
22

33
use color_eyre::{Result, eyre::WrapErr};
44
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
@@ -22,7 +22,11 @@ use super::{
2222
},
2323
view::{DetailData, DialogView, Snapshot},
2424
};
25-
use crate::commands::rm::{LocalBranchStatus, RemoveOutcome};
25+
use crate::{
26+
commands::rm::{LocalBranchStatus, RemoveOutcome},
27+
editor::LaunchOutcome,
28+
telemetry::EditorLaunchStatus,
29+
};
2630

2731
#[allow(dead_code)]
2832
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -140,16 +144,22 @@ where
140144
}
141145
}
142146

143-
pub fn run<F, G>(mut self, mut on_remove: F, mut on_create: G) -> Result<Option<Selection>>
147+
pub fn run<F, G, H>(
148+
mut self,
149+
mut on_remove: F,
150+
mut on_create: G,
151+
mut on_open_editor: H,
152+
) -> Result<Option<Selection>>
144153
where
145154
F: FnMut(&str, bool) -> Result<RemoveOutcome>,
146155
G: FnMut(&str, Option<&str>) -> Result<()>,
156+
H: FnMut(&str, &Path) -> color_eyre::Result<LaunchOutcome>,
147157
{
148158
self.terminal
149159
.hide_cursor()
150160
.wrap_err("failed to hide cursor")?;
151161

152-
let result = self.event_loop(&mut on_remove, &mut on_create);
162+
let result = self.event_loop(&mut on_remove, &mut on_create, &mut on_open_editor);
153163

154164
self.terminal
155165
.clear()
@@ -161,14 +171,16 @@ where
161171
result
162172
}
163173

164-
fn event_loop<F, G>(
174+
fn event_loop<F, G, H>(
165175
&mut self,
166176
on_remove: &mut F,
167177
on_create: &mut G,
178+
on_open_editor: &mut H,
168179
) -> Result<Option<Selection>>
169180
where
170181
F: FnMut(&str, bool) -> Result<RemoveOutcome>,
171182
G: FnMut(&str, Option<&str>) -> Result<()>,
183+
H: FnMut(&str, &Path) -> color_eyre::Result<LaunchOutcome>,
172184
{
173185
let mut state = ListState::default();
174186
self.sync_selection(&mut state);
@@ -179,23 +191,25 @@ where
179191
.draw(|frame| snapshot.render(frame, &mut state))?;
180192
let event = self.events.next()?;
181193

182-
match self.process_event(event, &mut state, on_remove, on_create)? {
194+
match self.process_event(event, &mut state, on_remove, on_create, on_open_editor)? {
183195
LoopControl::Continue => {}
184196
LoopControl::Exit(outcome) => return Ok(outcome),
185197
}
186198
}
187199
}
188200

189-
fn process_event<F, G>(
201+
fn process_event<F, G, H>(
190202
&mut self,
191203
event: Event,
192204
state: &mut ListState,
193205
on_remove: &mut F,
194206
on_create: &mut G,
207+
on_open_editor: &mut H,
195208
) -> Result<LoopControl>
196209
where
197210
F: FnMut(&str, bool) -> Result<RemoveOutcome>,
198211
G: FnMut(&str, Option<&str>) -> Result<()>,
212+
H: FnMut(&str, &Path) -> color_eyre::Result<LaunchOutcome>,
199213
{
200214
if let Some(dialog) = self.dialog.clone() {
201215
match dialog {
@@ -260,6 +274,21 @@ where
260274
self.handle_down(state);
261275
Ok(LoopControl::Continue)
262276
}
277+
KeyCode::Char('e') | KeyCode::Char('E') => match self.focus {
278+
Focus::Worktrees => {
279+
if let Some(entry) = self.current_entry().cloned() {
280+
self.trigger_open_in_editor(on_open_editor, &entry.name, &entry.path)?;
281+
} else {
282+
self.status = Some(StatusMessage::info("No worktree selected."));
283+
}
284+
Ok(LoopControl::Continue)
285+
}
286+
Focus::Actions => {
287+
self.select_action(Action::OpenInEditor);
288+
self.handle_enter(on_open_editor)
289+
}
290+
Focus::GlobalActions => Ok(LoopControl::Continue),
291+
},
263292
KeyCode::Left => {
264293
match self.focus {
265294
Focus::Actions => self.move_action(-1),
@@ -276,7 +305,7 @@ where
276305
}
277306
Ok(LoopControl::Continue)
278307
}
279-
KeyCode::Enter => self.handle_enter(),
308+
KeyCode::Enter => self.handle_enter(on_open_editor),
280309
_ => Ok(LoopControl::Continue),
281310
}
282311
}
@@ -327,7 +356,10 @@ where
327356
};
328357
}
329358

330-
fn handle_enter(&mut self) -> Result<LoopControl> {
359+
fn handle_enter<H>(&mut self, on_open_editor: &mut H) -> Result<LoopControl>
360+
where
361+
H: FnMut(&str, &Path) -> color_eyre::Result<LaunchOutcome>,
362+
{
331363
match self.focus {
332364
Focus::Worktrees => {
333365
if let Some(index) = self.selected {
@@ -349,6 +381,14 @@ where
349381
}
350382
self.status = Some(StatusMessage::info("No worktree selected."));
351383
}
384+
Action::OpenInEditor => {
385+
if let Some(entry) = self.current_entry().cloned() {
386+
self.trigger_open_in_editor(on_open_editor, &entry.name, &entry.path)?;
387+
} else {
388+
self.status = Some(StatusMessage::info("No worktree selected."));
389+
}
390+
return Ok(LoopControl::Continue);
391+
}
352392
Action::Remove => {
353393
if let Some(index) = self.selected {
354394
self.dialog = Some(Dialog::Remove(RemoveDialog::new(index)));
@@ -936,6 +976,46 @@ where
936976
.ensure_visible(visible_rows, Action::ALL.len());
937977
}
938978

979+
fn select_action(&mut self, action: Action) {
980+
if let Some(index) = Action::ALL
981+
.iter()
982+
.position(|candidate| *candidate == action)
983+
{
984+
self.action_panel.selected_index = index;
985+
let visible_rows = self.action_panel_visible_rows();
986+
self.action_panel
987+
.ensure_visible(visible_rows, Action::ALL.len());
988+
}
989+
}
990+
991+
fn trigger_open_in_editor<H>(
992+
&mut self,
993+
on_open_editor: &mut H,
994+
name: &str,
995+
path: &Path,
996+
) -> Result<()>
997+
where
998+
H: FnMut(&str, &Path) -> color_eyre::Result<LaunchOutcome>,
999+
{
1000+
match on_open_editor(name, path) {
1001+
Ok(outcome) => {
1002+
let status = match outcome.status {
1003+
EditorLaunchStatus::Success | EditorLaunchStatus::PreferenceMissing => {
1004+
StatusMessage::info(outcome.message)
1005+
}
1006+
_ => StatusMessage::error(outcome.message),
1007+
};
1008+
self.status = Some(status);
1009+
}
1010+
Err(error) => {
1011+
self.status = Some(StatusMessage::error(format!(
1012+
"Failed to open `{name}`: {error}"
1013+
)));
1014+
}
1015+
}
1016+
Ok(())
1017+
}
1018+
9391019
fn move_global_action(&mut self, delta: isize) {
9401020
let len = super::GLOBAL_ACTIONS.len() as isize;
9411021
if len == 0 {

src/commands/interactive/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,16 @@ pub(crate) enum Selection {
5454
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5555
pub(crate) enum Action {
5656
Open,
57+
OpenInEditor,
5758
Remove,
5859
PrGithub,
5960
MergePrGithub,
6061
}
6162

6263
impl Action {
63-
pub(crate) const ALL: [Action; 4] = [
64+
pub(crate) const ALL: [Action; 5] = [
6465
Action::Open,
66+
Action::OpenInEditor,
6567
Action::Remove,
6668
Action::PrGithub,
6769
Action::MergePrGithub,
@@ -70,6 +72,7 @@ impl Action {
7072
pub(crate) fn label(self) -> &'static str {
7173
match self {
7274
Action::Open => "Open",
75+
Action::OpenInEditor => "Open in Editor",
7376
Action::Remove => "Remove",
7477
Action::PrGithub => "PR (GitHub)",
7578
Action::MergePrGithub => "Merge PR (GitHub)",
@@ -79,7 +82,11 @@ impl Action {
7982
pub(crate) fn requires_selection(self) -> bool {
8083
matches!(
8184
self,
82-
Action::Open | Action::Remove | Action::PrGithub | Action::MergePrGithub
85+
Action::Open
86+
| Action::OpenInEditor
87+
| Action::Remove
88+
| Action::PrGithub
89+
| Action::MergePrGithub
8390
)
8491
}
8592

src/commands/interactive/runtime.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::{
1818
pr_github::{PrGithubCommand, PrGithubOptions},
1919
rm::RemoveCommand,
2020
},
21+
editor::launch_worktree,
2122
};
2223

2324
use super::{EventSource, Selection, WorktreeEntry, command::InteractiveCommand};
@@ -82,6 +83,7 @@ pub fn run(repo: &Repo) -> Result<()> {
8283
)),
8384
}
8485
},
86+
|name, path| launch_worktree(repo, name, path),
8587
);
8688
let cleanup_result = cleanup_terminal();
8789

0 commit comments

Comments
 (0)