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
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.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

# URL launching (for hyperlink support)
open = "5.0"

# Markdown & Text (Phase 3)
# pulldown-cmark = "0.9"
# rope = "0.1"
Expand Down
184 changes: 177 additions & 7 deletions src/terminal/pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,29 @@
use collections::HashMap;
use gpui::{
div, prelude::*, px, App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
KeyDownEvent, Render, Styled, Task, Window,
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Render, Styled, Task,
Window,
};
use settings::Settings;
use std::path::PathBuf;
use terminal::{
terminal_settings::TerminalSettings, Event as TerminalEvent, Terminal, TerminalBuilder,
terminal_settings::TerminalSettings, Event as TerminalEvent, MaybeNavigationTarget, Terminal,
TerminalBuilder,
};
use theme::ActiveTheme;
use util::shell::Shell;

use crate::terminal::tab::TerminalTab;

/// Default regex patterns for detecting file paths in terminal output.
/// Note: These can be noisy. Consider gating behind a setting if false positives are an issue.
const DEFAULT_PATH_REGEXES: &[&str] = &[
// File paths with optional line:col
r"[a-zA-Z0-9._\-~/]+/[a-zA-Z0-9._\-~/]+(?::\d+)?(?::\d+)?",
// Common source file extensions
r"[\w\-/\.]+\.(?:rs|js|ts|py|go|java|c|cpp|h|md|txt)",
];

