Projekt-Wizard und Projekt löschen

- create_project_full: Scaffold nach 08-07-Muster (memory/, Sentinel-
  .gitignore, settings.json mit autoMemoryDirectory/Permissions/pool-guard-
  Hook, ai-control.json mit Pool/Terminal); Arbeitsordner anlegen oder
  vorhandenen verknuepfen, Pruefung vor dem Anlegen; ersetzt create_project
- Wizard-Dialog: Name, Pool, Arbeitsordner (keiner/anlegen/Picker),
  Terminal-Titel + Theme
- delete_project: Guard bei laufender Session, optional Arbeitsordner
  mitloeschen (additionalDirectories, via project_work_dirs); Papierkorb-
  Button mit Bestaetigungs-Dialog inkl. Checkbox und Pfadliste
- 8 neue Tests (36 gesamt), i18n de/en
This commit is contained in:
marcus.hinz
2026-07-04 14:26:10 +02:00
parent f8fcd8d033
commit 42d975874e
4 changed files with 527 additions and 8 deletions
+206
View File
@@ -149,6 +149,101 @@ async function saveSettings() {
}
}
interface Wizard {
name: string;
pool: string;
workDirMode: "none" | "default" | "custom";
workDir: string | null;
title: string;
theme: string;
}
const wizard = ref<Wizard | null>(null);
function openWizard() {
error.value = "";
wizard.value = {
name: "",
pool: "",
workDirMode: "none",
workDir: null,
title: "",
theme: "mocha",
};
}
async function pickWorkDir() {
const dir = await open({ directory: true, multiple: false });
if (typeof dir === "string") {
wizard.value!.workDir = dir;
wizard.value!.workDirMode = "custom";
}
}
async function createProject() {
const w = wizard.value!;
const name = w.name.trim();
const workDir =
w.workDirMode === "default"
? `~/projects/${name}`
: w.workDirMode === "custom"
? w.workDir
: null;
const title = w.title.trim();
try {
await invoke("create_project_full", {
name,
pool: w.pool === "" ? null : w.pool,
workDir,
createWorkDir: w.workDirMode === "default",
terminal: {
theme: w.theme === "mocha" ? null : w.theme,
icon: null,
title: title === "" ? null : title,
},
});
wizard.value = null;
await refresh();
} catch (e) {
error.value = String(e);
}
}
interface PendingDelete {
name: string;
workDirs: string[];
deleteWorkDirs: boolean;
}
const pendingDelete = ref<PendingDelete | null>(null);
async function askDelete(p: Project) {
error.value = "";
try {
const workDirs = await invoke<string[]>("project_work_dirs", {
project: p.name,
});
pendingDelete.value = { name: p.name, workDirs, deleteWorkDirs: false };
} catch (e) {
error.value = String(e);
}
}
async function confirmDelete() {
const d = pendingDelete.value!;
try {
await invoke("delete_project", {
name: d.name,
deleteWorkDirs: d.deleteWorkDirs,
});
pendingDelete.value = null;
await refresh();
} catch (e) {
error.value = String(e);
pendingDelete.value = null;
}
}
let timer: number;
onMounted(() => {
refresh();
@@ -158,6 +253,12 @@ onUnmounted(() => window.clearInterval(timer));
</script>
<template>
<div class="toolbar">
<button class="primary" @click="openWizard">
{{ $t("projects.newProject") }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<table class="grid">
<colgroup>
@@ -200,6 +301,14 @@ onUnmounted(() => window.clearInterval(timer));
>
</button>
<button
class="gear danger"
:disabled="p.running"
:title="p.running ? $t('projects.deleteRunning') : $t('projects.delete')"
@click="askDelete(p)"
>
🗑
</button>
<button v-if="p.running" class="stop" @click="stop(p)">
{{ $t("projects.stop") }}
</button>
@@ -218,6 +327,103 @@ onUnmounted(() => window.clearInterval(timer));
</tbody>
</table>
<div v-if="wizard" class="overlay" @click.self="wizard = null">
<form class="dialog" @submit.prevent="createProject">
<h3>{{ $t("projects.wizardTitle") }}</h3>
<label class="field">
{{ $t("projects.name") }}
<input v-model="wizard.name" autofocus required />
</label>
<label class="field">
{{ $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">
{{ pool.name }}
</option>
</select>
</label>
<label class="field">
{{ $t("projects.workDir") }}
<span class="icon-row">
<select v-model="wizard.workDirMode">
<option value="none">{{ $t("projects.workDirNone") }}</option>
<option value="default">
~/projects/{{ wizard.name.trim() || "…" }}
</option>
<option value="custom">{{ $t("projects.workDirCustom") }}</option>
</select>
<button
v-if="wizard.workDirMode === 'custom'"
type="button"
@click="pickWorkDir"
>
{{ $t("projects.chooseFolder") }}
</button>
</span>
</label>
<label v-if="wizard.workDirMode === 'custom' && wizard.workDir" class="field">
<span></span>
<span class="icon-path">{{ wizard.workDir }}</span>
</label>
<label class="field">
{{ $t("projects.title") }}
<input v-model="wizard.title" :placeholder="wizard.name.trim()" />
</label>
<label class="field">
{{ $t("projects.theme") }}
<select v-model="wizard.theme">
<option v-for="[value, label] in THEME_NAMES" :key="value" :value="value">
{{ label }}
</option>
</select>
</label>
<p class="hint">{{ $t("projects.wizardHint") }}</p>
<div class="actions">
<button type="button" @click="wizard = null">
{{ $t("projects.cancel") }}
</button>
<button
type="submit"
class="primary"
:disabled="
wizard.name.trim() === '' ||
(wizard.workDirMode === 'custom' && !wizard.workDir)
"
>
{{ $t("projects.create") }}
</button>
</div>
</form>
</div>
<div v-if="pendingDelete" class="overlay" @click.self="pendingDelete = null">
<div class="dialog">
<h3>{{ $t("projects.deleteTitle", { name: pendingDelete.name }) }}</h3>
<p class="hint">{{ $t("projects.deleteWarning", { name: pendingDelete.name }) }}</p>
<template v-if="pendingDelete.workDirs.length">
<label class="checkline">
<input v-model="pendingDelete.deleteWorkDirs" type="checkbox" />
{{ $t("projects.deleteWorkDirs") }}
</label>
<ul class="affected">
<li v-for="wd in pendingDelete.workDirs" :key="wd">{{ wd }}</li>
</ul>
<p v-if="!pendingDelete.deleteWorkDirs" class="hint">
{{ $t("projects.workDirsStay") }}
</p>
</template>
<div class="actions">
<button type="button" @click="pendingDelete = null">
{{ $t("projects.cancel") }}
</button>
<button class="danger" @click="confirmDelete">
{{ $t("projects.delete") }}
</button>
</div>
</div>
</div>
<div v-if="settings" class="overlay">
<div class="dialog">
<h3>{{ $t("projects.terminal", { name: settings.name }) }}</h3>
+34
View File
@@ -50,6 +50,23 @@ const de = {
noPoolAssigned: "kein Pool",
needsPool: "Kein Pool zugeordnet",
needsKey: "Pool hat keinen API-Key",
newProject: "+ Projekt",
wizardTitle: "Neues Projekt",
name: "Name",
workDir: "Arbeitsordner",
workDirNone: "keiner",
workDirCustom: "vorhandenen Ordner verknüpfen",
chooseFolder: "Ordner wählen …",
create: "Anlegen",
wizardHint:
"Angelegt werden Projektordner mit memory/, Sentinel-.gitignore, settings.json mit pool-guard-Hook und die Pool-/Terminal-Zuordnung.",
delete: "Projekt löschen",
deleteRunning: "Läuft noch — erst beenden",
deleteTitle: "{name} löschen",
deleteWarning:
"Der Projektordner unter ~/claude-projects (inkl. memory/, Settings und Pool-Zuordnung) wird endgültig gelöscht.",
deleteWorkDirs: "Arbeitsordner mitlöschen:",
workDirsStay: "Die Arbeitsordner bleiben unangetastet.",
},
pools: {
pool: "Pool",
@@ -138,6 +155,23 @@ const en: typeof de = {
noPoolAssigned: "no pool",
needsPool: "No pool assigned",
needsKey: "Pool has no API key",
newProject: "+ Project",
wizardTitle: "New project",
name: "Name",
workDir: "Working folder",
workDirNone: "none",
workDirCustom: "link existing folder",
chooseFolder: "Choose folder …",
create: "Create",
wizardHint:
"Creates the project folder with memory/, sentinel .gitignore, settings.json with the pool-guard hook, and the pool/terminal assignment.",
delete: "Delete project",
deleteRunning: "Still running — stop it first",
deleteTitle: "Delete {name}",
deleteWarning:
"The project folder under ~/claude-projects (incl. memory/, settings and pool assignment) will be deleted permanently.",
deleteWorkDirs: "Also delete working folders:",
workDirsStay: "The working folders stay untouched.",
},
pools: {
pool: "Pool",
+8
View File
@@ -576,6 +576,14 @@ td select {
color: var(--overlay);
}
.dialog .checkline {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text);
cursor: pointer;
}
.dialog .affected {
margin: 0;
padding-left: 1.1rem;