diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 30e7448f..b91cdf46 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -640,6 +640,12 @@ "default": "true", "description": "Ask for a tab name before creating a new tab." }, + { + "key": "ui.prompt_new_workspace_name", + "type": "boolean", + "default": "false", + "description": "Ask for a workspace name before interactive TUI creation." + }, { "key": "ui.pane_borders", "type": "boolean", diff --git a/src/app/creation.rs b/src/app/creation.rs index 0277ba10..2e978ee9 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -76,6 +76,33 @@ impl App { }) } + pub(super) fn begin_tui_workspace_create(&mut self, request_id: &'static str) { + if self.state.prompt_new_workspace_name { + let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { + self.focused_pane_cwd_in_workspace(ws_idx) + .or_else(|| self.seed_cwd_from_workspace(ws_idx)) + }); + let cwd = self.resolve_new_terminal_cwd(follow_cwd); + super::input::open_new_workspace_dialog(&mut self.state, cwd); + return; + } + + self.runtime_workspace_create( + request_id, + crate::api::schema::WorkspaceCreateParams { + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ); + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + /// Create a workspace with a real PTY (needs event_tx). #[cfg(test)] pub(crate) fn create_workspace(&mut self) { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 73ddbefd..c17f64cd 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -49,7 +49,7 @@ mod terminal; pub(crate) use self::{ modal::{ handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, - insert_navigator_search_text, insert_rename_input_text, + insert_navigator_search_text, insert_rename_input_text, open_new_workspace_dialog, }, navigate::{ terminal_direct_indexed_navigation_action, terminal_direct_non_indexed_navigation_action, @@ -313,6 +313,9 @@ impl App { } if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { match action { + MouseAction::NewWorkspace => { + self.begin_tui_workspace_create("tui.mouse.workspace.create") + } MouseAction::Settings(action) => match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 84aed8da..23134ddc 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -302,6 +302,7 @@ pub(super) fn open_rename_workspace( terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, ws_idx: usize, ) { + state.pending_workspace_create_cwd = None; state.selected = ws_idx; state.rename_pane_target = None; state.name_input = @@ -310,9 +311,21 @@ pub(super) fn open_rename_workspace( state.mode = Mode::RenameWorkspace; } +pub(crate) fn open_new_workspace_dialog(state: &mut AppState, cwd: std::path::PathBuf) { + let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); + state.creating_new_tab = false; + state.requested_new_tab_name = None; + state.pending_workspace_create_cwd = Some(cwd); + state.rename_pane_target = None; + state.name_input = suggested_name; + state.name_input_replace_on_type = true; + state.mode = Mode::RenameWorkspace; +} + pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { state.creating_new_tab = false; state.requested_new_tab_name = None; + state.pending_workspace_create_cwd = None; state.rename_pane_target = None; if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { if let Some(name) = ws.active_tab_display_name() { @@ -333,6 +346,7 @@ pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::Pan let terminal = state.terminals.get(&pane.attached_terminal_id); state.creating_new_tab = false; state.requested_new_tab_name = None; + state.pending_workspace_create_cwd = None; state.rename_pane_target = Some(pane_id); state.name_input = terminal .and_then(|t| t.manual_label.clone()) @@ -341,6 +355,11 @@ pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::Pan state.mode = Mode::RenamePane; } +fn workspace_create_label(input: &str, suggested_name: &str) -> Option { + let name = input.trim(); + (!name.is_empty() && name != suggested_name).then(|| name.to_string()) +} + fn next_new_tab_default_name(state: &AppState) -> String { state .active @@ -352,6 +371,7 @@ fn next_new_tab_default_name(state: &AppState) -> String { pub(super) fn open_new_tab_dialog(state: &mut AppState) { state.creating_new_tab = true; state.requested_new_tab_name = None; + state.pending_workspace_create_cwd = None; state.rename_pane_target = None; state.name_input = next_new_tab_default_name(state); state.name_input_replace_on_type = true; @@ -423,7 +443,11 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { state.name_input.trim().to_string() }; match state.mode { - Mode::RenameWorkspace if !state.workspaces.is_empty() && !new_name.is_empty() => { + Mode::RenameWorkspace + if state.pending_workspace_create_cwd.is_none() + && !state.workspaces.is_empty() + && !new_name.is_empty() => + { let workspace_id = state.workspaces[state.selected].id.clone(); state.workspaces[state.selected].set_custom_name(new_name); crate::logging::workspace_renamed(&workspace_id); @@ -487,6 +511,7 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { _ => {} } state.creating_new_tab = false; + state.pending_workspace_create_cwd = None; state.rename_pane_target = None; state.name_input.clear(); state.name_input_replace_on_type = false; @@ -499,6 +524,7 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { ModalAction::Cancel => { state.creating_new_tab = false; state.requested_new_tab_name = None; + state.pending_workspace_create_cwd = None; state.rename_pane_target = None; state.name_input.clear(); state.name_input_replace_on_type = false; @@ -916,15 +942,29 @@ impl App { }; match self.state.mode { - Mode::RenameWorkspace if !self.state.workspaces.is_empty() && !new_name.is_empty() => { - let workspace_id = self.public_workspace_id(self.state.selected); - self.runtime_workspace_rename( - "tui.workspace.rename", - crate::api::schema::WorkspaceRenameParams { - workspace_id, - label: new_name, - }, - ); + Mode::RenameWorkspace => { + if let Some(cwd) = self.state.pending_workspace_create_cwd.take() { + let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); + let label = workspace_create_label(&new_name, &suggested_name); + self.runtime_workspace_create( + "tui.workspace.create_named", + crate::api::schema::WorkspaceCreateParams { + cwd: Some(cwd.display().to_string()), + focus: true, + label, + env: Default::default(), + }, + ); + } else if !self.state.workspaces.is_empty() && !new_name.is_empty() { + let workspace_id = self.public_workspace_id(self.state.selected); + self.runtime_workspace_rename( + "tui.workspace.rename", + crate::api::schema::WorkspaceRenameParams { + workspace_id, + label: new_name, + }, + ); + } } Mode::RenameTab if self.state.creating_new_tab => { let default_name = next_new_tab_default_name(&self.state); @@ -1256,6 +1296,7 @@ impl App { fn cancel_rename_modal(state: &mut AppState) { state.creating_new_tab = false; state.requested_new_tab_name = None; + state.pending_workspace_create_cwd = None; state.rename_pane_target = None; state.name_input.clear(); state.name_input_replace_on_type = false; @@ -1318,6 +1359,17 @@ mod tests { app } + #[test] + fn workspace_create_label_preserves_auto_name_for_suggestion_or_blank() { + assert_eq!(workspace_create_label("project", "project"), None); + assert_eq!(workspace_create_label("", "project"), None); + assert_eq!(workspace_create_label(" ", "project"), None); + assert_eq!( + workspace_create_label(" logs ", "project").as_deref(), + Some("logs") + ); + } + fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) { state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: key.into(), diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 6cbce83f..59dcf502 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -26,6 +26,7 @@ use super::{ }; pub(super) enum MouseAction { + NewWorkspace, Settings(SettingsAction), FocusWorkspace { ws_idx: usize, @@ -535,8 +536,7 @@ impl AppState { && mouse.column >= new_button.x && mouse.column < new_button.x + new_button.width; if on_new_button { - self.request_new_workspace = true; - return None; + return Some(MouseAction::NewWorkspace); } if let Some(target) = @@ -1152,7 +1152,7 @@ impl AppState { match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => { - self.request_new_workspace = true; + return MobileMouseResult::Action(MouseAction::NewWorkspace); } Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { self.mode = Mode::Terminal; @@ -3628,7 +3628,82 @@ mod tests { } #[test] - fn mobile_switcher_action_rows_create_workspace_and_open_tab_dialog() { + fn mobile_switcher_new_workspace_opens_prompt_when_enabled() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.prompt_new_workspace_name = true; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); + let switch = app.state.view.mobile_menu_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + switch.x + 1, + switch.y + 1, + )); + let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + viewport.x + 2, + viewport.y + 1, + )); + + assert_eq!(app.state.mode, Mode::RenameWorkspace); + assert!(app.state.pending_workspace_create_cwd.is_some()); + assert!(app.state.name_input_replace_on_type); + assert_eq!(app.state.workspaces.len(), 1); + } + + #[test] + fn desktop_new_workspace_opens_prompt_when_enabled() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.prompt_new_workspace_name = true; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); + let new_workspace = app.state.sidebar_new_button_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + new_workspace.x + 1, + new_workspace.y, + )); + + assert_eq!(app.state.mode, Mode::RenameWorkspace); + assert!(app.state.pending_workspace_create_cwd.is_some()); + assert!(app.state.name_input_replace_on_type); + assert_eq!(app.state.workspaces.len(), 1); + } + + #[tokio::test] + async fn desktop_new_workspace_creates_immediately_by_default() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); + let new_workspace = app.state.sidebar_new_button_rect(); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + new_workspace.x + 1, + new_workspace.y, + )); + + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.pending_workspace_create_cwd.is_none()); + crate::app::api::test_support::shutdown_test_runtimes(&mut app); + } + + #[test] + fn mobile_switcher_new_tab_opens_dialog_when_enabled() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("logs")); @@ -3645,21 +3720,12 @@ mod tests { switch.y + 1, )); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - viewport.x + 2, - viewport.y + 1, - )); - assert!(app.state.request_new_workspace); - - app.state.request_new_workspace = false; - app.state.mode = Mode::Navigate; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 5, )); + assert_eq!(app.state.mode, Mode::RenameTab); assert!(app.state.creating_new_tab); } diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 33177624..e1ffeb5d 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -184,16 +184,7 @@ impl App { let previous_mode = self.state.mode; match action { NavigateAction::NewWorkspace => { - self.runtime_workspace_create( - "tui.key.workspace.create", - crate::api::schema::WorkspaceCreateParams { - cwd: None, - focus: true, - label: None, - env: Default::default(), - }, - ); - leave_navigate_mode(&mut self.state); + self.begin_tui_workspace_create("tui.key.workspace.create"); } NavigateAction::NewWorktree => { if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { @@ -1999,6 +1990,74 @@ mod tests { assert_eq!(state.mode, Mode::Terminal); } + #[tokio::test] + async fn new_workspace_key_opens_prefilled_prompt_and_preserves_captured_cwd() { + let cwd = unique_temp_path("workspace-name-suggestion"); + std::fs::create_dir_all(&cwd).unwrap(); + let suggested_name = crate::workspace::derive_label_from_cwd(&cwd); + let mut app = app_with_test_workspaces(&["test"]); + app.state.new_terminal_cwd = + crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); + app.state.prompt_new_workspace_name = true; + app.state.mode = Mode::Navigate; + app.state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::RenameWorkspace); + assert_eq!(app.state.name_input, suggested_name); + assert!(app.state.name_input_replace_on_type); + assert_eq!(app.state.pending_workspace_create_cwd.as_ref(), Some(&cwd)); + assert_eq!(app.state.workspaces.len(), 1); + + app.state.new_terminal_cwd = + crate::config::NewTerminalCwdConfig::Path("/tmp/changed-after-prompt".into()); + app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.workspaces[1].identity_cwd, cwd); + assert!(app.state.workspaces[1].custom_name.is_none()); + assert!(app.state.pending_workspace_create_cwd.is_none()); + assert_eq!(app.state.mode, Mode::Terminal); + crate::app::api::test_support::shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(&cwd); + } + + #[tokio::test] + async fn new_workspace_prompt_saves_custom_name_atomically() { + let cwd = unique_temp_path("workspace-custom-name"); + std::fs::create_dir_all(&cwd).unwrap(); + let mut app = app_with_test_workspaces(&["test"]); + app.state.new_terminal_cwd = + crate::config::NewTerminalCwdConfig::Path(cwd.display().to_string()); + app.state.prompt_new_workspace_name = true; + app.state.mode = Mode::Navigate; + + app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); + app.state.name_input = " logs ".into(); + app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.workspaces[1].custom_name.as_deref(), Some("logs")); + assert_eq!(app.state.workspaces[1].identity_cwd, cwd); + crate::app::api::test_support::shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(&cwd); + } + + #[test] + fn cancelling_new_workspace_prompt_creates_nothing() { + let mut app = app_with_test_workspaces(&["test"]); + app.state.prompt_new_workspace_name = true; + app.state.mode = Mode::Navigate; + + app.execute_tui_navigate_action(NavigateAction::NewWorkspace, ActionContext::Navigate); + app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + + assert_eq!(app.state.workspaces.len(), 1); + assert!(app.state.pending_workspace_create_cwd.is_none()); + assert_eq!(app.state.mode, Mode::Terminal); + } + #[test] fn custom_new_worktree_key_requests_selected_workspace() { let mut state = state_with_workspaces(&["main", "scratch"]); diff --git a/src/app/mod.rs b/src/app/mod.rs index b6f2bbb8..d634dbb4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -534,6 +534,7 @@ impl App { request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, + pending_workspace_create_cwd: None, rename_pane_target: None, worktree_create: None, worktree_open: None, @@ -617,6 +618,7 @@ impl App { mouse_scroll_lines: config.ui.mouse_scroll_lines(), confirm_close: config.ui.confirm_close, prompt_new_tab_name: config.ui.prompt_new_tab_name, + prompt_new_workspace_name: config.ui.prompt_new_workspace_name, pane_borders: config.ui.pane_borders, pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, @@ -1151,7 +1153,10 @@ impl App { } pub(crate) fn ensure_default_workspace(&mut self) -> bool { - if !self.state.workspaces.is_empty() || self.state.mode == Mode::Onboarding { + if !self.state.workspaces.is_empty() + || self.state.mode == Mode::Onboarding + || self.state.pending_workspace_create_cwd.is_some() + { return false; } @@ -1405,6 +1410,7 @@ impl App { config.ui.right_click_passthrough_modifiers(); self.state.confirm_close = config.ui.confirm_close; self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; + self.state.prompt_new_workspace_name = config.ui.prompt_new_workspace_name; self.state.pane_borders = config.ui.pane_borders; self.state.pane_gaps = config.ui.pane_gaps; self.state.show_agent_labels_on_pane_borders = @@ -2385,6 +2391,34 @@ mod tests { assert!(!app.state.redraw_on_focus_gained); } + #[test] + fn workspace_name_prompt_suppresses_default_creation_while_pending() { + let mut app = test_app(); + app.state.prompt_new_workspace_name = true; + + app.begin_tui_workspace_create("test.workspace.create"); + + assert_eq!(app.state.mode, Mode::RenameWorkspace); + assert!(app.state.pending_workspace_create_cwd.is_some()); + assert!(!app.ensure_default_workspace()); + assert!(app.state.workspaces.is_empty()); + + app.handle_rename_key_via_api(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + assert!(app.state.workspaces.is_empty()); + assert!(app.state.pending_workspace_create_cwd.is_none()); + } + + #[test] + fn startup_uses_workspace_name_prompt_config() { + let mut config = Config::default(); + config.ui.prompt_new_workspace_name = true; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + + let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + assert!(app.state.prompt_new_workspace_name); + } + #[test] fn theme_auto_switch_is_opt_in_and_preserves_manual_default() { let mut config = Config::default(); @@ -2571,7 +2605,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -2619,6 +2653,7 @@ mod tests { assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); assert!(!app.state.redraw_on_focus_gained); assert!(!app.state.copy_on_select); + assert!(app.state.prompt_new_workspace_name); assert!(app.state.selection.is_some()); assert!(app.state.selection_autoscroll.is_some()); assert_eq!(app.selection_autoscroll_deadline, Some(selection_deadline)); diff --git a/src/app/state.rs b/src/app/state.rs index d821407b..0321a6ee 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1394,6 +1394,7 @@ pub struct AppState { pub request_clipboard_write: Option>, pub creating_new_tab: bool, pub requested_new_tab_name: Option, + pub pending_workspace_create_cwd: Option, pub rename_pane_target: Option, pub worktree_create: Option, pub worktree_open: Option, @@ -1461,6 +1462,7 @@ pub struct AppState { pub mouse_scroll_lines: usize, pub confirm_close: bool, pub prompt_new_tab_name: bool, + pub prompt_new_workspace_name: bool, pub pane_borders: bool, pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, @@ -1760,6 +1762,7 @@ impl AppState { request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, + pending_workspace_create_cwd: None, rename_pane_target: None, worktree_create: None, worktree_open: None, @@ -1834,6 +1837,7 @@ impl AppState { mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES, confirm_close: true, prompt_new_tab_name: true, + prompt_new_workspace_name: false, pane_borders: true, pane_gaps: false, show_agent_labels_on_pane_borders: false, diff --git a/src/config/model.rs b/src/config/model.rs index b32dc8a9..9fbca3a6 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -802,6 +802,8 @@ pub struct UiConfig { pub confirm_close: bool, /// Ask for a tab name before creating a new tab. Default: true. pub prompt_new_tab_name: bool, + /// Ask for a workspace name before interactive creation. Default: false. + pub prompt_new_workspace_name: bool, /// Draw borders around split panes. Default: true. pub pane_borders: bool, /// Keep split panes visually separated instead of sharing divider borders. Default: true. @@ -1003,6 +1005,7 @@ impl Default for UiConfig { mouse_scroll_lines: None, confirm_close: true, prompt_new_tab_name: true, + prompt_new_workspace_name: false, pane_borders: true, pane_gaps: true, show_agent_labels_on_pane_borders: false, @@ -1279,6 +1282,19 @@ prompt_new_tab_name = false assert!(!config.ui.prompt_new_tab_name); } + #[test] + fn prompt_new_workspace_name_defaults_off_and_parses() { + let default_config = Config::default(); + assert!(!default_config.ui.prompt_new_workspace_name); + + let toml = r#" +[ui] +prompt_new_workspace_name = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.ui.prompt_new_workspace_name); + } + #[test] fn reveal_hidden_cursor_for_cjk_ime_default_off_and_parse() { let default_config = Config::default(); diff --git a/src/main.rs b/src/main.rs index 05309f98..42ec3d01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -297,6 +297,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Set false to create tabs immediately with generated names. # prompt_new_tab_name = true +# Ask for a workspace name before interactive creation. +# prompt_new_workspace_name = false + # Draw borders around split panes. # pane_borders = true diff --git a/src/ui.rs b/src/ui.rs index c4789ea4..b7dc3fb1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -624,6 +624,26 @@ mod tests { ); } + #[test] + fn workspace_creation_dialog_renders_new_workspace_title() { + let mut app = crate::app::state::AppState::test_new(); + app.mode = Mode::RenameWorkspace; + app.pending_workspace_create_cwd = Some("/tmp/project".into()); + app.name_input = "project".into(); + + let area = Rect::new(0, 0, 80, 20); + compute_view(&mut app, area); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal.draw(|frame| render(&app, frame)).unwrap(); + let screen = (0..area.height) + .map(|row| buffer_row_text(terminal.backend().buffer(), area, row)) + .collect::>() + .join("\n"); + + assert!(screen.contains("new workspace"), "{screen}"); + assert!(screen.contains("project"), "{screen}"); + } + #[tokio::test] async fn focused_pane_cursor_wins_during_terminal_render() { let mut app = crate::app::state::AppState::test_new(); diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index e86edd8c..ff40693b 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -43,6 +43,7 @@ pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rec super::dim_background(frame, area); let title = match app.mode { + Mode::RenameWorkspace if app.pending_workspace_create_cwd.is_some() => "new workspace", Mode::RenameWorkspace => "rename workspace", Mode::RenameTab if app.creating_new_tab => "new tab", Mode::RenameTab => "rename tab",