From f1d86901daf095feaa209f77cce6662772777403 Mon Sep 17 00:00:00 2001 From: J3y0r Date: Thu, 7 May 2026 13:37:00 +0000 Subject: [PATCH] feat(tui): add workspace switch command --- crates/tui/src/commands/core.rs | 102 ++++++++++++++++++++++++++++++++ crates/tui/src/commands/mod.rs | 19 ++++++ crates/tui/src/localization.rs | 6 ++ crates/tui/src/tui/app.rs | 45 +++++++++----- crates/tui/src/tui/ui.rs | 58 ++++++++++++++++++ 5 files changed, 215 insertions(+), 15 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 8529233d..7f7fdb82 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -1,6 +1,7 @@ //! Core commands: help, clear, exit, model use std::fmt::Write; +use std::path::PathBuf; use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name}; use crate::localization::{MessageId, tr}; @@ -181,6 +182,50 @@ pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult { ) } +pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { + let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { + return CommandResult::message(format!("Current workspace: {}", app.workspace.display())); + }; + + let expanded = match expand_workspace_path(raw_path) { + Ok(path) => path, + Err(message) => return CommandResult::error(message), + }; + let candidate = if expanded.is_absolute() { + expanded + } else { + app.workspace.join(expanded) + }; + + if !candidate.exists() { + return CommandResult::error(format!("Workspace does not exist: {}", candidate.display())); + } + if !candidate.is_dir() { + return CommandResult::error(format!( + "Workspace is not a directory: {}", + candidate.display() + )); + } + + let workspace = candidate.canonicalize().unwrap_or(candidate); + CommandResult::with_message_and_action( + format!("Switching workspace to {}...", workspace.display()), + AppAction::SwitchWorkspace { workspace }, + ) +} + +fn expand_workspace_path(path: &str) -> Result { + if path == "~" { + return dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string()); + } + if let Some(rest) = path.strip_prefix("~/") { + let home = + dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string())?; + return Ok(home.join(rest)); + } + Ok(PathBuf::from(path)) +} + /// Show `DeepSeek` dashboard and docs links pub fn deepseek_links(app: &mut App) -> CommandResult { let locale = app.ui_locale; @@ -315,6 +360,7 @@ mod tests { use crate::tui::history::HistoryCell; use std::path::PathBuf; use std::time::Instant; + use tempfile::tempdir; fn create_test_app() -> App { let options = TuiOptions { @@ -477,6 +523,62 @@ mod tests { assert!(matches!(result.action, Some(AppAction::Quit))); } + #[test] + fn workspace_without_arg_shows_current_workspace() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, None); + let msg = result.message.expect("workspace should be shown"); + assert!(msg.contains("Current workspace:")); + assert!(msg.contains("/tmp/test-workspace")); + assert!(result.action.is_none()); + } + + #[test] + fn workspace_existing_absolute_dir_returns_switch_action() { + let mut app = create_test_app(); + let dir = tempdir().expect("temp dir"); + let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); + } + + #[test] + fn workspace_relative_dir_resolves_from_current_workspace() { + let root = tempdir().expect("temp dir"); + let child = root.path().join("child"); + std::fs::create_dir(&child).expect("child dir"); + let mut app = create_test_app(); + app.workspace = root.path().to_path_buf(); + + let result = workspace_switch(&mut app, Some("child")); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() + )); + } + + #[test] + fn workspace_rejects_missing_path() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, Some("definitely-missing")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); + } + + #[test] + fn workspace_rejects_file_path() { + let root = tempdir().expect("temp dir"); + let file = root.path().join("file.txt"); + std::fs::write(&file, "not a directory").expect("test file"); + let mut app = create_test_app(); + + let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); + assert!(result.is_error); + assert!(result.message.unwrap().contains("not a directory")); + } + #[test] fn test_model_change_updates_state() { let mut app = create_test_app(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 093a61aa..882c2028 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -213,6 +213,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/home", description_id: MessageId::CmdHomeDescription, }, + CommandInfo { + name: "workspace", + aliases: &["cwd"], + usage: "/workspace [path]", + description_id: MessageId::CmdWorkspaceDescription, + }, CommandInfo { name: "note", aliases: &[], @@ -509,6 +515,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "subagents" | "agents" => core::subagents(app), "links" | "dashboard" | "api" => core::deepseek_links(app), "home" | "stats" | "overview" => core::home_dashboard(app), + "workspace" | "cwd" => core::workspace_switch(app, arg), "note" => note::note(app, arg), "memory" => memory::memory(app, arg), "attach" | "image" | "media" => attachment::attach(app, arg), @@ -822,6 +829,7 @@ mod tests { use crate::config::Config; use crate::tui::app::{App, AppAction, TuiOptions}; use std::path::PathBuf; + use tempfile::tempdir; fn create_test_app() -> App { let options = TuiOptions { @@ -926,6 +934,17 @@ mod tests { } } + #[test] + fn execute_workspace_alias_switches_workspace() { + let dir = tempdir().expect("temp dir"); + let mut app = create_test_app(); + let result = execute(&format!("/cwd {}", dir.path().display()), &mut app); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); + } + #[test] fn removed_set_and_deepseek_commands_show_migration_hints() { let mut app = create_test_app(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 00cadf80..f1c7beee 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -268,6 +268,7 @@ pub enum MessageId { CmdTrustDescription, CmdLspDescription, CmdShareDescription, + CmdWorkspaceDescription, CmdUndoDescription, CmdYoloDescription, CmdCacheAdvice, @@ -458,6 +459,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdTrustDescription, MessageId::CmdLspDescription, MessageId::CmdShareDescription, + MessageId::CmdWorkspaceDescription, MessageId::CmdUndoDescription, MessageId::CmdYoloDescription, MessageId::CmdCacheAdvice, @@ -797,6 +799,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdTrustDescription => { "Manage workspace trust and per-path allowlist (`/trust add `, `/trust list`, `/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "Show or switch the current workspace", MessageId::CmdUndoDescription => "Remove last message pair", MessageId::CmdYoloDescription => "Enable YOLO mode (shell + trust + auto-approve)", MessageId::CmdCacheAdvice => { @@ -1082,6 +1085,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "ワークスペースの信頼設定とパス別許可リストを管理(`/trust add `、`/trust list`、`/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "現在のワークスペースを表示または切り替え", MessageId::CmdUndoDescription => "最後のメッセージ対を削除", MessageId::CmdYoloDescription => "YOLO モードを有効化(shell + 信頼 + 自動承認)", MessageId::CmdCacheAdvice => { @@ -1339,6 +1343,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "管理工作区信任与按路径的白名单(`/trust add `、`/trust list`、`/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "显示或切换当前工作空间", MessageId::CmdUndoDescription => "移除最后一组消息对", MessageId::CmdYoloDescription => "启用 YOLO 模式(shell + 信任 + 自动批准)", MessageId::CmdCacheAdvice => { @@ -1612,6 +1617,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdTrustDescription => { "Gerenciar a confiança do workspace e a allowlist por caminho (`/trust add `, `/trust list`, `/trust on|off`)" } + MessageId::CmdWorkspaceDescription => "Mostrar ou trocar o workspace atual", MessageId::CmdUndoDescription => "Remover o último par de mensagens", MessageId::CmdYoloDescription => { "Ativar o modo YOLO (shell + confiança + aprovação automática)" diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index bc6204c3..4f938376 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -56,6 +56,31 @@ pub enum OnboardingState { None, } +pub(crate) fn resolve_skills_dir( + workspace: &Path, + global_skills_dir: &Path, + config: &Config, +) -> PathBuf { + let agents_skills_dir = workspace.join(".agents").join("skills"); + if agents_skills_dir.exists() { + return agents_skills_dir; + } + + let local_skills_dir = workspace.join("skills"); + if local_skills_dir.exists() { + return local_skills_dir; + } + + if config.skills_dir.is_none() + && let Some(global_agents) = crate::skills::agents_global_skills_dir() + && global_agents.exists() + { + return global_agents; + } + + global_skills_dir.to_path_buf() +} + fn initial_onboarding_state( skip_onboarding: bool, was_onboarded: bool, @@ -1231,21 +1256,7 @@ impl App { // Initialize plan state let plan_state = new_shared_plan_state(); - let agents_skills_dir = workspace.join(".agents").join("skills"); - let local_skills_dir = workspace.join("skills"); - let agents_global_skills_dir = crate::skills::agents_global_skills_dir(); - let skills_dir = if agents_skills_dir.exists() { - agents_skills_dir - } else if local_skills_dir.exists() { - local_skills_dir - } else if config.skills_dir.is_none() - && let Some(global_agents) = agents_global_skills_dir - && global_agents.exists() - { - global_agents - } else { - global_skills_dir - }; + let skills_dir = resolve_skills_dir(&workspace, &global_skills_dir, config); let cached_skills = Self::discover_cached_skills(&skills_dir); let input_history = crate::composer_history::load_history(); @@ -3794,6 +3805,10 @@ pub enum AppAction { /// Profile name to load. profile: String, }, + /// Switch the workspace used by tools, hooks, tasks, and session metadata. + SwitchWorkspace { + workspace: PathBuf, + }, /// Export and share the current session as a web URL. ShareSession { history_len: usize, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b717568c..3a259c85 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4556,6 +4556,9 @@ async fn apply_command_result( AppAction::Mcp(action) => { handle_mcp_ui_action(app, config, action).await; } + AppAction::SwitchWorkspace { workspace } => { + switch_workspace(app, engine_handle, config, workspace).await; + } AppAction::SwitchProfile { profile } => { app.config_profile = Some(profile.clone()); match Config::load(app.config_path.clone(), Some(&profile)) { @@ -4623,6 +4626,61 @@ async fn apply_command_result( Ok(false) } +async fn switch_workspace( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &Config, + workspace: PathBuf, +) { + if app.is_loading { + app.status_message = + Some("Cannot switch workspace while a request is running.".to_string()); + app.add_message(HistoryCell::System { + content: "Cannot switch workspace while a request is running.".to_string(), + }); + return; + } + + if app.workspace == workspace { + app.status_message = Some(format!("Workspace unchanged: {}", workspace.display())); + return; + } + + app.workspace = workspace.clone(); + app.hooks = HookExecutor::new(config.hooks_config(), workspace.clone()); + app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config); + app.refresh_skill_cache(); + app.workspace_context = None; + if let Ok(mut cell) = app.workspace_context_cell.lock() { + *cell = None; + } + app.workspace_context_refreshed_at = None; + app.file_tree = None; + + let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone()); + app.runtime_services.shell_manager = Some(shell_manager); + app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); + + let _ = engine_handle.send(Op::Shutdown).await; + let engine_config = build_engine_config(app, config); + *engine_handle = spawn_engine(engine_config, config); + if !app.api_messages.is_empty() { + let _ = engine_handle + .send(Op::SyncSession { + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: workspace.clone(), + }) + .await; + } + + app.add_message(HistoryCell::System { + content: format!("Switched workspace to {}", workspace.display()), + }); + app.status_message = Some(format!("Workspace: {}", workspace.display())); +} + async fn handle_mcp_ui_action( app: &mut App, config: &Config,