feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
35
crates/application/src/terminal/mod.rs
Normal file
35
crates/application/src/terminal/mod.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! Terminal use cases (ARCHITECTURE §6, L3).
|
||||
//!
|
||||
//! Each use case is a struct carrying its ports as `Arc<dyn Port>` and exposing a
|
||||
//! single `execute(input) -> Result<output, AppError>` method (**Single
|
||||
//! Responsibility**). They talk **only** to the [`domain::ports::PtyPort`] (plus
|
||||
//! the [`domain::ports::EventBus`] for `OpenTerminal`); the composition root
|
||||
//! injects the concrete [`infrastructure::PortablePtyAdapter`].
|
||||
//!
|
||||
//! - [`OpenTerminal`] — resolve the cwd, spawn a PTY, create a
|
||||
//! [`domain::TerminalSession`], register the live handle, publish an event.
|
||||
//! - [`WriteToTerminal`] — forward bytes (keystrokes) to a PTY.
|
||||
//! - [`ResizeTerminal`] — resize a PTY.
|
||||
//! - [`CloseTerminal`] — kill a PTY and forget its handle.
|
||||
//!
|
||||
//! # Where the active-session registry lives, and why
|
||||
//!
|
||||
//! The domain [`PtyPort`] is *handle-oriented*: `spawn` returns a
|
||||
//! [`domain::ports::PtyHandle`] that every later call (`write`/`resize`/`kill`)
|
||||
//! must reference. Something has to remember, per [`SessionId`], the live
|
||||
//! `PtyHandle` (and the `TerminalSession` snapshot) between IPC calls. That state
|
||||
//! is **not domain state** (it is the in-flight wiring of an I/O resource), and
|
||||
//! it must not live in the adapter alone (other use cases need to address a
|
||||
//! session by id). It therefore lives in [`TerminalSessions`], an **application
|
||||
//! service** injected into the terminal use cases (and held in the composition
|
||||
//! root behind an `Arc`). This keeps the domain pure and the registry shared,
|
||||
//! testable, and transport-agnostic.
|
||||
|
||||
mod registry;
|
||||
mod usecases;
|
||||
|
||||
pub use registry::TerminalSessions;
|
||||
pub use usecases::{
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
81
crates/application/src/terminal/registry.rs
Normal file
81
crates/application/src/terminal/registry.rs
Normal file
@ -0,0 +1,81 @@
|
||||
//! [`TerminalSessions`] — the active-terminal registry (application service).
|
||||
//!
|
||||
//! Maps a [`SessionId`] to the live [`PtyHandle`] and the [`TerminalSession`]
|
||||
//! snapshot. Thread-safe (behind a [`Mutex`]); a single instance is shared
|
||||
//! (as `Arc`) by all terminal use cases via the composition root. See the module
|
||||
//! docs in `terminal/mod.rs` for the rationale of keeping this in the
|
||||
//! application layer rather than the domain or the adapter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use domain::ports::PtyHandle;
|
||||
use domain::{SessionId, TerminalSession};
|
||||
|
||||
/// A registered, live terminal: its PTY handle plus the domain snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
handle: PtyHandle,
|
||||
session: TerminalSession,
|
||||
}
|
||||
|
||||
/// In-memory registry of active terminal sessions.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalSessions {
|
||||
entries: Mutex<HashMap<SessionId, Entry>>,
|
||||
}
|
||||
|
||||
impl TerminalSessions {
|
||||
/// Creates an empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a freshly-opened session.
|
||||
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.insert(session.id, Entry { handle, session });
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`PtyHandle`] for a session, if registered.
|
||||
#[must_use]
|
||||
pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| e.handle.clone()))
|
||||
}
|
||||
|
||||
/// Returns the [`TerminalSession`] snapshot for a session, if registered.
|
||||
#[must_use]
|
||||
pub fn session(&self, id: &SessionId) -> Option<TerminalSession> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(id).map(|e| e.session.clone()))
|
||||
}
|
||||
|
||||
/// Removes a session from the registry, returning its handle if present.
|
||||
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut m| m.remove(id).map(|e| e.handle))
|
||||
}
|
||||
|
||||
/// Number of currently-registered sessions.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the registry is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
250
crates/application/src/terminal/usecases.rs
Normal file
250
crates/application/src/terminal/usecases.rs
Normal file
@ -0,0 +1,250 @@
|
||||
//! The four terminal use cases (ARCHITECTURE §6, L3).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, PtyPort, SpawnSpec};
|
||||
use domain::{
|
||||
DomainEvent, NodeId, ProjectPath, PtySize, SessionId, SessionKind, SessionStatus,
|
||||
TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::registry::TerminalSessions;
|
||||
|
||||
/// Input for [`OpenTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenTerminalInput {
|
||||
/// Working directory for the shell (absolute path; defaults applied by the
|
||||
/// caller — typically the project root).
|
||||
pub cwd: String,
|
||||
/// Initial terminal height in rows.
|
||||
pub rows: u16,
|
||||
/// Initial terminal width in columns.
|
||||
pub cols: u16,
|
||||
/// Command to run. `None` ⇒ the platform default login shell.
|
||||
pub command: Option<String>,
|
||||
/// Arguments for the command.
|
||||
pub args: Vec<String>,
|
||||
/// The layout leaf hosting this session. `None` ⇒ a fresh node id (L4 will
|
||||
/// thread the real layout node through here).
|
||||
pub node_id: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// Output of [`OpenTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenTerminalOutput {
|
||||
/// The created terminal session (its `id` is the [`SessionId`] minted by the
|
||||
/// PTY layer and reused everywhere — write/resize/close, the output channel).
|
||||
pub session: TerminalSession,
|
||||
}
|
||||
|
||||
/// Opens a PTY in a cwd, creates a [`TerminalSession`], registers the handle.
|
||||
pub struct OpenTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl OpenTerminal {
|
||||
/// Builds the use case from its injected ports/services.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pty,
|
||||
sessions,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the open: validate cwd + size, spawn the PTY, snapshot the
|
||||
/// session, register the live handle, publish [`DomainEvent::LayoutChanged`].
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
|
||||
/// - [`AppError::Process`] if the PTY fails to spawn.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: OpenTerminalInput,
|
||||
) -> Result<OpenTerminalOutput, AppError> {
|
||||
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
let command = input.command.unwrap_or_else(default_shell);
|
||||
let spec = SpawnSpec {
|
||||
command,
|
||||
args: input.args,
|
||||
cwd: cwd.clone(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
};
|
||||
|
||||
// The PTY layer owns the session identity; we adopt the returned handle's
|
||||
// id as the `TerminalSession.id` (single source of truth, ARCHITECTURE §4).
|
||||
let handle = self.pty.spawn(spec, size).await?;
|
||||
let session_id = handle.session_id;
|
||||
let node_id = input.node_id.unwrap_or_else(NodeId::new_random);
|
||||
|
||||
let mut session =
|
||||
TerminalSession::starting(session_id, node_id, cwd, SessionKind::Plain, size);
|
||||
session.status = SessionStatus::Running;
|
||||
|
||||
self.sessions.insert(handle, session.clone());
|
||||
|
||||
// Output streaming + per-session Channel wiring happens in the presentation
|
||||
// layer (it owns the transport). Announce so the UI can react.
|
||||
self.events.publish(DomainEvent::PtyOutput {
|
||||
session_id,
|
||||
bytes: Vec::new(),
|
||||
});
|
||||
|
||||
Ok(OpenTerminalOutput { session })
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`WriteToTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WriteToTerminalInput {
|
||||
/// Target session.
|
||||
pub session_id: SessionId,
|
||||
/// Bytes to write (typically keystrokes from xterm.js).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Forwards bytes (keystrokes) to a live PTY.
|
||||
pub struct WriteToTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
impl WriteToTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
|
||||
Self { pty, sessions }
|
||||
}
|
||||
|
||||
/// Writes to the session's PTY.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the session is unknown,
|
||||
/// - [`AppError::Process`] on PTY I/O failure.
|
||||
pub fn execute(&self, input: WriteToTerminalInput) -> Result<(), AppError> {
|
||||
let handle = self
|
||||
.sessions
|
||||
.handle(&input.session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
|
||||
self.pty.write(&handle, &input.data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ResizeTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResizeTerminalInput {
|
||||
/// Target session.
|
||||
pub session_id: SessionId,
|
||||
/// New height in rows.
|
||||
pub rows: u16,
|
||||
/// New width in columns.
|
||||
pub cols: u16,
|
||||
}
|
||||
|
||||
/// Resizes a live PTY.
|
||||
pub struct ResizeTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
impl ResizeTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
|
||||
Self { pty, sessions }
|
||||
}
|
||||
|
||||
/// Resizes the session's PTY.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a zero-sized terminal,
|
||||
/// - [`AppError::NotFound`] if the session is unknown,
|
||||
/// - [`AppError::Process`] on PTY failure.
|
||||
pub fn execute(&self, input: ResizeTerminalInput) -> Result<(), AppError> {
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let handle = self
|
||||
.sessions
|
||||
.handle(&input.session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
|
||||
self.pty.resize(&handle, size)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`CloseTerminal::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CloseTerminalInput {
|
||||
/// Target session.
|
||||
pub session_id: SessionId,
|
||||
}
|
||||
|
||||
/// Output of [`CloseTerminal::execute`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CloseTerminalOutput {
|
||||
/// Exit code reported by the killed process (`None` if signalled).
|
||||
pub code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Kills a live PTY and forgets its handle.
|
||||
pub struct CloseTerminal {
|
||||
pty: Arc<dyn PtyPort>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
}
|
||||
|
||||
impl CloseTerminal {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(pty: Arc<dyn PtyPort>, sessions: Arc<TerminalSessions>) -> Self {
|
||||
Self { pty, sessions }
|
||||
}
|
||||
|
||||
/// Kills the session's PTY and removes it from the registry. Idempotent on
|
||||
/// the registry side (removing an unknown session is a no-op error).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the session is unknown,
|
||||
/// - [`AppError::Process`] if the kill fails.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CloseTerminalInput,
|
||||
) -> Result<CloseTerminalOutput, AppError> {
|
||||
let handle = self
|
||||
.sessions
|
||||
.remove(&input.session_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("terminal session {}", input.session_id)))?;
|
||||
let status = self.pty.kill(&handle).await?;
|
||||
Ok(CloseTerminalOutput { code: status.code })
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform default interactive shell.
|
||||
///
|
||||
/// Resolution policy lives in the application layer (a metier default), not the
|
||||
/// adapter, so it is uniform and testable. On Unix we honour `$SHELL`, falling
|
||||
/// back to `/bin/sh`; on Windows we use `cmd.exe` (a ConPTY spike point — PowerShell
|
||||
/// could become the default, ARCHITECTURE §13.1).
|
||||
fn default_shell() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_owned())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user