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
+16 -7
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart";
import { useI18n } from "vue-i18n";
import ProjectList from "./components/ProjectList.vue";
import PoolList from "./components/PoolList.vue";
import { setLocale } from "./i18n";
const tab = ref<"projects" | "pools">("projects");
const autostart = ref(false);
const { locale } = useI18n();
onMounted(async () => {
autostart.value = await isEnabled();
@@ -23,19 +26,25 @@ async function toggleAutostart() {
<template>
<header>
<h1>AI Control</h1>
<h1>ai-control</h1>
<nav>
<button :class="{ active: tab === 'projects' }" @click="tab = 'projects'">
Projekte
{{ $t("app.projects") }}
</button>
<button :class="{ active: tab === 'pools' }" @click="tab = 'pools'">
Pools
{{ $t("app.pools") }}
</button>
</nav>
<label class="autostart">
<input type="checkbox" :checked="autostart" @change="toggleAutostart" />
Autostart
</label>
<div class="header-right">
<div class="lang">
<button :class="{ active: locale === 'de' }" @click="setLocale('de')">DE</button>
<button :class="{ active: locale === 'en' }" @click="setLocale('en')">EN</button>
</div>
<label class="autostart">
<input type="checkbox" :checked="autostart" @change="toggleAutostart" />
{{ $t("app.autostart") }}
</label>
</div>
</header>
<main>
<ProjectList v-if="tab === 'projects'" />
+78 -164
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface Pool {
name: string;
@@ -19,9 +22,9 @@ const dKey = ref("");
const dialogTitle = computed(() => {
if (!dialog.value) return "";
if (dialog.value.mode === "oauth") return "Neuen oAuth-Pool anlegen";
if (dialog.value.editing) return `API-Key ändern ${dName.value}`;
return "Neuen API-Key-Pool anlegen";
if (dialog.value.mode === "oauth") return t("pools.newOauthPool");
if (dialog.value.editing) return t("pools.editKey", { name: dName.value });
return t("pools.newApikeyPool");
});
function openNew(mode: Mode) {
@@ -128,65 +131,89 @@ onMounted(refresh);
<template>
<div class="toolbar">
<button class="primary" :disabled="busy" @click="openNew('oauth')">
+ oAuth
{{ $t("pools.newOauth") }}
</button>
<button class="primary" :disabled="busy" @click="openNew('apikey')">
+ apiKey
{{ $t("pools.newApikey") }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<table v-if="pools.length">
<table v-if="pools.length" class="grid">
<colgroup>
<col />
<col class="col-type" />
<col class="col-projects" />
<col class="col-actions-wide" />
</colgroup>
<thead>
<tr>
<th>Pool</th>
<th>Typ</th>
<th class="actions-col">Aktionen</th>
<th>{{ $t("pools.pool") }}</th>
<th>{{ $t("pools.type") }}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in pools" :key="p.name">
<td><strong>{{ p.name }}</strong></td>
<td class="cell-name">
<strong>{{ p.name }}</strong>
</td>
<td>
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
</td>
<td class="actions-col">
<template v-if="p.credentialType === 'oauth'">
<button :disabled="busy" @click="relogin(p.name)">Neu anmelden</button>
<button :disabled="busy" @click="renew(p.name)">Token erneuern</button>
</template>
<template v-else>
<button :disabled="busy" @click="openEditKey(p.name)">Key ändern</button>
</template>
<button
v-if="!p.projects.length"
class="danger"
:disabled="busy"
@click="askDelete(p.name)"
>
Löschen
</button>
<span v-else class="assigned" :title="p.projects.join(', ')">
zugeordnet: {{ p.projects.join(", ") }}
<td>
<span v-if="p.projects.length" class="assigned">
{{ $t("pools.assigned", p.projects.length) }}
<span class="pop">
<span v-for="name in p.projects" :key="name" class="pop-item">
{{ name }}
</span>
</span>
</span>
</td>
<td class="cell-actions">
<span class="row-actions">
<template v-if="p.credentialType === 'oauth'">
<button :disabled="busy" @click="relogin(p.name)">
{{ $t("pools.relogin") }}
</button>
<button :disabled="busy" @click="renew(p.name)">
{{ $t("pools.renewToken") }}
</button>
</template>
<template v-else>
<button :disabled="busy" @click="openEditKey(p.name)">
{{ $t("pools.changeKey") }}
</button>
</template>
<button
v-if="!p.projects.length"
class="danger"
:disabled="busy"
@click="askDelete(p.name)"
>
{{ $t("pools.delete") }}
</button>
</span>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty">Noch keine Pools angelegt.</p>
<p v-else class="empty">{{ $t("pools.empty") }}</p>
<div v-if="dialog" class="modal-backdrop" @click.self="closeDialog">
<form class="modal" @submit.prevent="submit">
<h2>{{ dialogTitle }}</h2>
<div v-if="dialog" class="overlay" @click.self="closeDialog">
<form class="dialog" @submit.prevent="submit">
<h3>{{ dialogTitle }}</h3>
<label v-if="!dialog.editing">
<span>Name</span>
<input v-model="dName" placeholder="z. B. Mx9Privat" autofocus required />
<label v-if="!dialog.editing" class="field">
{{ $t("pools.name") }}
<input v-model="dName" placeholder="z. B. privateDefault" autofocus required />
</label>
<label v-if="dialog.mode === 'apikey'">
<span>API-Key</span>
<label v-if="dialog.mode === 'apikey'" class="field">
{{ $t("pools.apiKey") }}
<input
v-model="dKey"
type="password"
@@ -197,148 +224,35 @@ onMounted(refresh);
</label>
<p v-if="dialog.mode === 'oauth'" class="hint">
Nach Login starten öffnet sich der Browser zur Anmeldung gegen dein Abo.
{{ $t("pools.oauthHint") }}
</p>
<p v-if="busy && dialog.mode === 'oauth'" class="hint busy">
Warte auf die Anmeldung im Browser
{{ $t("pools.oauthWaiting") }}
</p>
<div class="modal-actions">
<button type="button" :disabled="busy" @click="closeDialog">Abbrechen</button>
<div class="actions">
<button type="button" :disabled="busy" @click="closeDialog">
{{ $t("pools.cancel") }}
</button>
<button type="submit" class="primary" :disabled="busy">
{{ dialog.mode === "oauth" ? "Login starten" : "Speichern" }}
{{ dialog.mode === "oauth" ? $t("pools.startLogin") : $t("pools.save") }}
</button>
</div>
</form>
</div>
<div v-if="deleteName" class="modal-backdrop" @click.self="deleteName = null">
<div class="modal">
<h2>Pool löschen</h2>
<p class="hint">
Pool <strong>{{ deleteName }}</strong> und seine Credential-Datei werden
gelöscht. Das lässt sich nicht rückgängig machen.
</p>
<div class="modal-actions">
<div v-if="deleteName" class="overlay" @click.self="deleteName = null">
<div class="dialog">
<h3>{{ $t("pools.deletePool") }}</h3>
<p class="hint">{{ $t("pools.deleteWarning", { name: deleteName }) }}</p>
<div class="actions">
<button type="button" :disabled="busy" @click="deleteName = null">
Abbrechen
{{ $t("pools.cancel") }}
</button>
<button class="danger" :disabled="busy" @click="confirmDelete">
Löschen
{{ $t("pools.delete") }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.primary {
background: #4a4af0;
border-color: #4a4af0;
color: #fff;
}
.primary:hover:not(:disabled) {
background: #5b5bf5;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.actions-col {
text-align: right;
white-space: nowrap;
}
.actions-col button {
margin-left: 0.4rem;
}
.danger {
color: #ff6b6b;
border-color: #ff6b6b55;
}
.danger:hover:not(:disabled) {
background: #ff6b6b22;
}
.assigned {
color: #888;
font-size: 0.8rem;
}
.badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid #444;
color: #cfcfd6;
}
.badge.oauth {
border-color: #3ecf6a55;
color: #3ecf6a;
}
.badge.apikey {
border-color: #f0a84a55;
color: #f0a84a;
}
.empty {
color: #888;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.modal {
display: flex;
flex-direction: column;
gap: 0.9rem;
background: #23232a;
border: 1px solid #3a3a44;
border-radius: 12px;
padding: 1.5rem;
min-width: 24rem;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.modal h2 {
font-size: 1rem;
margin: 0;
}
.modal label {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.modal label span {
font-size: 0.8rem;
color: #999;
}
.modal input {
width: 100%;
}
.hint {
margin: 0;
font-size: 0.8rem;
color: #999;
}
.hint.busy {
color: #4a9af5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.25rem;
}
</style>
+125 -22
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
interface Project {
name: string;
path: string;
pool: string | null;
running: boolean;
terminal: { theme: string | null; icon: string | null; title: string | null };
}
interface Pool {
@@ -84,6 +86,58 @@ async function restartNow() {
restarting.value = false;
}
const THEME_NAMES: [string, string][] = [
["mocha", "Catppuccin Mocha"],
["dracula", "Dracula"],
["solarized-dark", "Solarized Dark"],
["gruvbox", "Gruvbox"],
["one-dark", "One Dark"],
];
interface TerminalSettings {
name: string;
theme: string;
icon: string | null;
title: string;
}
const settings = ref<TerminalSettings | null>(null);
function openSettings(p: Project) {
settings.value = {
name: p.name,
theme: p.terminal.theme ?? "mocha",
icon: p.terminal.icon,
title: p.terminal.title ?? "",
};
}
async function pickIcon() {
const file = await open({
multiple: false,
directory: false,
filters: [{ name: "Icon", extensions: ["png", "icns"] }],
});
if (typeof file === "string") settings.value!.icon = file;
}
async function saveSettings() {
const s = settings.value!;
try {
const title = s.title.trim();
await invoke("set_terminal_config", {
project: s.name,
theme: s.theme === "mocha" ? null : s.theme,
icon: s.icon,
title: title === "" ? null : title,
});
settings.value = null;
await refresh();
} catch (e) {
error.value = String(e);
}
}
let timer: number;
onMounted(() => {
refresh();
@@ -94,61 +148,110 @@ onUnmounted(() => window.clearInterval(timer));
<template>
<p v-if="error" class="error">{{ error }}</p>
<table>
<table class="grid">
<colgroup>
<col class="col-dot" />
<col />
<col class="col-pool" />
<col class="col-actions" />
</colgroup>
<thead>
<tr>
<th></th>
<th>Projekt</th>
<th>Pool</th>
<th>{{ $t("projects.project") }}</th>
<th>{{ $t("projects.pool") }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in projects" :key="p.name">
<td>
<td class="cell-dot">
<span class="dot" :class="{ on: p.running }"></span>
</td>
<td>
<td class="cell-name">
<strong>{{ p.name }}</strong>
<small>{{ p.path }}</small>
</td>
<td>
<select :value="p.pool ?? ''" @change="assign(p, $event)">
<option value="" disabled> kein Pool </option>
<option value="" disabled>{{ $t("projects.noPool") }}</option>
<option v-for="pool in pools" :key="pool.name" :value="pool.name">
{{ pool.name }}
</option>
</select>
</td>
<td>
<button v-if="p.running" class="stop" @click="stop(p)">Beenden</button>
<button v-else class="start" @click="openTerminal(p)">Starten</button>
<td class="cell-actions">
<span class="row-actions">
<button
class="gear"
:title="$t('projects.terminalSettings')"
@click="openSettings(p)"
>
</button>
<button v-if="p.running" class="stop" @click="stop(p)">
{{ $t("projects.stop") }}
</button>
<button v-else class="start" @click="openTerminal(p)">
{{ $t("projects.start") }}
</button>
</span>
</td>
</tr>
</tbody>
</table>
<div v-if="settings" class="overlay">
<div class="dialog">
<h3>{{ $t("projects.terminal", { name: settings.name }) }}</h3>
<label class="field">
{{ $t("projects.title") }}
<input v-model="settings.title" :placeholder="settings.name" />
</label>
<label class="field">
{{ $t("projects.theme") }}
<select v-model="settings.theme">
<option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
{{ label }}
</option>
</select>
</label>
<label class="field">
{{ $t("projects.dockIcon") }}
<span class="icon-row">
<button @click="pickIcon">{{ $t("projects.chooseFile") }}</button>
<button v-if="settings.icon" @click="settings.icon = null">
{{ $t("projects.remove") }}
</button>
<span class="icon-path">
{{ settings.icon ?? $t("projects.defaultIcon") }}
</span>
</span>
</label>
<p class="hint">{{ $t("projects.appliesNextStart") }}</p>
<div class="actions">
<button @click="settings = null">{{ $t("projects.cancel") }}</button>
<button class="primary" @click="saveSettings">
{{ $t("projects.save") }}
</button>
</div>
</div>
</div>
<div v-if="pending" class="overlay">
<div class="dialog">
<h3>{{ pending.name }} läuft noch</h3>
<p>
Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber
weiter im alten Pool der neue gilt erst ab dem nächsten Start.
</p>
<h3>{{ $t("projects.stillRunning", { name: pending.name }) }}</h3>
<p>{{ $t("projects.poolChangeSaved") }}</p>
<div class="pools">
<span class="chip">{{ pending.from ?? "kein Pool" }}</span>
<span class="chip">{{ pending.from ?? $t("projects.noPoolAssigned") }}</span>
<span class="arrow"></span>
<span class="chip new">{{ pending.to }}</span>
</div>
<p class="hint">
Der Neustart beendet die laufende Session. Der automatische
commit+push von claude-sync nach Sessionende entfällt dabei
Ghostty fragt bei laufendem Prozess vor dem Beenden noch einmal nach.
</p>
<p class="hint">{{ $t("projects.restartHint") }}</p>
<div class="actions">
<button @click="pending = null">Weiterlaufen lassen</button>
<button @click="pending = null">{{ $t("projects.keepRunning") }}</button>
<button class="primary" :disabled="restarting" @click="restartNow">
{{ restarting ? "Starte neu " : "Jetzt neu starten" }}
{{ restarting ? $t("projects.restarting") : $t("projects.restartNow") }}
</button>
</div>
</div>
+146
View File
@@ -0,0 +1,146 @@
import { createI18n } from "vue-i18n";
const de = {
app: {
projects: "Projekte",
pools: "Pools",
autostart: "Autostart",
},
projects: {
project: "Projekt",
pool: "Pool",
noPool: " kein Pool ",
start: "Starten",
stop: "Beenden",
terminalSettings: "Terminal-Einstellungen",
stillRunning: "{name} läuft noch",
poolChangeSaved:
"Der Pool-Wechsel ist gespeichert. Die laufende Session arbeitet aber weiter im alten Pool — der neue gilt erst ab dem nächsten Start.",
restartHint:
"Der Neustart beendet die laufende Session. Der automatische commit+push von claude-sync nach Sessionende entfällt dabei — Ghostty fragt bei laufendem Prozess vor dem Beenden noch einmal nach.",
keepRunning: "Weiterlaufen lassen",
restartNow: "Jetzt neu starten",
restarting: "Starte neu …",
terminal: "Terminal — {name}",
title: "Titel",
theme: "Theme",
dockIcon: "Dock-Icon",
chooseFile: "Datei wählen …",
remove: "Entfernen",
defaultIcon: "Standard-Icon",
appliesNextStart: "Gilt ab dem nächsten Terminal-Start.",
cancel: "Abbrechen",
save: "Speichern",
noPoolAssigned: "kein Pool",
},
pools: {
pool: "Pool",
type: "Typ",
actions: "Aktionen",
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Neu anmelden",
renewToken: "Token erneuern",
changeKey: "Key ändern",
delete: "Löschen",
assigned: "kein Projekt | 1 Projekt | {count} Projekte",
empty: "Noch keine Pools angelegt.",
newOauthPool: "Neuen oAuth-Pool anlegen",
newApikeyPool: "Neuen API-Key-Pool anlegen",
editKey: "API-Key ändern {name}",
name: "Name",
apiKey: "API-Key",
oauthHint:
"Nach „Login starten“ öffnet sich der Browser zur Anmeldung gegen dein Abo.",
oauthWaiting: "Warte auf die Anmeldung im Browser…",
cancel: "Abbrechen",
startLogin: "Login starten",
save: "Speichern",
deletePool: "Pool löschen",
deleteWarning:
"Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
},
};
const en: typeof de = {
app: {
projects: "Projects",
pools: "Pools",
autostart: "Autostart",
},
projects: {
project: "Project",
pool: "Pool",
noPool: " no pool ",
start: "Start",
stop: "Stop",
terminalSettings: "Terminal settings",
stillRunning: "{name} is still running",
poolChangeSaved:
"The pool change is saved. The running session keeps working in the old pool — the new one applies from the next start.",
restartHint:
"Restarting ends the running session. The automatic commit+push by claude-sync after the session is skipped — Ghostty asks once more before quitting a running process.",
keepRunning: "Keep running",
restartNow: "Restart now",
restarting: "Restarting …",
terminal: "Terminal — {name}",
title: "Title",
theme: "Theme",
dockIcon: "Dock icon",
chooseFile: "Choose file …",
remove: "Remove",
defaultIcon: "Default icon",
appliesNextStart: "Applies from the next terminal start.",
cancel: "Cancel",
save: "Save",
noPoolAssigned: "no pool",
},
pools: {
pool: "Pool",
type: "Type",
actions: "Actions",
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Sign in again",
renewToken: "Renew token",
changeKey: "Change key",
delete: "Delete",
assigned: "no projects | 1 project | {count} projects",
empty: "No pools yet.",
newOauthPool: "Create oAuth pool",
newApikeyPool: "Create API key pool",
editKey: "Change API key {name}",
name: "Name",
apiKey: "API key",
oauthHint:
"After “Start login” the browser opens to sign in against your subscription.",
oauthWaiting: "Waiting for the sign-in in the browser…",
cancel: "Cancel",
startLogin: "Start login",
save: "Save",
deletePool: "Delete pool",
deleteWarning:
"Pool {name} and its credential file will be deleted. This cannot be undone.",
},
};
const stored = localStorage.getItem("lang");
const initial =
stored === "de" || stored === "en"
? stored
: navigator.language.startsWith("de")
? "de"
: "en";
export const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: initial,
fallbackLocale: "en",
messages: { de, en },
});
export function setLocale(lang: "de" | "en") {
i18n.global.locale.value = lang;
localStorage.setItem("lang", lang);
}
+7 -4
View File
@@ -1,5 +1,8 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createApp } from "vue";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "./style.css";
import App from "./App.vue";
import { i18n } from "./i18n";
createApp(App).mount('#app')
createApp(App).use(i18n).mount("#app");
+348 -99
View File
@@ -1,8 +1,27 @@
/* Catppuccin Mocha — dieselbe Palette wie das eingebaute Terminal.
Die App verwaltet Terminals; sie spricht deren visuelle Sprache. */
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--surface0: #313244;
--surface1: #45475a;
--surface2: #585b70;
--text: #cdd6f4;
--subtext: #a6adc8;
--overlay: #6c7086;
--green: #a6e3a1;
--red: #f38ba8;
--blue: #89b4fa;
--peach: #fab387;
--lavender: #b4befe;
--mauve: #cba6f7;
--mono: "JetBrains Mono", Menlo, monospace;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
color: #e6e6e6;
background: #1b1b1f;
color: var(--text);
background: var(--base);
color-scheme: dark;
}
@@ -14,131 +33,351 @@ body {
margin: 0;
}
/* ---------- Header ---------- */
header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid #333;
padding: 0.85rem 1.5rem;
background: var(--mantle);
border-bottom: 1px solid var(--surface0);
}
h1 {
font-size: 1rem;
font-family: var(--mono);
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.03em;
color: var(--lavender);
margin: 0;
}
h2 {
font-size: 0.9rem;
margin: 0 0 0.5rem;
nav,
.lang {
display: flex;
gap: 2px;
padding: 2px;
background: var(--crust);
border-radius: 8px;
}
nav {
nav button,
.lang button {
background: transparent;
border: none;
border-radius: 6px;
color: var(--subtext);
padding: 0.3rem 0.9rem;
}
.lang button {
font-family: var(--mono);
font-size: 0.7rem;
padding: 0.3rem 0.55rem;
}
nav button:hover,
.lang button:hover {
background: var(--surface0);
color: var(--text);
}
nav button.active,
.lang button.active {
background: var(--surface0);
color: var(--text);
}
.header-right {
margin-left: auto;
display: flex;
gap: 0.5rem;
align-items: center;
gap: 1.25rem;
}
header .autostart {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--subtext);
cursor: pointer;
}
input[type="checkbox"] {
padding: 0;
accent-color: var(--mauve);
}
main {
padding: 1.25rem;
padding: 1.5rem;
}
/* ---------- Controls ---------- */
button,
select,
input {
font: inherit;
color: inherit;
background: #2a2a30;
border: 1px solid #444;
border-radius: 6px;
padding: 0.35rem 0.75rem;
color: var(--text);
background: var(--surface0);
border: 1px solid var(--surface1);
border-radius: 7px;
padding: 0.4rem 0.8rem;
}
button {
cursor: pointer;
background: transparent;
color: var(--subtext);
}
button:hover {
background: #35353d;
button:hover:not(:disabled) {
background: var(--surface0);
color: var(--text);
}
nav button.active {
background: #4a4af0;
border-color: #4a4af0;
button:disabled {
opacity: 0.5;
cursor: default;
}
table {
width: 100%;
border-collapse: collapse;
button:focus-visible,
select:focus-visible,
input:focus-visible {
outline: 2px solid var(--lavender);
outline-offset: 1px;
}
button.primary {
background: var(--blue);
border-color: var(--blue);
color: var(--crust);
font-weight: 500;
}
button.primary:hover:not(:disabled) {
background: var(--lavender);
border-color: var(--lavender);
color: var(--crust);
}
button.start {
background: var(--green);
border-color: var(--green);
color: var(--crust);
font-weight: 500;
width: 6.5rem;
}
button.start:hover:not(:disabled) {
background: #b8ecb3;
color: var(--crust);
}
button.stop {
background: var(--red);
border-color: var(--red);
color: var(--crust);
font-weight: 500;
width: 6.5rem;
}
button.stop:hover:not(:disabled) {
background: #f6a3bb;
color: var(--crust);
}
button.danger {
color: var(--red);
border-color: color-mix(in srgb, var(--red) 40%, transparent);
}
button.danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--red) 12%, transparent);
color: var(--red);
}
.row-actions {
display: inline-flex;
align-items: stretch;
gap: 0.4rem;
}
button.gear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.3rem;
padding: 0;
font-size: 1.2rem;
line-height: 1;
}
.toolbar {
display: flex;
gap: 0.6rem;
margin-bottom: 1.5rem;
}
/* ---------- Tabellen ---------- */
table.grid {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
}
col.col-dot {
width: 2.25rem;
}
col.col-pool {
width: 12rem;
}
col.col-actions {
width: 15rem;
}
col.col-actions-wide {
width: 19rem;
}
col.col-type {
width: 7rem;
}
col.col-projects {
width: 10rem;
}
th {
text-align: left;
font-size: 0.7rem;
font-weight: 600;
color: #999;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #333;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--overlay);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--surface0);
}
td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #26262b;
padding: 0.8rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--surface0) 45%, transparent);
vertical-align: middle;
}
td small {
display: block;
color: #888;
tbody tr:hover {
background: color-mix(in srgb, var(--mantle) 60%, transparent);
}
.cell-name strong {
font-family: var(--mono);
font-size: 0.85rem;
font-weight: 500;
}
.cell-name small {
display: block;
font-family: var(--mono);
font-size: 0.7rem;
color: var(--overlay);
margin-top: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-actions {
text-align: right;
white-space: nowrap;
}
td select {
width: 100%;
}
/* Status-Punkt: leuchtet nur, wenn eine Session läuft. */
.dot {
display: inline-block;
width: 10px;
height: 10px;
width: 9px;
height: 9px;
border-radius: 50%;
background: transparent;
}
.dot.on {
background: #3ecf6a;
background: var(--green);
box-shadow: 0 0 8px color-mix(in srgb, var(--green) 70%, transparent);
}
button.start {
background: #2f9e4f;
border-color: #2f9e4f;
/* ---------- Pools ---------- */
.badge {
display: inline-block;
font-family: var(--mono);
font-size: 0.7rem;
padding: 0.15rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--surface1);
color: var(--subtext);
}
button.start:hover {
background: #37b85c;
.badge.oauth {
border-color: color-mix(in srgb, var(--green) 40%, transparent);
color: var(--green);
}
button.stop {
background: #c0392b;
border-color: #c0392b;
.badge.apikey {
border-color: color-mix(in srgb, var(--peach) 40%, transparent);
color: var(--peach);
}
button.stop:hover {
background: #d64434;
/* Zugeordnete Projekte: Anzahl in der Zeile, Liste im Hover. */
.assigned {
position: relative;
color: var(--subtext);
font-size: 0.8rem;
cursor: default;
border-bottom: 1px dotted var(--surface2);
padding-bottom: 1px;
}
.assigned .pop {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 20;
min-width: 13rem;
flex-direction: column;
gap: 0.4rem;
background: var(--crust);
border: 1px solid var(--surface1);
border-radius: 9px;
padding: 0.7rem 0.9rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
}
.assigned:hover .pop {
display: flex;
}
.pop-item {
font-family: var(--mono);
font-size: 0.8rem;
line-height: 1.4;
color: var(--text);
white-space: nowrap;
}
.empty {
color: var(--overlay);
}
.error {
color: #ff6b6b;
color: var(--red);
white-space: pre-wrap;
}
form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
form h2 {
width: 100%;
}
form input {
min-width: 16rem;
}
/* ---------- Dialoge ---------- */
.overlay {
position: fixed;
@@ -152,84 +391,94 @@ form input {
}
.dialog {
background: #222228;
border: 1px solid #3a3a42;
display: flex;
flex-direction: column;
gap: 0.9rem;
background: var(--mantle);
border: 1px solid var(--surface0);
border-radius: 12px;
padding: 1.75rem 2rem;
min-width: 26rem;
max-width: 34rem;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5);
}
.dialog h3 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
margin: 0;
font-size: 1rem;
}
.dialog p {
margin: 0 0 0.75rem;
margin: 0;
line-height: 1.5;
color: #cfcfd6;
color: var(--subtext);
}
.dialog .hint {
color: #9a9aa4;
color: var(--overlay);
font-size: 0.85rem;
}
.hint.busy {
color: var(--blue);
}
.dialog .field {
display: grid;
grid-template-columns: 6rem 1fr;
align-items: center;
gap: 0.75rem;
color: var(--subtext);
font-size: 0.85rem;
}
.icon-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-path {
font-family: var(--mono);
color: var(--overlay);
font-size: 0.75rem;
word-break: break-all;
}
.dialog .pools {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 1rem 0 1.25rem;
margin: 0.25rem 0;
}
.chip {
background: #2a2a30;
border: 1px solid #444;
font-family: var(--mono);
font-size: 0.8rem;
background: var(--surface0);
border: 1px solid var(--surface1);
border-radius: 999px;
padding: 0.2rem 0.7rem;
}
.chip.new {
border-color: #4a4af0;
color: #b9b9ff;
border-color: var(--blue);
color: var(--blue);
}
.arrow {
color: #777;
color: var(--overlay);
}
.dialog .actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.5rem;
margin-top: 0.75rem;
}
button.primary {
background: #4a4af0;
border-color: #4a4af0;
}
/* ---------- Formulare (Pools-Dialoge) ---------- */
button.primary:hover {
background: #5c5cf2;
}
button:disabled {
opacity: 0.55;
cursor: default;
}
header .autostart {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4rem;
color: #999;
cursor: pointer;
}
input[type="checkbox"] {
padding: 0;
accent-color: #4a4af0;
form.dialog input {
width: 100%;
}
+163 -34
View File
@@ -28,33 +28,171 @@ window.addEventListener("unhandledrejection", (e) =>
const project = new URLSearchParams(location.search).get("project")!;
const win = getCurrentWebviewWindow();
document.getElementById("project-name")!.textContent = project;
// Catppuccin Mocha
const theme = {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b7080",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
// Fensterhintergründe je Theme stehen zusätzlich in terminal.rs
// (theme_background) — beide Stellen müssen zusammenpassen.
const THEMES: Record<
string,
{ header: string; border: string; xterm: Record<string, string> }
> = {
mocha: {
header: "#181825",
border: "#313244",
xterm: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#585b7080",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#f5c2e7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
},
},
dracula: {
header: "#21222c",
border: "#44475a",
xterm: {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a80",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
},
},
"solarized-dark": {
header: "#073642",
border: "#586e75",
xterm: {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#07364280",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#586e75",
brightRed: "#cb4b16",
brightGreen: "#859900",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
},
},
gruvbox: {
header: "#1d2021",
border: "#3c3836",
xterm: {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#3c383680",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
},
},
"one-dark": {
header: "#21252b",
border: "#3e4451",
xterm: {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#abb2bf",
cursorAccent: "#282c34",
selectionBackground: "#3e445180",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
},
},
};
interface Project {
name: string;
pool: string | null;
terminal: { theme: string | null; icon: string | null; title: string | null };
}
const projects = await invoke<Project[]>("list_projects");
const cfg = projects.find((p) => p.name === project);
if (cfg?.pool) document.getElementById("pool")!.textContent = cfg.pool;
document.getElementById("project-name")!.textContent =
cfg?.terminal.title ?? project;
const picked = THEMES[cfg?.terminal.theme ?? "mocha"];
const theme = picked.xterm;
// Seitenfarben ans Theme anpassen (Defaults in terminal.html sind Mocha).
document.documentElement.style.background = theme.background;
document.body.style.background = theme.background;
const header = document.querySelector("header") as HTMLElement;
header.style.background = picked.header;
header.style.borderBottomColor = picked.border;
header.style.color = theme.foreground;
const term = new Terminal({
// Ligatur-Addon nutzt die Character-Joiner-API (Proposed API)
allowProposedApi: true,
@@ -106,12 +244,3 @@ window.addEventListener("resize", () => fit.fit());
await invoke("term_start", { project, rows: term.rows, cols: term.cols });
term.focus();
// Pool-Chip im Titelbalken
interface Project {
name: string;
pool: string | null;
}
const projects = await invoke<Project[]>("list_projects");
const pool = projects.find((p) => p.name === project)?.pool;
if (pool) document.getElementById("pool")!.textContent = pool;