From f1d86901daf095feaa209f77cce6662772777403 Mon Sep 17 00:00:00 2001 From: J3y0r Date: Thu, 7 May 2026 13:37:00 +0000 Subject: [PATCH 1/2] 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, From 598822bf109c32c097be4fe1bc36280071297e33 Mon Sep 17 00:00:00 2001 From: J3y0r Date: Fri, 8 May 2026 02:33:43 +0000 Subject: [PATCH 2/2] fix(tui): sync runtime state on workspace switch --- crates/tui/src/task_manager.rs | 37 +++++++++++++++-- crates/tui/src/tools/shell.rs | 5 +++ crates/tui/src/tui/ui.rs | 51 ++++++++++++++--------- crates/tui/src/tui/ui/tests.rs | 74 +++++++++++++++++++++++++++++++++- 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index e19d146e..bc6fde3a 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -701,6 +701,7 @@ pub type SharedTaskManager = Arc; pub struct TaskManager { cfg: TaskManagerConfig, + default_workspace: Mutex, executor: Arc, tasks_dir: PathBuf, artifacts_dir: PathBuf, @@ -766,8 +767,10 @@ impl TaskManager { let (tasks, queue) = load_state(&tasks_dir, &queue_path)?; let cancel_token = CancellationToken::new(); + let default_workspace = cfg.default_workspace.clone(); let manager = Arc::new(Self { cfg, + default_workspace: Mutex::new(default_workspace), executor, tasks_dir, artifacts_dir, @@ -810,6 +813,15 @@ impl TaskManager { self.cancel_token.is_cancelled() } + pub async fn set_default_workspace(&self, workspace: PathBuf) { + let mut default_workspace = self.default_workspace.lock().await; + *default_workspace = workspace; + } + + pub async fn default_workspace(&self) -> PathBuf { + self.default_workspace.lock().await.clone() + } + /// Enqueue a new task. pub async fn add_task(&self, req: NewTaskRequest) -> Result { let prompt = req.prompt.trim().to_string(); @@ -822,9 +834,10 @@ impl TaskManager { id: format!("task_{}", &Uuid::new_v4().to_string()[..8]), prompt, model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()), - workspace: req - .workspace - .unwrap_or_else(|| self.cfg.default_workspace.clone()), + workspace: match req.workspace { + Some(workspace) => workspace, + None => self.default_workspace().await, + }, mode: req.mode.unwrap_or_else(|| self.cfg.default_mode.clone()), allow_shell: req.allow_shell.unwrap_or(self.cfg.allow_shell), trust_mode: req.trust_mode.unwrap_or(self.cfg.trust_mode), @@ -1763,6 +1776,24 @@ mod tests { Ok(()) } + #[tokio::test] + async fn default_workspace_updates_for_future_tasks() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); + let new_workspace = + std::env::temp_dir().join(format!("deepseek-workspace-{}", Uuid::new_v4())); + let manager = + TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?; + + manager.set_default_workspace(new_workspace.clone()).await; + let task = manager + .add_task(NewTaskRequest::from_prompt("test workspace default")) + .await?; + + assert_eq!(manager.default_workspace().await, new_workspace); + assert_eq!(task.workspace, new_workspace); + Ok(()) + } + #[tokio::test] async fn record_tool_metadata_updates_explicit_task() -> Result<()> { let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index e9f0cc3f..cae5b8e1 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -584,6 +584,11 @@ impl ShellManager { self.sandbox_manager.is_available() } + #[allow(dead_code)] + pub fn default_workspace(&self) -> &Path { + &self.default_workspace + } + /// Execute a shell command with the configured sandbox policy. #[allow(dead_code)] pub fn execute( diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3a259c85..96e84298 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -38,7 +38,7 @@ use crate::core::coherence::CoherenceState; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; -use crate::hooks::HookEvent; +use crate::hooks::{HookEvent, HookExecutor}; use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; use crate::palette; use crate::prompts; @@ -270,7 +270,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { match load_result { Ok(Some(saved)) => { - let recovered = apply_loaded_session(&mut app, &saved); + let recovered = apply_loaded_session(&mut app, config, &saved); if !recovered { app.status_message = Some(format!( "Resumed session: {}", @@ -4557,7 +4557,7 @@ async fn apply_command_result( handle_mcp_ui_action(app, config, action).await; } AppAction::SwitchWorkspace { workspace } => { - switch_workspace(app, engine_handle, config, workspace).await; + switch_workspace(app, engine_handle, task_manager, config, workspace).await; } AppAction::SwitchProfile { profile } => { app.config_profile = Some(profile.clone()); @@ -4626,9 +4626,31 @@ async fn apply_command_result( Ok(false) } +fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) { + 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); + app.runtime_services.shell_manager = Some(shell_manager); + app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone())); +} + +async fn sync_runtime_workspace_state(task_manager: &SharedTaskManager, workspace: PathBuf) { + task_manager.set_default_workspace(workspace).await; +} + async fn switch_workspace( app: &mut App, engine_handle: &mut EngineHandle, + task_manager: &SharedTaskManager, config: &Config, workspace: PathBuf, ) { @@ -4646,20 +4668,8 @@ async fn switch_workspace( 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())); + apply_workspace_runtime_state(app, config, workspace.clone()); + sync_runtime_workspace_state(task_manager, workspace.clone()).await; let _ = engine_handle.send(Op::Shutdown).await; let engine_config = build_engine_config(app, config); @@ -5564,7 +5574,8 @@ async fn handle_view_events( match manager.load_session(&session_id) { Ok(session) => { - let recovered = apply_loaded_session(app, &session); + let recovered = apply_loaded_session(app, config, &session); + sync_runtime_workspace_state(task_manager, app.workspace.clone()).await; let _ = engine_handle .send(Op::SyncSession { messages: app.api_messages.clone(), @@ -5888,7 +5899,7 @@ async fn apply_provider_picker_api_key( switch_provider(app, engine_handle, config, provider, None).await; } -fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { +fn apply_loaded_session(app: &mut App, config: &Config, session: &SavedSession) -> bool { let (messages, recovered_draft) = recover_interrupted_user_tail(&session.messages); app.api_messages = messages; app.clear_history(); @@ -5934,7 +5945,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) -> bool { app.viewport.transcript_selection.clear(); app.model.clone_from(&session.metadata.model); app.update_model_compaction_budget(); - app.workspace.clone_from(&session.metadata.workspace); + apply_workspace_runtime_state(app, config, session.metadata.workspace.clone()); app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); app.session.total_conversation_tokens = app.session.total_tokens; app.session.session_cost = 0.0; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 608f8878..0c42e57c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -586,7 +586,7 @@ fn apply_loaded_session_restores_dangling_user_tail_as_retry_draft() { "finish the Qthresh proof bundle", )]); - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(recovered); assert!(app.api_messages.is_empty()); @@ -637,7 +637,7 @@ fn apply_loaded_session_resets_unpersisted_telemetry() { let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); session.metadata.total_tokens = 500; - let recovered = apply_loaded_session(&mut app, &session); + let recovered = apply_loaded_session(&mut app, &Config::default(), &session); assert!(!recovered); assert_eq!(app.session.total_tokens, 500); @@ -657,6 +657,76 @@ fn apply_loaded_session_resets_unpersisted_telemetry() { assert!(app.session.turn_cache_history.is_empty()); } +#[test] +fn apply_loaded_session_resets_workspace_runtime_state() { + let mut app = create_test_app(); + let config = Config::default(); + let old_shell_manager = app + .runtime_services + .shell_manager + .as_ref() + .expect("shell manager") + .clone(); + let old_context_cell = app.workspace_context_cell.clone(); + app.workspace_context = Some(Workspace::new(PathBuf::from("."))); + if let Ok(mut cell) = old_context_cell.lock() { + *cell = Some("old workspace context".to_string()); + } + app.workspace_context_refreshed_at = Some(Instant::now()); + app.file_tree = Some(crate::tui::file_tree::FileTreeState::new(PathBuf::from( + ".", + ))); + + let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); + session.metadata.workspace = TempDir::new().expect("temp dir").path().to_path_buf(); + + let recovered = apply_loaded_session(&mut app, &config, &session); + + assert!(!recovered); + assert_eq!(app.workspace, session.metadata.workspace); + assert!(app.workspace_context.is_none()); + assert!(app.workspace_context_refreshed_at.is_none()); + assert!(app.file_tree.is_none()); + assert!(old_context_cell.lock().expect("context cell").is_none()); + let new_shell_manager = app + .runtime_services + .shell_manager + .as_ref() + .expect("shell manager") + .clone(); + assert!(!std::sync::Arc::ptr_eq( + &old_shell_manager, + &new_shell_manager + )); + assert_eq!( + new_shell_manager + .lock() + .expect("shell manager") + .default_workspace(), + session.metadata.workspace.as_path() + ); + assert!(app.runtime_services.hook_executor.is_some()); +} + +#[test] +fn apply_loaded_session_updates_current_workspace_display() { + let mut app = create_test_app(); + let config = Config::default(); + let workspace = TempDir::new().expect("temp dir"); + let mut session = saved_session_with_messages(vec![text_message("assistant", "ready")]); + session.metadata.workspace = workspace.path().to_path_buf(); + + let recovered = apply_loaded_session(&mut app, &config, &session); + let result = commands::core::workspace_switch(&mut app, None); + + assert!(!recovered); + assert_eq!( + result.message, + Some(format!("Current workspace: {}", workspace.path().display())) + ); + assert!(result.action.is_none()); +} + #[tokio::test] async fn drain_web_config_events_applies_draft_without_closing_session() { let mut app = create_test_app();