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:
+180
-19
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user