Projekt löschen in drei Stufen mit Artefakt-Vorschau; Listen-Scroll gefixt

Eskalationsleiter: 'integration' nimmt nur die ai-control-Spuren zurück
(Registry, .ai-control/, Archiv-Rechte + Todo-Hook in settings.json,
Panel-Dateien, .desktop — Ordner, memory/ und Claude-Code-Grundgerüst
bleiben), 'archive' löscht zusätzlich das Archiv, 'full' den Projekt-
ordner mit Arbeitsordner-Flag. delete_preview liefert die konkrete
Artefaktliste; der Dialog zeigt sie je Stufe. Ersetzt delete_project/
remove_project (Handler, App-ACL-Manifest und Capability nachgezogen).

Außerdem: main scrollt nicht mehr — Toolbars und Fehlerzeile stehen,
nur die Listen scrollen (alle drei Tabs, .list-scroll).
This commit is contained in:
marcus hinz
2026-07-20 13:37:02 +02:00
parent 6682040cc0
commit 6c765ff7b5
73 changed files with 1250 additions and 102 deletions
+2 -2
View File
@@ -247,8 +247,8 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
commands::list_projects,
commands::create_project_full,
commands::add_project,
commands::remove_project,
commands::delete_project,
commands::delete_preview,
commands::delete_project_scoped,
commands::project_work_dirs,
commands::set_project_dir,
commands::add_work_dir,
+20 -17
View File
@@ -13,12 +13,12 @@ use crate::domain::pool::{
link_pool_runtime_in, list_pools_in, read_pool, rename_pool_in, set_apikey_in, PoolInfo,
};
use crate::domain::project::{
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_project_in,
display_name_in, is_running, kill_terminals, list_projects_in, project_work_dirs_in,
read_project_config_in, remove_work_dir_in, resolve_icon_path, running_projects_using_pool,
set_project_dir_in, set_terminal_config_in, unassign_pool_in, Project, TerminalConfig,
add_project_in, add_work_dir_in, assign_pool_in, create_project_full_in, delete_preview_in,
delete_project_scoped_in, display_name_in, is_running, kill_terminals, list_projects_in,
project_work_dirs_in, read_project_config_in, remove_work_dir_in, resolve_icon_path,
running_projects_using_pool, set_project_dir_in, set_terminal_config_in, unassign_pool_in,
DeletePreview, Project, TerminalConfig,
};
use crate::domain::registry::unregister_project;
use crate::domain::settings;
use crate::domain::todo::{set_todo_in, todo_state_in};
use crate::domain::usage::{usage_stats_in, UsageRow};
@@ -60,16 +60,6 @@ pub(crate) fn add_project(path: String) -> Result<(), String> {
add_project_in(&Paths::real(), &path)
}
/// Projekt aus der Registry nehmen; der Ordner bleibt unangetastet.
#[tauri::command]
pub(crate) fn remove_project(project: String) -> Result<(), String> {
if is_running(&project) {
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
unregister_project(&Paths::real(), &project)
}
/// Projektordner neu zuordnen; bei laufender Session gesperrt.
#[tauri::command]
pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> {
@@ -90,13 +80,26 @@ pub(crate) fn remove_work_dir(project: String, dir: String) -> Result<(), String
remove_work_dir_in(&Paths::real(), &project, &dir)
}
/// Artefakt-Vorschau für den Lösch-Dialog.
#[tauri::command]
pub(crate) fn delete_project(project: String, delete_work_dirs: bool) -> Result<(), String> {
pub(crate) fn delete_preview(project: String) -> Result<DeletePreview, String> {
delete_preview_in(&Paths::real(), &project)
}
/// Löschen in drei Stufen: "integration" (nur ai-control-Spuren),
/// "archive" (zusätzlich Archiv), "full" (zusätzlich Projektordner,
/// Arbeitsordner per Flag).
#[tauri::command]
pub(crate) fn delete_project_scoped(
project: String,
scope: String,
delete_work_dirs: bool,
) -> Result<(), String> {
if is_running(&project) {
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
delete_project_in(&Paths::real(), &project, delete_work_dirs)
delete_project_scoped_in(&Paths::real(), &project, &scope, delete_work_dirs)
}
#[tauri::command]
+180 -19
View File
@@ -499,27 +499,125 @@ pub(crate) fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<Stri
)
}
/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
pub(crate) fn delete_project_in(
/// Vorschau für den Lösch-Dialog: welche Artefakte die drei Stufen
/// (Integration / Integration+Archiv / komplett) konkret treffen würden.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeletePreview {
pub(crate) name: String,
pub(crate) project_dir: String,
pub(crate) ai_control_dir: bool,
pub(crate) archive_permission: bool,
pub(crate) todo_hook: bool,
pub(crate) panel_files: usize,
pub(crate) archive_home: Option<String>,
pub(crate) archive_docs: usize,
pub(crate) work_dirs: Vec<String>,
}
pub(crate) fn delete_preview_in(paths: &Paths, project: &str) -> Result<DeletePreview, String> {
check_name(project)?;
let dir = project_dir(paths, project)?;
let cfg = read_project_config_in(paths, project)?;
let archive_docs = match cfg.archive_home.as_deref() {
Some(a) => {
let home = expand_home(paths, a);
if home.is_dir() {
crate::domain::archive_index::scan_archive(&home)?.len()
} else {
0
}
}
None => 0,
};
Ok(DeletePreview {
name: cfg.name.clone().unwrap_or_else(|| project.to_string()),
project_dir: contract_home(paths, &dir),
ai_control_dir: dir.join(PROJECT_CONFIG_DIR).is_dir(),
archive_permission: cfg.archive_home.is_some(),
todo_hook: crate::domain::todo::todo_state_in(paths, project)?,
panel_files: session_files(paths, project).iter().filter(|f| f.is_file()).count(),
archive_home: cfg.archive_home.clone(),
archive_docs,
work_dirs: project_work_dirs_in(paths, project)?,
})
}
/// Flüchtige Panel-Kanaldateien des Projekts unter ~/.config/ai-control/panels.
fn session_files(paths: &Paths, project: &str) -> [PathBuf; 4] {
let panels = paths.config_dir().join("panels");
[
panels.join(format!("{project}.md")),
panels.join(format!("{project}.commands.jsonl")),
panels.join(format!("{project}.search.json")),
panels.join(format!("{project}.wiki.json")),
]
}
/// Löscht ein Projekt in drei Stufen (Eskalationsleiter, jede schließt die
/// vorige ein):
/// - "integration": nur die ai-control-Spuren — Registry, .ai-control/,
/// Archiv-Rechte und Todo-Hook in der settings.json, Panel-Dateien,
/// .desktop. Ordner, memory/ und das Claude-Code-Grundgerüst der
/// settings.json bleiben.
/// - "archive": zusätzlich der Archiv-Ordner samt Dokumenten.
/// - "full": zusätzlich der Projektordner; Arbeitsordner per Flag.
pub(crate) fn delete_project_scoped_in(
paths: &Paths,
name: &str,
project: &str,
scope: &str,
delete_work_dirs: bool,
) -> Result<(), String> {
check_name(name)?;
let dir = project_dir(paths, name)?;
check_name(project)?;
let dir = project_dir(paths, project)?;
if !dir.is_dir() {
return Err(format!("Projekt nicht gefunden: {name}"));
return Err(format!("Projektordner nicht gefunden: {}", dir.display()));
}
if delete_work_dirs {
for wd in project_work_dirs_in(paths, name)? {
let wd_path = expand_home(paths, &wd);
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
let cfg = read_project_config_in(paths, project)?;
// Archiv vor der Integration räumen — Stufe 1 löscht die Config, die den
// Pfad kennt.
if scope == "archive" || scope == "full" {
if let Some(a) = cfg.archive_home.as_deref() {
let home = expand_home(paths, a);
if home.is_dir() {
fs::remove_dir_all(&home).map_err(|e| format!("{}: {e}", home.display()))?;
}
}
}
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
crate::domain::registry::unregister_project(paths, name)?;
crate::platform::remove_terminal_desktop(paths, name);
match scope {
"integration" | "archive" => {
if let Some(a) = cfg.archive_home.as_deref() {
crate::domain::archive::remove_archive_permission(paths, project, a)?;
}
if crate::domain::todo::todo_state_in(paths, project)? {
crate::domain::todo::set_todo_in(paths, project, false)?;
}
let ac = dir.join(PROJECT_CONFIG_DIR);
if ac.is_dir() {
fs::remove_dir_all(&ac).map_err(|e| format!("{}: {e}", ac.display()))?;
}
}
"full" => {
if delete_work_dirs {
for wd in project_work_dirs_in(paths, project)? {
let wd_path = expand_home(paths, &wd);
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
}
}
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
other => return Err(format!("unbekannter Lösch-Umfang: {other}")),
}
for f in session_files(paths, project) {
if f.is_file() {
fs::remove_file(&f).map_err(|e| format!("{}: {e}", f.display()))?;
}
}
crate::domain::registry::unregister_project(paths, project)?;
crate::platform::remove_terminal_desktop(paths, project);
Ok(())
}
@@ -1149,7 +1247,7 @@ mod tests {
fn projekt_loeschen() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
delete_project_in(&p, "proj", false).unwrap();
delete_project_scoped_in(&p, "proj", "full", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
}
@@ -1158,7 +1256,7 @@ mod tests {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, &id, false).unwrap();
delete_project_scoped_in(&p, &id, "full", false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
assert!(p.home.join("projects").join("proj").is_dir());
}
@@ -1168,7 +1266,7 @@ mod tests {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, &id, true).unwrap();
delete_project_scoped_in(&p, &id, "full", true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
assert!(!p.home.join("projects").join("proj").exists());
}
@@ -1179,7 +1277,7 @@ mod tests {
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, &id, true).is_err());
assert!(delete_project_scoped_in(&p, &id, "full", true).is_err());
}
#[test]
@@ -1276,9 +1374,72 @@ mod tests {
assert!(read_for_panel_in(&p, "proj", "/dev/zero").is_err());
}
/// Stufe „nur Integration": ai-control-Spuren weg, Ordner und
/// Claude-Code-Bestand bleiben.
#[test]
fn loeschen_nur_integration() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), true)
.unwrap();
let dir = p.projects_dir().join("proj");
// Archiv-Home samt Permission wie über die UI gesetzt
let mut cfg = read_project_config_in(&p, &id).unwrap();
cfg.archive_home = Some("~/archiv/proj".into());
write_project_config_in(&p, &id, &cfg).unwrap();
crate::domain::archive::add_archive_permission(&p, &id, "~/archiv/proj").unwrap();
fs::create_dir_all(p.home.join("archiv/proj")).unwrap();
fs::write(p.home.join("archiv/proj/doc.md"), "inhalt").unwrap();
// flüchtige Panel-Datei
fs::create_dir_all(p.config_dir().join("panels")).unwrap();
fs::write(p.config_dir().join("panels").join(format!("{id}.md")), "x").unwrap();
let preview = delete_preview_in(&p, &id).unwrap();
assert!(preview.ai_control_dir && preview.archive_permission && preview.todo_hook);
assert_eq!(preview.panel_files, 1);
assert_eq!(preview.archive_docs, 1);
delete_project_scoped_in(&p, &id, "integration", false).unwrap();
assert!(dir.is_dir());
assert!(!dir.join(PROJECT_CONFIG_DIR).exists());
let sp = fs::read_to_string(settings_path(&dir)).unwrap();
assert!(!sp.contains("archiv/proj"));
assert!(!sp.contains("OFFENE-PUNKTE.md")); // Hook weg (leere Hook-Liste darf bleiben)
assert!(sp.contains("autoMemoryDirectory")); // Claude-Code-Bestand bleibt
assert!(dir.join(crate::domain::todo::TODO_FILE).is_file()); // Nutzerinhalt bleibt
assert!(p.home.join("archiv/proj/doc.md").is_file()); // Archiv bleibt
assert!(!p.config_dir().join("panels").join(format!("{id}.md")).exists());
assert!(load_registry(&p).unwrap().is_empty());
}
/// Stufe „Integration & Archiv": zusätzlich stirbt der Archiv-Ordner.
#[test]
fn loeschen_integration_und_archiv() {
let p = tmp_paths();
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
let mut cfg = read_project_config_in(&p, &id).unwrap();
cfg.archive_home = Some("~/archiv/proj".into());
write_project_config_in(&p, &id, &cfg).unwrap();
fs::create_dir_all(p.home.join("archiv/proj")).unwrap();
delete_project_scoped_in(&p, &id, "archive", false).unwrap();
assert!(p.projects_dir().join("proj").is_dir());
assert!(!p.home.join("archiv/proj").exists());
assert!(load_registry(&p).unwrap().is_empty());
}
#[test]
fn loeschen_unbekannter_umfang_scheitert() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
assert!(delete_project_scoped_in(&p, "proj", "halb", false).is_err());
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
assert!(delete_project_in(&p, "gibtsnicht", false).is_err());
assert!(delete_project_scoped_in(&p, "gibtsnicht", "full", false).is_err());
}
}