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
+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());
}
}