Eingebautes Terminal (xterm.js + portable-pty) und Start/Beenden-Button

- Terminal-Fenster als eigener Prozess (--terminal <projekt>) mit eigener PTY,
  startet claude-sync im Projektordner
- Projektliste: ein Button pro Zeile — grün Starten (open_terminal) bzw.
  rot Beenden (stop_project, SIGTERM auf exakte PID / Ghostty-Quit)
- is_running erfasst Ghostty oder laufenden Terminal-Prozess
- Status-Kreis ohne Prozess unsichtbar
This commit is contained in:
marcus.hinz
2026-07-04 10:00:12 +02:00
parent 70e4898259
commit f7473797a9
13 changed files with 724 additions and 22 deletions
+159
View File
@@ -0,0 +1,159 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
/// Eine laufende PTY-Session, gekoppelt an ein Terminal-Fenster (Key = Fenster-Label).
pub struct Session {
writer: Box<dyn Write + Send>,
master: Box<dyn MasterPty + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
}
#[derive(Default)]
pub struct Terminals(pub Mutex<HashMap<String, Session>>);
/// Öffnet ein Terminal für ein Projekt: startet das eigene Binary als
/// Terminal-Prozess (`--terminal <projekt>`), damit jedes Terminal ein
/// eigenes Dock-Icon bekommt.
#[tauri::command]
pub fn open_terminal(project: String) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
let dir = PathBuf::from(home).join("claude-projects").join(&project);
if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display()));
}
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
std::process::Command::new(exe)
.args(["--terminal", &project])
.spawn()
.map_err(|e| e.to_string())?;
Ok(())
}
/// Baut das Terminal-Fenster des Terminal-Prozesses. Die PTY entsteht erst,
/// wenn das Fenster geladen ist und `term_start` ruft — so gehen keine
/// Ausgaben verloren, bevor der Event-Listener steht.
pub fn build_window(app: &AppHandle, project: &str) -> tauri::Result<()> {
let builder = WebviewWindowBuilder::new(
app,
format!("term-{project}"),
WebviewUrl::App(format!("terminal.html?project={project}").into()),
)
.title(format!("{project} — Session"))
.inner_size(980.0, 640.0)
// Theme-Hintergrund (Catppuccin Mocha), sonst blitzt beim Öffnen Weiß auf.
.background_color(tauri::window::Color(0x1e, 0x1e, 0x2e, 0xff));
// Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
#[cfg(target_os = "macos")]
let builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
builder.build()?;
Ok(())
}
/// Startet die PTY für das rufende Fenster: `claude-sync` im Projektordner.
/// zsh -i lädt die .zshrc (PATH, fnm); Pool, Sentinel und git-Sync macht
/// claude-sync selbst — identisch zum Ghostty-Starter.
#[tauri::command]
pub fn term_start(
window: tauri::WebviewWindow,
terminals: State<Terminals>,
project: String,
rows: u16,
cols: u16,
) -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| e.to_string())?;
let cwd = PathBuf::from(home).join("claude-projects").join(&project);
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())?;
let mut cmd = CommandBuilder::new("/bin/zsh");
cmd.args(["-ic", "claude-sync"]);
cmd.cwd(&cwd);
cmd.env("TERM", "xterm-256color");
let mut child = pty.slave.spawn_command(cmd).map_err(|e| e.to_string())?;
drop(pty.slave);
let killer = child.clone_killer();
let writer = pty.master.take_writer().map_err(|e| e.to_string())?;
let mut reader = pty.master.try_clone_reader().map_err(|e| e.to_string())?;
let label = window.label().to_string();
let app = window.app_handle().clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let _ = app.emit_to(&label, "pty-output", STANDARD.encode(&buf[..n]));
}
}
}
let _ = child.wait();
let _ = app.emit_to(&label, "pty-exit", ());
});
terminals.0.lock().unwrap().insert(
window.label().to_string(),
Session { writer, master: pty.master, killer },
);
Ok(())
}
/// Debug: JS-Fehler aus dem Terminal-Fenster ins Dev-Log.
#[tauri::command]
pub fn term_log(window: tauri::WebviewWindow, msg: String) {
eprintln!("[{}] {msg}", window.label());
}
/// Tastatureingaben des Fensters in die PTY.
#[tauri::command]
pub fn term_write(
window: tauri::WebviewWindow,
terminals: State<Terminals>,
data: String,
) -> Result<(), String> {
let mut map = terminals.0.lock().unwrap();
let s = map
.get_mut(window.label())
.ok_or("keine Session für dieses Fenster")?;
s.writer.write_all(data.as_bytes()).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn term_resize(
window: tauri::WebviewWindow,
terminals: State<Terminals>,
rows: u16,
cols: u16,
) -> Result<(), String> {
let map = terminals.0.lock().unwrap();
let s = map
.get(window.label())
.ok_or("keine Session für dieses Fenster")?;
s.master
.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| e.to_string())
}
/// Beim Schließen eines Terminal-Fensters: Kind killen, Session verwerfen.
/// Das Droppen des Masters schließt die PTY — claude bekommt HUP wie beim
/// Fenster-X in Ghostty.
pub fn close(window: &tauri::Window) {
let terminals = window.state::<Terminals>();
let session = terminals.0.lock().unwrap().remove(window.label());
if let Some(mut s) = session {
let _ = s.killer.kill();
}
}