From e418752393ab95aaba02911ef8ec28b2c4bcbcea Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Fri, 5 Jun 2026 10:24:08 -0700 Subject: [PATCH 1/7] Add design spec for native folder dialog on new project tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Augments the existing inline "Project directory:" prompt with a clickable [ Browse… ] hint that opens a native folder picker (rfd) starting at $HOME, spawning the project tab immediately on selection. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-05-native-folder-dialog-design.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md diff --git a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md new file mode 100644 index 0000000..c5a5157 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md @@ -0,0 +1,171 @@ +# Native Folder Dialog for New Project Tabs — Design + +**Date:** 2026-06-05 +**Status:** Approved, ready for implementation planning + +## Summary + +Add a native OS folder-selection dialog as an additional way to choose a +project directory when opening a new project. The existing inline +`Project directory:` text prompt (triggered by Ctrl+T) is kept unchanged; a +new clickable `[ Browse… ]` hint in that pending-tab UI pops the native folder +picker. Choosing a folder spawns the project tab immediately. + +## Decisions (from brainstorming) + +- **Relationship to existing flow:** Augment, not replace. The inline text + prompt and typing + Enter remain fully functional. The dialog is an added + option. +- **Pick type:** Folder picker (a project is a directory; the chosen folder + becomes `project_dir`). +- **Window model:** Unchanged. Single-window / tabs model. "New project window" + means the new project *tab* that gets spawned. No OS-level multi-window work. +- **Default directory:** The user's home directory (`$HOME`). +- **Invocation:** A clickable `[ Browse… ]` button/hint rendered in the pending + tab UI (no new keybinding). +- **After selection:** Spawn the project immediately (same behavior as pressing + Enter on a typed path: canonicalize, dedup against already-open projects, + spawn, focus). + +## Chosen Approach + +Use the [`rfd`](https://crates.io/crates/rfd) crate (the de-facto cross-platform +native dialog library for Rust/winit GUI apps) via `rfd::AsyncFileDialog`, +driven through Iced's `Task::perform`. The async API is the correct fit for a +GUI event loop and avoids the macOS main-thread pitfalls of the synchronous API. + +### Approaches rejected + +- **Sync `rfd::FileDialog` on a background thread** (via the existing + `spawn_blocking_task`): simpler wiring, but on macOS native dialogs must run + on the main thread; calling the sync API off-thread risks crashes/hangs. +- **Custom in-app folder browser widget:** no new dependency, but it is a large + amount of UI work and is not a *native* dialog, which is the requirement. + +## Architecture + +### Current flow (for reference) + +- Ctrl+T → `Message::NewTab` → `spawn_pending_project_tab()` creates a + `TerminalTab` with `pending_input: Some(String::new())` + (`src/ui/handlers/tabs.rs`). +- The pending tab renders as a text prompt `Project directory: {input}_` inside + the custom `Terminal` widget's `draw()` (`src/widget/terminal.rs:293-312`). +- Keystrokes publish `Message::PendingInput(PendingKey::{Char,Backspace,Submit,Cancel})`. +- `handle_pending_input` (`src/ui/handlers/tabs.rs:357-413`) handles the keys; + `Submit` expands `~`, canonicalizes, dedups via `find_project_for_dir`, + removes the pending tab, and calls `spawn_tab(..., AgentRank::Project, ...)`, + then focuses the new tab. + +### Changes + +**1. Dependency.** Add `rfd` to `Cargo.toml`. + +**2. Extract shared open-project helper.** Pull the canonicalize → dedup → +remove-pending → `spawn_tab` → focus sequence out of the `Submit` arm into a +reusable method: + +``` +fn open_project_from_path(&mut self, tab_id: usize, path: PathBuf) -> Task +``` + +- Canonicalizes `path` (`std::fs::canonicalize`, falling back to the raw path). +- If `find_project_for_dir` returns an existing project tab, focus it and close + the pending `tab_id`. +- Otherwise capture the pending tab's `parent_id`, remove the pending tab, call + `spawn_tab(true, AgentRank::Project, Some(canonical), parent_id, ...)`, focus + the new tab, and return its `Task`. + +The `Submit` arm becomes: expand `~`, then call `open_project_from_path`. The +dialog result calls `open_project_from_path` directly (the dialog already +returns an absolute path, so no `~` expansion needed). + +**3. `[ Browse… ]` hint rendering.** In the pending-tab branch of +`Terminal::draw` (`src/widget/terminal.rs:293`), render a second line below the +`Project directory:` prompt — `[ Browse… ]` — in an accent color. A small helper +computes the hint's `Rectangle` from the widget `bounds` so `draw` and `update` +agree on its location: + +``` +fn browse_hint_rect(&self, bounds: &Rectangle) -> Rectangle +``` + +**4. Click handling.** In `Terminal::update`'s +`Event::Mouse(ButtonPressed(Left))` arm (`src/widget/terminal.rs:531`), when the +tab is pending (`self.tab.pending_input.is_some()`) and the cursor falls inside +`browse_hint_rect`, publish `Message::OpenProjectDialog(self.tab.id)` and +`shell.capture_event()`. + +**5. New messages.** + +``` +OpenProjectDialog(usize), // tab_id of the pending tab +ProjectDialogResult { tab_id: usize, path: Option }, +``` + +- `OpenProjectDialog(tab_id)` handler returns: + + ``` + Task::perform( + async move { + rfd::AsyncFileDialog::new() + .set_title("Open Project") + .set_directory(home_dir()) // $HOME + .pick_folder() + .await + .map(|handle| handle.path().to_path_buf()) + }, + move |path| Message::ProjectDialogResult { tab_id, path }, + ) + ``` + +- `ProjectDialogResult { tab_id, path }` handler: + - `Some(path)` → `self.open_project_from_path(tab_id, path)`. + - `None` (cancelled) → `Task::none()`; the pending tab stays so the user can + still type a path. + +### Data flow + +``` +Ctrl+T ──► pending project tab (inline prompt + [ Browse… ] hint) + │ + ├─ type path + Enter ──► PendingInput(Submit) ──► open_project_from_path ──► project tab + │ + └─ click [ Browse… ] ──► OpenProjectDialog(tab_id) + └─► rfd::AsyncFileDialog.pick_folder() + └─► ProjectDialogResult { tab_id, path } + ├─ Some ──► open_project_from_path ──► project tab + └─ None ──► no-op (stay in pending tab) +``` + +### Error handling + +- Dialog cancelled / no selection → `path = None` → no-op. +- Non-canonicalizable path → fall back to the raw `PathBuf` (existing behavior). +- Already-open project → focus the existing tab, close the pending one (existing + behavior, preserved by the shared helper). + +## Testing + +Native dialogs cannot be driven in unit tests, so the dialog invocation is kept +as a thin shim. Tested units: + +- `open_project_from_path`: canonicalization, already-open dedup (focus existing + + close pending), and spawn/focus of a new project tab. The `Submit` path + exercising this helper should retain its existing behavior. +- `browse_hint_rect` geometry and the click hit-test (point inside vs. outside + the rect). + +## Risk to validate early + +`rfd::AsyncFileDialog` main-thread behavior under Iced 0.14's winit backend on +macOS — confirm the picker opens and returns a path/None correctly before +building out the surrounding wiring. + +## Out of scope + +- OS-level multi-window support. +- Replacing or removing the inline text-input flow. +- Remembering the last-used directory (default is always `$HOME`). +- File (non-folder) selection. +- A keybinding for the dialog (invocation is the clickable hint only). From 01383dd5cf20ceaa5f8178f99ee8faa5664b7b5e Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Fri, 12 Jun 2026 15:05:07 -0700 Subject: [PATCH 2/7] Revise folder-dialog spec: threading, lifecycle, and testing gaps - Require the main-thread dialog spawn pattern (build the rfd future in update(), await in Task::perform) instead of constructing the dialog inside the async block, which Iced polls off-main-thread - Guard open_project_from_path against stale tab_ids (dialog is non-modal; pending tab can be closed or submitted while it is open) - Add a re-entrancy guard against double-spawned dialogs - Put the pending check first in the mouse-press arm and swallow other mouse handling for pending tabs; add Pointer hover affordance - Acknowledge the missing App-level test harness and push testable logic into pure functions / TabStore methods - Pin down rfd version/feature notes, $HOME consistency, unparented dialog behavior, and a drag-and-drop follow-up enabled by the helper Co-Authored-By: Claude Fable 5 --- .../2026-06-05-native-folder-dialog-design.md | 146 ++++++++++++++---- 1 file changed, 114 insertions(+), 32 deletions(-) diff --git a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md index c5a5157..9670607 100644 --- a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md +++ b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md @@ -1,6 +1,8 @@ # Native Folder Dialog for New Project Tabs — Design -**Date:** 2026-06-05 +**Date:** 2026-06-05 (revised 2026-06-12: main-thread dialog spawn, tab +lifecycle guards, re-entrancy, click-order/hover, testing-harness reality, +dependency specifics) **Status:** Approved, ready for implementation planning ## Summary @@ -34,6 +36,27 @@ native dialog library for Rust/winit GUI apps) via `rfd::AsyncFileDialog`, driven through Iced's `Task::perform`. The async API is the correct fit for a GUI event loop and avoids the macOS main-thread pitfalls of the synchronous API. +**Main-thread spawn pattern (required).** rfd's docs recommend *spawning* +dialogs on the main thread and *awaiting* them elsewhere; off-main-thread +spawning in windowed apps is possible but adds overhead, and rfd-on-winit-macOS +has a history of event-loop freezes (winit #1779, #2752, #3179). Iced polls +`Task::perform` futures on a background executor — so the dialog must NOT be +constructed inside the async block. Instead, build the future synchronously in +the `update()` handler (which runs on the main thread) and only await it in the +task: + +```rust +// inside the OpenProjectDialog handler — main thread +let dialog = rfd::AsyncFileDialog::new() + .set_title("Open Project") + .set_directory(home) + .pick_folder(); // dialog spawned here, on the main thread +Task::perform(dialog, move |handle| Message::ProjectDialogResult { + tab_id, + path: handle.map(|h| h.path().to_path_buf()), +}) +``` + ### Approaches rejected - **Sync `rfd::FileDialog` on a background thread** (via the existing @@ -59,7 +82,14 @@ GUI event loop and avoids the macOS main-thread pitfalls of the synchronous API. ### Changes -**1. Dependency.** Add `rfd` to `Cargo.toml`. +**1. Dependency.** Add `rfd` (currently 0.17.x) to `Cargo.toml`. Feature flags +are a no-op on macOS; if/when Linux builds matter, the backend choice (`gtk3` +vs the default `xdg-portal`) determines system dependencies and async-runtime +wiring and should be chosen deliberately at that point. + +For the default directory, use `std::env::var("HOME")` for consistency with the +existing `~` expansion in the `Submit` arm (`src/ui/handlers/tabs.rs:379`) +rather than introducing a second mechanism. **2. Extract shared open-project helper.** Pull the canonicalize → dedup → remove-pending → `spawn_tab` → focus sequence out of the `Submit` arm into a @@ -69,6 +99,14 @@ reusable method: fn open_project_from_path(&mut self, tab_id: usize, path: PathBuf) -> Task ``` +- **Guards that `tab_id` still exists** (`self.tabs.get(tab_id)`), returning + `Task::none()` if not. The dialog is non-modal with respect to the app: while + it is open the user can press Esc (closing the pending tab) or type a path + and hit Enter (removing the pending tab and spawning the project), so the + result can arrive with a dead `tab_id`. Dropping the result is correct — the + pending tab's `parent_id` is unrecoverable once it's gone, and the user + already abandoned the flow. (Tab IDs are monotonically increasing and never + reused, so a stale ID cannot alias onto a newer tab.) - Canonicalizes `path` (`std::fs::canonicalize`, falling back to the raw path). - If `find_project_for_dir` returns an existing project tab, focus it and close the pending `tab_id`. @@ -76,6 +114,10 @@ fn open_project_from_path(&mut self, tab_id: usize, path: PathBuf) -> Task Rectangle +fn browse_hint_rect(bounds: &Rectangle, char_width: f32, char_height: f32) -> Rectangle ``` **4. Click handling.** In `Terminal::update`'s @@ -96,6 +139,24 @@ tab is pending (`self.tab.pending_input.is_some()`) and the cursor falls inside `browse_hint_rect`, publish `Message::OpenProjectDialog(self.tab.id)` and `shell.capture_event()`. +The pending check must come **first** in that arm — before the existing +link-click, scrollbar, and text-selection branches. A pending tab has a real +(empty) terminal grid, so today clicks on it fall through into the selection +machinery. Swallow all mouse-press handling for pending tabs (clicks outside +the hint rect do nothing), rather than only special-casing the hint. + +**4a. Hover affordance.** `Terminal::mouse_interaction` +(`src/widget/terminal.rs:500`) already returns `mouse::Interaction::Pointer` +when hovering a link; do the same when the tab is pending and the cursor is +inside `browse_hint_rect`. It receives the cursor position, so it can reuse the +rect helper directly — no extra state needed. + +**4b. Re-entrancy guard.** Nothing else stops a second click from spawning a +second dialog. Track an open dialog (e.g. a `dialog_open` flag on the pending +tab's meta or on the app), set in the `OpenProjectDialog` handler and cleared +in `ProjectDialogResult`; ignore `OpenProjectDialog` while set. This also gives +the renderer a way to draw the hint as disabled while the picker is up. + **5. New messages.** ``` @@ -103,24 +164,16 @@ OpenProjectDialog(usize), // tab_id of the pend ProjectDialogResult { tab_id: usize, path: Option }, ``` -- `OpenProjectDialog(tab_id)` handler returns: - - ``` - Task::perform( - async move { - rfd::AsyncFileDialog::new() - .set_title("Open Project") - .set_directory(home_dir()) // $HOME - .pick_folder() - .await - .map(|handle| handle.path().to_path_buf()) - }, - move |path| Message::ProjectDialogResult { tab_id, path }, - ) - ``` - -- `ProjectDialogResult { tab_id, path }` handler: - - `Some(path)` → `self.open_project_from_path(tab_id, path)`. +- `OpenProjectDialog(tab_id)` handler: if the dialog-open guard is set or the + tab is gone, return `Task::none()`. Otherwise set the guard and return the + main-thread-spawn `Task::perform` shown under **Chosen Approach** above (the + dialog future is built synchronously in the handler, NOT inside an async + block). + +- `ProjectDialogResult { tab_id, path }` handler: clear the dialog-open guard, + then: + - `Some(path)` → `self.open_project_from_path(tab_id, path)` (which itself + no-ops if the pending tab has since been closed). - `None` (cancelled) → `Task::none()`; the pending tab stays so the user can still type a path. @@ -140,27 +193,49 @@ Ctrl+T ──► pending project tab (inline prompt + [ Browse… ] hint) ### Error handling -- Dialog cancelled / no selection → `path = None` → no-op. +- Dialog cancelled / no selection → `path = None` → clear guard, no-op. +- Pending tab closed (Esc) or submitted via typed path while the dialog was + open → result arrives with a dead `tab_id` → dropped by the guard in + `open_project_from_path`. +- Second click on the hint while a dialog is open → ignored via the + re-entrancy guard. - Non-canonicalizable path → fall back to the raw `PathBuf` (existing behavior). - Already-open project → focus the existing tab, close the pending one (existing behavior, preserved by the shared helper). +- Unparented dialog: without `set_parent` the picker is a free-floating panel + and can land behind the mandelbot window on macOS. Accepted for now; Iced + 0.14's `window::run_with_handle` can supply a parent handle to rfd's + `set_parent` as follow-up polish. ## Testing Native dialogs cannot be driven in unit tests, so the dialog invocation is kept -as a thin shim. Tested units: - -- `open_project_from_path`: canonicalization, already-open dedup (focus existing - + close pending), and spawn/focus of a new project tab. The `Submit` path - exercising this helper should retain its existing behavior. +as a thin shim. + +**Caveat:** there is currently no test harness for `App`-level handlers — only +`src/tabs.rs` (the `TabStore`) has test modules; `src/ui/handlers/tabs.rs` and +`src/widget/terminal.rs` have none, and `App` drags in window size, config, and +channels. Rather than building an `App` test harness, factor the testable logic +down (pure functions / `TabStore` methods, per the helper design above) and +test there. + +Tested units: + +- Path expansion + canonicalization and the already-open dedup decision, as + pure/`TabStore`-level functions extracted from `open_project_from_path`. The + `Submit` path exercising the shared helper should retain its existing + behavior (verified manually if no harness exists at that level). +- Stale-tab guard: result for a removed `tab_id` is a no-op. - `browse_hint_rect` geometry and the click hit-test (point inside vs. outside - the rect). + the rect) — testable because the rect helper is a pure function of bounds and + char metrics. ## Risk to validate early `rfd::AsyncFileDialog` main-thread behavior under Iced 0.14's winit backend on -macOS — confirm the picker opens and returns a path/None correctly before -building out the surrounding wiring. +macOS — before building out the surrounding wiring, write a ~20-line spike +using the main-thread spawn pattern from **Chosen Approach** and confirm the +picker opens, returns a path, and returns `None` on cancel. ## Out of scope @@ -169,3 +244,10 @@ building out the surrounding wiring. - Remembering the last-used directory (default is always `$HOME`). - File (non-folder) selection. - A keybinding for the dialog (invocation is the clickable hint only). +- Parenting the dialog to the main window via `set_parent` (see Error + handling; follow-up polish). +- Drag-and-drop of a folder onto the pending tab. Today `FileDropped` pastes + the shell-escaped path into the PTY (`src/widget/terminal.rs:821`), which is + nonsensical for a pending tab. Once `open_project_from_path` exists, + drop-folder-to-open-project becomes a near-free second consumer of the + helper — noted here as an enabled follow-up. From a642f38348c3e3d03370b334a5dd516e7dab151e Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Fri, 12 Jun 2026 15:16:52 -0700 Subject: [PATCH 3/7] Add rfd spike validating the macOS main-thread dialog risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo run --example rfd_spike: builds the AsyncFileDialog future synchronously in update() (main thread, verified by thread-name logging) and awaits it via Task::perform. On macOS / Iced 0.14 / Rust 1.94 the picker opens with the app responsive, returns the picked path, and returns None on cancel — no freezes or panics. rfd 0.17 lands as a dev-dependency for now; promote to a real dependency when the feature is wired. Spec's risk section updated with the validation result. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 36 +++++++++ Cargo.toml | 5 ++ .../2026-06-05-native-folder-dialog-design.md | 15 ++++ examples/rfd_spike.rs | 77 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 examples/rfd_spike.rs 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..b2446ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,11 @@ libc = "0.2" uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } +[dev-dependencies] +# Spike for docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md; +# promote to [dependencies] when the feature lands. +rfd = "0.17" + # The profile that 'dist' will build with [profile.dist] inherits = "release" diff --git a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md index 9670607..294d239 100644 --- a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md +++ b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md @@ -237,6 +237,21 @@ macOS — before building out the surrounding wiring, write a ~20-line spike using the main-thread spawn pattern from **Chosen Approach** and confirm the picker opens, returns a path, and returns `None` on cancel. +**Validated 2026-06-12** via `examples/rfd_spike.rs` (`cargo run --example +rfd_spike`, rfd 0.17 as a dev-dependency) on macOS (Darwin 25.5.0), Iced +0.14.x, Rust 1.94.0: + +- The dialog future is built on the `main` thread inside `update()` (verified + by thread-name logging); the picker opens and the app stays responsive. +- Picking a folder returns the absolute path through `Task::perform`. +- Cancel returns `None`; a subsequent dialog opens fine after the first + completes. +- No freezes, panics, or event-loop stalls; clean exit. + +The risk is retired. Implementation can proceed; promote `rfd` from +`[dev-dependencies]` to `[dependencies]` when wiring the real feature, and +delete the example (or keep it as a manual test harness). + ## Out of scope - OS-level multi-window support. diff --git a/examples/rfd_spike.rs b/examples/rfd_spike.rs new file mode 100644 index 0000000..630b5a7 --- /dev/null +++ b/examples/rfd_spike.rs @@ -0,0 +1,77 @@ +//! Spike: validate rfd::AsyncFileDialog under Iced 0.14's winit backend on +//! macOS, per docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md. +//! +//! Pattern under test: build the dialog future synchronously in `update()` +//! (main thread), await it via `Task::perform` (background executor). +//! +//! Run with: cargo run --example rfd_spike +//! Validate: click [ Browse… ] and pick a folder (path shown + printed), +//! then click again and press Cancel (shows "cancelled"). The window must +//! stay responsive while the dialog is open. + +use std::path::PathBuf; + +use iced::Task; +use iced::widget::{button, column, text}; + +#[derive(Debug, Clone)] +enum Message { + OpenDialog, + DialogResult(Option), +} + +#[derive(Default)] +struct Spike { + dialog_open: bool, + result: String, +} + +impl Spike { + fn update(&mut self, message: Message) -> Task { + match message { + Message::OpenDialog => { + if self.dialog_open { + return Task::none(); + } + self.dialog_open = true; + eprintln!( + "spawning dialog on thread {:?}", + std::thread::current().name() + ); + 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, |handle| { + Message::DialogResult(handle.map(|h| h.path().to_path_buf())) + }) + } + Message::DialogResult(path) => { + self.dialog_open = false; + self.result = match path { + Some(p) => format!("picked: {}", p.display()), + None => "cancelled".to_string(), + }; + eprintln!("{}", self.result); + Task::none() + } + } + } + + fn view(&self) -> iced::Element<'_, Message> { + column![ + button("[ Browse… ]").on_press(Message::OpenDialog), + text(&self.result), + ] + .spacing(10) + .padding(20) + .into() + } +} + +fn main() -> iced::Result { + iced::application(Spike::default, Spike::update, Spike::view) + .title("rfd spike") + .run() +} From 430f4db4ccf8d864267b02aa2bdbcc69b7604a03 Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Fri, 12 Jun 2026 16:05:06 -0700 Subject: [PATCH 4/7] Add native folder dialog to pending project tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clickable [ Browse… ] hint below the Project directory: prompt opens the OS folder picker (rfd::AsyncFileDialog); picking a folder spawns the project tab exactly like typing the path and pressing Enter. The inline text prompt is unchanged. Implementation notes, per the design spec: - The dialog future is built inside update() (main thread — required by rfd on macOS) and only awaited via Task::perform. - The Submit arm's canonicalize → dedup → spawn → focus sequence moved into a shared open_project_from_path helper, which no-ops when the pending tab is gone: the dialog is non-modal, so its result can arrive after the tab was closed or submitted by typing. - An app-level project_dialog_open flag drops re-entrant Browse clicks while a picker is in flight. - Pending tabs swallow mouse presses so clicks no longer fall through to the selection machinery on the empty grid; hovering the hint shows a pointer cursor. - Pending-tab text renders in the configured terminal font so the drawn label matches the char-metric-based click rect (Font::MONOSPACE has a different advance and clipped the trailing bracket). - ~ expansion extracted to expand_tilde and unit-tested, along with browse_hint_rect geometry and hit-testing. Manually verified on macOS: hint hover/click, picker open/cancel, spawn-on-pick, dedup-to-existing-project, and re-entrancy guard. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 4 -- src/ui.rs | 12 ++++ src/ui/handlers/tabs.rs | 148 +++++++++++++++++++++++++++++++--------- src/widget/terminal.rs | 115 +++++++++++++++++++++++++++++-- 4 files changed, 236 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b2446ef..24f079c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,6 @@ 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"] } - -[dev-dependencies] -# Spike for docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md; -# promote to [dependencies] when the feature lands. rfd = "0.17" # The profile that 'dist' will build with diff --git a/src/ui.rs b/src/ui.rs index c182343..5f11118 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -62,6 +62,10 @@ pub enum Message { FocusPreviousTab, NextIdle, PendingInput(PendingKey), + /// Open the native folder picker for the pending tab `tab_id`. + OpenProjectDialog(usize), + /// Folder picker resolved; `None` means cancelled. + ProjectDialogResult { tab_id: usize, path: Option }, McpSpawnAgent(usize, Option, Option, Option, Option, Option, Option), McpCloseTab(usize, usize), McpCheckpoint(usize), @@ -232,6 +236,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 `ProjectDialogResult` arrives. + project_dialog_open: bool, } impl App { @@ -272,6 +279,7 @@ impl App { ckpt_store, toasts: Vec::new(), next_toast_id: 0, + project_dialog_open: false, }; (app, listen_task) @@ -307,6 +315,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::ProjectDialogResult { tab_id, path } => { + self.handle_project_dialog_result(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..2485f21 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::ProjectDialogResult { + tab_id, + path: handle.map(|h| h.path().to_path_buf()), + }) + } + + pub(in crate::ui) fn handle_project_dialog_result( + &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))); + } +} From 0f9d0f69a976c858e285ea5a0a3661f00b5784e2 Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Fri, 12 Jun 2026 16:11:00 -0700 Subject: [PATCH 5/7] Drop the design spec from the branch The spec served its purpose during design, spike, and implementation; the PR carries the relevant context in its description and commit messages instead. Co-Authored-By: Claude Fable 5 --- .../2026-06-05-native-folder-dialog-design.md | 268 ------------------ 1 file changed, 268 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md diff --git a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md b/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md deleted file mode 100644 index 294d239..0000000 --- a/docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md +++ /dev/null @@ -1,268 +0,0 @@ -# Native Folder Dialog for New Project Tabs — Design - -**Date:** 2026-06-05 (revised 2026-06-12: main-thread dialog spawn, tab -lifecycle guards, re-entrancy, click-order/hover, testing-harness reality, -dependency specifics) -**Status:** Approved, ready for implementation planning - -## Summary - -Add a native OS folder-selection dialog as an additional way to choose a -project directory when opening a new project. The existing inline -`Project directory:` text prompt (triggered by Ctrl+T) is kept unchanged; a -new clickable `[ Browse… ]` hint in that pending-tab UI pops the native folder -picker. Choosing a folder spawns the project tab immediately. - -## Decisions (from brainstorming) - -- **Relationship to existing flow:** Augment, not replace. The inline text - prompt and typing + Enter remain fully functional. The dialog is an added - option. -- **Pick type:** Folder picker (a project is a directory; the chosen folder - becomes `project_dir`). -- **Window model:** Unchanged. Single-window / tabs model. "New project window" - means the new project *tab* that gets spawned. No OS-level multi-window work. -- **Default directory:** The user's home directory (`$HOME`). -- **Invocation:** A clickable `[ Browse… ]` button/hint rendered in the pending - tab UI (no new keybinding). -- **After selection:** Spawn the project immediately (same behavior as pressing - Enter on a typed path: canonicalize, dedup against already-open projects, - spawn, focus). - -## Chosen Approach - -Use the [`rfd`](https://crates.io/crates/rfd) crate (the de-facto cross-platform -native dialog library for Rust/winit GUI apps) via `rfd::AsyncFileDialog`, -driven through Iced's `Task::perform`. The async API is the correct fit for a -GUI event loop and avoids the macOS main-thread pitfalls of the synchronous API. - -**Main-thread spawn pattern (required).** rfd's docs recommend *spawning* -dialogs on the main thread and *awaiting* them elsewhere; off-main-thread -spawning in windowed apps is possible but adds overhead, and rfd-on-winit-macOS -has a history of event-loop freezes (winit #1779, #2752, #3179). Iced polls -`Task::perform` futures on a background executor — so the dialog must NOT be -constructed inside the async block. Instead, build the future synchronously in -the `update()` handler (which runs on the main thread) and only await it in the -task: - -```rust -// inside the OpenProjectDialog handler — main thread -let dialog = rfd::AsyncFileDialog::new() - .set_title("Open Project") - .set_directory(home) - .pick_folder(); // dialog spawned here, on the main thread -Task::perform(dialog, move |handle| Message::ProjectDialogResult { - tab_id, - path: handle.map(|h| h.path().to_path_buf()), -}) -``` - -### Approaches rejected - -- **Sync `rfd::FileDialog` on a background thread** (via the existing - `spawn_blocking_task`): simpler wiring, but on macOS native dialogs must run - on the main thread; calling the sync API off-thread risks crashes/hangs. -- **Custom in-app folder browser widget:** no new dependency, but it is a large - amount of UI work and is not a *native* dialog, which is the requirement. - -## Architecture - -### Current flow (for reference) - -- Ctrl+T → `Message::NewTab` → `spawn_pending_project_tab()` creates a - `TerminalTab` with `pending_input: Some(String::new())` - (`src/ui/handlers/tabs.rs`). -- The pending tab renders as a text prompt `Project directory: {input}_` inside - the custom `Terminal` widget's `draw()` (`src/widget/terminal.rs:293-312`). -- Keystrokes publish `Message::PendingInput(PendingKey::{Char,Backspace,Submit,Cancel})`. -- `handle_pending_input` (`src/ui/handlers/tabs.rs:357-413`) handles the keys; - `Submit` expands `~`, canonicalizes, dedups via `find_project_for_dir`, - removes the pending tab, and calls `spawn_tab(..., AgentRank::Project, ...)`, - then focuses the new tab. - -### Changes - -**1. Dependency.** Add `rfd` (currently 0.17.x) to `Cargo.toml`. Feature flags -are a no-op on macOS; if/when Linux builds matter, the backend choice (`gtk3` -vs the default `xdg-portal`) determines system dependencies and async-runtime -wiring and should be chosen deliberately at that point. - -For the default directory, use `std::env::var("HOME")` for consistency with the -existing `~` expansion in the `Submit` arm (`src/ui/handlers/tabs.rs:379`) -rather than introducing a second mechanism. - -**2. Extract shared open-project helper.** Pull the canonicalize → dedup → -remove-pending → `spawn_tab` → focus sequence out of the `Submit` arm into a -reusable method: - -``` -fn open_project_from_path(&mut self, tab_id: usize, path: PathBuf) -> Task -``` - -- **Guards that `tab_id` still exists** (`self.tabs.get(tab_id)`), returning - `Task::none()` if not. The dialog is non-modal with respect to the app: while - it is open the user can press Esc (closing the pending tab) or type a path - and hit Enter (removing the pending tab and spawning the project), so the - result can arrive with a dead `tab_id`. Dropping the result is correct — the - pending tab's `parent_id` is unrecoverable once it's gone, and the user - already abandoned the flow. (Tab IDs are monotonically increasing and never - reused, so a stale ID cannot alias onto a newer tab.) -- Canonicalizes `path` (`std::fs::canonicalize`, falling back to the raw path). -- If `find_project_for_dir` returns an existing project tab, focus it and close - the pending `tab_id`. -- Otherwise capture the pending tab's `parent_id`, remove the pending tab, call - `spawn_tab(true, AgentRank::Project, Some(canonical), parent_id, ...)`, focus - the new tab, and return its `Task`. - -To keep the testing story honest (see Testing below), factor the pure parts — -path expansion/canonicalization and the dedup decision — into free functions or -`TabStore`-level methods; the `App` method stays a thin orchestrator. - -The `Submit` arm becomes: expand `~`, then call `open_project_from_path`. The -dialog result calls `open_project_from_path` directly (the dialog already -returns an absolute path, so no `~` expansion needed). - -**3. `[ Browse… ]` hint rendering.** In the pending-tab branch of -`Terminal::draw` (`src/widget/terminal.rs:293`), render a second line below the -`Project directory:` prompt — `[ Browse… ]` — in an accent color. A small helper -computes the hint's `Rectangle` so `draw` and `update` agree on its location. -Make it pure geometry over the inputs it needs (bounds plus char metrics) -rather than a method requiring a fully-built widget, so it is unit-testable: - -``` -fn browse_hint_rect(bounds: &Rectangle, char_width: f32, char_height: f32) -> Rectangle -``` - -**4. Click handling.** In `Terminal::update`'s -`Event::Mouse(ButtonPressed(Left))` arm (`src/widget/terminal.rs:531`), when the -tab is pending (`self.tab.pending_input.is_some()`) and the cursor falls inside -`browse_hint_rect`, publish `Message::OpenProjectDialog(self.tab.id)` and -`shell.capture_event()`. - -The pending check must come **first** in that arm — before the existing -link-click, scrollbar, and text-selection branches. A pending tab has a real -(empty) terminal grid, so today clicks on it fall through into the selection -machinery. Swallow all mouse-press handling for pending tabs (clicks outside -the hint rect do nothing), rather than only special-casing the hint. - -**4a. Hover affordance.** `Terminal::mouse_interaction` -(`src/widget/terminal.rs:500`) already returns `mouse::Interaction::Pointer` -when hovering a link; do the same when the tab is pending and the cursor is -inside `browse_hint_rect`. It receives the cursor position, so it can reuse the -rect helper directly — no extra state needed. - -**4b. Re-entrancy guard.** Nothing else stops a second click from spawning a -second dialog. Track an open dialog (e.g. a `dialog_open` flag on the pending -tab's meta or on the app), set in the `OpenProjectDialog` handler and cleared -in `ProjectDialogResult`; ignore `OpenProjectDialog` while set. This also gives -the renderer a way to draw the hint as disabled while the picker is up. - -**5. New messages.** - -``` -OpenProjectDialog(usize), // tab_id of the pending tab -ProjectDialogResult { tab_id: usize, path: Option }, -``` - -- `OpenProjectDialog(tab_id)` handler: if the dialog-open guard is set or the - tab is gone, return `Task::none()`. Otherwise set the guard and return the - main-thread-spawn `Task::perform` shown under **Chosen Approach** above (the - dialog future is built synchronously in the handler, NOT inside an async - block). - -- `ProjectDialogResult { tab_id, path }` handler: clear the dialog-open guard, - then: - - `Some(path)` → `self.open_project_from_path(tab_id, path)` (which itself - no-ops if the pending tab has since been closed). - - `None` (cancelled) → `Task::none()`; the pending tab stays so the user can - still type a path. - -### Data flow - -``` -Ctrl+T ──► pending project tab (inline prompt + [ Browse… ] hint) - │ - ├─ type path + Enter ──► PendingInput(Submit) ──► open_project_from_path ──► project tab - │ - └─ click [ Browse… ] ──► OpenProjectDialog(tab_id) - └─► rfd::AsyncFileDialog.pick_folder() - └─► ProjectDialogResult { tab_id, path } - ├─ Some ──► open_project_from_path ──► project tab - └─ None ──► no-op (stay in pending tab) -``` - -### Error handling - -- Dialog cancelled / no selection → `path = None` → clear guard, no-op. -- Pending tab closed (Esc) or submitted via typed path while the dialog was - open → result arrives with a dead `tab_id` → dropped by the guard in - `open_project_from_path`. -- Second click on the hint while a dialog is open → ignored via the - re-entrancy guard. -- Non-canonicalizable path → fall back to the raw `PathBuf` (existing behavior). -- Already-open project → focus the existing tab, close the pending one (existing - behavior, preserved by the shared helper). -- Unparented dialog: without `set_parent` the picker is a free-floating panel - and can land behind the mandelbot window on macOS. Accepted for now; Iced - 0.14's `window::run_with_handle` can supply a parent handle to rfd's - `set_parent` as follow-up polish. - -## Testing - -Native dialogs cannot be driven in unit tests, so the dialog invocation is kept -as a thin shim. - -**Caveat:** there is currently no test harness for `App`-level handlers — only -`src/tabs.rs` (the `TabStore`) has test modules; `src/ui/handlers/tabs.rs` and -`src/widget/terminal.rs` have none, and `App` drags in window size, config, and -channels. Rather than building an `App` test harness, factor the testable logic -down (pure functions / `TabStore` methods, per the helper design above) and -test there. - -Tested units: - -- Path expansion + canonicalization and the already-open dedup decision, as - pure/`TabStore`-level functions extracted from `open_project_from_path`. The - `Submit` path exercising the shared helper should retain its existing - behavior (verified manually if no harness exists at that level). -- Stale-tab guard: result for a removed `tab_id` is a no-op. -- `browse_hint_rect` geometry and the click hit-test (point inside vs. outside - the rect) — testable because the rect helper is a pure function of bounds and - char metrics. - -## Risk to validate early - -`rfd::AsyncFileDialog` main-thread behavior under Iced 0.14's winit backend on -macOS — before building out the surrounding wiring, write a ~20-line spike -using the main-thread spawn pattern from **Chosen Approach** and confirm the -picker opens, returns a path, and returns `None` on cancel. - -**Validated 2026-06-12** via `examples/rfd_spike.rs` (`cargo run --example -rfd_spike`, rfd 0.17 as a dev-dependency) on macOS (Darwin 25.5.0), Iced -0.14.x, Rust 1.94.0: - -- The dialog future is built on the `main` thread inside `update()` (verified - by thread-name logging); the picker opens and the app stays responsive. -- Picking a folder returns the absolute path through `Task::perform`. -- Cancel returns `None`; a subsequent dialog opens fine after the first - completes. -- No freezes, panics, or event-loop stalls; clean exit. - -The risk is retired. Implementation can proceed; promote `rfd` from -`[dev-dependencies]` to `[dependencies]` when wiring the real feature, and -delete the example (or keep it as a manual test harness). - -## Out of scope - -- OS-level multi-window support. -- Replacing or removing the inline text-input flow. -- Remembering the last-used directory (default is always `$HOME`). -- File (non-folder) selection. -- A keybinding for the dialog (invocation is the clickable hint only). -- Parenting the dialog to the main window via `set_parent` (see Error - handling; follow-up polish). -- Drag-and-drop of a folder onto the pending tab. Today `FileDropped` pastes - the shell-escaped path into the PTY (`src/widget/terminal.rs:821`), which is - nonsensical for a pending tab. Once `open_project_from_path` exists, - drop-folder-to-open-project becomes a near-free second consumer of the - helper — noted here as an enabled follow-up. From 52ef479616557e20f4fcae2dc8a45b105993f779 Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Fri, 12 Jun 2026 16:18:21 -0700 Subject: [PATCH 6/7] Drop the rfd spike example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It validated the macOS main-thread dialog spawn before the feature was wired; the real Browse… flow now exercises the same pattern, so the example had no remaining job and would only bitrot. Co-Authored-By: Claude Fable 5 --- examples/rfd_spike.rs | 77 ------------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 examples/rfd_spike.rs diff --git a/examples/rfd_spike.rs b/examples/rfd_spike.rs deleted file mode 100644 index 630b5a7..0000000 --- a/examples/rfd_spike.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Spike: validate rfd::AsyncFileDialog under Iced 0.14's winit backend on -//! macOS, per docs/superpowers/specs/2026-06-05-native-folder-dialog-design.md. -//! -//! Pattern under test: build the dialog future synchronously in `update()` -//! (main thread), await it via `Task::perform` (background executor). -//! -//! Run with: cargo run --example rfd_spike -//! Validate: click [ Browse… ] and pick a folder (path shown + printed), -//! then click again and press Cancel (shows "cancelled"). The window must -//! stay responsive while the dialog is open. - -use std::path::PathBuf; - -use iced::Task; -use iced::widget::{button, column, text}; - -#[derive(Debug, Clone)] -enum Message { - OpenDialog, - DialogResult(Option), -} - -#[derive(Default)] -struct Spike { - dialog_open: bool, - result: String, -} - -impl Spike { - fn update(&mut self, message: Message) -> Task { - match message { - Message::OpenDialog => { - if self.dialog_open { - return Task::none(); - } - self.dialog_open = true; - eprintln!( - "spawning dialog on thread {:?}", - std::thread::current().name() - ); - 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, |handle| { - Message::DialogResult(handle.map(|h| h.path().to_path_buf())) - }) - } - Message::DialogResult(path) => { - self.dialog_open = false; - self.result = match path { - Some(p) => format!("picked: {}", p.display()), - None => "cancelled".to_string(), - }; - eprintln!("{}", self.result); - Task::none() - } - } - } - - fn view(&self) -> iced::Element<'_, Message> { - column![ - button("[ Browse… ]").on_press(Message::OpenDialog), - text(&self.result), - ] - .spacing(10) - .padding(20) - .into() - } -} - -fn main() -> iced::Result { - iced::application(Spike::default, Spike::update, Spike::view) - .title("rfd spike") - .run() -} From 245bf6b47ae5e78b05e8ca65120f5b716eb972cb Mon Sep 17 00:00:00 2001 From: Jason Thigpen Date: Mon, 15 Jun 2026 09:24:48 -0700 Subject: [PATCH 7/7] Rename ProjectDialogResult to SpawnOrFocusProjectTab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per PR review: the result message and its handler are now named for the action they perform (spawn a new project tab or focus an existing one) rather than the dialog mechanism, matching the action-oriented convention of the other Message variants. OpenProjectDialog keeps its name — it parallels OpenPr and accurately describes opening the picker. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui.rs | 11 ++++++----- src/ui/handlers/tabs.rs | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 5f11118..423f3da 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -64,8 +64,9 @@ pub enum Message { PendingInput(PendingKey), /// Open the native folder picker for the pending tab `tab_id`. OpenProjectDialog(usize), - /// Folder picker resolved; `None` means cancelled. - ProjectDialogResult { tab_id: usize, path: Option }, + /// 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), @@ -237,7 +238,7 @@ pub struct App { toasts: Vec, next_toast_id: usize, /// A native folder picker is in flight; further `OpenProjectDialog` - /// requests are ignored until its `ProjectDialogResult` arrives. + /// requests are ignored until its `SpawnOrFocusProjectTab` arrives. project_dialog_open: bool, } @@ -316,8 +317,8 @@ impl App { 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::ProjectDialogResult { tab_id, path } => { - self.handle_project_dialog_result(tab_id, path) + 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), diff --git a/src/ui/handlers/tabs.rs b/src/ui/handlers/tabs.rs index 2485f21..c265ff7 100644 --- a/src/ui/handlers/tabs.rs +++ b/src/ui/handlers/tabs.rs @@ -431,13 +431,13 @@ impl App { .set_directory(home) .pick_folder(); - Task::perform(dialog, move |handle| Message::ProjectDialogResult { + Task::perform(dialog, move |handle| Message::SpawnOrFocusProjectTab { tab_id, path: handle.map(|h| h.path().to_path_buf()), }) } - pub(in crate::ui) fn handle_project_dialog_result( + pub(in crate::ui) fn handle_spawn_or_focus_project_tab( &mut self, tab_id: usize, path: Option,