Skip to content

Commit 9f21056

Browse files
committed
feat: added terminal
1 parent 4693f08 commit 9f21056

File tree

10 files changed

+1845
-367
lines changed

10 files changed

+1845
-367
lines changed

src-tauri/src/commands.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::config_handler::ConfigHandler;
22
use crate::log_handler::LogHandler;
33
use crate::monitoring::Monitoring;
44
use crate::process_manager::ProcessManager;
5+
use crate::terminal::TerminalManager;
56
use crate::types::{ProcessConfig, ProcessState};
67
use chrono::Local;
78
use serde_json::json;
@@ -16,6 +17,8 @@ pub struct AppState {
1617
pub log_handler: Arc<LogHandler>,
1718
/// Persistent sysinfo System — keeps prior CPU snapshot so delta is accurate.
1819
pub system: Arc<Mutex<System>>,
20+
/// Integrated terminal sessions.
21+
pub terminal: Arc<Mutex<TerminalManager>>,
1922
}
2023

2124
/// Spawn background threads that read stdout/stderr from a child process,
@@ -378,3 +381,164 @@ pub async fn stop_all(state: State<'_, AppState>, window: WebviewWindow) -> Resu
378381

379382
Ok(())
380383
}
384+
385+
// ═══════════════════════════════════════════════════════════════
386+
// Terminal commands
387+
// ═══════════════════════════════════════════════════════════════
388+
389+
/// Run a shell command in the given terminal session.
390+
/// Streams stdout/stderr via `terminal:output:{session_id}` events.
391+
/// Emits `terminal:done:{session_id}` when the process exits.
392+
#[tauri::command]
393+
pub async fn terminal_run(
394+
session_id: String,
395+
command: String,
396+
job_id: String,
397+
state: State<'_, AppState>,
398+
window: WebviewWindow,
399+
) -> Result<(), String> {
400+
// Grab current CWD for this session
401+
let cwd = {
402+
let mut terminal = state.terminal.lock().map_err(|e| e.to_string())?;
403+
terminal.get_cwd(&session_id)
404+
};
405+
406+
// Spawn via PowerShell on Windows
407+
let mut child = std::process::Command::new("powershell")
408+
.args(["-NoProfile", "-NonInteractive", "-Command", &command])
409+
.current_dir(&cwd)
410+
.stdout(std::process::Stdio::piped())
411+
.stderr(std::process::Stdio::piped())
412+
.spawn()
413+
.map_err(|e| format!("Failed to spawn command: {}", e))?;
414+
415+
let stdout = child.stdout.take().expect("stdout piped");
416+
let stderr = child.stderr.take().expect("stderr piped");
417+
418+
// Wrap child so both streaming threads + kill command can access it
419+
let child_arc: std::sync::Arc<Mutex<Option<std::process::Child>>> =
420+
std::sync::Arc::new(Mutex::new(Some(child)));
421+
422+
// Register job
423+
{
424+
let mut terminal = state.terminal.lock().map_err(|e| e.to_string())?;
425+
terminal.add_job(job_id.clone(), std::sync::Arc::clone(&child_arc));
426+
}
427+
428+
// ── stdout reader thread ──
429+
{
430+
let win = window.clone();
431+
let sid = session_id.clone();
432+
let jid = job_id.clone();
433+
std::thread::spawn(move || {
434+
let reader = std::io::BufReader::new(stdout);
435+
for line in reader.lines() {
436+
if let Ok(msg) = line {
437+
let ts = Local::now().format("%H:%M:%S%.3f").to_string();
438+
let _ = win.emit(
439+
&format!("terminal:output:{}", sid),
440+
json!({ "jobId": jid, "line": msg, "isError": false, "timestamp": ts }),
441+
);
442+
}
443+
}
444+
});
445+
}
446+
447+
// ── stderr reader + exit-waiter thread ──
448+
{
449+
let win = window.clone();
450+
let sid = session_id.clone();
451+
let jid = job_id.clone();
452+
let child_ref = std::sync::Arc::clone(&child_arc);
453+
let terminal_arc = std::sync::Arc::clone(&state.terminal);
454+
std::thread::spawn(move || {
455+
// Read stderr
456+
let reader = std::io::BufReader::new(stderr);
457+
for line in reader.lines() {
458+
if let Ok(msg) = line {
459+
let ts = Local::now().format("%H:%M:%S%.3f").to_string();
460+
let _ = win.emit(
461+
&format!("terminal:output:{}", sid),
462+
json!({ "jobId": jid, "line": msg, "isError": true, "timestamp": ts }),
463+
);
464+
}
465+
}
466+
467+
// Wait for child to exit
468+
let exit_code = {
469+
let mut guard = child_ref.lock().unwrap_or_else(|e| e.into_inner());
470+
if let Some(ref mut c) = *guard {
471+
c.wait().map(|s| s.code()).ok().flatten().unwrap_or(-1)
472+
} else {
473+
-1
474+
}
475+
};
476+
477+
// Remove completed job from manager
478+
if let Ok(mut mgr) = terminal_arc.lock() {
479+
mgr.remove_job(&jid);
480+
}
481+
482+
let _ = win.emit(
483+
&format!("terminal:done:{}", sid),
484+
json!({ "jobId": jid, "exitCode": exit_code }),
485+
);
486+
});
487+
}
488+
489+
Ok(())
490+
}
491+
492+
/// Kill a running terminal job.
493+
#[tauri::command]
494+
pub async fn terminal_kill(
495+
job_id: String,
496+
state: State<'_, AppState>,
497+
) -> Result<(), String> {
498+
let mut terminal = state.terminal.lock().map_err(|e| e.to_string())?;
499+
terminal.kill_job(&job_id)
500+
}
501+
502+
/// Update the working directory for a terminal session.
503+
/// Returns the resolved absolute CWD or an error.
504+
#[tauri::command]
505+
pub async fn terminal_set_cwd(
506+
session_id: String,
507+
path: String,
508+
state: State<'_, AppState>,
509+
) -> Result<String, String> {
510+
let mut terminal = state.terminal.lock().map_err(|e| e.to_string())?;
511+
terminal.set_cwd(&session_id, &path)
512+
}
513+
514+
/// Return the current working directory for a terminal session.
515+
#[tauri::command]
516+
pub async fn terminal_get_cwd(
517+
session_id: String,
518+
state: State<'_, AppState>,
519+
) -> Result<String, String> {
520+
let mut terminal = state.terminal.lock().map_err(|e| e.to_string())?;
521+
Ok(terminal.get_cwd(&session_id))
522+
}
523+
524+
/// Promote a terminal command to a managed process.
525+
/// The running job (if any) is left untouched; a new process entry is created.
526+
#[tauri::command]
527+
pub async fn terminal_add_process(
528+
name: String,
529+
command: String,
530+
working_dir: Option<String>,
531+
state: State<'_, AppState>,
532+
) -> Result<String, String> {
533+
let parts: Vec<&str> = command.trim().splitn(2, ' ').collect();
534+
let exe = parts.first().copied().unwrap_or("").to_string();
535+
let args: Vec<String> = if parts.len() > 1 {
536+
parts[1].split_whitespace().map(|s| s.to_string()).collect()
537+
} else {
538+
vec![]
539+
};
540+
541+
let mut manager = state.manager.lock().map_err(|e| e.to_string())?;
542+
let id = manager.add_process(name, exe, args, working_dir, false);
543+
Ok(id)
544+
}

