diff --git a/crates/but/src/args/metrics.rs b/crates/but/src/args/metrics.rs index 614de27deff..d7c028fe8d6 100644 --- a/crates/but/src/args/metrics.rs +++ b/crates/but/src/args/metrics.rs @@ -11,6 +11,7 @@ pub enum CommandName { Rub, Diff, Edit, + Open, Show, Commit, CommitEmpty, diff --git a/crates/but/src/args/mod.rs b/crates/but/src/args/mod.rs index a1c6a3c2fe2..8d6c1f59f89 100644 --- a/crates/but/src/args/mod.rs +++ b/crates/but/src/args/mod.rs @@ -216,6 +216,24 @@ pub enum Subcommands { file: String, }, + /// Open a file with uncommitted changes in the workspace using your configured editor. + /// + /// This is a shorthand command to be able to open a file in the workspace using a CLI ID. The + /// primary use case is to run `but status` followed by `but open ` using one of the IDs + /// emitted by `status`. + /// + /// ## Examples + /// + /// ```text + /// but open nt + /// ``` + /// + #[clap(verbatim_doc_comment)] + Open { + /// The CLI ID of the entity to open in your editor + target: String, + }, + /// Shows detailed information about a commit or branch. /// /// When given a commit ID, displays the full commit message, author information, diff --git a/crates/but/src/command/legacy/mod.rs b/crates/but/src/command/legacy/mod.rs index bfe8251f7d8..516799bb9c7 100644 --- a/crates/but/src/command/legacy/mod.rs +++ b/crates/but/src/command/legacy/mod.rs @@ -13,6 +13,7 @@ pub mod mark; pub mod mcp; pub mod mcp_internal; pub mod merge; +pub mod open; pub mod oplog; pub mod pick; pub mod pull; diff --git a/crates/but/src/command/legacy/open.rs b/crates/but/src/command/legacy/open.rs new file mode 100644 index 00000000000..ba718f858f8 --- /dev/null +++ b/crates/but/src/command/legacy/open.rs @@ -0,0 +1,33 @@ +use anyhow::{Result, bail}; +use but_ctx::Context; + +use crate::{CliId, IdMap, tui::get_text}; + +pub(crate) fn open_target(ctx: &mut Context, target: &str) -> Result<()> { + let id_map = IdMap::new_from_context(ctx, None)?; + let cli_ids = id_map.parse_using_context(target, ctx)?; + if cli_ids.is_empty() { + bail!("ID '{target}' not found") + } + + if cli_ids.len() > 1 { + bail!( + "ID '{target}' is ambiguous. Found {} matches", + cli_ids.len() + ) + } + + let cli_id = &cli_ids[0]; + + match cli_id { + CliId::Uncommitted(uncommitted_id) => { + if !uncommitted_id.is_entire_file { + bail!("Cannot open part of file") + } + + let path = &uncommitted_id.hunk_assignments.head.path; + get_text::launch_editor(path.as_ref()) + } + _ => bail!("Can only open uncommitted files"), + } +} diff --git a/crates/but/src/lib.rs b/crates/but/src/lib.rs index 6dca90e9c27..f7e005d33c9 100644 --- a/crates/but/src/lib.rs +++ b/crates/but/src/lib.rs @@ -235,6 +235,17 @@ async fn match_subcommand( Ok(()) } Subcommands::Gui => command::gui::open(&args.current_dir).emit_metrics(metrics_ctx), + Subcommands::Open { target } => { + let mut ctx = setup::init_ctx( + &args, + InitCtxOptions { + background_sync: BackgroundSync::Enabled, + ..Default::default() + }, + out, + )?; + command::legacy::open::open_target(&mut ctx, target.as_ref()).emit_metrics(metrics_ctx) + } Subcommands::Completions { shell } => { command::completions::generate_completions(shell).emit_metrics(metrics_ctx) } diff --git a/crates/but/src/tui/get_text.rs b/crates/but/src/tui/get_text.rs index 397f98c1c41..d89828ca19c 100644 --- a/crates/but/src/tui/get_text.rs +++ b/crates/but/src/tui/get_text.rs @@ -1,11 +1,26 @@ //! Various functions that involve launching the Git editor (i.e. `GIT_EDITOR`). //! //! When no external editor is configured, falls back to the built-in TUI editor. -use std::ffi::OsStr; +use std::{ffi::OsStr, path::Path}; use anyhow::{Result, bail}; use bstr::{BStr, BString, ByteSlice}; +/// Opens the provided filepath in the user's preferred editor. +/// +/// # Note +/// +/// The user-configured editor command is allowed to be a shell expression (e.g. `"code --wait"`), +/// and is therefore executed within a shell. As such, the path passed into this function **must be +/// safe to use in a shell context**. Never pass in user-supplied strings that have not been +/// verified to point to a file that `but` is supposed to be allowed to open. +pub fn launch_editor(path: &Path) -> Result<()> { + match get_editor_command() { + Some(editor_cmd) => launch_external_editor(&editor_cmd, path), + None => bail!("Built-in editor not yet supported"), + } +} + /// Launches the user's preferred text editor to edit some `initial_text`, /// identified by a `filename_safe_intent` to help the user understand what's wanted of them. /// Note that this string must be valid in filenames. @@ -68,20 +83,30 @@ fn from_external_editor( .tempfile()?; std::fs::write(&tempfile, initial_text)?; - // The editor command is allowed to be a shell expression, e.g. "code --wait" is somewhat common. - // We need to execute within a shell to make sure we don't get "No such file or directory" errors. + launch_external_editor(editor_cmd, tempfile.path())?; + Ok(std::fs::read(&tempfile)?.into()) +} + +/// Launch an external editor. +/// +/// # Note +/// +/// The editor command is allowed to be a shell expression (e.g. `"code --wait"`), +/// so it is executed within a shell to avoid "No such file or directory" errors. +fn launch_external_editor(editor_cmd: &str, path_safe_intent: &Path) -> Result<(), anyhow::Error> { let status = gix::command::prepare(editor_cmd) - .arg(tempfile.path()) + .arg(path_safe_intent) .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) .with_shell() .spawn()? .wait()?; - if !status.success() { bail!("Editor exited with non-zero status"); } - Ok(std::fs::read(&tempfile)?.into()) + + Ok(()) } /// Launch the built-in TUI editor. diff --git a/crates/but/src/utils/metrics.rs b/crates/but/src/utils/metrics.rs index 4131a2f6296..23e638f9ea0 100644 --- a/crates/but/src/utils/metrics.rs +++ b/crates/but/src/utils/metrics.rs @@ -198,6 +198,7 @@ impl Subcommands { skill::Subcommands::Check { .. } => SkillCheck, }, Subcommands::Edit { .. } => Edit, + Subcommands::Open { .. } => Open, Subcommands::Link(link::Platform { .. }) => Unknown, Subcommands::Onboarding | Subcommands::EvalHook => Unknown, }