diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index 7ec91ed0..7639b8ca 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -40,6 +40,15 @@ pub struct WorkspaceIdentityDto { pub emoji: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceWorktreeInfoDto { + pub path: String, + pub branch: Option, + pub main_repo_path: String, + pub is_main: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceInfoDto { @@ -57,6 +66,8 @@ pub struct WorkspaceInfoDto { pub statistics: Option, pub identity: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub connection_name: Option, @@ -97,6 +108,10 @@ impl WorkspaceInfoDto { .identity .as_ref() .map(WorkspaceIdentityDto::from_workspace_identity), + worktree: info + .worktree + .as_ref() + .map(WorkspaceWorktreeInfoDto::from_workspace_worktree_info), connection_id, connection_name, } @@ -116,6 +131,19 @@ impl WorkspaceIdentityDto { } } +impl WorkspaceWorktreeInfoDto { + pub fn from_workspace_worktree_info( + info: &bitfun_core::service::workspace::manager::WorkspaceWorktreeInfo, + ) -> Self { + Self { + path: info.path.clone(), + branch: info.branch.clone(), + main_repo_path: info.main_repo_path.clone(), + is_main: info.is_main, + } + } +} + impl WorkspaceTypeDto { pub fn from_workspace_type( workspace_type: &bitfun_core::service::workspace::manager::WorkspaceType, diff --git a/src/apps/desktop/src/api/terminal_api.rs b/src/apps/desktop/src/api/terminal_api.rs index 39c5de42..1e19d067 100644 --- a/src/apps/desktop/src/api/terminal_api.rs +++ b/src/apps/desktop/src/api/terminal_api.rs @@ -16,7 +16,8 @@ use bitfun_core::service::terminal::{ ExecuteCommandResponse as CoreExecuteCommandResponse, GetHistoryRequest as CoreGetHistoryRequest, GetHistoryResponse as CoreGetHistoryResponse, ResizeRequest as CoreResizeRequest, SendCommandRequest as CoreSendCommandRequest, - SessionResponse as CoreSessionResponse, ShellInfo as CoreShellInfo, ShellType, + SessionResponse as CoreSessionResponse, SessionSource as CoreSessionSource, + ShellInfo as CoreShellInfo, ShellType, SignalRequest as CoreSignalRequest, TerminalApi, TerminalConfig, WriteRequest as CoreWriteRequest, }; @@ -97,6 +98,7 @@ pub struct CreateSessionRequest { pub env: Option>, pub cols: Option, pub rows: Option, + pub source: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -114,6 +116,7 @@ pub struct SessionResponse { /// None/null for local terminals. #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, + pub source: String, } impl From for SessionResponse { @@ -128,6 +131,7 @@ impl From for SessionResponse { cols: resp.cols, rows: resp.rows, connection_id: None, + source: format_session_source(&resp.source), } } } @@ -276,6 +280,21 @@ fn parse_shell_type(s: &str) -> Option { } } +fn parse_session_source(source: &str) -> Option { + match source.to_lowercase().as_str() { + "manual" => Some(CoreSessionSource::Manual), + "agent" => Some(CoreSessionSource::Agent), + _ => None, + } +} + +fn format_session_source(source: &CoreSessionSource) -> String { + match source { + CoreSessionSource::Manual => "manual".to_string(), + CoreSessionSource::Agent => "agent".to_string(), + } +} + #[tauri::command] pub async fn terminal_get_shells( state: State<'_, TerminalState>, @@ -326,6 +345,7 @@ pub async fn terminal_create( request.cols.unwrap_or(80), request.rows.unwrap_or(24), Some(remote_cwd.as_str()), + request.source.as_deref().and_then(parse_session_source), ) .await .map_err(|e| format!("Failed to create remote session: {}", e))?; @@ -344,6 +364,7 @@ pub async fn terminal_create( cols: session.cols, rows: session.rows, connection_id: Some(connection_id.clone()), + source: format_session_source(&session.source), }; let app_handle = _app.clone(); @@ -408,6 +429,7 @@ pub async fn terminal_create( cols: request.cols, rows: request.rows, remote_connection_id: None, + source: request.source.as_deref().and_then(parse_session_source), }; let session = api @@ -437,6 +459,7 @@ pub async fn terminal_get( cols: session.cols, rows: session.rows, connection_id: Some(session.connection_id), + source: format_session_source(&session.source), }); } } @@ -472,6 +495,7 @@ pub async fn terminal_list( cols: s.cols, rows: s.rows, connection_id: Some(s.connection_id), + source: format_session_source(&s.source), })); } } diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index e03373b2..16e76084 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -11,6 +11,7 @@ use futures::StreamExt; use log::{debug, error, info}; use serde_json::{json, Value}; use std::time::{Duration, Instant}; +use terminal_core::session::SessionSource; use terminal_core::shell::{ShellDetector, ShellType}; use terminal_core::{ CommandCompletionReason, CommandStreamEvent, ExecuteCommandRequest, SendCommandRequest, @@ -509,6 +510,7 @@ Usage notes: )), shell_type: shell_type.clone(), env: Some(Self::noninteractive_env()), + source: Some(SessionSource::Agent), ..Default::default() }, ) @@ -723,6 +725,7 @@ impl BashTool { session_name: None, shell_type, env: Some(Self::noninteractive_env()), + source: Some(SessionSource::Agent), ..Default::default() }, ) diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 21d34e95..6823e9af 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -1680,6 +1680,7 @@ impl RemoteExecutionDispatcher { // exists and the 30-second readiness wait is skipped entirely. { use terminal_core::{TerminalApi, TerminalBindingOptions}; + use terminal_core::session::SessionSource; let sid = session_id.to_string(); let binding_workspace_for_terminal = binding_workspace.clone(); tokio::spawn(async move { @@ -1702,6 +1703,7 @@ impl RemoteExecutionDispatcher { env: Some( crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), ), + source: Some(SessionSource::Agent), ..Default::default() }, ) diff --git a/src/crates/core/src/service/remote_ssh/remote_terminal.rs b/src/crates/core/src/service/remote_ssh/remote_terminal.rs index 83876df0..ea439760 100644 --- a/src/crates/core/src/service/remote_ssh/remote_terminal.rs +++ b/src/crates/core/src/service/remote_ssh/remote_terminal.rs @@ -7,6 +7,7 @@ //! - This eliminates Mutex deadlock between read and write operations use crate::service::remote_ssh::manager::SSHConnectionManager; +use crate::service::terminal::session::SessionSource; use anyhow::Context; use std::collections::HashMap; use std::sync::Arc; @@ -31,6 +32,7 @@ pub struct RemoteTerminalSession { pub status: SessionStatus, pub cols: u16, pub rows: u16, + pub source: SessionSource, } #[derive(Debug, Clone, PartialEq)] @@ -87,6 +89,7 @@ impl RemoteTerminalManager { cols: u16, rows: u16, initial_cwd: Option<&str>, + source: Option, ) -> anyhow::Result { let ssh_guard = self.ssh_manager.read().await; let manager = ssh_guard.as_ref().context("SSH manager not initialized")?; @@ -123,6 +126,7 @@ impl RemoteTerminalManager { status: SessionStatus::Active, cols, rows, + source: source.unwrap_or_default(), }; { diff --git a/src/crates/core/src/service/terminal/src/api.rs b/src/crates/core/src/service/terminal/src/api.rs index 776fd961..875692e8 100644 --- a/src/crates/core/src/service/terminal/src/api.rs +++ b/src/crates/core/src/service/terminal/src/api.rs @@ -14,7 +14,8 @@ use crate::config::TerminalConfig; use crate::events::TerminalEvent; use crate::session::{ get_session_manager, init_session_manager, is_session_manager_initialized, - CommandCompletionReason, CommandExecuteResult, ExecuteOptions, SessionManager, TerminalSession, + CommandCompletionReason, CommandExecuteResult, ExecuteOptions, SessionManager, SessionSource, + TerminalSession, }; use crate::shell::{ShellDetector, ShellType}; use crate::{TerminalError, TerminalResult}; @@ -48,6 +49,9 @@ pub struct CreateSessionRequest { /// Optional remote connection ID (for remote workspace sessions) #[serde(rename = "remoteConnectionId", skip_serializing_if = "Option::is_none")] pub remote_connection_id: Option, + /// Optional session creation source + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, } /// Response for session creation @@ -69,6 +73,8 @@ pub struct SessionResponse { /// Terminal dimensions pub cols: u16, pub rows: u16, + /// Session creation source + pub source: SessionSource, } impl From for SessionResponse { @@ -82,6 +88,7 @@ impl From for SessionResponse { status: format!("{:?}", session.status), cols: session.cols, rows: session.rows, + source: session.source, } } } @@ -314,6 +321,7 @@ impl TerminalApi { request.env, request.cols, request.rows, + request.source, ) .await?; diff --git a/src/crates/core/src/service/terminal/src/lib.rs b/src/crates/core/src/service/terminal/src/lib.rs index c8a62f3f..799828e2 100644 --- a/src/crates/core/src/service/terminal/src/lib.rs +++ b/src/crates/core/src/service/terminal/src/lib.rs @@ -47,7 +47,8 @@ pub use pty::{ }; pub use session::{ CommandCompletionReason, CommandExecuteResult, CommandStream, CommandStreamEvent, - ExecuteOptions, SessionManager, SessionStatus, TerminalBindingOptions, TerminalSession, + ExecuteOptions, SessionManager, SessionSource, SessionStatus, TerminalBindingOptions, + TerminalSession, TerminalSessionBinding, }; pub use shell::{ diff --git a/src/crates/core/src/service/terminal/src/session/binding.rs b/src/crates/core/src/service/terminal/src/session/binding.rs index 0368885d..a3d2869a 100644 --- a/src/crates/core/src/service/terminal/src/session/binding.rs +++ b/src/crates/core/src/service/terminal/src/session/binding.rs @@ -17,6 +17,7 @@ use dashmap::DashMap; use log::warn; use crate::session::get_session_manager; +use crate::session::SessionSource; use crate::shell::ShellType; use crate::{TerminalError, TerminalResult}; @@ -37,6 +38,8 @@ pub struct TerminalBindingOptions { pub cols: Option, /// Terminal rows (default: 30) pub rows: Option, + /// Source of the terminal session + pub source: Option, } /// Terminal Session Binding Manager @@ -114,6 +117,7 @@ impl TerminalSessionBinding { options.env, options.cols, options.rows, + options.source, ) .await?; @@ -191,6 +195,7 @@ impl TerminalSessionBinding { options.env, options.cols, options.rows, + options.source, ) .await?; diff --git a/src/crates/core/src/service/terminal/src/session/manager.rs b/src/crates/core/src/service/terminal/src/session/manager.rs index a62d7fa6..f0a24e88 100644 --- a/src/crates/core/src/service/terminal/src/session/manager.rs +++ b/src/crates/core/src/service/terminal/src/session/manager.rs @@ -20,7 +20,7 @@ use crate::shell::{ }; use crate::{TerminalError, TerminalResult}; -use super::{SessionStatus, TerminalSession}; +use super::{SessionSource, SessionStatus, TerminalSession}; const COMMAND_TIMEOUT_INTERRUPT_GRACE_MS: Duration = Duration::from_millis(500); @@ -387,9 +387,20 @@ impl SessionManager { env: Option>, cols: Option, rows: Option, + source: Option, ) -> TerminalResult { - self.create_session_with_options(session_id, name, shell_type, cwd, env, cols, rows, true) - .await + self.create_session_with_options( + session_id, + name, + shell_type, + cwd, + env, + cols, + rows, + true, + source, + ) + .await } /// Create a new terminal session with optional shell integration @@ -403,6 +414,7 @@ impl SessionManager { cols: Option, rows: Option, enable_integration: bool, + source: Option, ) -> TerminalResult { // Use provided session ID or generate a new one let session_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); @@ -506,6 +518,7 @@ impl SessionManager { cwd, cols, rows, + source.unwrap_or_default(), ); // Store the session diff --git a/src/crates/core/src/service/terminal/src/session/mod.rs b/src/crates/core/src/service/terminal/src/session/mod.rs index 372da17f..6add098d 100644 --- a/src/crates/core/src/service/terminal/src/session/mod.rs +++ b/src/crates/core/src/service/terminal/src/session/mod.rs @@ -93,6 +93,10 @@ pub struct TerminalSession { /// Session metadata pub metadata: SessionMetadata, + /// Session creation source + #[serde(default)] + pub source: SessionSource, + /// Whether the session should persist pub should_persist: bool, @@ -121,6 +125,7 @@ impl TerminalSession { cwd: String, cols: u16, rows: u16, + source: SessionSource, ) -> Self { let now = Utc::now(); Self { @@ -138,6 +143,7 @@ impl TerminalSession { last_activity: now, env: HashMap::new(), metadata: SessionMetadata::default(), + source, should_persist: true, exit_code: None, output_history: Vec::new(), @@ -239,6 +245,15 @@ impl TerminalSession { } } +/// Source that created the terminal session. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SessionSource { + #[default] + Manual, + Agent, +} + /// Session metadata #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SessionMetadata { diff --git a/src/crates/core/src/service/terminal/src/session/serializer.rs b/src/crates/core/src/service/terminal/src/session/serializer.rs index 458cced7..3dc64eff 100644 --- a/src/crates/core/src/service/terminal/src/session/serializer.rs +++ b/src/crates/core/src/service/terminal/src/session/serializer.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use crate::shell::ShellType; use crate::{TerminalError, TerminalResult}; -use super::{SessionMetadata, SessionStatus, TerminalSession}; +use super::{SessionMetadata, SessionSource, SessionStatus, TerminalSession}; /// Version of the serialization format const SERIALIZATION_VERSION: u32 = 1; @@ -52,6 +52,10 @@ pub struct SerializedSession { /// Session metadata pub metadata: SessionMetadata, + /// Session creation source + #[serde(default)] + pub source: SessionSource, + /// Replay events for restoring terminal content pub replay_events: Vec, @@ -89,6 +93,7 @@ impl SessionSerializer { rows: s.rows, env: s.env.clone(), metadata: s.metadata.clone(), + source: s.source.clone(), replay_events: Vec::new(), // TODO: Capture replay events created_at: s.created_at.timestamp(), }) @@ -127,6 +132,7 @@ impl SessionSerializer { serialized.cwd.clone(), serialized.cols, serialized.rows, + serialized.source.clone(), ); session.initial_cwd = serialized.initial_cwd.clone(); @@ -159,6 +165,7 @@ impl SessionSerializer { rows: session.rows, env: session.env.clone(), metadata: session.metadata.clone(), + source: session.source.clone(), replay_events: vec![replay_event], created_at: session.created_at.timestamp(), }; @@ -180,6 +187,7 @@ mod tests { "/home/user".to_string(), 80, 24, + SessionSource::Manual, ); let serialized = SessionSerializer::serialize(&[session.clone()]) diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index 55b760aa..fead7a3c 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -1,5 +1,6 @@ //! Workspace manager. +use crate::service::git::GitService; use crate::util::{errors::*, FrontMatterMarkdown}; use log::warn; @@ -57,6 +58,17 @@ pub struct WorkspaceIdentity { pub emoji: Option, } +/// Git worktree metadata attached to a workspace. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceWorktreeInfo { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + pub main_repo_path: String, + pub is_main: bool, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct WorkspaceIdentityFrontmatter { @@ -181,6 +193,8 @@ pub struct WorkspaceInfo { pub statistics: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, pub metadata: HashMap, } @@ -293,12 +307,14 @@ impl WorkspaceInfo { tags: Vec::new(), statistics: None, identity: None, + worktree: None, metadata: HashMap::new(), }; if !is_remote { workspace.detect_workspace_type().await; workspace.load_identity().await; + workspace.load_worktree().await; if options.scan_options.calculate_statistics { workspace.scan_workspace(options.scan_options).await?; @@ -339,6 +355,33 @@ impl WorkspaceInfo { self.identity = identity; } + async fn load_worktree(&mut self) { + self.worktree = Self::resolve_worktree_info(&self.root_path).await; + } + + async fn resolve_worktree_info(workspace_root: &Path) -> Option { + let normalized_workspace_path = workspace_root.to_string_lossy().replace('\\', "/"); + let worktrees = match GitService::list_worktrees(workspace_root).await { + Ok(worktrees) => worktrees, + Err(_) => return None, + }; + + let main_repo_path = worktrees + .iter() + .find(|worktree| worktree.is_main) + .map(|worktree| worktree.path.clone())?; + + worktrees + .into_iter() + .find(|worktree| worktree.path == normalized_workspace_path) + .map(|worktree| WorkspaceWorktreeInfo { + path: worktree.path, + branch: worktree.branch, + main_repo_path: main_repo_path.clone(), + is_main: worktree.is_main, + }) + } + /// Detects the workspace type. async fn detect_workspace_type(&mut self) { let root = &self.root_path; @@ -698,6 +741,7 @@ impl WorkspaceManager { workspace.name = display_name.clone(); } workspace.load_identity().await; + workspace.load_worktree().await; } self.ensure_workspace_open(&workspace_id); if options.auto_set_current { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 4e168d74..1b005116 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -4,15 +4,24 @@ import { Folder, FolderOpen, MoreHorizontal, GitBranch, FolderSearch, Plus, Chev import { ConfirmDialog, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { + createWorktreeWorkspace, + deleteWorktreeWorkspace, +} from '@/infrastructure/services/business/worktreeWorkspaceService'; import { useNavSceneStore } from '@/app/stores/navSceneStore'; import { useApp } from '@/app/hooks/useApp'; import { useGitBasicInfo } from '@/tools/git/hooks/useGitState'; -import { workspaceAPI, gitAPI } from '@/infrastructure/api'; +import { workspaceAPI } from '@/infrastructure/api'; import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; import SessionsSection from '../sessions/SessionsSection'; -import { WorkspaceKind, isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; +import { + WorkspaceKind, + isLinkedWorktreeWorkspace, + isRemoteWorkspace, + type WorkspaceInfo, +} from '@/shared/types'; import { SSHContext } from '@/features/ssh-remote/SSHRemoteProvider'; interface WorkspaceItemProps { @@ -35,15 +44,23 @@ const WorkspaceItem: React.FC = ({ onDragEnd, }) => { const { t } = useI18n('common'); - const { setActiveWorkspace, closeWorkspaceById, deleteAssistantWorkspace, resetAssistantWorkspace } = useWorkspaceContext(); + const { + openWorkspace, + setActiveWorkspace, + closeWorkspaceById, + deleteAssistantWorkspace, + resetAssistantWorkspace, + } = useWorkspaceContext(); const { switchLeftPanelTab } = useApp(); const openNavScene = useNavSceneStore(s => s.openNavScene); const { isRepository, currentBranch } = useGitBasicInfo(workspace.rootPath); const [menuOpen, setMenuOpen] = useState(false); const [worktreeModalOpen, setWorktreeModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteWorktreeDialogOpen, setDeleteWorktreeDialogOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); const [isDeletingAssistant, setIsDeletingAssistant] = useState(false); + const [isDeletingWorktree, setIsDeletingWorktree] = useState(false); const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const menuRef = useRef(null); @@ -60,6 +77,7 @@ const WorkspaceItem: React.FC = ({ workspace.workspaceKind === WorkspaceKind.Assistant ? workspace.identity?.name?.trim() || workspace.name : workspace.name; + const isLinkedWorktree = isLinkedWorktreeWorkspace(workspace); // Remote connection status — optional: safe if not inside SSHRemoteProvider const sshContext = useContext(SSHContext); @@ -249,17 +267,60 @@ const WorkspaceItem: React.FC = ({ const handleCreateWorktree = useCallback(async (result: BranchSelectResult) => { try { - await gitAPI.addWorktree(workspace.rootPath, result.branch, result.isNew); - notificationService.success(t('nav.workspaces.worktreeCreated'), { duration: 2500 }); + const created = await createWorktreeWorkspace({ + repositoryPath: workspace.rootPath, + branch: result.branch, + isNew: result.isNew, + openAfterCreate: result.openAfterCreate, + openWorkspace, + }); + notificationService.success( + created.openedWorkspace + ? t('nav.workspaces.worktreeCreatedAndOpened') + : t('nav.workspaces.worktreeCreated'), + { duration: 2500 }, + ); } catch (error) { notificationService.error( - t('nav.workspaces.worktreeCreateFailed', { + t( + result.openAfterCreate + ? 'nav.workspaces.worktreeCreateOrOpenFailed' + : 'nav.workspaces.worktreeCreateFailed', + { error: error instanceof Error ? error.message : String(error), - }), + }, + ), { duration: 4000 } ); } - }, [t, workspace.rootPath]); + }, [openWorkspace, t, workspace.rootPath]); + + const handleRequestDeleteWorktree = useCallback(() => { + setMenuOpen(false); + setDeleteWorktreeDialogOpen(true); + }, []); + + const handleConfirmDeleteWorktree = useCallback(async () => { + if (!isLinkedWorktree || isDeletingWorktree) { + return; + } + + setIsDeletingWorktree(true); + try { + await deleteWorktreeWorkspace({ + workspace, + closeWorkspaceById, + }); + notificationService.success(t('nav.workspaces.worktreeDeleted'), { duration: 2500 }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.deleteWorktreeFailed'), + { duration: 4000 }, + ); + } finally { + setIsDeletingWorktree(false); + } + }, [closeWorkspaceById, isDeletingWorktree, isLinkedWorktree, t, workspace]); const handleOpenFiles = useCallback(async () => { try { @@ -525,18 +586,30 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.newCoworkSession')} - + {isLinkedWorktree ? ( + + ) : ( + + )} - - ); - }, [activeSceneId, activeTerminalSessionId, deleteTerminal, getEntryMenuItems, navView, openContextMenu, openTerminal, t]); + return { + icon: , + title: t('nav.shell.context.close'), + onClick: () => { void deleteEntry(entry); }, + }; + }, [deleteEntry, stopEntry, t]); return (
{t('nav.shell.title')} - {workspaceName ? ( -
- - - {workspaceMenuOpen && hasMultipleWorkspaces ? ( - workspaceMenuPosition ? createPortal( -
- {openedWorkspacesList.map((workspace) => { - const isActive = workspace.id === activeWorkspace?.id; - const label = getWorkspaceDisplayName(workspace); - - return ( - - ); - })} -
, - document.body, - ) : null - ) : null} -
- ) : null} +
- +
+ + +
{menuOpen ? (
- - - {isGitRepo ? ( - - ) : null} + ))} + {shellMenuItems.length > 0 ?
: null}
{hasVisibleContent ? ( - <> - {visibleMainEntries.length > 0 ? ( -
- {visibleMainEntries.map((entry) => renderTerminalEntry(entry))} -
- ) : null} - - {visibleWorktrees.map((worktree) => { - const entries = getWorktreeEntries(worktree.path); - const expanded = expandedWorktrees.has(worktree.path); - const branchLabel = worktree.branch || worktree.path.split(/[/\\]/).pop() || worktree.path; - - return ( -
-
toggleWorktree(worktree.path)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { toggleWorktree(worktree.path); } }} - onContextMenu={(event) => openContextMenu(event, getWorktreeMenuItems(worktree), { worktree })} - > - - - {branchLabel} - {entries.length} - -
- - {expanded ? ( -
- {entries.length > 0 ? ( - entries.map((entry) => renderTerminalEntry(entry)) - ) : navView === 'hub' ? ( -
- {t('nav.shell.empty.hub')} -
- ) : null} -
- ) : null} -
- ); - })} - +
+ {visibleEntries.map((entry) => ( + + ))} +
) : (
- {navView === 'hub' ? t('nav.shell.empty.hub') : t('nav.shell.empty.all')} + {navView === 'agent' ? t('nav.shell.empty.agent') : t('nav.shell.empty.manual')}
)}
- {workspacePath ? ( - setBranchModalOpen(false)} - onSelect={(result) => { void handleBranchSelect(result); }} - repositoryPath={workspacePath} - currentBranch={currentBranch} - existingWorktreeBranches={worktrees.map((worktree) => worktree.branch).filter(Boolean) as string[]} - title={t('nav.shell.actions.addWorktree')} - /> - ) : null} - {editingTerminal ? ( ) : null}
diff --git a/src/web-ui/src/app/scenes/shell/components/ShellNavEntryItem.tsx b/src/web-ui/src/app/scenes/shell/components/ShellNavEntryItem.tsx new file mode 100644 index 00000000..73ec5b72 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/components/ShellNavEntryItem.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Bookmark, SquareTerminal } from 'lucide-react'; +import type { MenuItem } from '@/shared/context-menu-system/types/menu.types'; +import type { ShellEntry } from '../hooks/shellEntryTypes'; + +interface QuickAction { + icon: React.ReactNode; + title: string; + onClick: () => void; +} + +interface ShellNavEntryItemProps { + entry: ShellEntry; + isActive: boolean; + showSavedBadge: boolean; + startupCommandBadgeLabel: string; + savedBadgeLabel: string; + quickAction: QuickAction; + getEntryMenuItems: (entry: ShellEntry) => MenuItem[]; + onOpen: (entry: ShellEntry) => Promise; + onOpenContextMenu: ( + event: React.MouseEvent, + items: MenuItem[], + data: Record, + ) => void; +} + +const ShellNavEntryItem: React.FC = ({ + entry, + isActive, + showSavedBadge, + startupCommandBadgeLabel, + savedBadgeLabel, + quickAction, + getEntryMenuItems, + onOpen, + onOpenContextMenu, +}) => { + return ( +
{ void onOpen(entry); }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + void onOpen(entry); + } + }} + onContextMenu={(event) => { + const menuItems = getEntryMenuItems(entry); + if (menuItems.length === 0) { + return; + } + + onOpenContextMenu(event, menuItems, { entry }); + }} + title={entry.name} + > + {showSavedBadge ? ( + + ) : ( + + )} + + {entry.name} + + {showSavedBadge ? ( + {savedBadgeLabel} + ) : null} + + {entry.startupCommand ? ( + {startupCommandBadgeLabel} + ) : null} + + + + +
+ ); +}; + +export default ShellNavEntryItem; diff --git a/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx b/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx new file mode 100644 index 00000000..ae832727 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/components/ShellNavWorkspaceSwitcher.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { Check, ChevronDown } from 'lucide-react'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; + +interface ShellNavWorkspaceSwitcherProps { + workspaceName?: string; + hasMultipleWorkspaces: boolean; + workspaceMenuOpen: boolean; + workspaceMenuPosition: { top: number; left: number } | null; + openedWorkspacesList: WorkspaceInfo[]; + activeWorkspaceId?: string; + workspaceMenuRef: React.RefObject; + workspaceTriggerRef: React.RefObject; + switchWorkspaceLabel: string; + onToggle: () => void; + onSelectWorkspace: (workspaceId: string) => Promise; +} + +function getWorkspaceDisplayName(workspace: WorkspaceInfo): string { + return workspace.workspaceKind === WorkspaceKind.Assistant + ? workspace.identity?.name?.trim() || workspace.name + : workspace.name; +} + +const ShellNavWorkspaceSwitcher: React.FC = ({ + workspaceName, + hasMultipleWorkspaces, + workspaceMenuOpen, + workspaceMenuPosition, + openedWorkspacesList, + activeWorkspaceId, + workspaceMenuRef, + workspaceTriggerRef, + switchWorkspaceLabel, + onToggle, + onSelectWorkspace, +}) => { + if (!workspaceName) { + return null; + } + + return ( +
+ + + {workspaceMenuOpen && hasMultipleWorkspaces && workspaceMenuPosition + ? createPortal( +
+ {openedWorkspacesList.map((workspace) => { + const isActive = workspace.id === activeWorkspaceId; + const label = getWorkspaceDisplayName(workspace); + + return ( + + ); + })} +
, + document.body, + ) + : null} +
+ ); +}; + +export default ShellNavWorkspaceSwitcher; diff --git a/src/web-ui/src/app/scenes/shell/hooks/index.ts b/src/web-ui/src/app/scenes/shell/hooks/index.ts index d59d86cd..1d7b06d2 100644 --- a/src/web-ui/src/app/scenes/shell/hooks/index.ts +++ b/src/web-ui/src/app/scenes/shell/hooks/index.ts @@ -1,2 +1,4 @@ +export * from './useManualTerminalProfiles'; export * from './useShellEntries'; -export * from './useWorktrees'; +export * from './useTerminalSessions'; +export type { SaveShellEntryInput, ShellEntry, ShellEntryKind } from './shellEntryTypes'; diff --git a/src/web-ui/src/app/scenes/shell/hooks/shellEntryTypes.ts b/src/web-ui/src/app/scenes/shell/hooks/shellEntryTypes.ts new file mode 100644 index 00000000..6b8f4588 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/shellEntryTypes.ts @@ -0,0 +1,83 @@ +import type { ManualTerminalProfile } from '@/tools/terminal/services/manualTerminalProfileService'; +import type { SessionResponse, TerminalSessionSource } from '@/tools/terminal/types/session'; + +export const MANUAL_SOURCE: TerminalSessionSource = 'manual'; +export const AGENT_SOURCE: TerminalSessionSource = 'agent'; + +export type ShellEntryKind = 'manual-profile' | 'manual-session' | 'agent-session'; + +export interface ShellEntry { + id: string; + kind: ShellEntryKind; + source: TerminalSessionSource; + sessionId: string; + name: string; + isRunning: boolean; + isPersisted: boolean; + profileId?: string; + cwd?: string; + workingDirectory?: string; + startupCommand?: string; + shellType?: string; +} + +export interface SaveShellEntryInput { + name: string; + workingDirectory?: string; + startupCommand?: string; +} + +export function isSessionRunning(session: SessionResponse): boolean { + const normalizedStatus = String(session.status).toLowerCase(); + return !['exited', 'stopped', 'error', 'terminating'].includes(normalizedStatus); +} + +export function compareShellEntries(a: ShellEntry, b: ShellEntry): number { + if (a.isPersisted !== b.isPersisted) { + return a.isPersisted ? -1 : 1; + } + + if (a.isRunning !== b.isRunning) { + return a.isRunning ? -1 : 1; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} + +export function createManualProfileEntry( + profile: ManualTerminalProfile, + session?: SessionResponse, +): ShellEntry { + return { + id: profile.id, + kind: 'manual-profile', + source: MANUAL_SOURCE, + sessionId: profile.sessionId, + name: profile.name, + isRunning: session ? isSessionRunning(session) : false, + isPersisted: true, + profileId: profile.id, + cwd: session?.cwd, + workingDirectory: profile.workingDirectory, + startupCommand: profile.startupCommand, + shellType: session?.shellType ?? profile.shellType, + }; +} + +export function createSessionEntry( + session: SessionResponse, + kind: 'manual-session' | 'agent-session', +): ShellEntry { + return { + id: session.id, + kind, + source: kind === 'agent-session' ? AGENT_SOURCE : MANUAL_SOURCE, + sessionId: session.id, + name: session.name, + isRunning: isSessionRunning(session), + isPersisted: false, + cwd: session.cwd, + workingDirectory: session.cwd, + shellType: session.shellType, + }; +} diff --git a/src/web-ui/src/app/scenes/shell/hooks/useManualTerminalProfiles.ts b/src/web-ui/src/app/scenes/shell/hooks/useManualTerminalProfiles.ts new file mode 100644 index 00000000..b9a2f55c --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/useManualTerminalProfiles.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + deleteManualTerminalProfile, + getManualTerminalProfileById, + getManualTerminalProfileBySessionId, + listManualTerminalProfiles, + type ManualTerminalProfile, + type ManualTerminalProfileInput, + upsertManualTerminalProfile, +} from '@/tools/terminal/services/manualTerminalProfileService'; + +interface UseManualTerminalProfilesReturn { + profiles: ManualTerminalProfile[]; + profilesBySessionId: Map; + refreshProfiles: () => void; + saveProfile: (input: ManualTerminalProfileInput) => ManualTerminalProfile | null; + removeProfile: (profileId: string) => void; + getProfileById: (profileId: string) => ManualTerminalProfile | undefined; + getProfileBySessionId: (sessionId: string) => ManualTerminalProfile | undefined; +} + +export function useManualTerminalProfiles( + workspacePath?: string, +): UseManualTerminalProfilesReturn { + const [profiles, setProfiles] = useState([]); + + const refreshProfiles = useCallback(() => { + if (!workspacePath) { + setProfiles([]); + return; + } + + setProfiles(listManualTerminalProfiles(workspacePath)); + }, [workspacePath]); + + useEffect(() => { + refreshProfiles(); + }, [refreshProfiles]); + + const saveProfile = useCallback((input: ManualTerminalProfileInput) => { + if (!workspacePath) { + return null; + } + + const profile = upsertManualTerminalProfile(workspacePath, input); + refreshProfiles(); + return profile; + }, [refreshProfiles, workspacePath]); + + const removeProfile = useCallback((profileId: string) => { + if (!workspacePath) { + return; + } + + deleteManualTerminalProfile(workspacePath, profileId); + refreshProfiles(); + }, [refreshProfiles, workspacePath]); + + const getProfileById = useCallback((profileId: string) => { + if (!workspacePath) { + return undefined; + } + + return getManualTerminalProfileById(workspacePath, profileId); + }, [workspacePath]); + + const getProfileBySessionId = useCallback((sessionId: string) => { + if (!workspacePath) { + return undefined; + } + + return getManualTerminalProfileBySessionId(workspacePath, sessionId); + }, [workspacePath]); + + const profilesBySessionId = useMemo( + () => new Map(profiles.map((profile) => [profile.sessionId, profile])), + [profiles], + ); + + return { + profiles, + profilesBySessionId, + refreshProfiles, + saveProfile, + removeProfile, + getProfileById, + getProfileBySessionId, + }; +} diff --git a/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts b/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts index 9e8c33d5..2eb56818 100644 --- a/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts +++ b/src/web-ui/src/app/scenes/shell/hooks/useShellEntries.ts @@ -1,120 +1,36 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getTerminalService } from '@/tools/terminal'; -import type { TerminalService } from '@/tools/terminal'; -import type { SessionResponse, TerminalEvent } from '@/tools/terminal/types/session'; +import { useCallback, useMemo, useState } from 'react'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { TerminalConfig } from '@/infrastructure/config/types'; -import { createLogger } from '@/shared/utils/logger'; import { openShellSessionTarget } from '@/shared/services/openShellSessionTarget'; - -const log = createLogger('useShellEntries'); - -const TERMINAL_HUB_STORAGE_KEY = 'bitfun-terminal-hub-config'; -const HUB_TERMINAL_ID_PREFIX = 'hub_'; - -export interface HubTerminalEntry { - sessionId: string; - name: string; - startupCommand?: string; -} - -export interface HubConfig { - terminals: HubTerminalEntry[]; - worktrees: Record; -} - -export interface ShellEntry { - sessionId: string; - name: string; - isRunning: boolean; - isHub: boolean; - worktreePath?: string; - cwd?: string; - startupCommand?: string; -} +import { + AGENT_SOURCE, + compareShellEntries, + createManualProfileEntry, + createSessionEntry, + MANUAL_SOURCE, + type SaveShellEntryInput, + type ShellEntry, +} from './shellEntryTypes'; +import { useManualTerminalProfiles } from './useManualTerminalProfiles'; +import { useTerminalSessions } from './useTerminalSessions'; interface EditingTerminalState { - terminal: HubTerminalEntry; - isHub: boolean; - worktreePath?: string; + entry: ShellEntry; } export interface UseShellEntriesReturn { - mainEntries: ShellEntry[]; - hubMainEntries: ShellEntry[]; - adHocEntries: ShellEntry[]; - getWorktreeEntries: (worktreePath: string) => ShellEntry[]; + manualEntries: ShellEntry[]; + agentEntries: ShellEntry[]; editModalOpen: boolean; editingTerminal: EditingTerminalState | null; closeEditModal: () => void; refresh: () => Promise; - createAdHocTerminal: () => Promise; - createHubTerminal: (worktreePath?: string) => Promise; - promoteToHub: (entry: ShellEntry) => void; - openTerminal: (entry: ShellEntry) => Promise; - startTerminal: (entry: ShellEntry) => Promise; - stopTerminal: (sessionId: string) => Promise; - deleteTerminal: (entry: ShellEntry) => Promise; + createManualTerminal: (shellType?: string) => Promise; + openEntry: (entry: ShellEntry) => Promise; + startEntry: (entry: ShellEntry) => Promise; + stopEntry: (entry: ShellEntry) => Promise; + deleteEntry: (entry: ShellEntry) => Promise; openEditModal: (entry: ShellEntry) => void; - saveEdit: (newName: string, newStartupCommand?: string) => void; - closeWorktreeTerminals: (worktreePath: string) => Promise; - removeWorktreeConfig: (worktreePath: string) => void; -} - -function loadHubConfig(workspacePath: string): HubConfig { - try { - const raw = localStorage.getItem(`${TERMINAL_HUB_STORAGE_KEY}:${workspacePath}`); - if (raw) { - return JSON.parse(raw) as HubConfig; - } - } catch (error) { - log.error('Failed to load hub config', error); - } - - return { terminals: [], worktrees: {} }; -} - -function saveHubConfig(workspacePath: string, config: HubConfig) { - try { - localStorage.setItem(`${TERMINAL_HUB_STORAGE_KEY}:${workspacePath}`, JSON.stringify(config)); - } catch (error) { - log.error('Failed to save hub config', error); - } -} - -function generateHubTerminalId(): string { - return `${HUB_TERMINAL_ID_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; -} - -async function getDefaultShellType(): Promise { - try { - const config = await configManager.getConfig('terminal'); - return config?.default_shell || undefined; - } catch { - return undefined; - } -} - -function dispatchTerminalDestroyed(sessionId: string) { - if (typeof window === 'undefined') { - return; - } - - window.dispatchEvent(new CustomEvent('terminal-session-destroyed', { detail: { sessionId } })); -} - -function dispatchTerminalRenamed(sessionId: string, newName: string) { - if (typeof window === 'undefined') { - return; - } - - window.dispatchEvent(new CustomEvent('terminal-session-renamed', { detail: { sessionId, newName } })); -} - -function isSessionRunning(session: SessionResponse): boolean { - const normalizedStatus = String(session.status).toLowerCase(); - return !['exited', 'stopped', 'error', 'terminating'].includes(normalizedStatus); + saveEdit: (input: SaveShellEntryInput) => void; } export function useShellEntries(): UseShellEntriesReturn { @@ -122,383 +38,105 @@ export function useShellEntries(): UseShellEntriesReturn { const isRemote = workspace?.workspaceKind === 'remote'; const currentConnectionId = workspace?.connectionId ?? null; - const [sessions, setSessions] = useState([]); - const [hubConfig, setHubConfig] = useState({ terminals: [], worktrees: {} }); const [editModalOpen, setEditModalOpen] = useState(false); const [editingTerminal, setEditingTerminal] = useState(null); - const serviceRef = useRef(null); - - const sessionMap = useMemo( - () => new Map(sessions.map((session) => [session.id, session])), + const { + profiles, + profilesBySessionId, + refreshProfiles, + saveProfile, + removeProfile, + getProfileById, + getProfileBySessionId, + } = useManualTerminalProfiles(workspacePath); + const { + sessions, + sessionMap, + refreshSessions, + startEntrySession, + createManualSession, + stopEntrySession, + closeSessionIfPresent, + renameSessionLocally, + hasSession, + } = useTerminalSessions({ + workspacePath, + isRemote, + currentConnectionId, + }); + + const manualEntries = useMemo(() => { + const profileEntries = profiles.map((profile) => + createManualProfileEntry(profile, sessionMap.get(profile.sessionId)), + ); + + const ephemeralEntries = sessions + .filter((session) => session.source === MANUAL_SOURCE && !profilesBySessionId.has(session.id)) + .map((session) => createSessionEntry(session, 'manual-session')); + + return [...profileEntries, ...ephemeralEntries].sort(compareShellEntries); + }, [profiles, profilesBySessionId, sessionMap, sessions]); + + const agentEntries = useMemo( + () => + sessions + .filter((session) => session.source === AGENT_SOURCE) + .map((session) => createSessionEntry(session, 'agent-session')) + .sort(compareShellEntries), [sessions], ); - const runningIds = useMemo( - () => new Set(sessions.filter(isSessionRunning).map((session) => session.id)), - [sessions], - ); - - const configuredIds = useMemo(() => { - const ids = new Set(); - - hubConfig.terminals.forEach((terminal) => ids.add(terminal.sessionId)); - Object.values(hubConfig.worktrees).forEach((terminals) => { - terminals.forEach((terminal) => ids.add(terminal.sessionId)); - }); - - return ids; - }, [hubConfig]); - - const refreshSessions = useCallback(async () => { - const service = serviceRef.current; - if (!service) { - return; - } - - try { - const allSessions = await service.listSessions(); - // Filter sessions based on current workspace type: - // - Remote workspace: only show terminals belonging to this connection - // - Local workspace: only show local (non-remote) terminals - const filtered = allSessions.filter(session => { - const isRemoteSession = session.shellType === 'Remote'; - if (isRemote) { - return isRemoteSession && session.connectionId === currentConnectionId; - } - return !isRemoteSession; - }); - setSessions(filtered); - } catch (error) { - log.error('Failed to list sessions', error); - } - }, [isRemote, currentConnectionId]); - - useEffect(() => { - if (!workspacePath) { - setHubConfig({ terminals: [], worktrees: {} }); - return; - } - - setHubConfig(loadHubConfig(workspacePath)); - }, [workspacePath]); - - useEffect(() => { - const service = getTerminalService(); - serviceRef.current = service; - - const init = async () => { - try { - await service.connect(); - await refreshSessions(); - } catch (error) { - log.error('Failed to connect terminal service', error); - } - }; - - void init(); - - const unsubscribe = service.onEvent((event: TerminalEvent) => { - if (event.type === 'ready' || event.type === 'exit') { - void refreshSessions(); - } - }); - - return () => unsubscribe(); - }, [refreshSessions]); - - const mainEntries = useMemo(() => { - const hubEntries = hubConfig.terminals.map((terminal) => ({ - sessionId: terminal.sessionId, - name: terminal.name, - isRunning: runningIds.has(terminal.sessionId), - isHub: true, - cwd: sessionMap.get(terminal.sessionId)?.cwd, - startupCommand: terminal.startupCommand, - })); - - const adHocEntries = sessions - .filter((session) => !configuredIds.has(session.id)) - .map((session) => ({ - sessionId: session.id, - name: session.name, - isRunning: isSessionRunning(session), - isHub: false, - cwd: session.cwd, - })); - - return [...hubEntries, ...adHocEntries]; - }, [configuredIds, hubConfig.terminals, runningIds, sessionMap, sessions]); - - const hubMainEntries = useMemo( - () => mainEntries.filter((entry) => entry.isHub), - [mainEntries], - ); - - const adHocEntries = useMemo( - () => mainEntries.filter((entry) => !entry.isHub), - [mainEntries], - ); - - const worktreeEntries = useMemo>(() => { - const result: Record = {}; - - Object.entries(hubConfig.worktrees).forEach(([worktreePath, terminals]) => { - result[worktreePath] = terminals.map((terminal) => ({ - sessionId: terminal.sessionId, - name: terminal.name, - isRunning: runningIds.has(terminal.sessionId), - isHub: true, - worktreePath, - cwd: sessionMap.get(terminal.sessionId)?.cwd, - startupCommand: terminal.startupCommand, - })); - }); - - return result; - }, [hubConfig.worktrees, runningIds, sessionMap]); - - const updateHubConfig = useCallback((updater: (prev: HubConfig) => HubConfig) => { - if (!workspacePath) { - return; - } - - setHubConfig((prev) => { - const next = updater(prev); - saveHubConfig(workspacePath, next); - return next; - }); - }, [workspacePath]); - - const getWorktreeEntries = useCallback( - (worktreePath: string) => worktreeEntries[worktreePath] ?? [], - [worktreeEntries], - ); const refresh = useCallback(async () => { await refreshSessions(); - - if (workspacePath) { - setHubConfig(loadHubConfig(workspacePath)); - } - }, [refreshSessions, workspacePath]); + refreshProfiles(); + }, [refreshProfiles, refreshSessions]); const openShellSession = useCallback((sessionId: string, sessionName: string) => { openShellSessionTarget({ sessionId, sessionName }); }, []); + const startEntry = useCallback( + (entry: ShellEntry) => startEntrySession(entry), + [startEntrySession], + ); - const startTerminal = useCallback(async (entry: ShellEntry): Promise => { - const service = serviceRef.current; - const existingSession = sessionMap.get(entry.sessionId); - // #region agent log - console.error('[DBG-366fda][H-B] startTerminal called', {entrySessionId:entry.sessionId,entryName:entry.name,isHub:entry.isHub,isRunning:entry.isRunning,startupCommand:entry.startupCommand,workspacePath,hasService:!!service,existingStatus:existingSession?.status}); - // #endregion - if (!service) { - return false; - } - - try { - if (existingSession && !isSessionRunning(existingSession)) { - await service.closeSession(entry.sessionId); - } - - const shellType = await getDefaultShellType(); - - const createdSession = await service.createSession({ - sessionId: entry.sessionId, - workingDirectory: entry.worktreePath ?? entry.cwd ?? workspacePath, - name: entry.name, - shellType, - }); - // #region agent log - console.error('[DBG-366fda][H-B] session created', {createdId:createdSession.id,createdStatus:createdSession.status,requestedId:entry.sessionId,idMatch:createdSession.id===entry.sessionId}); - // #endregion - - if (entry.startupCommand?.trim()) { - await new Promise((resolve) => setTimeout(resolve, 800)); - try { - await service.sendCommand(entry.sessionId, entry.startupCommand); - } catch (error) { - log.error('Failed to run startup command', error); - } - } - - await refreshSessions(); - return true; - } catch (error) { - // #region agent log - console.error('[DBG-366fda][H-B] startTerminal FAILED', {error:String(error),entrySessionId:entry.sessionId}); - // #endregion - log.error('Failed to start terminal', error); - return false; - } - }, [refreshSessions, sessionMap, workspacePath]); - - const openTerminal = useCallback(async (entry: ShellEntry) => { - // #region agent log - console.error('[DBG-366fda][H-A] openTerminal called', {entrySessionId:entry.sessionId,isHub:entry.isHub,isRunning:entry.isRunning,startupCommand:entry.startupCommand}); - // #endregion + const openEntry = useCallback(async (entry: ShellEntry) => { if (!entry.isRunning) { - const started = await startTerminal(entry); - // #region agent log - console.error('[DBG-366fda][H-A] startTerminal result', {started,entrySessionId:entry.sessionId}); - // #endregion + const started = await startEntry(entry); if (!started) { return; } } openShellSession(entry.sessionId, entry.name); - }, [openShellSession, startTerminal]); - - const createAdHocTerminal = useCallback(async () => { - const service = serviceRef.current; - if (!service) { - return; - } + }, [openShellSession, startEntry]); - try { - const shellType = await getDefaultShellType(); - const nextIndex = adHocEntries.length + 1; - const session = await service.createSession({ - workingDirectory: workspacePath, - name: `Shell ${nextIndex}`, - shellType, - }); - - await refreshSessions(); + const createManualTerminal = useCallback(async (shellType?: string) => { + const session = await createManualSession(shellType); + if (session) { openShellSession(session.id, session.name); - } catch (error) { - log.error('Failed to create ad-hoc terminal', error); } - }, [adHocEntries.length, openShellSession, refreshSessions, workspacePath]); + }, [createManualSession, openShellSession]); - const createHubTerminal = useCallback(async (worktreePath?: string) => { - const service = serviceRef.current; - if (!workspacePath || !service) { - return; - } - - const newEntry: HubTerminalEntry = { - sessionId: generateHubTerminalId(), - name: `Terminal ${Date.now() % 1000}`, - }; - - try { - const shellType = await getDefaultShellType(); - await service.createSession({ - sessionId: newEntry.sessionId, - workingDirectory: worktreePath ?? workspacePath, - name: newEntry.name, - shellType, - }); - - updateHubConfig((prev) => { - if (worktreePath) { - const terminals = prev.worktrees[worktreePath] || []; - return { - ...prev, - worktrees: { - ...prev.worktrees, - [worktreePath]: [...terminals, newEntry], - }, - }; - } + const stopEntry = useCallback(async (entry: ShellEntry) => { + await stopEntrySession(entry); + }, [stopEntrySession]); - return { - ...prev, - terminals: [...prev.terminals, newEntry], - }; - }); - - await refreshSessions(); - openShellSession(newEntry.sessionId, newEntry.name); - } catch (error) { - log.error('Failed to create hub terminal', error); + const deleteEntry = useCallback(async (entry: ShellEntry) => { + if (entry.profileId) { + removeProfile(entry.profileId); } - }, [openShellSession, refreshSessions, updateHubConfig, workspacePath]); - const promoteToHub = useCallback((entry: ShellEntry) => { - if (!workspacePath || entry.isHub) { - return; - } - - updateHubConfig((prev) => ({ - ...prev, - terminals: [ - ...prev.terminals, - { - sessionId: entry.sessionId, - name: entry.name, - }, - ], - })); - }, [updateHubConfig, workspacePath]); - - const stopTerminal = useCallback(async (sessionId: string) => { - const service = serviceRef.current; - if (!service || !runningIds.has(sessionId)) { - return; - } - - try { - await service.closeSession(sessionId); - dispatchTerminalDestroyed(sessionId); - await refreshSessions(); - } catch (error) { - log.error('Failed to stop terminal', error); - } - }, [refreshSessions, runningIds]); - - const deleteTerminal = useCallback(async (entry: ShellEntry) => { - const service = serviceRef.current; - - if (entry.isRunning && service) { - try { - await service.closeSession(entry.sessionId); - } catch (error) { - log.error('Failed to close terminal session', error); - } - } - - dispatchTerminalDestroyed(entry.sessionId); - - if (entry.isHub) { - updateHubConfig((prev) => { - if (entry.worktreePath) { - const terminals = (prev.worktrees[entry.worktreePath] || []).filter( - (terminal) => terminal.sessionId !== entry.sessionId, - ); - - return { - ...prev, - worktrees: { - ...prev.worktrees, - [entry.worktreePath]: terminals, - }, - }; - } - - return { - ...prev, - terminals: prev.terminals.filter((terminal) => terminal.sessionId !== entry.sessionId), - }; - }); + if (hasSession(entry.sessionId)) { + await closeSessionIfPresent(entry.sessionId); } await refreshSessions(); - }, [refreshSessions, updateHubConfig]); + }, [closeSessionIfPresent, hasSession, refreshSessions, removeProfile]); const openEditModal = useCallback((entry: ShellEntry) => { - setEditingTerminal({ - terminal: { - sessionId: entry.sessionId, - name: entry.name, - startupCommand: entry.startupCommand, - }, - isHub: entry.isHub, - worktreePath: entry.worktreePath, - }); + setEditingTerminal({ entry }); setEditModalOpen(true); }, []); @@ -507,105 +145,45 @@ export function useShellEntries(): UseShellEntriesReturn { setEditingTerminal(null); }, []); - const saveEdit = useCallback((newName: string, newStartupCommand?: string) => { - if (!editingTerminal) { + const saveEdit = useCallback((input: SaveShellEntryInput) => { + if (!editingTerminal || !workspacePath) { return; } - const { terminal, worktreePath, isHub } = editingTerminal; + const entry = editingTerminal.entry; + const existingProfile = entry.profileId + ? getProfileById(entry.profileId) + : getProfileBySessionId(entry.sessionId); - if (isHub) { - updateHubConfig((prev) => { - if (worktreePath) { - return { - ...prev, - worktrees: { - ...prev.worktrees, - [worktreePath]: (prev.worktrees[worktreePath] || []).map((item) => - item.sessionId === terminal.sessionId - ? { ...item, name: newName, startupCommand: newStartupCommand } - : item, - ), - }, - }; - } - - return { - ...prev, - terminals: prev.terminals.map((item) => - item.sessionId === terminal.sessionId - ? { ...item, name: newName, startupCommand: newStartupCommand } - : item, - ), - }; - }); - } + saveProfile({ + id: existingProfile?.id, + sessionId: entry.sessionId, + name: input.name, + workingDirectory: input.workingDirectory ?? entry.workingDirectory ?? entry.cwd ?? workspacePath, + startupCommand: input.startupCommand, + shellType: entry.shellType, + }); - if (runningIds.has(terminal.sessionId)) { - setSessions((prev) => - prev.map((session) => - session.id === terminal.sessionId ? { ...session, name: newName } : session, - ), - ); - dispatchTerminalRenamed(terminal.sessionId, newName); + if (hasSession(entry.sessionId)) { + renameSessionLocally(entry.sessionId, input.name); } closeEditModal(); - }, [closeEditModal, editingTerminal, runningIds, updateHubConfig]); - - const closeWorktreeTerminals = useCallback(async (worktreePath: string) => { - const service = serviceRef.current; - if (!service) { - return; - } - - const terminals = hubConfig.worktrees[worktreePath] || []; - for (const terminal of terminals) { - if (!runningIds.has(terminal.sessionId)) { - continue; - } - - try { - await service.closeSession(terminal.sessionId); - dispatchTerminalDestroyed(terminal.sessionId); - } catch (error) { - log.error('Failed to close worktree terminal', error); - } - } - - await refreshSessions(); - }, [hubConfig.worktrees, refreshSessions, runningIds]); - - const removeWorktreeConfig = useCallback((worktreePath: string) => { - updateHubConfig((prev) => { - const nextWorktrees = { ...prev.worktrees }; - delete nextWorktrees[worktreePath]; - return { - ...prev, - worktrees: nextWorktrees, - }; - }); - }, [updateHubConfig]); + }, [closeEditModal, editingTerminal, getProfileById, getProfileBySessionId, hasSession, renameSessionLocally, saveProfile, workspacePath]); return { - mainEntries, - hubMainEntries, - adHocEntries, - getWorktreeEntries, + manualEntries, + agentEntries, editModalOpen, editingTerminal, closeEditModal, refresh, - createAdHocTerminal, - createHubTerminal, - promoteToHub, - openTerminal, - startTerminal, - stopTerminal, - deleteTerminal, + createManualTerminal, + openEntry, + startEntry, + stopEntry, + deleteEntry, openEditModal, saveEdit, - closeWorktreeTerminals, - removeWorktreeConfig, }; } diff --git a/src/web-ui/src/app/scenes/shell/hooks/useShellNavMenuState.ts b/src/web-ui/src/app/scenes/shell/hooks/useShellNavMenuState.ts new file mode 100644 index 00000000..a45cf943 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/useShellNavMenuState.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface MenuPosition { + top: number; + left: number; +} + +interface UseShellNavMenuStateReturn { + menuOpen: boolean; + setMenuOpen: React.Dispatch>; + workspaceMenuOpen: boolean; + setWorkspaceMenuOpen: React.Dispatch>; + workspaceMenuPosition: MenuPosition | null; + menuRef: React.RefObject; + workspaceMenuRef: React.RefObject; + workspaceTriggerRef: React.RefObject; +} + +export function useShellNavMenuState( + hasMultipleWorkspaces: boolean, +): UseShellNavMenuStateReturn { + const [menuOpen, setMenuOpen] = useState(false); + const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); + const [workspaceMenuPosition, setWorkspaceMenuPosition] = useState(null); + + const menuRef = useRef(null); + const workspaceMenuRef = useRef(null); + const workspaceTriggerRef = useRef(null); + + useEffect(() => { + if (!menuOpen && !workspaceMenuOpen) { + return; + } + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if ( + target && + (menuRef.current?.contains(target) || + workspaceMenuRef.current?.contains(target) || + workspaceTriggerRef.current?.contains(target)) + ) { + return; + } + + setMenuOpen(false); + setWorkspaceMenuOpen(false); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setMenuOpen(false); + setWorkspaceMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [menuOpen, workspaceMenuOpen]); + + useEffect(() => { + if (!hasMultipleWorkspaces && workspaceMenuOpen) { + setWorkspaceMenuOpen(false); + } + }, [hasMultipleWorkspaces, workspaceMenuOpen]); + + const updateWorkspaceMenuPosition = useCallback(() => { + const trigger = workspaceTriggerRef.current; + if (!trigger) { + return; + } + + const rect = trigger.getBoundingClientRect(); + const viewportPadding = 8; + const estimatedWidth = 220; + const maxLeft = window.innerWidth - estimatedWidth - viewportPadding; + + setWorkspaceMenuPosition({ + top: Math.max(viewportPadding, rect.bottom + 6), + left: Math.max(viewportPadding, Math.min(rect.left, maxLeft)), + }); + }, []); + + useEffect(() => { + if (!workspaceMenuOpen) { + return; + } + + updateWorkspaceMenuPosition(); + + const handleViewportChange = () => updateWorkspaceMenuPosition(); + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); + + return () => { + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); + }; + }, [updateWorkspaceMenuPosition, workspaceMenuOpen]); + + return { + menuOpen, + setMenuOpen, + workspaceMenuOpen, + setWorkspaceMenuOpen, + workspaceMenuPosition, + menuRef, + workspaceMenuRef, + workspaceTriggerRef, + }; +} diff --git a/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts b/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts new file mode 100644 index 00000000..4af288c8 --- /dev/null +++ b/src/web-ui/src/app/scenes/shell/hooks/useTerminalSessions.ts @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getTerminalService } from '@/tools/terminal'; +import type { TerminalService } from '@/tools/terminal'; +import type { SessionResponse, TerminalEvent } from '@/tools/terminal/types/session'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { TerminalConfig } from '@/infrastructure/config/types'; +import { createLogger } from '@/shared/utils/logger'; +import { + isSessionRunning, + MANUAL_SOURCE, + type ShellEntry, +} from './shellEntryTypes'; + +const log = createLogger('useTerminalSessions'); + +interface UseTerminalSessionsOptions { + workspacePath?: string; + isRemote: boolean; + currentConnectionId: string | null; +} + +interface UseTerminalSessionsReturn { + sessions: SessionResponse[]; + sessionMap: Map; + refreshSessions: () => Promise; + startEntrySession: (entry: ShellEntry) => Promise; + createManualSession: (shellType?: string) => Promise; + stopEntrySession: (entry: ShellEntry) => Promise; + closeSessionIfPresent: (sessionId: string) => Promise; + renameSessionLocally: (sessionId: string, newName: string) => void; + hasSession: (sessionId: string) => boolean; +} + +async function getDefaultShellType(): Promise { + try { + const config = await configManager.getConfig('terminal'); + return config?.default_shell || undefined; + } catch { + return undefined; + } +} + +function dispatchTerminalDestroyed(sessionId: string) { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent('terminal-session-destroyed', { detail: { sessionId } })); +} + +function dispatchTerminalRenamed(sessionId: string, newName: string) { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent('terminal-session-renamed', { detail: { sessionId, newName } })); +} + +export function useTerminalSessions( + options: UseTerminalSessionsOptions, +): UseTerminalSessionsReturn { + const { workspacePath, isRemote, currentConnectionId } = options; + const [sessions, setSessions] = useState([]); + const serviceRef = useRef(null); + + const sessionMap = useMemo( + () => new Map(sessions.map((session) => [session.id, session])), + [sessions], + ); + + const refreshSessions = useCallback(async () => { + const service = serviceRef.current; + if (!service) { + return; + } + + try { + const allSessions = await service.listSessions(); + const filtered = allSessions.filter((session) => { + const isRemoteSession = session.shellType === 'Remote'; + if (isRemote) { + return isRemoteSession && session.connectionId === currentConnectionId; + } + return !isRemoteSession; + }); + setSessions(filtered); + } catch (error) { + log.error('Failed to list sessions', error); + } + }, [currentConnectionId, isRemote]); + + useEffect(() => { + const service = getTerminalService(); + serviceRef.current = service; + + const init = async () => { + try { + await service.connect(); + await refreshSessions(); + } catch (error) { + log.error('Failed to connect terminal service', error); + } + }; + + void init(); + + const unsubscribe = service.onEvent((event: TerminalEvent) => { + if (event.type === 'ready' || event.type === 'exit') { + void refreshSessions(); + } + }); + + return () => unsubscribe(); + }, [refreshSessions]); + + const closeSessionIfPresent = useCallback(async (sessionId: string) => { + const service = serviceRef.current; + if (!service || !sessionMap.has(sessionId)) { + return; + } + + try { + await service.closeSession(sessionId); + dispatchTerminalDestroyed(sessionId); + } catch (error) { + log.error('Failed to close terminal session', { sessionId, error }); + } + }, [sessionMap]); + + const startEntrySession = useCallback(async (entry: ShellEntry): Promise => { + const service = serviceRef.current; + const existingSession = sessionMap.get(entry.sessionId); + if (!service) { + return false; + } + + try { + if (existingSession && !isSessionRunning(existingSession)) { + await service.closeSession(entry.sessionId); + } + + const shellType = entry.shellType ?? await getDefaultShellType(); + await service.createSession({ + sessionId: entry.sessionId, + workingDirectory: entry.workingDirectory ?? entry.cwd ?? workspacePath, + name: entry.name, + shellType, + source: entry.source, + }); + + if (entry.startupCommand?.trim()) { + await new Promise((resolve) => setTimeout(resolve, 800)); + try { + await service.sendCommand(entry.sessionId, entry.startupCommand); + } catch (error) { + log.error('Failed to run startup command', { sessionId: entry.sessionId, error }); + } + } + + await refreshSessions(); + return true; + } catch (error) { + log.error('Failed to start terminal entry', { entry, error }); + return false; + } + }, [refreshSessions, sessionMap, workspacePath]); + + const createManualSession = useCallback(async (shellTypeOverride?: string): Promise => { + const service = serviceRef.current; + if (!service) { + return null; + } + + try { + const shellType = shellTypeOverride ?? await getDefaultShellType(); + const nextIndex = sessions.filter((session) => session.source === MANUAL_SOURCE).length + 1; + const session = await service.createSession({ + workingDirectory: workspacePath, + name: `Shell ${nextIndex}`, + shellType, + source: MANUAL_SOURCE, + }); + + await refreshSessions(); + return session; + } catch (error) { + log.error('Failed to create manual terminal', error); + return null; + } + }, [refreshSessions, sessions, workspacePath]); + + const stopEntrySession = useCallback(async (entry: ShellEntry) => { + const session = sessionMap.get(entry.sessionId); + if (!session || !isSessionRunning(session)) { + return; + } + + await closeSessionIfPresent(entry.sessionId); + await refreshSessions(); + }, [closeSessionIfPresent, refreshSessions, sessionMap]); + + const renameSessionLocally = useCallback((sessionId: string, newName: string) => { + if (!sessionMap.has(sessionId)) { + return; + } + + setSessions((prev) => + prev.map((session) => (session.id === sessionId ? { ...session, name: newName } : session)), + ); + dispatchTerminalRenamed(sessionId, newName); + }, [sessionMap]); + + const hasSession = useCallback((sessionId: string) => sessionMap.has(sessionId), [sessionMap]); + + return { + sessions, + sessionMap, + refreshSessions, + startEntrySession, + createManualSession, + stopEntrySession, + closeSessionIfPresent, + renameSessionLocally, + hasSession, + }; +} diff --git a/src/web-ui/src/app/scenes/shell/hooks/useWorktrees.ts b/src/web-ui/src/app/scenes/shell/hooks/useWorktrees.ts deleted file mode 100644 index 36a3433b..00000000 --- a/src/web-ui/src/app/scenes/shell/hooks/useWorktrees.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { gitAPI, type GitWorktreeInfo } from '@/infrastructure/api/service-api/GitAPI'; -import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('useWorktrees'); - -export interface UseWorktreesReturn { - workspacePath?: string; - worktrees: GitWorktreeInfo[]; - nonMainWorktrees: GitWorktreeInfo[]; - isGitRepo: boolean; - currentBranch?: string; - refresh: () => Promise; - addWorktree: (branch: string, isNew: boolean) => Promise; - removeWorktree: (worktreePath: string) => Promise; -} - -export function useWorktrees(): UseWorktreesReturn { - const { workspacePath } = useCurrentWorkspace(); - - const [worktrees, setWorktrees] = useState([]); - const [isGitRepo, setIsGitRepo] = useState(false); - const [currentBranch, setCurrentBranch] = useState(); - - const refresh = useCallback(async () => { - if (!workspacePath) { - setWorktrees([]); - setIsGitRepo(false); - setCurrentBranch(undefined); - return; - } - - try { - const repository = await gitAPI.isGitRepository(workspacePath); - setIsGitRepo(repository); - - if (!repository) { - setWorktrees([]); - setCurrentBranch(undefined); - return; - } - - const [worktreeList, branches] = await Promise.all([ - gitAPI.listWorktrees(workspacePath), - gitAPI.getBranches(workspacePath, false), - ]); - - setWorktrees(worktreeList); - setCurrentBranch(branches.find((branch) => branch.current)?.name); - } catch (error) { - log.error('Failed to load worktrees', error); - setWorktrees([]); - setIsGitRepo(false); - setCurrentBranch(undefined); - } - }, [workspacePath]); - - useEffect(() => { - void refresh(); - }, [refresh]); - - const addWorktree = useCallback(async (branch: string, isNew: boolean) => { - if (!workspacePath) { - return; - } - - try { - await gitAPI.addWorktree(workspacePath, branch, isNew); - await refresh(); - } catch (error) { - log.error('Failed to add worktree', error); - throw error; - } - }, [refresh, workspacePath]); - - const removeWorktree = useCallback(async (worktreePath: string): Promise => { - if (!workspacePath) { - return false; - } - - try { - await gitAPI.removeWorktree(workspacePath, worktreePath); - await refresh(); - return true; - } catch (error) { - log.error('Failed to remove worktree', error); - return false; - } - }, [refresh, workspacePath]); - - const nonMainWorktrees = useMemo( - () => worktrees.filter((worktree) => !worktree.isMain), - [worktrees], - ); - - return { - workspacePath, - worktrees, - nonMainWorktrees, - isGitRepo, - currentBranch, - refresh, - addWorktree, - removeWorktree, - }; -} diff --git a/src/web-ui/src/app/scenes/shell/shellConfig.ts b/src/web-ui/src/app/scenes/shell/shellConfig.ts index a20a1502..d984e4e0 100644 --- a/src/web-ui/src/app/scenes/shell/shellConfig.ts +++ b/src/web-ui/src/app/scenes/shell/shellConfig.ts @@ -1,3 +1,3 @@ -export type ShellNavView = 'all' | 'hub'; +export type ShellNavView = 'manual' | 'agent'; -export const DEFAULT_SHELL_NAV_VIEW: ShellNavView = 'all'; +export const DEFAULT_SHELL_NAV_VIEW: ShellNavView = 'manual'; diff --git a/src/web-ui/src/app/scenes/shell/shellStore.ts b/src/web-ui/src/app/scenes/shell/shellStore.ts index 360b193e..fa2fb3a6 100644 --- a/src/web-ui/src/app/scenes/shell/shellStore.ts +++ b/src/web-ui/src/app/scenes/shell/shellStore.ts @@ -5,22 +5,9 @@ import { DEFAULT_SHELL_NAV_VIEW } from './shellConfig'; interface ShellState { navView: ShellNavView; setNavView: (view: ShellNavView) => void; - expandedWorktrees: Set; - toggleWorktree: (path: string) => void; } export const useShellStore = create((set) => ({ navView: DEFAULT_SHELL_NAV_VIEW, setNavView: (view) => set({ navView: view }), - expandedWorktrees: new Set(), - toggleWorktree: (path) => - set((state) => { - const next = new Set(state.expandedWorktrees); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } - return { expandedWorktrees: next }; - }), })); diff --git a/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx b/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx index 7e5cc1e7..59506d82 100644 --- a/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx +++ b/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx @@ -1,6 +1,6 @@ /** * TerminalScene — renders a ConnectedTerminal for the session selected - * via terminalSceneStore (set from NavPanel Shell list or Shell Hub). + * via terminalSceneStore (set from the Shell navigation). * * When no session is active, shows a minimal empty state prompting the * user to open a terminal from the navigation panel. @@ -16,11 +16,6 @@ import './TerminalScene.scss'; const TerminalScene: React.FC = () => { const { activeSessionId, setActiveSession } = useTerminalSceneStore(); const { t } = useTranslation('panels/terminal'); - // #region agent log - React.useEffect(() => { - console.error('[DBG-366fda][H-D] TerminalScene activeSessionId changed', {activeSessionId}); - }, [activeSessionId]); - // #endregion const handleExit = useCallback(() => { setActiveSession(null); diff --git a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts index e28e02d8..324451c3 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts @@ -32,6 +32,13 @@ export interface WorkspaceIdentity { emoji?: string | null; } +export interface WorkspaceWorktreeInfo { + path: string; + branch?: string | null; + mainRepoPath: string; + isMain: boolean; +} + export interface WorkspaceInfo { id: string; name: string; @@ -46,6 +53,7 @@ export interface WorkspaceInfo { tags: string[]; statistics?: ProjectStatistics | null; identity?: WorkspaceIdentity | null; + worktree?: WorkspaceWorktreeInfo | null; connectionId?: string; connectionName?: string; } diff --git a/src/web-ui/src/infrastructure/services/business/worktreeWorkspaceService.ts b/src/web-ui/src/infrastructure/services/business/worktreeWorkspaceService.ts new file mode 100644 index 00000000..237cff44 --- /dev/null +++ b/src/web-ui/src/infrastructure/services/business/worktreeWorkspaceService.ts @@ -0,0 +1,57 @@ +import { gitAPI, type GitWorktreeInfo } from '@/infrastructure/api/service-api/GitAPI'; +import type { WorkspaceInfo } from '@/shared/types'; +import { isLinkedWorktreeWorkspace } from '@/shared/types'; + +export interface CreateWorktreeWorkspaceOptions { + repositoryPath: string; + branch: string; + isNew: boolean; + openAfterCreate: boolean; + openWorkspace: (path: string) => Promise; +} + +export interface CreateWorktreeWorkspaceResult { + worktree: GitWorktreeInfo; + openedWorkspace?: WorkspaceInfo; +} + +export interface DeleteWorktreeWorkspaceOptions { + workspace: WorkspaceInfo; + closeWorkspaceById: (workspaceId: string) => Promise; +} + +export async function createWorktreeWorkspace( + options: CreateWorktreeWorkspaceOptions, +): Promise { + const worktree = await gitAPI.addWorktree( + options.repositoryPath, + options.branch, + options.isNew, + ); + + if (!options.openAfterCreate) { + return { worktree }; + } + + const openedWorkspace = await options.openWorkspace(worktree.path); + return { + worktree, + openedWorkspace, + }; +} + +export async function deleteWorktreeWorkspace( + options: DeleteWorktreeWorkspaceOptions, +): Promise { + const { workspace, closeWorkspaceById } = options; + + if (!isLinkedWorktreeWorkspace(workspace) || !workspace.worktree) { + throw new Error('Current workspace is not a removable linked worktree'); + } + + await closeWorkspaceById(workspace.id); + await gitAPI.removeWorktree( + workspace.worktree.mainRepoPath, + workspace.rootPath, + ); +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index f627a35c..61d3f2df 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -116,33 +116,30 @@ "shell": { "title": "Shell", "views": { - "all": "All", - "hub": "Hub" + "manual": "Mine", + "agent": "Agent" }, "actions": { "newTerminal": "New Terminal", - "newHubTerminal": "New Hub Terminal", - "addWorktree": "Add Worktree", "refresh": "Refresh" }, "context": { "start": "Start", "stop": "Stop", - "edit": "Edit", "editConfig": "Edit Config", + "saveConfig": "Save Config", + "deleteSavedTerminal": "Delete Saved Terminal", "delete": "Delete", - "close": "Close", - "rename": "Rename", - "promoteToHub": "Save to Hub", - "newWorktreeTerminal": "New Terminal", - "removeWorktree": "Remove Worktree" + "close": "Close" }, "badges": { - "startupCommand": "cmd" + "startupCommand": "cmd", + "saved": "saved", + "default": "Default" }, "empty": { - "all": "Create a terminal from the menu to get started", - "hub": "No Hub terminals yet" + "manual": "No user terminals yet", + "agent": "No agent terminals yet" } }, "sessions": { @@ -268,7 +265,11 @@ "copyPathFailed": "Failed to copy path", "createSessionFailed": "Failed to create session", "worktreeCreated": "Worktree created", + "worktreeCreatedAndOpened": "Worktree created and opened", "worktreeCreateFailed": "Failed to create worktree: {{error}}", + "worktreeCreateOrOpenFailed": "Worktree was created, but opening it as a workspace failed: {{error}}", + "worktreeDeleted": "Worktree deleted", + "deleteWorktreeFailed": "Failed to delete worktree", "expandSessions": "Expand sessions", "collapseSessions": "Collapse sessions", "groups": { @@ -284,6 +285,7 @@ "deleteAssistant": "Delete personal assistant", "resetWorkspace": "Reset workspace", "newWorktree": "New worktree", + "deleteWorktree": "Delete worktree", "copyPath": "Copy path", "reveal": "Reveal in explorer", "close": "Close workspace" @@ -297,6 +299,11 @@ "title": "Reset {{name}}?", "message": "This will clear everything inside the workspace directory and restore the default persona files without deleting the workspace directory itself.", "pathLabel": "Workspace path" + }, + "deleteWorktreeDialog": { + "title": "Delete {{name}}?", + "message": "This will close the workspace and remove the worktree directory.", + "pathLabel": "Worktree path" } }, "agents": { diff --git a/src/web-ui/src/locales/en-US/panels/git.json b/src/web-ui/src/locales/en-US/panels/git.json index 5ff9f2ec..0a6aa672 100644 --- a/src/web-ui/src/locales/en-US/panels/git.json +++ b/src/web-ui/src/locales/en-US/panels/git.json @@ -207,6 +207,10 @@ "inputPlaceholder": "Enter new branch name...", "loading": "Loading...", "createNewLabel": "Create new branch:", + "openAfterCreate": { + "label": "Open after create", + "description": "Open the new worktree as a workspace immediately after it is created." + }, "badges": { "inUse": "In Use" }, diff --git a/src/web-ui/src/locales/en-US/panels/terminal.json b/src/web-ui/src/locales/en-US/panels/terminal.json index 2626e38a..356f1431 100644 --- a/src/web-ui/src/locales/en-US/panels/terminal.json +++ b/src/web-ui/src/locales/en-US/panels/terminal.json @@ -1,61 +1,19 @@ { "title": "Terminal", "emptyState": "Open a terminal from the Shell navigation", - "actions": { - "refresh": "Refresh Terminal List", - "newTerminal": "New Terminal", - "newWorktree": "New Worktree", - "edit": "Edit", - "stopTerminal": "Stop Terminal", - "deleteTerminal": "Delete Terminal", - "closeTerminal": "Close Terminal", - "deleteWorktree": "Delete Worktree" - }, - "sections": { - "terminalHub": "Terminal Workshop", - "terminals": "Terminal Sessions" - }, - "status": { - "running": "Running", - "idle": "Idle" - }, - "worktree": { - "count": "{{count}}", - "terminalsCount": "{{count}} terminals" - }, "dialog": { - "deleteWorktree": { - "title": "Confirm Delete", - "message": "Are you sure you want to delete this Worktree?", - "hint": "This will close all terminals in this Worktree and delete the working directory.", - "cancel": "Cancel", - "confirm": "Delete" - }, "editTerminal": { "title": "Edit Terminal", "nameLabel": "Name", "namePlaceholder": "Terminal name", + "workingDirectoryLabel": "Startup Path", + "workingDirectoryPlaceholder": "Path to open when the terminal starts", + "workingDirectoryHint": "Used when reopening a saved terminal", "startupCommandLabel": "Startup Command (Optional)", "startupCommandPlaceholder": "Command to run after the terminal starts", "startupCommandHint": "This command runs automatically after the terminal starts", "cancel": "Cancel", "save": "Save" } - }, - "notifications": { - "loadSessionsFailed": "Failed to load session list", - "connectFailed": "Failed to connect to terminal service", - "createFailed": "Failed to create terminal", - "startFailed": "Failed to start terminal", - "stopFailed": "Failed to stop terminal", - "deleteWorktreeFailed": "Failed to delete worktree: Directory may still be in use, please close related programs and try again", - "addWorktreeFailed": "Failed to create worktree: {{error}}", - "notGitRepo": "Current directory is not a Git repository" - }, - "branch": { - "selectBranch": "Select Branch", - "createNew": "Create New Branch", - "existingBranches": "Existing Branches", - "branchName": "Branch Name" } } diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 012ba294..be3661c6 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -116,33 +116,30 @@ "shell": { "title": "Shell", "views": { - "all": "全部", - "hub": "Hub" + "manual": "我的", + "agent": "Agent" }, "actions": { "newTerminal": "新建终端", - "newHubTerminal": "新建 Hub 终端", - "addWorktree": "添加 Worktree", "refresh": "刷新" }, "context": { "start": "启动", "stop": "停止", - "edit": "编辑", "editConfig": "编辑配置", + "saveConfig": "保存配置", + "deleteSavedTerminal": "删除已保存终端", "delete": "删除", - "close": "关闭", - "rename": "重命名", - "promoteToHub": "保存到 Hub", - "newWorktreeTerminal": "新建终端", - "removeWorktree": "移除 Worktree" + "close": "关闭" }, "badges": { - "startupCommand": "cmd" + "startupCommand": "cmd", + "saved": "已保存", + "default": "默认" }, "empty": { - "all": "从菜单里创建一个终端开始使用", - "hub": "还没有 Hub 终端" + "manual": "还没有用户终端", + "agent": "还没有 Agent 终端" } }, "sessions": { @@ -268,7 +265,11 @@ "copyPathFailed": "复制路径失败", "createSessionFailed": "新建会话失败", "worktreeCreated": "已创建 worktree", + "worktreeCreatedAndOpened": "已创建并打开 worktree", "worktreeCreateFailed": "创建 worktree 失败:{{error}}", + "worktreeCreateOrOpenFailed": "worktree 已创建,但作为工作区打开失败:{{error}}", + "worktreeDeleted": "已删除 worktree", + "deleteWorktreeFailed": "删除 worktree 失败", "expandSessions": "展开会话列表", "collapseSessions": "收起会话列表", "groups": { @@ -284,6 +285,7 @@ "deleteAssistant": "删除个人助理", "resetWorkspace": "重置工作区", "newWorktree": "新建 worktree", + "deleteWorktree": "删除 worktree", "copyPath": "复制路径", "reveal": "在资源管理器中打开", "close": "关闭工作区" @@ -297,6 +299,11 @@ "title": "重置 {{name}}?", "message": "此操作会清空该工作区目录下的所有内容,并恢复默认 persona 文件;不会删除工作区目录本身。", "pathLabel": "工作区路径" + }, + "deleteWorktreeDialog": { + "title": "删除 {{name}}?", + "message": "此操作会关闭该工作区,并删除对应的 worktree 目录。", + "pathLabel": "Worktree 路径" } }, "agents": { diff --git a/src/web-ui/src/locales/zh-CN/panels/git.json b/src/web-ui/src/locales/zh-CN/panels/git.json index e7a11335..03d79c53 100644 --- a/src/web-ui/src/locales/zh-CN/panels/git.json +++ b/src/web-ui/src/locales/zh-CN/panels/git.json @@ -207,6 +207,10 @@ "inputPlaceholder": "输入新分支名...", "loading": "加载中...", "createNewLabel": "创建新分支:", + "openAfterCreate": { + "label": "创建后打开", + "description": "创建完成后,立即将新的 worktree 作为工作区打开。" + }, "badges": { "inUse": "已使用" }, diff --git a/src/web-ui/src/locales/zh-CN/panels/terminal.json b/src/web-ui/src/locales/zh-CN/panels/terminal.json index db9a9354..43d09733 100644 --- a/src/web-ui/src/locales/zh-CN/panels/terminal.json +++ b/src/web-ui/src/locales/zh-CN/panels/terminal.json @@ -1,61 +1,19 @@ { "title": "终端", "emptyState": "从 Shell 导航中打开终端", - "actions": { - "refresh": "刷新终端列表", - "newTerminal": "新建终端", - "newWorktree": "新建 Worktree", - "edit": "编辑", - "stopTerminal": "停止终端", - "deleteTerminal": "删除终端", - "closeTerminal": "关闭终端", - "deleteWorktree": "删除 Worktree" - }, - "sections": { - "terminalHub": "终端工坊", - "terminals": "终端会话" - }, - "status": { - "running": "运行中", - "idle": "空闲" - }, - "worktree": { - "count": "{{count}}", - "terminalsCount": "{{count}} 个终端" - }, "dialog": { - "deleteWorktree": { - "title": "确认删除", - "message": "确定要删除 Worktree 吗?", - "hint": "此操作将关闭该 Worktree 下的所有终端并删除工作目录。", - "cancel": "取消", - "confirm": "删除" - }, "editTerminal": { "title": "编辑终端", "nameLabel": "名称", "namePlaceholder": "终端名称", + "workingDirectoryLabel": "启动路径", + "workingDirectoryPlaceholder": "终端启动时打开的路径", + "workingDirectoryHint": "重新打开已保存的终端时会使用这个路径", "startupCommandLabel": "启动命令(可选)", "startupCommandPlaceholder": "终端启动后自动执行的命令", "startupCommandHint": "终端启动后将自动执行此命令", "cancel": "取消", "save": "保存" } - }, - "notifications": { - "loadSessionsFailed": "加载会话列表失败", - "connectFailed": "连接终端服务失败", - "createFailed": "创建终端失败", - "startFailed": "启动终端失败", - "stopFailed": "停止终端失败", - "deleteWorktreeFailed": "删除 worktree 失败: 目录可能仍被占用,请手动关闭相关程序后重试", - "addWorktreeFailed": "创建 worktree 失败: {{error}}", - "notGitRepo": "当前目录不是 Git 仓库" - }, - "branch": { - "selectBranch": "选择分支", - "createNew": "创建新分支", - "existingBranches": "已有分支", - "branchName": "分支名称" } } diff --git a/src/web-ui/src/shared/constants/app.ts b/src/web-ui/src/shared/constants/app.ts index d9ee0bff..6980593f 100644 --- a/src/web-ui/src/shared/constants/app.ts +++ b/src/web-ui/src/shared/constants/app.ts @@ -21,7 +21,7 @@ export const STORAGE_KEYS = { MODEL_CONFIGS: 'bitfun-model-configs', CHAT_HISTORY: 'bitfun-chat-history', DIFF_CLOSE_WARNING_DISABLED: 'bitfun-diff-close-warning-disabled', - TERMINAL_HUB_CONFIG: 'bitfun-terminal-hub-config' + MANUAL_TERMINAL_PROFILES: 'bitfun-manual-terminal-profiles' } as const; diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 8da387f3..19a57aca 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -74,6 +74,13 @@ export interface WorkspaceIdentity { modelFast?: string; } +export interface WorkspaceWorktreeInfo { + path: string; + branch?: string | null; + mainRepoPath: string; + isMain: boolean; +} + export interface WorkspaceInfo { id: string; @@ -89,6 +96,7 @@ export interface WorkspaceInfo { tags: string[]; statistics?: ProjectStatistics; identity?: WorkspaceIdentity | null; + worktree?: WorkspaceWorktreeInfo | null; connectionId?: string; connectionName?: string; } @@ -97,6 +105,14 @@ export function isRemoteWorkspace(workspace: WorkspaceInfo | null | undefined): return workspace?.workspaceKind === WorkspaceKind.Remote; } +export function isWorktreeWorkspace(workspace: WorkspaceInfo | null | undefined): boolean { + return Boolean(workspace?.worktree); +} + +export function isLinkedWorktreeWorkspace(workspace: WorkspaceInfo | null | undefined): boolean { + return Boolean(workspace?.worktree && !workspace.worktree.isMain); +} + export enum WorkspaceAction { Opened = 'opened', @@ -234,6 +250,21 @@ function mapWorkspaceIdentity( }; } +function mapWorkspaceWorktree( + worktree: APIWorkspaceInfo['worktree'] +): WorkspaceWorktreeInfo | null | undefined { + if (!worktree) { + return worktree; + } + + return { + path: worktree.path, + branch: worktree.branch ?? undefined, + mainRepoPath: worktree.mainRepoPath, + isMain: worktree.isMain, + }; +} + function mapWorkspaceInfo(workspace: APIWorkspaceInfo): WorkspaceInfo { return { id: workspace.id, @@ -258,6 +289,7 @@ function mapWorkspaceInfo(workspace: APIWorkspaceInfo): WorkspaceInfo { } : undefined, identity: mapWorkspaceIdentity(workspace.identity), + worktree: mapWorkspaceWorktree(workspace.worktree), connectionId: workspace.connectionId, connectionName: workspace.connectionName, }; diff --git a/src/web-ui/src/tools/terminal/index.ts b/src/web-ui/src/tools/terminal/index.ts index 67c5d6fe..6f936b2e 100644 --- a/src/web-ui/src/tools/terminal/index.ts +++ b/src/web-ui/src/tools/terminal/index.ts @@ -10,7 +10,21 @@ export type { ConnectedTerminalProps, } from './components'; -export { TerminalService, getTerminalService } from './services'; +export { + TerminalService, + getTerminalService, + deleteManualTerminalProfile, + generateManualTerminalProfileId, + getManualTerminalProfileById, + getManualTerminalProfileBySessionId, + listManualTerminalProfiles, + loadManualTerminalProfiles, + saveManualTerminalProfiles, + upsertManualTerminalProfile, + type ManualTerminalProfile, + type ManualTerminalProfileInput, + type ManualTerminalProfilesState, +} from './services'; export { useTerminal } from './hooks'; export type { UseTerminalOptions, UseTerminalReturn } from './hooks'; diff --git a/src/web-ui/src/tools/terminal/services/index.ts b/src/web-ui/src/tools/terminal/services/index.ts index 2f84f640..920fd302 100644 --- a/src/web-ui/src/tools/terminal/services/index.ts +++ b/src/web-ui/src/tools/terminal/services/index.ts @@ -3,6 +3,19 @@ */ export { TerminalService, getTerminalService } from './TerminalService'; +export { + deleteManualTerminalProfile, + generateManualTerminalProfileId, + getManualTerminalProfileById, + getManualTerminalProfileBySessionId, + listManualTerminalProfiles, + loadManualTerminalProfiles, + saveManualTerminalProfiles, + upsertManualTerminalProfile, + type ManualTerminalProfile, + type ManualTerminalProfileInput, + type ManualTerminalProfilesState, +} from './manualTerminalProfileService'; export { terminalActionManager, diff --git a/src/web-ui/src/tools/terminal/services/manualTerminalProfileService.ts b/src/web-ui/src/tools/terminal/services/manualTerminalProfileService.ts new file mode 100644 index 00000000..331d54b2 --- /dev/null +++ b/src/web-ui/src/tools/terminal/services/manualTerminalProfileService.ts @@ -0,0 +1,160 @@ +import { STORAGE_KEYS } from '@/shared/constants/app'; +import { createLogger } from '@/shared/utils/logger'; + +const logger = createLogger('ManualTerminalProfileService'); + +export interface ManualTerminalProfile { + id: string; + sessionId: string; + name: string; + workingDirectory?: string; + startupCommand?: string; + shellType?: string; +} + +export interface ManualTerminalProfilesState { + version: 1; + profiles: ManualTerminalProfile[]; +} + +export interface ManualTerminalProfileInput { + id?: string; + sessionId: string; + name: string; + workingDirectory?: string; + startupCommand?: string; + shellType?: string; +} + +const EMPTY_STATE: ManualTerminalProfilesState = { + version: 1, + profiles: [], +}; + +function getStorageKey(workspacePath: string): string { + return `${STORAGE_KEYS.MANUAL_TERMINAL_PROFILES}:${workspacePath}`; +} + +export function generateManualTerminalProfileId(): string { + return `manual_profile_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function normalizeProfile(profile: Partial): ManualTerminalProfile | null { + if (!profile.id || !profile.sessionId || !profile.name?.trim()) { + return null; + } + + return { + id: profile.id, + sessionId: profile.sessionId, + name: profile.name.trim(), + workingDirectory: profile.workingDirectory?.trim() || undefined, + startupCommand: profile.startupCommand?.trim() || undefined, + shellType: profile.shellType?.trim() || undefined, + }; +} + +function normalizeState(raw: unknown): ManualTerminalProfilesState { + if (!raw || typeof raw !== 'object') { + return EMPTY_STATE; + } + + const profiles = Array.isArray((raw as { profiles?: unknown[] }).profiles) + ? (raw as { profiles: unknown[] }).profiles + .map((item) => normalizeProfile(item as Partial)) + .filter((item): item is ManualTerminalProfile => item !== null) + : []; + + return { + version: 1, + profiles, + }; +} + +export function loadManualTerminalProfiles(workspacePath: string): ManualTerminalProfilesState { + try { + const raw = localStorage.getItem(getStorageKey(workspacePath)); + if (raw) { + return normalizeState(JSON.parse(raw)); + } + } catch (error) { + logger.error('Failed to load manual terminal profiles', { workspacePath, error }); + } + + return EMPTY_STATE; +} + +export function saveManualTerminalProfiles( + workspacePath: string, + state: ManualTerminalProfilesState, +): void { + try { + localStorage.setItem(getStorageKey(workspacePath), JSON.stringify(normalizeState(state))); + } catch (error) { + logger.error('Failed to save manual terminal profiles', { workspacePath, error }); + } +} + +export function listManualTerminalProfiles(workspacePath: string): ManualTerminalProfile[] { + return loadManualTerminalProfiles(workspacePath).profiles; +} + +export function getManualTerminalProfileById( + workspacePath: string, + profileId: string, +): ManualTerminalProfile | undefined { + return listManualTerminalProfiles(workspacePath).find((profile) => profile.id === profileId); +} + +export function getManualTerminalProfileBySessionId( + workspacePath: string, + sessionId: string, +): ManualTerminalProfile | undefined { + return listManualTerminalProfiles(workspacePath).find((profile) => profile.sessionId === sessionId); +} + +export function upsertManualTerminalProfile( + workspacePath: string, + input: ManualTerminalProfileInput, +): ManualTerminalProfile { + const currentState = loadManualTerminalProfiles(workspacePath); + const existingProfile = currentState.profiles.find( + (profile) => profile.id === input.id || profile.sessionId === input.sessionId, + ); + const normalizedProfile = normalizeProfile({ + id: existingProfile?.id ?? input.id ?? generateManualTerminalProfileId(), + sessionId: input.sessionId, + name: input.name, + workingDirectory: input.workingDirectory, + startupCommand: input.startupCommand, + shellType: input.shellType, + }); + + if (!normalizedProfile) { + throw new Error('Invalid manual terminal profile'); + } + + const existingIndex = currentState.profiles.findIndex((profile) => profile.id === normalizedProfile.id); + const nextProfiles = [...currentState.profiles]; + + if (existingIndex >= 0) { + nextProfiles[existingIndex] = normalizedProfile; + } else { + nextProfiles.push(normalizedProfile); + } + + saveManualTerminalProfiles(workspacePath, { + version: 1, + profiles: nextProfiles, + }); + + return normalizedProfile; +} + +export function deleteManualTerminalProfile(workspacePath: string, profileId: string): void { + const currentState = loadManualTerminalProfiles(workspacePath); + saveManualTerminalProfiles(workspacePath, { + version: 1, + profiles: currentState.profiles.filter((profile) => profile.id !== profileId), + }); +} diff --git a/src/web-ui/src/tools/terminal/types/session.ts b/src/web-ui/src/tools/terminal/types/session.ts index 84d0e07d..f4ea1f0a 100644 --- a/src/web-ui/src/tools/terminal/types/session.ts +++ b/src/web-ui/src/tools/terminal/types/session.ts @@ -4,6 +4,8 @@ export type SessionStatus = 'Running' | 'Stopped' | 'Exited' | 'Error'; +export type TerminalSessionSource = 'manual' | 'agent'; + export type ShellType = | 'PowerShell' | 'Cmd' @@ -20,6 +22,7 @@ export interface CreateSessionRequest { env?: Record; cols?: number; rows?: number; + source?: TerminalSessionSource; } export interface SessionResponse { @@ -32,6 +35,7 @@ export interface SessionResponse { cols: number; rows: number; connectionId?: string; + source: TerminalSessionSource; } export interface ShellInfo {