/// Events emitted by the terminal pane
#[derive(Clone, Debug)]
pub enum TerminalPaneEvent {
Expand Down Expand Up @@ -90,6 +101,12 @@ impl TerminalPane {
.insert(workspace_id, working_directory.clone());
let working_dir = working_directory.clone();

// Prepare path hyperlink regex patterns
let path_hyperlink_regexes: Vec<String> = DEFAULT_PATH_REGEXES
.iter()
.map(|s| (*s).to_string())
.collect();

// Spawn terminal asynchronously
let terminal_task: Task<anyhow::Result<TerminalBuilder>> = TerminalBuilder::new(
working_directory,
Expand All @@ -99,9 +116,9 @@ impl TerminalPane {
cursor_shape,
alternate_scroll,
max_scroll_history,
Vec::new(), // path_hyperlink_regexes
500, // path_hyperlink_timeout_ms
false, // is_remote_terminal
path_hyperlink_regexes, // Use configured patterns
500, // path_hyperlink_timeout_ms
false, // is_remote_terminal
window_id,
None, // completion_tx
cx,
Expand All @@ -122,7 +139,7 @@ impl TerminalPane {
pane.handle_terminal_event(&terminal, event, cx);
});

let tab = TerminalTab::new(terminal.clone(), subscription, working_dir, cx);
let tab = TerminalTab::new(terminal.clone(), working_dir, subscription, cx);

let tabs = pane
.tabs_by_workspace
Expand Down Expand Up @@ -166,10 +183,66 @@ impl TerminalPane {
// Could play a sound or flash the window
tracing::debug!("Terminal bell");
}
// Handle URL open events
TerminalEvent::Open(target) => {
self.handle_open_target(target, cx);
}
// Handle hover state changes
TerminalEvent::NewNavigationTarget(target) => {
self.handle_navigation_target(target, cx);
}
_ => {}
}
}

/// Handle opening a URL or path
#[allow(clippy::needless_pass_by_ref_mut)] // Called from event handler context
#[allow(clippy::unused_self)] // Method signature required by event handler pattern
fn handle_open_target(&mut self, target: &MaybeNavigationTarget, _cx: &mut Context<Self>) {
match target {
MaybeNavigationTarget::Url(url) => {
tracing::info!("Opening URL: {}", url);
if let Err(e) = open::that(url) {
tracing::error!("Failed to open URL {}: {}", url, e);
}
}
MaybeNavigationTarget::PathLike(path_target) => {
let base_path = strip_line_col_suffix(&path_target.maybe_path);
let path = std::path::Path::new(base_path);

// Resolve relative paths using terminal's working directory
let full_path = if path.is_absolute() {
path.to_path_buf()
} else if let Some(terminal_dir) = &path_target.terminal_dir {
terminal_dir.join(path)
} else {
path.to_path_buf()
};

tracing::info!("Opening path: {:?}", full_path);
if let Err(e) = open::that(&full_path) {
tracing::error!("Failed to open path {:?}: {}", full_path, e);
}
}
}
}

/// Handle navigation target hover state changes
#[allow(clippy::needless_pass_by_ref_mut)] // Called from event handler context
#[allow(clippy::unused_self)] // Method signature required by event handler pattern
#[allow(clippy::ref_option)] // API signature from Zed terminal crate
fn handle_navigation_target(
&mut self,
target: &Option<MaybeNavigationTarget>,
cx: &mut Context<Self>,
) {
if let Some(MaybeNavigationTarget::Url(url)) = target {
tracing::debug!("Hovering URL: {}", url);
}
// Ensure hover state clears immediately when target becomes None
cx.notify();
}

/// Switch to a specific workspace (lazy-loads terminals on first switch)
pub fn set_active_workspace(
&mut self,
Expand Down Expand Up @@ -261,6 +334,51 @@ impl TerminalPane {
}
}

/// Handle mouse move events - forwards to Zed terminal for hyperlink detection
fn handle_mouse_move(
&mut self,
event: &MouseMoveEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(tab) = self.active_tab_mut() {
tab.terminal.update(cx, |terminal, cx| {
terminal.mouse_move(event, cx);
});
cx.notify();
}
}

/// Handle mouse down events - forwards to Zed terminal
fn handle_mouse_down(
&mut self,
event: &MouseDownEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(tab) = self.active_tab_mut() {
tab.terminal.update(cx, |terminal, cx| {
terminal.mouse_down(event, cx);
});
cx.notify();
}
}

/// Handle mouse up events - forwards to Zed terminal for URL opening
fn handle_mouse_up(
&mut self,
event: &MouseUpEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(tab) = self.active_tab_mut() {
tab.terminal.update(cx, |terminal, cx| {
terminal.mouse_up(event, cx);
});
cx.notify();
}
}

/// Render terminal tabs bar
#[allow(clippy::needless_pass_by_ref_mut)] // cx.listener requires &mut Context
fn render_tabs(&self, cx: &mut Context<Self>) -> impl IntoElement {
Expand Down Expand Up @@ -412,6 +530,9 @@ impl Render for TerminalPane {
.flex_col()
.size_full()
.on_key_down(cx.listener(Self::handle_key_down))
.on_mouse_move(cx.listener(Self::handle_mouse_move))
.on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
.child(self.render_terminal_content(cx))
.child(self.render_tabs(cx))
}
Expand Down Expand Up @@ -457,9 +578,26 @@ fn clamp_active_index(active_index: usize, len: usize) -> Option<usize> {
}
}

fn strip_line_col_suffix(path: &str) -> &str {
let Some((head, tail)) = path.rsplit_once(':') else {
return path;
};
if !tail.chars().all(|c| c.is_ascii_digit()) {
return path;
}
let Some((head2, tail2)) = head.rsplit_once(':') else {
return head;
};
if tail2.chars().all(|c| c.is_ascii_digit()) {
head2
} else {
head
}
}

#[cfg(test)]
mod tests {
use super::clamp_active_index;
use super::{clamp_active_index, strip_line_col_suffix};

#[test]
fn clamp_active_index_handles_empty() {
Expand All @@ -477,4 +615,36 @@ mod tests {
fn clamp_active_index_out_of_bounds() {
assert_eq!(clamp_active_index(5, 2), Some(1));
}

#[test]
fn strip_line_col_suffix_no_suffix() {
assert_eq!(strip_line_col_suffix("src/main.rs"), "src/main.rs");
}

#[test]
fn strip_line_col_suffix_line_only() {
assert_eq!(strip_line_col_suffix("src/main.rs:12"), "src/main.rs");
}

#[test]
fn strip_line_col_suffix_line_col() {
assert_eq!(strip_line_col_suffix("src/main.rs:12:5"), "src/main.rs");
}

#[test]
fn strip_line_col_suffix_windows_drive() {
assert_eq!(
strip_line_col_suffix("C:\\path\\file.rs"),
"C:\\path\\file.rs"
);
assert_eq!(
strip_line_col_suffix("C:\\path\\file.rs:12:3"),
"C:\\path\\file.rs"
);
}

#[test]
fn strip_line_col_suffix_non_numeric_tail() {
assert_eq!(strip_line_col_suffix("/tmp/foo:bar"), "/tmp/foo:bar");
}
}
10 changes: 5 additions & 5 deletions src/terminal/tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,28 @@ use terminal::Terminal;
pub struct TerminalTab {
/// The underlying Zed terminal
pub terminal: Entity<Terminal>,
/// Subscription to terminal events
_subscription: Subscription,
/// Working directory for this terminal
working_directory: Option<PathBuf>,
/// Custom title (if set by user)
custom_title: Option<String>,
/// Subscription to terminal events (automatically dropped when tab is dropped)
_subscription: Subscription,
}

impl TerminalTab {
/// Create a new terminal tab
/// Create a new terminal tab with its event subscription
#[allow(clippy::missing_const_for_fn)] // Cannot be const due to generic lifetime bounds
pub fn new<V: 'static>(
terminal: Entity<Terminal>,
subscription: Subscription,
working_directory: Option<PathBuf>,
subscription: Subscription,
_cx: &mut Context<V>,
) -> Self {
Self {
terminal,
_subscription: subscription,
working_directory,
custom_title: None,
_subscription: subscription,
}
}

Expand Down