diff --git a/Cargo.lock b/Cargo.lock index 8128435..15cea8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,6 +696,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1788,6 +1790,7 @@ dependencies = [ "open", "portable-pty", "regex", + "rfd", "serde", "serde_json", "tokio", @@ -2509,6 +2512,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2716,6 +2725,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "roxmltree" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index 1ac7633..24f079c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", " libc = "0.2" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } +rfd = "0.17" # The profile that 'dist' will build with [profile.dist] diff --git a/src/ui.rs b/src/ui.rs index c182343..423f3da 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -62,6 +62,11 @@ pub enum Message { FocusPreviousTab, NextIdle, PendingInput(PendingKey), + /// Open the native folder picker for the pending tab `tab_id`. + OpenProjectDialog(usize), + /// Folder picker resolved: spawn or focus the project tab for the + /// chosen folder. `None` means cancelled (no-op). + SpawnOrFocusProjectTab { tab_id: usize, path: Option }, McpSpawnAgent(usize, Option, Option, Option, Option, Option, Option), McpCloseTab(usize, usize), McpCheckpoint(usize), @@ -232,6 +237,9 @@ pub struct App { ckpt_store: crate::checkpoint_store::CheckpointStore, toasts: Vec, next_toast_id: usize, + /// A native folder picker is in flight; further `OpenProjectDialog` + /// requests are ignored until its `SpawnOrFocusProjectTab` arrives. + project_dialog_open: bool, } impl App { @@ -272,6 +280,7 @@ impl App { ckpt_store, toasts: Vec::new(), next_toast_id: 0, + project_dialog_open: false, }; (app, listen_task) @@ -307,6 +316,10 @@ impl App { Message::FocusPreviousTab => self.handle_focus_previous_tab(), Message::NextIdle => self.handle_next_idle(), Message::PendingInput(key) => self.handle_pending_input(key), + Message::OpenProjectDialog(tab_id) => self.handle_open_project_dialog(tab_id), + Message::SpawnOrFocusProjectTab { tab_id, path } => { + self.handle_spawn_or_focus_project_tab(tab_id, path) + } Message::CloseTab(tab_id) => self.close_tab(tab_id), Message::McpCloseTab(rtid, target) => self.handle_mcp_close_tab(rtid, target), Message::SelectTab(tab_id) => self.handle_select_tab(tab_id), diff --git a/src/ui/handlers/tabs.rs b/src/ui/handlers/tabs.rs index b3b6906..c265ff7 100644 --- a/src/ui/handlers/tabs.rs +++ b/src/ui/handlers/tabs.rs @@ -374,44 +374,81 @@ impl App { self.close_tab(tab_id) } PendingKey::Submit => { - let raw_path = input.clone(); - let expanded = if raw_path.starts_with('~') { - let home = std::env::var("HOME").unwrap_or_default(); - raw_path.replacen('~', &home, 1) - } else { - raw_path - }; - let path = PathBuf::from(&expanded); - let canonical = std::fs::canonicalize(&path) - .unwrap_or(path); - - if let Some(existing) = self.tabs.find_project_for_dir(&canonical) { - self.focus_tab(existing); - return self.close_tab(tab_id); - } - - let parent_id = match self.tabs.get(tab_id) { - Some(t) => t.parent_id, - None => return Task::none(), - }; - self.tabs.remove(tab_id); - - let (id, task) = self.spawn_tab( - true, - AgentRank::Project, - Some(canonical), - parent_id, - None, - None, - None, - None, - ); - self.focus_tab(id); - task + let home = std::env::var("HOME").unwrap_or_default(); + let path = expand_tilde(input, &home); + self.open_project_from_path(tab_id, path) } } } + /// Open a project from `path`, consuming the pending tab `tab_id`: + /// canonicalize, focus an already-open project if one matches, + /// otherwise replace the pending tab with a spawned project tab. + /// No-op when `tab_id` is gone — the folder dialog is non-modal, so + /// its result can arrive after the pending tab was closed or + /// submitted via a typed path. + fn open_project_from_path(&mut self, tab_id: usize, path: PathBuf) -> Task { + let parent_id = match self.tabs.get(tab_id) { + Some(t) => t.parent_id, + None => return Task::none(), + }; + + let canonical = std::fs::canonicalize(&path).unwrap_or(path); + + if let Some(existing) = self.tabs.find_project_for_dir(&canonical) { + self.focus_tab(existing); + return self.close_tab(tab_id); + } + + self.tabs.remove(tab_id); + + let (id, task) = self.spawn_tab( + true, + AgentRank::Project, + Some(canonical), + parent_id, + None, + None, + None, + None, + ); + self.focus_tab(id); + task + } + + pub(in crate::ui) fn handle_open_project_dialog(&mut self, tab_id: usize) -> Task { + if self.project_dialog_open || !self.tabs.contains(tab_id) { + return Task::none(); + } + self.project_dialog_open = true; + + // The dialog future must be built here in update() — on macOS rfd + // dialogs have to be spawned on the main thread; only the await + // runs on the executor. + let home = std::env::var("HOME").unwrap_or_default(); + let dialog = rfd::AsyncFileDialog::new() + .set_title("Open Project") + .set_directory(home) + .pick_folder(); + + Task::perform(dialog, move |handle| Message::SpawnOrFocusProjectTab { + tab_id, + path: handle.map(|h| h.path().to_path_buf()), + }) + } + + pub(in crate::ui) fn handle_spawn_or_focus_project_tab( + &mut self, + tab_id: usize, + path: Option, + ) -> Task { + self.project_dialog_open = false; + match path { + Some(path) => self.open_project_from_path(tab_id, path), + None => Task::none(), + } + } + pub(in crate::ui) fn handle_mcp_close_tab( &mut self, requesting_tab_id: usize, @@ -583,6 +620,16 @@ impl App { } } +/// Expand a leading `~` to `home`. Only the first occurrence is +/// replaced, matching shell behavior for `~/...` paths. +fn expand_tilde(raw: &str, home: &str) -> PathBuf { + if raw.starts_with('~') { + PathBuf::from(raw.replacen('~', home, 1)) + } else { + PathBuf::from(raw) + } +} + fn build_list_tabs_json( tabs: &crate::tabs::Tabs, requesting_tab_id: usize, @@ -638,6 +685,39 @@ fn build_list_tabs_json( .collect() } +#[cfg(test)] +mod expand_tilde_tests { + use std::path::PathBuf; + + use super::expand_tilde; + + #[test] + fn expands_leading_tilde() { + assert_eq!( + expand_tilde("~/work", "/Users/me"), + PathBuf::from("/Users/me/work"), + ); + } + + #[test] + fn bare_tilde_is_home() { + assert_eq!(expand_tilde("~", "/Users/me"), PathBuf::from("/Users/me")); + } + + #[test] + fn absolute_path_untouched() { + assert_eq!(expand_tilde("/tmp/x", "/Users/me"), PathBuf::from("/tmp/x")); + } + + #[test] + fn interior_tilde_untouched() { + assert_eq!( + expand_tilde("/tmp/~x", "/Users/me"), + PathBuf::from("/tmp/~x"), + ); + } +} + #[cfg(test)] mod list_tabs_tests { use super::build_list_tabs_json; diff --git a/src/widget/terminal.rs b/src/widget/terminal.rs index 3256e1a..7b7cabe 100644 --- a/src/widget/terminal.rs +++ b/src/widget/terminal.rs @@ -16,7 +16,7 @@ use iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Text, Widget}; use iced::keyboard; use iced::mouse; use iced::window; -use iced::{Border, Color, Element, Event, Font, Length, Point, Rectangle, Size}; +use iced::{Border, Color, Element, Event, Length, Point, Rectangle, Size}; use crate::config::Config; use crate::keys; @@ -27,6 +27,20 @@ use crate::ui::Message; pub const SCROLLBAR_WIDTH: f32 = 8.0; +const BROWSE_HINT: &str = "[ Browse… ]"; + +/// Where the `[ Browse… ]` hint sits in a pending tab: one blank line +/// below the `Project directory:` prompt. Pure geometry so `draw`, +/// `update`, and `mouse_interaction` agree on the hit target. +fn browse_hint_rect(bounds: &Rectangle, char_width: f32, char_height: f32) -> Rectangle { + Rectangle { + x: bounds.x, + y: bounds.y + 2.0 * char_height, + width: BROWSE_HINT.chars().count() as f32 * char_width, + height: char_height, + } +} + const SCROLLBAR_MIN_THUMB: f32 = 12.0; struct TrackGeometry { @@ -289,7 +303,9 @@ impl<'a> Widget for TerminalWidget<'a> { ) { let bounds = layout.bounds(); - // Pending tab: render a simple text prompt at top-left. + // Pending tab: render a simple text prompt at top-left. Both lines + // use the configured terminal font so char_width/char_height (and + // therefore browse_hint_rect) match what is actually drawn. if let Some(input) = &self.tab.pending_input { let label = format!("Project directory: {}_", input); @@ -299,7 +315,7 @@ impl<'a> Widget for TerminalWidget<'a> { bounds: Size::new(bounds.width, self.char_height()), size: self.config.font_size.into(), line_height: text::LineHeight::Relative(self.config.line_height), - font: Font::MONOSPACE, + font: self.config.font(), align_x: iced::alignment::Horizontal::Left.into(), align_y: iced::alignment::Vertical::Top.into(), shaping: text::Shaping::Advanced, @@ -309,6 +325,27 @@ impl<'a> Widget for TerminalWidget<'a> { self.theme.fg, bounds, ); + + let hint = browse_hint_rect(&bounds, self.char_width(), self.char_height()); + renderer.fill_text( + Text { + content: BROWSE_HINT.to_string(), + // Lay out against the full widget width; the rect only + // defines the click target, and a too-small layout box + // clips the trailing glyphs. + bounds: Size::new(bounds.width, self.char_height()), + size: self.config.font_size.into(), + line_height: text::LineHeight::Relative(self.config.line_height), + font: self.config.font(), + align_x: iced::alignment::Horizontal::Left.into(), + align_y: iced::alignment::Vertical::Top.into(), + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + }, + hint.position(), + self.theme.blue, + bounds, + ); return; } @@ -492,11 +529,21 @@ impl<'a> Widget for TerminalWidget<'a> { fn mouse_interaction( &self, tree: &widget::Tree, - _layout: Layout<'_>, - _cursor: mouse::Cursor, + layout: Layout<'_>, + cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &iced::Renderer, ) -> mouse::Interaction { + if self.tab.pending_input.is_some() { + let bounds = layout.bounds(); + let hint = browse_hint_rect(&bounds, self.char_width(), self.char_height()); + return if cursor.position().is_some_and(|pos| hint.contains(pos)) { + mouse::Interaction::Pointer + } else { + mouse::Interaction::default() + }; + } + let state = tree.state.downcast_ref::(); if state.drop_hovering { mouse::Interaction::Copy @@ -530,6 +577,24 @@ impl<'a> Widget for TerminalWidget<'a> { match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if let Some(pos) = cursor_pos { + // Pending tabs only respond to the [ Browse… ] hint; + // swallow other presses so they don't fall through to + // the selection machinery on the empty grid below. + if self.tab.pending_input.is_some() { + if bounds.contains(pos) { + let hint = browse_hint_rect( + &bounds, + self.char_width(), + self.char_height(), + ); + if hint.contains(pos) { + shell.publish(Message::OpenProjectDialog(self.tab.id)); + } + shell.capture_event(); + } + return; + } + // Open link on control+click. if let Interaction::HoveringLink { url, .. } = &state.interaction { let _ = open::that(url); @@ -1015,3 +1080,43 @@ impl<'a> From> for Element<'a, Message, iced::Theme, iced::Re Self::new(widget) } } + +#[cfg(test)] +mod browse_hint_tests { + use super::*; + + fn rect() -> Rectangle { + let bounds = Rectangle { x: 10.0, y: 20.0, width: 800.0, height: 600.0 }; + browse_hint_rect(&bounds, 8.0, 16.0) + } + + #[test] + fn sits_one_blank_line_below_the_prompt() { + let hint = rect(); + assert_eq!(hint.y, 20.0 + 2.0 * 16.0); + assert_eq!(hint.x, 10.0); + assert_eq!(hint.height, 16.0); + assert_eq!(hint.width, BROWSE_HINT.chars().count() as f32 * 8.0); + } + + #[test] + fn hit_test_inside() { + let hint = rect(); + assert!(hint.contains(Point::new(hint.x + 1.0, hint.y + 1.0))); + assert!(hint.contains(Point::new( + hint.x + hint.width - 1.0, + hint.y + hint.height - 1.0, + ))); + } + + #[test] + fn hit_test_outside() { + let hint = rect(); + // On the prompt line above. + assert!(!hint.contains(Point::new(hint.x + 1.0, 20.0 + 8.0))); + // Past the right edge of the label. + assert!(!hint.contains(Point::new(hint.x + hint.width + 1.0, hint.y + 1.0))); + // Below the hint line. + assert!(!hint.contains(Point::new(hint.x + 1.0, hint.y + hint.height + 1.0))); + } +}