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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user