diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 00:07:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 16:29:44 +0300 |
| commit | 3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch) | |
| tree | a52280bccc824ca656f8bc986a756f30901974e4 /src/app | |
| parent | 5d7e56737ef30d9de5802b0684a9428c40131dda (diff) | |
feat: add native windows beta support
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 51 | ||||
| -rw-r--r-- | src/app/agent_resume.rs | 40 | ||||
| -rw-r--r-- | src/app/api.rs | 7 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 20 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 1 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 8 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 9 | ||||
| -rw-r--r-- | src/app/mod.rs | 16 | ||||
| -rw-r--r-- | src/app/state.rs | 35 |
10 files changed, 167 insertions, 21 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 06ffe51b..371457a8 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2334,6 +2334,25 @@ impl AppState { // Intercepted in App::handle_internal_event before reaching this // dispatch; never touches AppState. AppEvent::ClipboardWrite { .. } => Vec::new(), + AppEvent::TerminalCwdReported { pane_id, cwd } => { + if !cwd.is_absolute() || !cwd.is_dir() { + return Vec::new(); + } + let Some(terminal_id) = self.workspaces.iter().find_map(|ws| { + ws.pane_state(pane_id) + .map(|pane| pane.attached_terminal_id.clone()) + }) else { + return Vec::new(); + }; + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return Vec::new(); + }; + if terminal.cwd != cwd { + terminal.cwd = cwd; + self.mark_session_dirty(); + } + Vec::new() + } AppEvent::GitStatusRefreshed { results, cache_updates, @@ -4282,6 +4301,9 @@ mod tests { fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let test_dir = std::env::current_dir().unwrap(); + let first_session = test_dir.join("one.jsonl").display().to_string(); + let second_session = test_dir.join("two.jsonl").display().to_string(); let first_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, @@ -4291,7 +4313,7 @@ mod tests { message: None, custom_status: None, seq: Some(20), - session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/one.jsonl"), + session_ref: crate::agent_resume::AgentSessionRef::path(first_session), }); assert_eq!(first_updates.len(), 1); state.session_dirty = false; @@ -4304,7 +4326,7 @@ mod tests { message: None, custom_status: None, seq: Some(21), - session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/two.jsonl"), + session_ref: crate::agent_resume::AgentSessionRef::path(second_session), }); assert!(second_updates.is_empty()); @@ -4312,6 +4334,31 @@ mod tests { } #[test] + fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() { + let mut state = app_with_workspaces(&["active"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let cwd = + std::env::temp_dir().join(format!("herdr-cwd-report-test-{}", std::process::id())); + std::fs::create_dir_all(&cwd).unwrap(); + state.session_dirty = false; + + let updates = state.handle_app_event(AppEvent::TerminalCwdReported { + pane_id, + cwd: cwd.clone(), + }); + + assert!(updates.is_empty()); + assert_eq!(state.terminals.get(&terminal_id).unwrap().cwd, cwd); + assert!(state.session_dirty); + let _ = std::fs::remove_dir_all(cwd); + } + + #[test] fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 8ff6cc4d..894b0b37 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -339,6 +339,7 @@ fn shell_quote(value: &str) -> String { mod tests { use super::*; + #[cfg(unix)] fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( @@ -350,6 +351,21 @@ mod tests { ) } + #[cfg(unix)] + fn long_running_test_argv() -> Vec<String> { + vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()] + } + + #[cfg(unix)] + fn marker_resume_test_argv() -> Vec<String> { + vec![ + "/bin/sh".into(), + "-c".into(), + "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(), + ] + } + + #[cfg(unix)] #[tokio::test] async fn pending_agent_resume_waits_for_host_theme_before_launch() { let mut app = test_app(); @@ -371,11 +387,7 @@ mod tests { .expect("test terminal should exist"); terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec![ - "/bin/sh".into(), - "-c".into(), - "printf '%s' 'restored agent: shell quoted | marker'; sleep 5".into(), - ], + argv: marker_resume_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); @@ -432,6 +444,7 @@ mod tests { } } + #[cfg(unix)] #[tokio::test] async fn pending_agent_resume_can_launch_after_theme_wait_expires() { let mut app = test_app(); @@ -451,7 +464,7 @@ mod tests { .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); @@ -465,6 +478,7 @@ mod tests { } } + #[cfg(not(windows))] #[tokio::test] async fn pending_agent_resume_launches_hidden_panes_with_current_terminal_area() { let mut app = test_app(); @@ -500,7 +514,7 @@ mod tests { .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + argv: long_running_test_argv(), dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"), }); } @@ -520,6 +534,7 @@ mod tests { } } + #[cfg(not(windows))] #[tokio::test] async fn pending_agent_resume_launches_inactive_tab_panes_with_current_terminal_area() { let mut app = test_app(); @@ -562,7 +577,7 @@ mod tests { .expect("inactive tab terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(), }); @@ -583,6 +598,7 @@ mod tests { } } + #[cfg(not(windows))] #[tokio::test] async fn pending_agent_resume_launches_zoom_hidden_active_tab_panes() { let mut app = test_app(); @@ -620,7 +636,7 @@ mod tests { .expect("hidden zoom pane terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(), }); @@ -641,6 +657,7 @@ mod tests { } } + #[cfg(not(windows))] #[tokio::test] async fn pending_agent_resume_uses_current_terminal_area_for_background_panes() { let mut app = test_app(); @@ -676,7 +693,7 @@ mod tests { .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); @@ -699,6 +716,7 @@ mod tests { } } + #[cfg(unix)] #[tokio::test] async fn pending_agent_resume_launches_with_inner_rect_size() { let mut app = test_app(); @@ -734,7 +752,7 @@ mod tests { .expect("test terminal should exist") .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), - argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), }); diff --git a/src/app/api.rs b/src/app/api.rs index 4bb89a77..166ea789 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -117,8 +117,12 @@ impl App { } else { None }; + let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. }); let previous_toast = self.state.toast.clone(); let pane_updates = self.state.handle_app_event(ev); + if terminal_cwd_reported { + self.mark_git_status_refresh_due(Instant::now()); + } for update in &pane_updates { self.refresh_new_herdr_toast_context_for_update(update, &previous_toast); self.emit_pane_state_update(update); @@ -811,6 +815,7 @@ mod tests { use super::*; use crate::detect::{Agent, AgentState}; + #[cfg(unix)] fn init_repo(path: &std::path::Path) { let status = std::process::Command::new("git") .args(["init", "-q"]) @@ -820,6 +825,7 @@ mod tests { assert!(status.success(), "git init failed for {}", path.display()); } + #[cfg(unix)] #[tokio::test] async fn herdr_toast_context_uses_live_root_runtime_cwd_label() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -912,6 +918,7 @@ mod tests { let _ = std::fs::remove_dir_all(temp_root); } + #[cfg(unix)] #[tokio::test] async fn delayed_herdr_toast_context_uses_live_root_runtime_cwd_label() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index eb45a6f2..d6a269ca 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -898,8 +898,19 @@ mod tests { App::new(&Config::default(), true, None, api_rx, event_hub) } + #[cfg(windows)] + fn test_shell() -> &'static str { + "C:\\Windows\\System32\\whoami.exe" + } + + #[cfg(not(windows))] + fn test_shell() -> &'static str { + "/usr/bin/true" + } + fn app_with_parent(repo: &Path) -> App { let mut app = test_app(); + app.state.default_shell = test_shell().into(); let mut parent = Workspace::test_new("main"); parent.identity_cwd = repo.to_path_buf(); app.state.workspaces = vec![parent]; @@ -954,6 +965,9 @@ mod tests { ); assert!(workspace.worktree.unwrap().is_linked_worktree); + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } let remove = crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false); crate::worktree::run_worktree_command(&remove).unwrap(); @@ -968,6 +982,7 @@ mod tests { let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); app.state.worktree_directory = worktree_root.clone(); + app.state.default_shell = test_shell().into(); let response = app.handle_api_request(Request { id: "req".into(), @@ -1000,6 +1015,9 @@ mod tests { "auto-created parent workspace event should include parent worktree membership" ); + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } let remove = crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false); crate::worktree::run_worktree_command(&remove).unwrap(); @@ -1230,7 +1248,7 @@ mod tests { let repo = create_committed_repo("api-worktree-open-source-repo"); let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); - app.state.default_shell = "/usr/bin/true".into(); + app.state.default_shell = test_shell().into(); let response = app.handle_api_request(Request { id: "req".into(), diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index e8761a35..59fa1ccd 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -526,6 +526,7 @@ fn unique_temp_path(name: &str) -> std::path::PathBuf { } #[cfg(test)] +#[cfg(unix)] fn wait_for_file(path: &std::path::Path) -> String { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < deadline { diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index ae10c0fa..e69bb2c1 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -2519,6 +2519,7 @@ mod tests { assert_eq!(app.state.workspaces[0].display_name(), "a"); } + #[cfg(unix)] #[tokio::test] async fn keyboard_context_menu_split_keeps_new_runtime() { let mut app = app_for_mouse_test(); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 8dacc5c9..9d861f66 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -988,12 +988,15 @@ fn shell_quote(value: &str) -> String { #[cfg(test)] mod tests { + #[cfg(unix)] use std::time::Duration; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::Direction; - use super::super::{state_with_workspaces, unique_temp_path, wait_for_file}; + #[cfg(unix)] + use super::super::wait_for_file; + use super::super::{state_with_workspaces, unique_temp_path}; use super::*; use crate::{ app::App, config::Config, input::TerminalKey, terminal::TerminalState, workspace::Workspace, @@ -1897,6 +1900,7 @@ last_pane = "prefix+tab" assert_eq!(state.workspaces.len(), 2); } + #[cfg(unix)] #[tokio::test] async fn custom_command_runs_from_prefix_key_in_navigate_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -1946,6 +1950,7 @@ last_pane = "prefix+tab" let _ = std::fs::remove_file(output_path); } + #[cfg(unix)] #[tokio::test] async fn pane_overlay_command_opens_and_closes_after_exit() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -2033,6 +2038,7 @@ last_pane = "prefix+tab" } } + #[cfg(unix)] #[tokio::test] async fn edit_scrollback_key_opens_focused_runtime_scrollback_in_editor_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index c60a9f48..60f2d4cc 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -197,9 +197,9 @@ mod tests { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::Rect; - use super::super::{ - app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file, - }; + use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes}; + #[cfg(unix)] + use super::super::{unique_temp_path, wait_for_file}; use super::*; use crate::{config::Config, events::AppEvent, workspace::Workspace}; @@ -738,6 +738,7 @@ mod tests { assert_eq!(app.state.mode, Mode::Terminal); } + #[cfg(unix)] #[tokio::test] async fn terminal_direct_edit_scrollback_opens_editor_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -791,6 +792,7 @@ mod tests { let _ = std::fs::remove_file(output_path); } + #[cfg(unix)] #[tokio::test] async fn direct_custom_command_runs_before_forwarding_to_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -827,6 +829,7 @@ mod tests { let _ = std::fs::remove_file(output_path); } + #[cfg(unix)] #[tokio::test] async fn direct_custom_pane_command_opens_overlay_pane() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 6a387c9a..3a038dee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1511,6 +1511,16 @@ mod tests { ) } + #[cfg(windows)] + fn exiting_test_command() -> &'static str { + "C:\\Windows\\System32\\whoami.exe" + } + + #[cfg(not(windows))] + fn exiting_test_command() -> &'static str { + "/usr/bin/true" + } + #[derive(Clone, Default)] struct FakePrefixInputSource { switch_calls: Rc<Cell<usize>>, @@ -3112,7 +3122,7 @@ mod tests { async fn pane_split_request_targets_pane_in_background_tab() { let _guard = config_env_lock().lock().unwrap(); let original_shell = std::env::var_os("SHELL"); - std::env::set_var("SHELL", "/usr/bin/true"); + std::env::set_var("SHELL", exiting_test_command()); let mut app = test_app(); let mut workspace = Workspace::test_new("api-pane-split-background-tab"); @@ -3209,7 +3219,7 @@ mod tests { async fn pane_split_request_focuses_new_pane_when_requested() { let _guard = config_env_lock().lock().unwrap(); let original_shell = std::env::var_os("SHELL"); - std::env::set_var("SHELL", "/usr/bin/true"); + std::env::set_var("SHELL", exiting_test_command()); let mut app = test_app(); let mut workspace = Workspace::test_new("api-pane-split-focus-background-tab"); @@ -3363,7 +3373,7 @@ mod tests { tab_id: None, split: Some(crate::api::schema::SplitDirection::Right), focus: true, - argv: vec!["/usr/bin/true".into()], + argv: vec![exiting_test_command().into()], }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); diff --git a/src/app/state.rs b/src/app/state.rs index 494bee0f..32cf087d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1419,6 +1419,22 @@ impl AppState { self.switch_ascii_input_source_in_prefix } + pub(crate) fn pane_exposes_host_cursor( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> bool { + let has_effective_agent_label = self + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .and_then(|terminal_id| self.terminals.get(terminal_id)) + .and_then(crate::terminal::TerminalState::effective_agent_label) + .is_some(); + + pane_exposes_host_cursor_for_target(has_effective_agent_label, cfg!(windows)) + } + pub(crate) fn integration_updates_available(&self) -> bool { self.integration_recommendations .iter() @@ -1734,12 +1750,31 @@ impl AppState { } } +fn pane_exposes_host_cursor_for_target( + has_effective_agent_label: bool, + target_is_windows: bool, +) -> bool { + !target_is_windows || !has_effective_agent_label +} + #[cfg(test)] mod tests { use super::*; use crossterm::event::KeyEvent; #[test] + fn windows_agent_panes_do_not_expose_host_cursor() { + assert!(!pane_exposes_host_cursor_for_target(true, true)); + assert!(pane_exposes_host_cursor_for_target(false, true)); + } + + #[test] + fn non_windows_agent_panes_keep_existing_host_cursor_behavior() { + assert!(pane_exposes_host_cursor_for_target(true, false)); + assert!(pane_exposes_host_cursor_for_target(false, false)); + } + + #[test] fn built_in_theme_names_resolve() { for name in THEME_NAMES { assert!( |
