Tray-Start ohne Pool oeffnet die Pool-Auswahl

Ein Projekt ohne zugewiesenen Pool liefert jetzt den Fehler NO_POOL,
statt still claudes Default-CLAUDE_CONFIG_DIR zu uebernehmen. Der
Tray-/Popup-Start faengt ihn ab, holt das Hauptfenster nach vorn und
oeffnet die Pool-Auswahl (pool-required-Event, ProjectList), mit Tests.
This commit is contained in:
marcus.hinz
2026-07-26 21:01:41 +02:00
parent b0a9e9a783
commit 082422c32a
5 changed files with 145 additions and 4 deletions
+16
View File
@@ -291,6 +291,18 @@ pub(crate) fn restart_project(app: tauri::AppHandle, project: String) -> Result<
terminal::open_terminal(app, project) terminal::open_terminal(app, project)
} }
/// Projekt ohne Pool: Hauptfenster nach vorn holen und dort die Pool-Auswahl
/// öffnen. Aus dem Tray heraus gibt es sonst keinen Ort für die Wahl — und
/// still den Default zu nehmen ist genau das, was die Pool-Pflicht verhindert.
fn request_pool_choice(app: &tauri::AppHandle, project: &str) {
use tauri::{Emitter, Manager};
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
let _ = app.emit("pool-required", project);
}
/// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn, /// Tray-Klick auf ein Projekt: läuft es, kommt das Terminal-Fenster nach vorn,
/// sonst startet es. /// sonst startet es.
fn start_or_focus(app: &tauri::AppHandle, project: &str) { fn start_or_focus(app: &tauri::AppHandle, project: &str) {
@@ -298,11 +310,15 @@ fn start_or_focus(app: &tauri::AppHandle, project: &str) {
Some(pid) => crate::platform::focus_terminal(*pid), Some(pid) => crate::platform::focus_terminal(*pid),
None => { None => {
if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) { if let Err(e) = terminal::open_terminal(app.clone(), project.to_string()) {
if e == crate::domain::project::NO_POOL {
request_pool_choice(app, project);
} else {
eprintln!("{project} starten: {e}"); eprintln!("{project} starten: {e}");
} }
} }
} }
} }
}
/// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren. /// Tray-/Popup-Klick: Projekt starten oder das laufende Terminal fokussieren.
#[tauri::command] #[tauri::command]
+24 -1
View File
@@ -205,9 +205,20 @@ pub(crate) fn verify_project_dir_in(paths: &Paths, project: &str) -> Result<Path
Ok(dir) Ok(dir)
} }
/// Fehlerkennung „Projekt hat keinen Pool". Der Tray-/Popup-Start fängt genau
/// diese ab und öffnet die Pool-Auswahl. Eine Kennung statt Klartext, damit die
/// Unterscheidung nicht an einer übersetzbaren Meldung hängt.
pub(crate) const NO_POOL: &str = "no-pool";
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als /// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben. /// CLAUDE_CONFIG_DIR mitgegeben.
/// ///
/// Ohne zugewiesenen Pool ist das ein Fehler, kein `Ok(None)`: Sonst startete
/// die Session ohne CLAUDE_CONFIG_DIR und übernähme damit stillschweigend
/// claudes Default-Verzeichnis samt dessen Login — ein Verzeichnis, das der
/// Nutzer nie gewählt hat. `Ok(None)` bleibt allein dem Referenzpool auf
/// `~/.claude` vorbehalten: dort ist der Default die ausdrückliche Wahl.
///
/// Der Pool-Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon /// Der Pool-Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon
/// tut: Die Migration übernimmt Pool-Einträge aus der alten, versionierten /// tut: Die Migration übernimmt Pool-Einträge aus der alten, versionierten
/// ai-control.json eines geklonten Repos. Ungeprüft bestimmte ein `../…` /// ai-control.json eines geklonten Repos. Ungeprüft bestimmte ein `../…`
@@ -223,7 +234,7 @@ pub(crate) fn project_pool_dir_in(
project: &str, project: &str,
) -> Result<Option<PathBuf>, String> { ) -> Result<Option<PathBuf>, String> {
let Some(pool) = project_pool(paths, project)? else { let Some(pool) = project_pool(paths, project)? else {
return Ok(None); return Err(NO_POOL.to_string());
}; };
check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?; check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?;
let dir = crate::domain::pool::pool_config_dir(paths, &pool)?; let dir = crate::domain::pool::pool_config_dir(paths, &pool)?;
@@ -798,6 +809,18 @@ mod tests {
assert!(err.contains("ungültiger Pool")); assert!(err.contains("ungültiger Pool"));
} }
/// Ohne zugewiesenen Pool gibt es kein CLAUDE_CONFIG_DIR und damit auch
/// keine Session: Der Fehler trägt die Kennung, an der der Tray-Start die
/// Pool-Auswahl öffnet. Ein stilles `Ok(None)` würde claudes
/// Default-Verzeichnis ungefragt übernehmen.
#[test]
fn projekt_ohne_pool_liefert_fehler() {
let p = tmp_paths();
create_project(&p, "ohne").unwrap();
assert_eq!(project_pool_dir_in(&p, "ohne").unwrap_err(), NO_POOL);
}
/// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur /// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur
/// theme/icon/title — unbekannte Keys müssen trotzdem stehen bleiben. /// theme/icon/title — unbekannte Keys müssen trotzdem stehen bleiben.
#[test] #[test]
+4
View File
@@ -38,6 +38,10 @@ pub fn open_terminal(app: AppHandle, project: String) -> Result<(), String> {
if !dir.is_dir() { if !dir.is_dir() {
return Err(format!("Projektordner fehlt: {}", dir.display())); return Err(format!("Projektordner fehlt: {}", dir.display()));
} }
// Pool-Pflicht vor dem Fenster: Ohne zugewiesenen Pool gibt es kein
// Terminal. Die Prüfung gehört hierher und nicht erst in `term_start` —
// dort stünde bereits ein Fenster, das nur noch scheitern könnte.
project_pool_dir(&project)?;
let bundle_id = app.config().identifier.clone(); let bundle_id = app.config().identifier.clone();
app app
.run_on_main_thread(move || crate::platform::yield_activation(&bundle_id)) .run_on_main_thread(move || crate::platform::yield_activation(&bundle_id))
+88 -2
View File
@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface Project { interface Project {
id: string; id: string;
@@ -470,12 +474,69 @@ async function confirmDelete() {
} }
} }
// ---------- Pool-Pflicht ----------
// Start ohne zugewiesenen Pool: Der Kern verweigert die Session, die App holt
// die Wahl hier nach. Kein Pool bedeutet keinen Start — auch nicht still im
// Default-Verzeichnis von claude.
const poolRequired = ref<{ id: string; name: string } | null>(null);
const poolPick = ref("");
// Claudes Default-Verzeichnis, solange kein Pool darauf zeigt: als
// ausdrücklich wählbarer Eintrag, nicht als stille Übernahme.
const defaultDir = ref<string | null>(null);
const DEFAULT_PICK = "__default__";
async function askForPool(id: string) {
await refresh();
const p = projects.value.find((x) => x.id === id);
if (!p || p.pool) return;
try {
defaultDir.value = await invoke<string | null>("default_config_dir");
} catch {
defaultDir.value = null;
}
poolPick.value = "";
poolRequired.value = { id, name: p.name };
}
async function confirmPool() {
const req = poolRequired.value!;
try {
let pool = poolPick.value;
if (pool === DEFAULT_PICK) {
pool = await invoke<string>("create_reference_pool", {
name: t("pools.referenceDefaultName"),
dir: defaultDir.value,
});
}
await invoke("assign_pool", { project: req.id, pool });
poolRequired.value = null;
await invoke("open_terminal", { project: req.id });
await refresh();
} catch (e) {
error.value = String(e);
poolRequired.value = null;
}
}
// Abbruch ist eine Entscheidung, kein Nichts: kein Terminal, und die Meldung
// sagt, warum.
function cancelPool() {
error.value = t("projects.poolRequiredCancelled", { name: poolRequired.value!.name });
poolRequired.value = null;
}
let timer: number; let timer: number;
onMounted(() => { let unlistenPool: UnlistenFn | undefined;
onMounted(async () => {
refresh(); refresh();
timer = window.setInterval(refresh, 3000); timer = window.setInterval(refresh, 3000);
unlistenPool = await listen<string>("pool-required", (e) => askForPool(e.payload));
});
onUnmounted(() => {
window.clearInterval(timer);
unlistenPool?.();
}); });
onUnmounted(() => window.clearInterval(timer));
</script> </script>
<template> <template>
@@ -892,6 +953,31 @@ onUnmounted(() => window.clearInterval(timer));
</div> </div>
</div> </div>
</div> </div>
<div v-if="poolRequired" class="overlay">
<div class="dialog">
<h3>{{ $t("projects.poolRequiredTitle", { name: poolRequired.name }) }}</h3>
<p>{{ $t("projects.poolRequiredHint") }}</p>
<label class="field">
{{ $t("projects.pool") }}
<select v-model="poolPick">
<option value="" disabled>{{ $t("projects.poolRequiredChoose") }}</option>
<option v-for="pool in pools" :key="pool.id" :value="pool.id">
{{ pool.name }}
</option>
<option v-if="defaultDir" :value="DEFAULT_PICK">
{{ $t("projects.poolRequiredDefault", { dir: contractHome(defaultDir) }) }}
</option>
</select>
</label>
<div class="actions">
<button type="button" @click="cancelPool">{{ $t("projects.cancel") }}</button>
<button class="primary" :disabled="!poolPick" @click="confirmPool">
{{ $t("projects.start") }}
</button>
</div>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
+12
View File
@@ -55,6 +55,12 @@ const de = {
noPoolAssigned: "kein Pool", noPoolAssigned: "kein Pool",
needsPool: "Kein Pool zugeordnet", needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key", needsKey: "Pool hat keinen API-Key",
poolRequiredTitle: "{name} hat keinen Pool",
poolRequiredHint:
"Jede Session läuft in einem Pool — welcher es sein soll, entscheidest du. Ohne Wahl startet das Projekt nicht.",
poolRequiredChoose: " bitte wählen ",
poolRequiredDefault: "Standard-Verzeichnis ({dir})",
poolRequiredCancelled: "{name} wurde nicht gestartet: kein Pool gewählt.",
newProject: "+ Projekt", newProject: "+ Projekt",
addFolder: "Projekt importieren …", addFolder: "Projekt importieren …",
addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen", addFolderTitle: "Bestehenden Ordner als Projekt aufnehmen",
@@ -335,6 +341,12 @@ const en: typeof de = {
noPoolAssigned: "no pool", noPoolAssigned: "no pool",
needsPool: "No pool assigned", needsPool: "No pool assigned",
needsKey: "Pool has no API key", needsKey: "Pool has no API key",
poolRequiredTitle: "{name} has no pool",
poolRequiredHint:
"Every session runs in a pool — which one is your call. Without a choice the project does not start.",
poolRequiredChoose: " please choose ",
poolRequiredDefault: "Default directory ({dir})",
poolRequiredCancelled: "{name} was not started: no pool chosen.",
newProject: "+ Project", newProject: "+ Project",
addFolder: "Import project …", addFolder: "Import project …",
addFolderTitle: "Add an existing folder as a project", addFolderTitle: "Add an existing folder as a project",