feat(tui): add workspace switch command

This commit is contained in:
J3y0r
2026-05-07 13:37:00 +00:00
parent 39b2d528cd
commit f1d86901da
5 changed files with 215 additions and 15 deletions
+102
View File
@@ -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<PathBuf, String> {
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();
+19
View File
@@ -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();
+6
View File
@@ -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 <path>`, `/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 <path>`、`/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 <path>`、`/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 <path>`, `/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)"
+30 -15
View File
@@ -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,
+58
View File
@@ -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,