Pools: löschbar mit Zuordnungs-Auflösung, Neuanmelden über Keychain, Key-Status

- delete_pool löst Projektzuordnungen (Terminal-Config bleibt), Dialog
  zeigt betroffene Projekte
- oauth_login: nur bei ungenutztem Pool, löscht vorher den suffixierten
  Keychain-Eintrag; Warn-Dialog mit keychain_status; oauth_refresh raus
- PoolInfo.hasCredentials: apikey-Dateicheck; Button Key ändern/eintragen,
  einheitliche Button-Breite
- Starten deaktiviert ohne Pool bzw. ohne Key (Tooltip unterscheidet)
- Icon-Pfad im Terminal-Dialog gekürzt mit Hover-Popover
This commit is contained in:
marcus.hinz
2026-07-04 13:27:54 +02:00
parent fc560d4e5f
commit 9b008cbf51
5 changed files with 258 additions and 70 deletions
+84 -26
View File
@@ -9,6 +9,7 @@ interface Pool {
name: string;
credentialType: string;
projects: string[];
hasCredentials: boolean;
}
const pools = ref<Pool[]>([]);
@@ -79,6 +80,10 @@ async function submit() {
const deleteName = ref<string | null>(null);
const deleteProjects = computed(
() => pools.value.find((p) => p.name === deleteName.value)?.projects ?? [],
);
function askDelete(pool: string) {
error.value = "";
deleteName.value = pool;
@@ -100,26 +105,42 @@ async function confirmDelete() {
}
}
async function relogin(pool: string) {
interface Project {
name: string;
pool: string | null;
running: boolean;
}
const reloginPool = ref<string | null>(null);
const reloginHasEntry = ref(false);
const reloginRunning = ref<string[]>([]);
async function askRelogin(pool: string) {
error.value = "";
try {
reloginHasEntry.value = await invoke<boolean>("keychain_status", { pool });
const projects = await invoke<Project[]>("list_projects");
reloginRunning.value = projects
.filter((p) => p.pool === pool && p.running)
.map((p) => p.name);
reloginPool.value = pool;
} catch (e) {
error.value = String(e);
}
}
async function confirmRelogin() {
const pool = reloginPool.value;
if (!pool) return;
busy.value = true;
error.value = "";
try {
await invoke("oauth_login", { pool });
reloginPool.value = null;
await refresh();
} catch (e) {
error.value = String(e);
} finally {
busy.value = false;
}
}
async function renew(pool: string) {
busy.value = true;
error.value = "";
try {
await invoke("oauth_refresh", { pool });
} catch (e) {
error.value = String(e);
reloginPool.value = null;
} finally {
busy.value = false;
}
@@ -164,7 +185,7 @@ onMounted(refresh);
<span class="badge" :class="p.credentialType">{{ p.credentialType }}</span>
</td>
<td>
<span v-if="p.projects.length" class="assigned">
<span v-if="p.projects.length" class="assigned hover-pop">
{{ $t("pools.assigned", p.projects.length) }}
<span class="pop">
<span v-for="name in p.projects" :key="name" class="pop-item">
@@ -176,24 +197,16 @@ onMounted(refresh);
<td class="cell-actions">
<span class="row-actions">
<template v-if="p.credentialType === 'oauth'">
<button :disabled="busy" @click="relogin(p.name)">
<button class="w-action" :disabled="busy" @click="askRelogin(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 class="w-action" :disabled="busy" @click="openEditKey(p.name)">
{{ p.hasCredentials ? $t("pools.changeKey") : $t("pools.insertKey") }}
</button>
</template>
<button
v-if="!p.projects.length"
class="danger"
:disabled="busy"
@click="askDelete(p.name)"
>
<button class="danger" :disabled="busy" @click="askDelete(p.name)">
{{ $t("pools.delete") }}
</button>
</span>
@@ -241,10 +254,55 @@ onMounted(refresh);
</form>
</div>
<div
v-if="reloginPool"
class="overlay"
@click.self="busy ? undefined : (reloginPool = null)"
>
<div class="dialog">
<h3>{{ $t("pools.reloginTitle", { name: reloginPool }) }}</h3>
<template v-if="reloginRunning.length">
<p class="hint">{{ $t("pools.reloginBlocked") }}</p>
<ul class="affected">
<li v-for="name in reloginRunning" :key="name">{{ name }}</li>
</ul>
<div class="actions">
<button type="button" @click="reloginPool = null">
{{ $t("pools.cancel") }}
</button>
</div>
</template>
<template v-else>
<p class="hint">
{{
reloginHasEntry
? $t("pools.reloginWarning", { name: reloginPool })
: $t("pools.reloginNoEntry")
}}
</p>
<p v-if="busy" class="hint busy">{{ $t("pools.oauthWaiting") }}</p>
<div class="actions">
<button type="button" :disabled="busy" @click="reloginPool = null">
{{ $t("pools.cancel") }}
</button>
<button class="primary" :disabled="busy" @click="confirmRelogin">
{{ $t("pools.startLogin") }}
</button>
</div>
</template>
</div>
</div>
<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>
<template v-if="deleteProjects.length">
<p class="hint">{{ $t("pools.deleteUnassigns") }}</p>
<ul class="affected">
<li v-for="name in deleteProjects" :key="name">{{ name }}</li>
</ul>
</template>
<div class="actions">
<button type="button" :disabled="busy" @click="deleteName = null">
{{ $t("pools.cancel") }}
+23 -4
View File
@@ -13,8 +13,19 @@ interface Project {
interface Pool {
name: string;
credentialFile: string;
credentialType: string;
hasCredentials: boolean;
}
function poolReady(p: Project): boolean {
if (!p.pool) return false;
const pool = pools.value.find((x) => x.name === p.pool);
return pool !== undefined && pool.hasCredentials;
}
function startBlockedReason(p: Project): string | undefined {
if (poolReady(p)) return undefined;
return p.pool ? "projects.needsKey" : "projects.needsPool";
}
const projects = ref<Project[]>([]);
@@ -192,7 +203,13 @@ onUnmounted(() => window.clearInterval(timer));
<button v-if="p.running" class="stop" @click="stop(p)">
{{ $t("projects.stop") }}
</button>
<button v-else class="start" @click="openTerminal(p)">
<button
v-else
class="start"
:disabled="!poolReady(p)"
:title="startBlockedReason(p) && $t(startBlockedReason(p)!)"
@click="openTerminal(p)"
>
{{ $t("projects.start") }}
</button>
</span>
@@ -223,9 +240,11 @@ onUnmounted(() => window.clearInterval(timer));
<button v-if="settings.icon" @click="settings.icon = null">
{{ $t("projects.remove") }}
</button>
<span class="icon-path">
{{ settings.icon ?? $t("projects.defaultIcon") }}
<span v-if="settings.icon" class="icon-path hover-pop">
{{ settings.icon.split("/").pop() }}
<span class="pop pop-path">{{ settings.icon }}</span>
</span>
<span v-else class="icon-path">{{ $t("projects.defaultIcon") }}</span>
</span>
</label>
<p class="hint">{{ $t("projects.appliesNextStart") }}</p>
+22 -2
View File
@@ -32,6 +32,8 @@ const de = {
cancel: "Abbrechen",
save: "Speichern",
noPoolAssigned: "kein Pool",
needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key",
},
pools: {
pool: "Pool",
@@ -40,8 +42,8 @@ const de = {
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Neu anmelden",
renewToken: "Token erneuern",
changeKey: "Key ändern",
insertKey: "Key eintragen",
delete: "Löschen",
assigned: "kein Projekt | 1 Projekt | {count} Projekte",
empty: "Noch keine Pools angelegt.",
@@ -59,6 +61,14 @@ const de = {
deletePool: "Pool löschen",
deleteWarning:
"Pool {name} und seine Credential-Datei werden gelöscht. Das lässt sich nicht rückgängig machen.",
deleteUnassigns: "Diese Projekte verlieren ihre Pool-Zuordnung:",
reloginTitle: "Neu anmelden {name}",
reloginWarning:
"Der im Schlüsselbund gespeicherte Zugriffstoken von {name} wird gelöscht. Danach öffnet sich der Browser zur Neuanmeldung.",
reloginNoEntry:
"Kein Schlüsselbund-Eintrag vorhanden — es erfolgt nur die Neuanmeldung im Browser.",
reloginBlocked:
"Neuanmeldung ist nur bei ungenutztem Pool möglich. Diese Projekte laufen noch:",
},
};
@@ -94,6 +104,8 @@ const en: typeof de = {
cancel: "Cancel",
save: "Save",
noPoolAssigned: "no pool",
needsPool: "No pool assigned",
needsKey: "Pool has no API key",
},
pools: {
pool: "Pool",
@@ -102,8 +114,8 @@ const en: typeof de = {
newOauth: "+ oAuth",
newApikey: "+ apiKey",
relogin: "Sign in again",
renewToken: "Renew token",
changeKey: "Change key",
insertKey: "Insert key",
delete: "Delete",
assigned: "no projects | 1 project | {count} projects",
empty: "No pools yet.",
@@ -121,6 +133,14 @@ const en: typeof de = {
deletePool: "Delete pool",
deleteWarning:
"Pool {name} and its credential file will be deleted. This cannot be undone.",
deleteUnassigns: "These projects lose their pool assignment:",
reloginTitle: "Sign in again {name}",
reloginWarning:
"The access token for {name} stored in the keychain will be deleted. The browser then opens for a new sign-in.",
reloginNoEntry:
"No keychain entry present — only the new sign-in happens.",
reloginBlocked:
"Signing in again requires an unused pool. These projects are still running:",
},
};
+41 -10
View File
@@ -204,6 +204,10 @@ button.danger:hover:not(:disabled) {
gap: 0.4rem;
}
.row-actions .w-action {
width: 9.5rem;
}
button.gear {
display: inline-flex;
align-items: center;
@@ -238,7 +242,7 @@ col.col-actions {
width: 15rem;
}
col.col-actions-wide {
width: 19rem;
width: 16rem;
}
col.col-type {
width: 7rem;
@@ -330,23 +334,20 @@ td select {
color: var(--peach);
}
/* Zugeordnete Projekte: Anzahl in der Zeile, Liste im Hover. */
.assigned {
/* Hover-Popover: kompakter Text in der Zeile, Details im Hover. */
.hover-pop {
position: relative;
color: var(--subtext);
font-size: 0.8rem;
cursor: default;
border-bottom: 1px dotted var(--surface2);
padding-bottom: 1px;
}
.assigned .pop {
.hover-pop .pop {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 20;
min-width: 13rem;
max-width: 26rem;
flex-direction: column;
gap: 0.4rem;
background: var(--crust);
@@ -356,10 +357,26 @@ td select {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
}
.assigned:hover .pop {
.hover-pop:hover .pop {
display: flex;
}
.pop-path {
font-family: var(--mono);
font-size: 0.75rem;
line-height: 1.5;
color: var(--text);
word-break: break-all;
}
/* Zugeordnete Projekte: Anzahl in der Zeile, Liste im Hover. */
.assigned {
color: var(--subtext);
font-size: 0.8rem;
border-bottom: 1px dotted var(--surface2);
padding-bottom: 1px;
}
.pop-item {
font-family: var(--mono);
font-size: 0.8rem;
@@ -442,7 +459,10 @@ td select {
font-family: var(--mono);
color: var(--overlay);
font-size: 0.75rem;
word-break: break-all;
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog .pools {
@@ -470,6 +490,17 @@ td select {
color: var(--overlay);
}
.dialog .affected {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
font-family: var(--mono);
font-size: 0.8rem;
color: var(--text);
}
.dialog .actions {
display: flex;
justify-content: flex-end;