Pools: Anzeigename von Pool-ID (UUID-Ordner) getrennt + Umbenennen; API-Keys in Keychain/Keyring, Datei-Fallback nur nach Rückfrage
This commit is contained in:
+110
-35
@@ -6,6 +6,7 @@ import { useI18n } from "vue-i18n";
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Pool {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialType: string;
|
||||
projects: string[];
|
||||
@@ -21,6 +22,8 @@ type Mode = "oauth" | "apikey";
|
||||
const dialog = ref<{ mode: Mode; editing: boolean } | null>(null);
|
||||
const dName = ref("");
|
||||
const dKey = ref("");
|
||||
// Ziel-Pool (ID) beim Key-Ändern; bei Neuanlage leer.
|
||||
const dPool = ref("");
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (!dialog.value) return "";
|
||||
@@ -32,13 +35,15 @@ const dialogTitle = computed(() => {
|
||||
function openNew(mode: Mode) {
|
||||
dName.value = "";
|
||||
dKey.value = "";
|
||||
dPool.value = "";
|
||||
error.value = "";
|
||||
dialog.value = { mode, editing: false };
|
||||
}
|
||||
|
||||
function openEditKey(pool: string) {
|
||||
dName.value = pool;
|
||||
function openEditKey(pool: Pool) {
|
||||
dName.value = pool.name;
|
||||
dKey.value = "";
|
||||
dPool.value = pool.id;
|
||||
error.value = "";
|
||||
dialog.value = { mode: "apikey", editing: true };
|
||||
}
|
||||
@@ -57,7 +62,11 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
// Zweistufig: erster Versuch ohne Datei-Erlaubnis; meldet das Backend
|
||||
// keychain-unavailable, fragt ein Dialog nach und wiederholt mit allowFile.
|
||||
const fileConfirm = ref(false);
|
||||
|
||||
async function submit(allowFile = false) {
|
||||
const d = dialog.value;
|
||||
if (!d) return;
|
||||
busy.value = true;
|
||||
@@ -66,12 +75,44 @@ async function submit() {
|
||||
if (d.mode === "oauth") {
|
||||
await invoke("create_oauth_pool", { name: dName.value });
|
||||
} else if (d.editing) {
|
||||
await invoke("set_apikey", { pool: dName.value, key: dKey.value });
|
||||
await invoke("set_apikey", { pool: dPool.value, key: dKey.value, allowFile });
|
||||
} else {
|
||||
await invoke("create_apikey_pool", { name: dName.value, key: dKey.value });
|
||||
await invoke("create_apikey_pool", { name: dName.value, key: dKey.value, allowFile });
|
||||
}
|
||||
dialog.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (String(e) === "keychain-unavailable") {
|
||||
fileConfirm.value = true;
|
||||
} else {
|
||||
error.value = String(e);
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmFileFallback() {
|
||||
fileConfirm.value = false;
|
||||
await submit(true);
|
||||
}
|
||||
|
||||
const deletePool = ref<Pool | null>(null);
|
||||
|
||||
function askDelete(pool: Pool) {
|
||||
error.value = "";
|
||||
deletePool.value = pool;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const pool = deletePool.value;
|
||||
if (!pool) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await invoke("delete_pool", { pool: pool.id });
|
||||
deletePool.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
@@ -79,25 +120,23 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteName = ref<string | null>(null);
|
||||
const renamePool = ref<Pool | null>(null);
|
||||
const rName = ref("");
|
||||
|
||||
const deleteProjects = computed(
|
||||
() => pools.value.find((p) => p.name === deleteName.value)?.projects ?? [],
|
||||
);
|
||||
|
||||
function askDelete(pool: string) {
|
||||
function openRename(pool: Pool) {
|
||||
error.value = "";
|
||||
deleteName.value = pool;
|
||||
rName.value = pool.name;
|
||||
renamePool.value = pool;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const name = deleteName.value;
|
||||
if (!name) return;
|
||||
async function confirmRename() {
|
||||
const pool = renamePool.value;
|
||||
if (!pool) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await invoke("delete_pool", { name });
|
||||
deleteName.value = null;
|
||||
await invoke("rename_pool", { pool: pool.id, name: rName.value });
|
||||
renamePool.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
@@ -112,17 +151,17 @@ interface Project {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const reloginPool = ref<string | null>(null);
|
||||
const reloginPool = ref<Pool | null>(null);
|
||||
const reloginHasEntry = ref(false);
|
||||
const reloginRunning = ref<string[]>([]);
|
||||
|
||||
async function askRelogin(pool: string) {
|
||||
async function askRelogin(pool: Pool) {
|
||||
error.value = "";
|
||||
try {
|
||||
reloginHasEntry.value = await invoke<boolean>("keychain_status", { pool });
|
||||
reloginHasEntry.value = await invoke<boolean>("keychain_status", { pool: pool.id });
|
||||
const projects = await invoke<Project[]>("list_projects");
|
||||
reloginRunning.value = projects
|
||||
.filter((p) => p.pool === pool && p.running)
|
||||
.filter((p) => p.pool === pool.id && p.running)
|
||||
.map((p) => p.name);
|
||||
reloginPool.value = pool;
|
||||
} catch (e) {
|
||||
@@ -136,7 +175,7 @@ async function confirmRelogin() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await invoke("oauth_login", { pool });
|
||||
await invoke("oauth_login", { pool: pool.id });
|
||||
reloginPool.value = null;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
@@ -178,7 +217,7 @@ onMounted(refresh);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in pools" :key="p.name">
|
||||
<tr v-for="p in pools" :key="p.id">
|
||||
<td class="cell-name">
|
||||
<strong>{{ p.name }}</strong>
|
||||
</td>
|
||||
@@ -197,13 +236,16 @@ onMounted(refresh);
|
||||
</td>
|
||||
<td class="cell-actions">
|
||||
<span class="row-actions">
|
||||
<button :disabled="busy" @click="openRename(p)">
|
||||
{{ $t("pools.rename") }}
|
||||
</button>
|
||||
<template v-if="p.credentialType === 'oauth'">
|
||||
<button class="w-action" :disabled="busy" @click="askRelogin(p.name)">
|
||||
<button class="w-action" :disabled="busy" @click="askRelogin(p)">
|
||||
{{ $t("pools.relogin") }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="w-action" :disabled="busy" @click="openEditKey(p.name)">
|
||||
<button class="w-action" :disabled="busy" @click="openEditKey(p)">
|
||||
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -211,7 +253,7 @@ onMounted(refresh);
|
||||
<button
|
||||
class="danger"
|
||||
:disabled="busy || p.running.length > 0"
|
||||
@click="askDelete(p.name)"
|
||||
@click="askDelete(p)"
|
||||
>
|
||||
{{ $t("pools.delete") }}
|
||||
</button>
|
||||
@@ -229,12 +271,12 @@ onMounted(refresh);
|
||||
<p v-else class="empty">{{ $t("pools.empty") }}</p>
|
||||
|
||||
<div v-if="dialog" class="overlay" @click.self="closeDialog">
|
||||
<form class="dialog" @submit.prevent="submit">
|
||||
<form class="dialog" @submit.prevent="submit()">
|
||||
<h3>{{ dialogTitle }}</h3>
|
||||
|
||||
<label v-if="!dialog.editing" class="field">
|
||||
{{ $t("pools.name") }}
|
||||
<input v-model="dName" placeholder="z. B. privateDefault" autofocus required />
|
||||
<input v-model="dName" placeholder="z. B. private" autofocus required />
|
||||
</label>
|
||||
|
||||
<label v-if="dialog.mode === 'apikey'" class="field">
|
||||
@@ -269,7 +311,7 @@ onMounted(refresh);
|
||||
@click.self="busy ? undefined : (reloginPool = null)"
|
||||
>
|
||||
<div class="dialog">
|
||||
<h3>{{ $t("pools.reloginTitle", { name: reloginPool }) }}</h3>
|
||||
<h3>{{ $t("pools.reloginTitle", { name: reloginPool.name }) }}</h3>
|
||||
<template v-if="reloginRunning.length">
|
||||
<p class="hint">{{ $t("pools.reloginBlocked") }}</p>
|
||||
<ul class="affected">
|
||||
@@ -285,7 +327,7 @@ onMounted(refresh);
|
||||
<p class="hint">
|
||||
{{
|
||||
reloginHasEntry
|
||||
? $t("pools.reloginWarning", { name: reloginPool })
|
||||
? $t("pools.reloginWarning", { name: reloginPool.name })
|
||||
: $t("pools.reloginNoEntry")
|
||||
}}
|
||||
</p>
|
||||
@@ -301,18 +343,18 @@ onMounted(refresh);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteName" class="overlay" @click.self="deleteName = null">
|
||||
<div v-if="deletePool" class="overlay" @click.self="deletePool = null">
|
||||
<div class="dialog">
|
||||
<h3>{{ $t("pools.deletePool") }}</h3>
|
||||
<p class="hint">{{ $t("pools.deleteWarning", { name: deleteName }) }}</p>
|
||||
<template v-if="deleteProjects.length">
|
||||
<p class="hint">{{ $t("pools.deleteWarning", { name: deletePool.name }) }}</p>
|
||||
<template v-if="deletePool.projects.length">
|
||||
<p class="hint">{{ $t("pools.deleteUnassigns") }}</p>
|
||||
<ul class="affected">
|
||||
<li v-for="name in deleteProjects" :key="name">{{ name }}</li>
|
||||
<li v-for="name in deletePool.projects" :key="name">{{ name }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="busy" @click="deleteName = null">
|
||||
<button type="button" :disabled="busy" @click="deletePool = null">
|
||||
{{ $t("pools.cancel") }}
|
||||
</button>
|
||||
<button class="danger" :disabled="busy" @click="confirmDelete">
|
||||
@@ -321,4 +363,37 @@ onMounted(refresh);
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fileConfirm" class="overlay">
|
||||
<div class="dialog">
|
||||
<h3>{{ $t("pools.keychainUnavailableTitle") }}</h3>
|
||||
<p class="hint">{{ $t("pools.keychainUnavailable") }}</p>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="busy" @click="fileConfirm = false">
|
||||
{{ $t("pools.cancel") }}
|
||||
</button>
|
||||
<button class="danger" :disabled="busy" @click="confirmFileFallback">
|
||||
{{ $t("pools.storeAsFile") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="renamePool" class="overlay" @click.self="busy ? undefined : (renamePool = null)">
|
||||
<form class="dialog" @submit.prevent="confirmRename">
|
||||
<h3>{{ $t("pools.renameTitle", { name: renamePool.name }) }}</h3>
|
||||
<label class="field">
|
||||
{{ $t("pools.name") }}
|
||||
<input v-model="rName" autofocus required />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" :disabled="busy" @click="renamePool = null">
|
||||
{{ $t("pools.cancel") }}
|
||||
</button>
|
||||
<button type="submit" class="primary" :disabled="busy">
|
||||
{{ $t("pools.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ interface Project {
|
||||
}
|
||||
|
||||
interface Pool {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialType: string;
|
||||
hasCredentials: boolean;
|
||||
@@ -19,10 +20,16 @@ interface Pool {
|
||||
|
||||
function poolReady(p: Project): boolean {
|
||||
if (!p.pool) return false;
|
||||
const pool = pools.value.find((x) => x.name === p.pool);
|
||||
const pool = pools.value.find((x) => x.id === p.pool);
|
||||
return pool !== undefined && pool.hasCredentials;
|
||||
}
|
||||
|
||||
// Projekt-Configs tragen die Pool-ID; angezeigt wird der Name aus pool.json.
|
||||
function poolName(id: string | null): string | null {
|
||||
if (!id) return null;
|
||||
return pools.value.find((x) => x.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function startBlockedReason(p: Project): string | undefined {
|
||||
if (poolReady(p)) return undefined;
|
||||
return p.pool ? "projects.needsKey" : "projects.needsPool";
|
||||
@@ -32,6 +39,35 @@ const projects = ref<Project[]>([]);
|
||||
const pools = ref<Pool[]>([]);
|
||||
const error = ref("");
|
||||
|
||||
// data-URLs pro (Projekt, Icon-Pfad) — Fehlversuche werden als "" gemerkt,
|
||||
// damit der 3s-Poll nicht dieselbe Fehlermeldung wiederholt.
|
||||
const icons = ref<Record<string, string>>({});
|
||||
|
||||
function iconKey(p: Project): string | null {
|
||||
return p.terminal.icon ? `${p.name}:${p.terminal.icon}` : null;
|
||||
}
|
||||
|
||||
function projIcon(p: Project): string | undefined {
|
||||
const key = iconKey(p);
|
||||
return key ? icons.value[key] || undefined : undefined;
|
||||
}
|
||||
|
||||
async function loadIcons() {
|
||||
for (const p of projects.value) {
|
||||
const key = iconKey(p);
|
||||
if (!key || key in icons.value) continue;
|
||||
try {
|
||||
const data = await invoke<string | null>("project_icon", {
|
||||
project: p.name,
|
||||
});
|
||||
icons.value[key] = data ?? "";
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
icons.value[key] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
projects.value = await invoke<Project[]>("list_projects");
|
||||
@@ -40,6 +76,7 @@ async function refresh() {
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
await loadIcons();
|
||||
}
|
||||
|
||||
async function stop(project: Project) {
|
||||
@@ -103,6 +140,14 @@ const THEME_NAMES: [string, string][] = [
|
||||
["solarized-dark", "Solarized Dark"],
|
||||
["gruvbox", "Gruvbox"],
|
||||
["one-dark", "One Dark"],
|
||||
["nord", "Nord"],
|
||||
["tokyo-night", "Tokyo Night"],
|
||||
["monokai", "Monokai"],
|
||||
["rose-pine", "Rosé Pine"],
|
||||
["everforest", "Everforest"],
|
||||
["solarized-light", "Solarized Light"],
|
||||
["catppuccin-latte", "Catppuccin Latte"],
|
||||
["one-light", "One Light"],
|
||||
];
|
||||
|
||||
interface TerminalSettings {
|
||||
@@ -292,13 +337,16 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<span class="dot" :class="{ on: p.running }"></span>
|
||||
</td>
|
||||
<td class="cell-name">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="name-row">
|
||||
<img v-if="projIcon(p)" class="proj-icon" :src="projIcon(p)" />
|
||||
<strong>{{ p.name }}</strong>
|
||||
</span>
|
||||
<small>{{ p.path }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<select :value="p.pool ?? ''" @change="assign(p, $event)">
|
||||
<option value="" disabled>{{ $t("projects.noPool") }}</option>
|
||||
<option v-for="pool in pools" :key="pool.name" :value="pool.name">
|
||||
<option v-for="pool in pools" :key="pool.id" :value="pool.id">
|
||||
{{ pool.name }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -349,7 +397,7 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
{{ $t("projects.pool") }}
|
||||
<select v-model="wizard.pool">
|
||||
<option value="">{{ $t("projects.noPool") }}</option>
|
||||
<option v-for="pool in pools" :key="pool.name" :value="pool.name">
|
||||
<option v-for="pool in pools" :key="pool.id" :value="pool.id">
|
||||
{{ pool.name }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -493,9 +541,9 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<h3>{{ $t("projects.stillRunning", { name: pending.name }) }}</h3>
|
||||
<p>{{ $t("projects.poolChangeSaved") }}</p>
|
||||
<div class="pools">
|
||||
<span class="chip">{{ pending.from ?? $t("projects.noPoolAssigned") }}</span>
|
||||
<span class="chip">{{ poolName(pending.from) ?? $t("projects.noPoolAssigned") }}</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="chip new">{{ pending.to }}</span>
|
||||
<span class="chip new">{{ poolName(pending.to) }}</span>
|
||||
</div>
|
||||
<p class="hint">{{ $t("projects.restartHint") }}</p>
|
||||
<div class="actions">
|
||||
|
||||
+14
-2
@@ -33,7 +33,7 @@ const de = {
|
||||
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.",
|
||||
"Der Neustart beendet die laufende Session. Der automatische commit+push von claude-sync nach Sessionende entfällt dabei.",
|
||||
keepRunning: "Weiterlaufen lassen",
|
||||
restartNow: "Jetzt neu starten",
|
||||
restarting: "Starte neu …",
|
||||
@@ -77,6 +77,8 @@ const de = {
|
||||
newOauth: "+ oAuth",
|
||||
newApikey: "+ apiKey",
|
||||
relogin: "Zurücksetzen",
|
||||
rename: "Umbenennen",
|
||||
renameTitle: "{name} umbenennen",
|
||||
changeKey: "Key ändern",
|
||||
insertKey: "Key eintragen",
|
||||
delete: "Löschen",
|
||||
@@ -106,6 +108,10 @@ const de = {
|
||||
"Kein Schlüsselbund-Eintrag vorhanden — beim nächsten Start meldet sich claude ohnehin per /login an.",
|
||||
reloginBlocked:
|
||||
"Zurücksetzen ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
|
||||
keychainUnavailableTitle: "Keine sichere Ablage",
|
||||
keychainUnavailable:
|
||||
"Keychain/Keyring ist nicht verfügbar. Der Key kann stattdessen ungesichert als Datei im Pool-Ordner liegen (0600).",
|
||||
storeAsFile: "Als Datei ablegen",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -142,7 +148,7 @@ const en: typeof de = {
|
||||
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.",
|
||||
"Restarting ends the running session. The automatic commit+push by claude-sync after the session is skipped.",
|
||||
keepRunning: "Keep running",
|
||||
restartNow: "Restart now",
|
||||
restarting: "Restarting …",
|
||||
@@ -186,6 +192,8 @@ const en: typeof de = {
|
||||
newOauth: "+ oAuth",
|
||||
newApikey: "+ apiKey",
|
||||
relogin: "Reset",
|
||||
rename: "Rename",
|
||||
renameTitle: "Rename {name}",
|
||||
changeKey: "Change key",
|
||||
insertKey: "Insert key",
|
||||
delete: "Delete",
|
||||
@@ -215,6 +223,10 @@ const en: typeof de = {
|
||||
"No keychain entry present — claude signs in via /login on the next start anyway.",
|
||||
reloginBlocked:
|
||||
"Resetting requires an unused pool. These projects are still running:",
|
||||
keychainUnavailableTitle: "No secure storage",
|
||||
keychainUnavailable:
|
||||
"Keychain/keyring is unavailable. The key can instead be stored unprotected as a file in the pool folder (0600).",
|
||||
storeAsFile: "Store as file",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user