feat(tui): add workspace switch command
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user