src-tauri/src/lib.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod config_handler;
33
mod log_handler;
44
mod monitoring;
55
mod process_manager;
6+
mod terminal;
67
mod types;
78

89
use commands::AppState;
@@ -39,6 +40,7 @@ pub fn run() {
3940
manager: Arc::clone(&process_manager),
4041
log_handler: Arc::clone(&log_handler),
4142
system: Arc::new(std::sync::Mutex::new(sys)),
43+
terminal: Arc::new(std::sync::Mutex::new(terminal::TerminalManager::new())),
4244
};
4345

4446
tauri::Builder::default()
@@ -63,8 +65,14 @@ pub fn run() {
6365
.icon(app.default_window_icon().unwrap().clone())
6466
.menu(&menu)
6567
.on_tray_icon_event(|tray, event| {
66-
// Single click on tray icon → show window
67-
if let tauri::tray::TrayIconEvent::Click { .. } = event {
68+
// Left-click on tray icon → show window.
69+
// Right-click is handled automatically by the menu.
70+
if let tauri::tray::TrayIconEvent::Click {
71+
button: tauri::tray::MouseButton::Left,
72+
button_state: tauri::tray::MouseButtonState::Up,
73+
..
74+
} = event
75+
{
6876
if let Some(w) = tray.app_handle().get_webview_window("main") {
6977
let _ = w.show();
7078
let _ = w.set_focus();
@@ -124,6 +132,11 @@ pub fn run() {
124132
commands::set_auto_start,
125133
commands::start_all,
126134
commands::stop_all,
135+
commands::terminal_run,
136+
commands::terminal_kill,
137+
commands::terminal_set_cwd,
138+
commands::terminal_get_cwd,
139+
commands::terminal_add_process,
127140
])
128141
.run(tauri::generate_context!())
129142
.expect("error while running tauri application");

src-tauri/src/terminal.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use std::collections::HashMap;
2+
use std::path::PathBuf;
3+
use std::process::Child;
4+
use std::sync::{Arc, Mutex};
5+
6+
/// One running terminal job (a single command execution).
7+
pub struct TerminalJob {
8+
pub child: Arc<Mutex<Option<Child>>>,
9+
}
10+
11+
/// Per-session state: tracks working directory and active jobs.
12+
pub struct TerminalSession {
13+
pub cwd: String,
14+
}
15+
16+
/// Top-level terminal state stored in AppState.
17+
pub struct TerminalManager {
18+
pub sessions: HashMap<String, TerminalSession>,
19+
pub jobs: HashMap<String, TerminalJob>,
20+
}
21+
22+
impl TerminalManager {
23+
pub fn new() -> Self {
24+
let cwd = std::env::current_dir()
25+
.map(|p| p.to_string_lossy().to_string())
26+
.unwrap_or_else(|_| "C:\\".to_string());
27+
28+
let mut sessions = HashMap::new();
29+
sessions.insert(
30+
"default".to_string(),
31+
TerminalSession { cwd },
32+
);
33+
34+
Self {
35+
sessions,
36+
jobs: HashMap::new(),
37+
}
38+
}
39+
40+
/// Return the current working directory for a session, creating it lazily.
41+
pub fn get_cwd(&mut self, session_id: &str) -> String {
42+
if !self.sessions.contains_key(session_id) {
43+
let cwd = std::env::current_dir()
44+
.map(|p| p.to_string_lossy().to_string())
45+
.unwrap_or_else(|_| "C:\\".to_string());
46+
self.sessions
47+
.insert(session_id.to_string(), TerminalSession { cwd });
48+
}
49+
self.sessions[session_id].cwd.clone()
50+
}
51+
52+
/// Set a new CWD for a session. Resolves relative paths.
53+
/// Returns the resolved absolute path, or an error.
54+
pub fn set_cwd(&mut self, session_id: &str, path: &str) -> Result<String, String> {
55+
let current = if let Some(s) = self.sessions.get(session_id) {
56+
PathBuf::from(&s.cwd)
57+
} else {
58+
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("C:\\"))
59+
};
60+
61+
let target = if PathBuf::from(path).is_absolute() {
62+
PathBuf::from(path)
63+
} else {
64+
current.join(path)
65+
};
66+
67+
// Canonicalize to resolve .. and symlinks
68+
let resolved = target
69+
.canonicalize()
70+
.map_err(|_| format!("Directory not found: {}", path))?;
71+
72+
if !resolved.is_dir() {
73+
return Err(format!("Not a directory: {}", resolved.display()));
74+
}
75+
76+
let cwd_str = resolved.to_string_lossy().to_string();
77+
78+
if let Some(s) = self.sessions.get_mut(session_id) {
79+
s.cwd = cwd_str.clone();
80+
} else {
81+
self.sessions
82+
.insert(session_id.to_string(), TerminalSession { cwd: cwd_str.clone() });
83+
}
84+
85+
Ok(cwd_str)
86+
}
87+
88+
/// Kill a running job by job_id. No-op if already done.
89+
pub fn kill_job(&mut self, job_id: &str) -> Result<(), String> {
90+
if let Some(job) = self.jobs.get(job_id) {
91+
let mut guard = job.child.lock().map_err(|e| e.to_string())?;
92+
if let Some(ref mut child) = *guard {
93+
child.kill().ok();
94+
}
95+
}
96+
Ok(())
97+
}
98+
99+
/// Register a new job.
100+
pub fn add_job(&mut self, job_id: String, child: Arc<Mutex<Option<Child>>>) {
101+
self.jobs.insert(job_id, TerminalJob { child });
102+
}
103+
104+
/// Remove a completed job.
105+
pub fn remove_job(&mut self, job_id: &str) {
106+
self.jobs.remove(job_id);
107+
}
108+
}

0 commit comments

Comments
 (0)