Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/forge_main/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ anyhow.workspace = true
derive_setters.workspace = true
lazy_static.workspace = true
reedline.workspace = true
crossterm = "0.29.0"
nu-ansi-term.workspace = true
nucleo.workspace = true
tracing.workspace = true
Expand Down
259 changes: 255 additions & 4 deletions crates/forge_main/src/editor.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crossterm::event::Event;
use forge_api::Environment;
use nu_ansi_term::{Color, Style};
use reedline::{
ColumnarMenu, DefaultHinter, EditCommand, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
MenuBuilder, Prompt, Reedline, ReedlineEvent, ReedlineMenu, Signal, default_emacs_keybindings,
ColumnarMenu, DefaultHinter, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode,
KeyModifiers, MenuBuilder, Prompt, PromptEditMode, Reedline, ReedlineEvent, ReedlineMenu,
ReedlineRawEvent, Signal, default_emacs_keybindings,
};

use super::completer::InputCompleter;
Expand Down Expand Up @@ -83,7 +85,7 @@ impl ForgeEditor {
.with_selected_text_style(Style::new().on(Color::White).fg(Color::Black)),
);

let edit_mode = Box::new(Emacs::new(Self::init()));
let edit_mode = Box::new(ForgeEditMode::new(Self::init()));

let editor = Reedline::create()
.with_completer(Box::new(InputCompleter::new(env.cwd, manager)))
Expand Down Expand Up @@ -117,6 +119,129 @@ impl ForgeEditor {
#[error(transparent)]
pub struct ReadLineError(std::io::Error);

/// Custom edit mode that wraps Emacs and intercepts paste events.
///
/// When the terminal sends a bracketed-paste (e.g. from a drag-and-drop),
/// this mode checks whether the pasted text is an existing file path and,
/// if so, wraps it in `@[...]` before it reaches the reedline buffer. This
/// gives the user immediate visual feedback in the input field.
struct ForgeEditMode {
inner: Emacs,
}

impl ForgeEditMode {
/// Creates a new `ForgeEditMode` wrapping an Emacs mode with the given
/// keybindings.
fn new(keybindings: reedline::Keybindings) -> Self {
Self { inner: Emacs::new(keybindings) }
}
}

impl EditMode for ForgeEditMode {
fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent {
// Convert to the underlying crossterm event so we can inspect it
let raw: Event = event.into();

if let Event::Paste(ref body) = raw {
let wrapped = wrap_pasted_text(body);
return ReedlineEvent::Edit(vec![EditCommand::InsertString(wrapped)]);
}

// For every other event, delegate to the inner Emacs mode.
// We need to reconstruct a ReedlineRawEvent from the crossterm Event.
// ReedlineRawEvent implements TryFrom<Event>.
match ReedlineRawEvent::try_from(raw) {
Ok(raw_event) => self.inner.parse_event(raw_event),
Err(()) => ReedlineEvent::None,
}
}

fn edit_mode(&self) -> PromptEditMode {
self.inner.edit_mode()
}
}

/// Transforms pasted text by wrapping bare file paths in `@[...]` syntax.
///
/// Called when a bracketed-paste event is received. The pasted content is
/// normalised (CRLF to LF) and then each whitespace-delimited token is
/// checked: if it is an absolute path pointing to an existing file it gets
/// wrapped so that forge's attachment parser picks it up.
///
/// Already-wrapped `@[...]` references and non-existent paths are left
/// untouched.
fn wrap_pasted_text(pasted: &str) -> String {
let normalised = pasted.replace("\r\n", "\n").replace('\r', "\n");
let trimmed = normalised.trim();

// If the whole paste is empty, just return normalised form
if trimmed.is_empty() {
return normalised;
}

// Scan token by token, wrapping any absolute paths that exist on disk
wrap_tokens(&normalised)
}

/// Strips surrounding single or double quotes that some terminals add
/// when dragging files with spaces in their names.
fn strip_surrounding_quotes(s: &str) -> &str {
if s.len() < 2 {
return s;
}
if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
&s[1..s.len() - 1]
} else {
s
}
}

/// Walks through `input` token-by-token and wraps absolute file paths.
fn wrap_tokens(input: &str) -> String {
let mut result = String::with_capacity(input.len() + 32);
let mut remaining = input;

while !remaining.is_empty() {
// Preserve leading whitespace
let ws_end = remaining
.find(|c: char| !c.is_whitespace())
.unwrap_or(remaining.len());
result.push_str(&remaining[..ws_end]);
remaining = &remaining[ws_end..];

if remaining.is_empty() {
break;
}

// Skip already-wrapped @[...] references
if remaining.starts_with("@[")
&& let Some(close) = remaining.find(']')
{
result.push_str(&remaining[..=close]);
remaining = &remaining[close + 1..];
continue;
}

// Extract the next whitespace-delimited token
let token_end = remaining
.find(|c: char| c.is_whitespace())
.unwrap_or(remaining.len());
let token = &remaining[..token_end];

let clean = strip_surrounding_quotes(token);
let path = Path::new(clean);
if path.is_absolute() && path.is_file() {
result.push_str(&format!("@[{}]", clean));
} else {
result.push_str(token);
}

remaining = &remaining[token_end..];
}

result
}

