aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-18 01:55:46 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-18 01:55:46 +0300
commite298e4589050349630d0faaec00ef11555b83ae1 (patch)
treef9487e37bb641cb99e5bb053d7ad8106db13b047 /src/app/input
parent13ce20c4f536892c5b939bd62e5286fac9c2eaee (diff)
feat: add optional workspace name prompt
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs5
-rw-r--r--src/app/input/modal.rs72
-rw-r--r--src/app/input/mouse.rs88
-rw-r--r--src/app/input/navigate.rs79
4 files changed, 212 insertions, 32 deletions
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<String> {
+ 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,14 +3628,13 @@ 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();
- let mut ws = Workspace::test_new("one");
- ws.test_add_tab(Some("logs"));
- app.state.workspaces = vec![ws];
+ 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;
@@ -3645,21 +3644,88 @@ 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;
+ 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"));
+ app.state.workspaces = vec![ws];
+ 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, 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 + 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"]);