Terminal-Config (Theme/Icon/Titel), UI-Überarbeitung, i18n de/en

- Terminal-Einstellungen pro Projekt in ai-control.json: 5 Themes
  (Paletten in terminal.ts, Fenster-BG gespiegelt in terminal.rs),
  Dock-Icon (PNG/ICNS via NSApplication in RunEvent::Ready),
  Fenstertitel mit Fallback Projektname; Zahnrad-Dialog mit Datei-Picker
  (tauri-plugin-dialog)
- UI: Catppuccin-Mocha-Token, JetBrains Mono für Namen/Pfade/Chips,
  feste Tabellenspalten, Status-Punkt mit Glow, Pool-Projekte als
  Hover-Popover, Buttons ausgerichtet (Zahnrad links von Starten/Beenden)
- i18n: vue-i18n, Deutsch/Englisch, Umschalter im Header (localStorage)
- generate_context nur noch einmal expandiert (Release-Build-Fehler)
This commit is contained in:
marcus.hinz
2026-07-04 12:58:25 +02:00
parent f7473797a9
commit fc560d4e5f
14 changed files with 1309 additions and 365 deletions
+110 -27
View File
@@ -58,6 +58,7 @@ struct Project {
path: String,
pool: Option<String>,
running: bool,
terminal: TerminalConfig,
}
#[derive(Serialize, Deserialize)]
@@ -67,9 +68,28 @@ struct Pool {
credential_type: String,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
pool: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pool: Option<String>,
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
terminal: TerminalConfig,
}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct TerminalConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl TerminalConfig {
fn is_empty(&self) -> bool {
self.theme.is_none() && self.icon.is_none() && self.title.is_none()
}
}
fn bundle_id(project: &str) -> String {
@@ -130,20 +150,13 @@ fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let cfg_path = path.join(PROJECT_FILE);
let pool = if cfg_path.is_file() {
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let cfg: ProjectConfig = serde_json::from_str(&raw)
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
Some(cfg.pool)
} else {
None
};
let cfg = read_project_config_in(paths, &name)?;
projects.push(Project {
running: is_running(&name),
path: path.to_string_lossy().into_owned(),
pool: cfg.pool,
terminal: cfg.terminal,
name,
pool,
});
}
projects.sort_by(|a, b| a.name.cmp(&b.name));
@@ -168,18 +181,59 @@ fn delete_project_in(paths: &Paths, name: &str) -> Result<(), String> {
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
}
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
let cfg_path = paths.project_config(project);
if !cfg_path.is_file() {
return Ok(ProjectConfig::default());
}
let raw =
fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
}
fn write_project_config_in(
paths: &Paths,
project: &str,
cfg: &ProjectConfig,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
}
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
return Err(format!("Pool existiert nicht: {pool}"));
}
let cfg = ProjectConfig { pool: pool.to_string() };
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = Some(pool.to_string());
write_project_config_in(paths, project, &cfg)
}
/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
/// Terminal-Einstellungen trägt.
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let cfg_path = paths.project_config(project);
fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = None;
if cfg.terminal.is_empty() {
let cfg_path = paths.project_config(project);
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
}
write_project_config_in(paths, project, &cfg)
}
fn set_terminal_config_in(
paths: &Paths,
project: &str,
terminal: TerminalConfig,
) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
}
/// Namen aller Projekte, die `pool` zugeordnet haben.
@@ -194,7 +248,7 @@ fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String>
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.pool == pool {
if cfg.pool.as_deref() == Some(pool) {
users.push(entry.file_name().to_string_lossy().into_owned());
}
}
@@ -372,6 +426,16 @@ fn unassign_pool(project: String) -> Result<(), String> {
unassign_pool_in(&Paths::real(), &project)
}
#[tauri::command]
fn set_terminal_config(
project: String,
theme: Option<String>,
icon: Option<String>,
title: Option<String>,
) -> Result<(), String> {
set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
}
#[tauri::command]
fn set_apikey(pool: String, key: String) -> Result<(), String> {
set_apikey_in(&Paths::real(), &pool, &key)
@@ -675,18 +739,34 @@ fn pct_decode(s: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
let context = tauri::generate_context!();
let mut args = std::env::args().skip(1);
let builder = match args.next().as_deref() {
match args.next().as_deref() {
// `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => {
terminal_builder(args.next().expect("--terminal braucht einen Projektnamen"))
let project = args.next().expect("--terminal braucht einen Projektnamen");
let icon = terminal_config(&project)
.expect("Projekt-Config nicht lesbar")
.icon;
terminal_builder(project)
.build(context)
.expect("error while building tauri application")
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
.run(move |_app, event| {
if let tauri::RunEvent::Ready = event {
if let Some(icon) = icon.as_deref() {
terminal::set_dock_icon(icon);
}
}
});
}
_ => main_builder(),
};
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
_ => main_builder()
.run(context)
.expect("error while running tauri application"),
}
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
@@ -698,6 +778,7 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -755,6 +836,7 @@ fn main_builder() -> tauri::Builder<tauri::Wry> {
delete_pool,
assign_pool,
unassign_pool,
set_terminal_config,
start_project,
stop_project,
restart_project,
@@ -771,7 +853,8 @@ fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.manage(terminal::Terminals::default())
.setup(move |app| {
terminal::build_window(app.handle(), &project)?;
let cfg = terminal_config(&project)?;
terminal::build_window(app.handle(), &project, &cfg)?;
Ok(())
})
// Fenster zu → PTY-Session abräumen; danach endet der Prozess.
@@ -982,7 +1065,7 @@ mod tests {
let cfg: ProjectConfig =
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap())
.unwrap();
assert_eq!(cfg.pool, "kunde");
assert_eq!(cfg.pool.as_deref(), Some("kunde"));
let pools = list_pools_in(&p).unwrap();
assert_eq!(pools[0].projects, vec!["proj"]);
+37 -4
View File
@@ -34,19 +34,52 @@ pub fn open_terminal(project: String) -> Result<(), String> {
Ok(())
}
/// Fenster-Hintergrund je Theme — muss zu den Theme-Definitionen in
/// terminal.ts passen, sonst blitzt beim Öffnen die falsche Farbe auf.
fn theme_background(theme: &str) -> (u8, u8, u8) {
match theme {
"dracula" => (0x28, 0x2a, 0x36),
"solarized-dark" => (0x00, 0x2b, 0x36),
"gruvbox" => (0x28, 0x28, 0x28),
"one-dark" => (0x28, 0x2c, 0x34),
_ => (0x1e, 0x1e, 0x2e), // Catppuccin Mocha
}
}
/// Setzt das Dock-Icon dieses Terminal-Prozesses aus einer PNG/ICNS-Datei.
#[cfg(target_os = "macos")]
pub fn set_dock_icon(path: &str) {
use objc2::{AnyThread, MainThreadMarker};
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSString;
let mtm = MainThreadMarker::new().expect("set_dock_icon läuft nicht auf dem Main-Thread");
let img = NSImage::initWithContentsOfFile(NSImage::alloc(), &NSString::from_str(path))
.unwrap_or_else(|| panic!("Dock-Icon nicht ladbar: {path}"));
// unsafe laut objc2-Signatur; das NSImage stammt aus einer Datei und ist gültig.
unsafe {
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&img));
}
}
/// 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<()> {
pub fn build_window(
app: &AppHandle,
project: &str,
cfg: &crate::TerminalConfig,
) -> tauri::Result<()> {
let (r, g, b) = theme_background(cfg.theme.as_deref().unwrap_or_default());
let title = cfg.title.as_deref().unwrap_or(project);
let builder = WebviewWindowBuilder::new(
app,
format!("term-{project}"),
WebviewUrl::App(format!("terminal.html?project={project}").into()),
)
.title(format!("{project} — Session"))
.title(format!("{title} — 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));
.background_color(tauri::window::Color(r, g, b, 0xff));
// Titelbar liegt über dem Inhalt; der Header in terminal.html ist Drag-Region.
#[cfg(target_os = "macos")]