impl From<Signal> for ReadResult {
fn from(signal: Signal) -> Self {
match signal {
Expand All @@ -133,3 +258,129 @@ impl From<Signal> for ReadResult {
}
}
}

#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;

use super::*;

#[test]
fn test_wrap_pasted_text_no_paths() {
let fixture = "hello world";
let actual = wrap_pasted_text(fixture);
let expected = "hello world";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_already_wrapped() {
let fixture = "check @[/usr/bin/env]";
let actual = wrap_pasted_text(fixture);
let expected = "check @[/usr/bin/env]";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_existing_file() {
// /usr/bin/env exists on macOS/Linux
let fixture = "look at /usr/bin/env please";
let actual = wrap_pasted_text(fixture);
let expected = "look at @[/usr/bin/env] please";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_nonexistent_path_untouched() {
let fixture = "look at /nonexistent/path/file.rs please";
let actual = wrap_pasted_text(fixture);
let expected = "look at /nonexistent/path/file.rs please";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_bare_path_only() {
// Just a bare path (typical drag-and-drop result)
// /usr/bin/env is a real file, so it should be wrapped
let fixture = "/usr/bin/env";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_bare_path_nonexistent() {
let fixture = "/nonexistent/path/file.rs";
let actual = wrap_pasted_text(fixture);
let expected = "/nonexistent/path/file.rs";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_with_text_before() {
let fixture = "analyze /usr/bin/env";
let actual = wrap_pasted_text(fixture);
let expected = "analyze @[/usr/bin/env]";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_preserves_whitespace() {
let fixture = "hello world";
let actual = wrap_pasted_text(fixture);
let expected = "hello world";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_mixed_existing_and_nonexistent() {
let fixture = "check /usr/bin/env and /nonexistent/foo.rs";
let actual = wrap_pasted_text(fixture);
let expected = "check @[/usr/bin/env] and /nonexistent/foo.rs";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_crlf_normalised() {
let fixture = "/usr/bin/env\r\n";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]\n";
assert_eq!(actual, expected);
}

#[test]
fn test_wrap_pasted_text_single_quoted_path() {
let fixture = "'/usr/bin/env'";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]";
assert_eq!(actual, expected);
}

#[test]
fn test_strip_surrounding_quotes_single() {
let actual = strip_surrounding_quotes("'/some/path'");
let expected = "/some/path";
assert_eq!(actual, expected);
}

#[test]
fn test_strip_surrounding_quotes_double() {
let actual = strip_surrounding_quotes("\"/some/path\"");
let expected = "/some/path";
assert_eq!(actual, expected);
}

#[test]
fn test_strip_surrounding_quotes_none() {
let actual = strip_surrounding_quotes("/some/path");
let expected = "/some/path";
assert_eq!(actual, expected);
}

#[test]
fn test_strip_surrounding_quotes_single_char() {
let actual = strip_surrounding_quotes("'");
let expected = "'";
assert_eq!(actual, expected);
}
}
33 changes: 31 additions & 2 deletions shell-plugin/lib/bindings.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,41 @@
zle -N forge-accept-line
zle -N forge-completion

# Custom bracketed-paste handler to fix syntax highlighting after paste
# Addresses timing issues by ensuring buffer state stabilizes before prompt reset
# Custom bracketed-paste handler that wraps dropped file paths in @[] syntax
# and fixes syntax highlighting after paste
function forge-bracketed-paste() {
# Capture the cursor position before the paste to isolate the pasted text
local pre_cursor=$CURSOR
local pre_lbuffer="$LBUFFER"

# Call the built-in bracketed-paste widget first
zle .$WIDGET "$@"

# Extract the text that was actually pasted by comparing before/after state
local pasted="${LBUFFER#$pre_lbuffer}"

# Strip surrounding whitespace and quotes that terminals may add when
# dragging and dropping file paths
local trimmed="${pasted##[[:space:]]}"
trimmed="${trimmed%%[[:space:]]}"
# Remove surrounding single or double quotes (e.g. iTerm2 wraps paths with spaces)
local sq="'"
local dq='"'
if [[ "$trimmed" == ${sq}*${sq} ]]; then
trimmed="${trimmed#${sq}}"
trimmed="${trimmed%${sq}}"
elif [[ "$trimmed" == ${dq}*${dq} ]]; then
trimmed="${trimmed#${dq}}"
trimmed="${trimmed%${dq}}"
fi

# Check if the pasted text looks like a single file path that exists on disk
# and is not already wrapped in @[...]
if [[ -n "$trimmed" && -f "$trimmed" && "$pre_lbuffer" != *"@[" ]]; then
# Replace the pasted text with the @[...] wrapped version
LBUFFER="${pre_lbuffer}@[${trimmed}]"
fi

# Explicitly redisplay the buffer to ensure paste content is visible
# This is critical for large or multiline pastes
zle redisplay
Expand Down
Loading