Bugfix-Runde Panel/Archiv: Datenverlust, Suche, Löschen, Markdown

- ProjectConfig reicht unbekannte Keys durch (serde flatten). Bisher verlor
  jeder read-modify-write die Felder neuerer Versionen; so ging archiveHome
  bei der Pool-Zuweisung durch einen älteren Build verloren.
- Archiv-Suche: Nutzereingabe wird in einen FTS5-Ausdruck übersetzt, der nicht
  scheitern kann. ai-control, C++ oder eine halbe Phrase warfen bisher einen
  Syntaxfehler — bei der Live-Suche in jedem Tippzwischenstand.
- commands_delete: Befehlstext als Identität, Indizes werden geprüft. Bisher
  Panik oder Löschen der falschen Kachel, wenn die Panel-Indizes der Datei
  hinterherhingen.
- Archivieren überschreibt keine Datei mehr: Der Stempel hat Minutenauflösung,
  bei Kollision wird -2, -3, ... angehängt.
- archive_panel prüft erst auf leeres Panel, dann wird das Archiv-Home gesetzt;
  bisher blieb ein umkonfiguriertes Home trotz Meldung "nicht archiviert".
- Kaputte .claude.json eines Pools wird nicht mehr durch eine frische ersetzt;
  nur eine fehlende Datei rechtfertigt das.
- Archiv-Formular leert beim Schließen — sonst galten Ordner und Schlagwörter
  des vorigen Dokuments unbemerkt fürs nächste.
- Markdown fürs Panel entschärft (src/markdown.ts): rohes HTML wird als Text
  ausgegeben, Link- und Bildziele auf harmlose Schemata beschränkt. Das Panel
  teilt sich den Webview mit dem Terminal, dessen term_write in die PTY
  schreibt; ohne CSP war das ein Weg von Fremdinhalt zu Befehlsausführung.

Regressionstests je Fix: 73 Rust-Tests, 35 Frontend-Tests.
This commit is contained in:
marcusH
2026-07-19 23:04:17 +02:00
parent 1345ef799c
commit b127269e89
14 changed files with 306 additions and 28 deletions
+50 -9
View File
@@ -145,18 +145,23 @@ pub(crate) fn archive_panel_content(
dir_override: Option<&str>,
meta: &ArchiveMeta,
) -> Result<PathBuf, String> {
if let Some(d) = dir_override {
set_project_archive_home(project, d)?;
}
let home = require_archive_home(project)?;
let dir = match &meta.folder {
Some(f) => home.join(check_folder(f)?),
None => home,
};
// Erst prüfen, dann konfigurieren: `set_project_archive_home` legt Ordner an
// und trägt eine Berechtigung in die settings.json des Projekts ein. Käme das
// vor der Leer-Prüfung, hinterließe ein Archivieren mit leerem Panel die
// Meldung „nicht archiviert“ — und ein dauerhaft umgestelltes Archiv-Home.
let text = fs::read_to_string(panel_file(project)).unwrap_or_default();
if text.trim().is_empty() {
return Err("Panel ist leer — nichts zu archivieren".into());
}
let folder = meta.folder.as_deref().map(check_folder).transpose()?;
if let Some(d) = dir_override {
set_project_archive_home(project, d)?;
}
let home = require_archive_home(project)?;
let dir = match folder {
Some(f) => home.join(f),
None => home,
};
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let secs = SystemTime::now()
@@ -165,12 +170,29 @@ pub(crate) fn archive_panel_content(
.as_secs();
let (stamp, iso) = utc_stamp(secs);
let title = first_line(&text);
let path = dir.join(format!("{stamp}-{}.md", slugify(&title)));
let path = free_path(&dir, &stamp, &slugify(&title));
let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end());
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path)
}
/// Freier Dateiname für das Archiv-Dokument.
///
/// Der Zeitstempel hat Minutenauflösung; zweimal Archivieren innerhalb einer
/// Minute mit derselben Titelzeile ergäbe denselben Namen. Da mit `fs::write`
/// geschrieben wird, wäre das stiller Datenverlust — ausgerechnet im Archiv,
/// das die dauerhafte Ablage ist. Darum bei Kollision `-2`, `-3`, … anhängen.
fn free_path(dir: &std::path::Path, stamp: &str, slug: &str) -> PathBuf {
let first = dir.join(format!("{stamp}-{slug}.md"));
if !first.exists() {
return first;
}
(2..)
.map(|n| dir.join(format!("{stamp}-{slug}-{n}.md")))
.find(|p| !p.exists())
.expect("unendlicher Zahlenraum")
}
/// Unterordner-Pfad: relativ, nur normale Komponenten (kein `..`, kein Root).
fn check_folder(folder: &str) -> Result<&std::path::Path, String> {
let p = std::path::Path::new(folder);
@@ -347,6 +369,25 @@ mod tests {
assert_eq!(iso, "2026-07-11T11:14:15Z");
}
/// Zweimal Archivieren in derselben Minute mit gleichem Titel darf die erste
/// Datei nicht überschreiben — der Stempel hat nur Minutenauflösung.
#[test]
fn gleicher_stempel_und_titel_kollidiert_nicht() {
let dir = crate::domain::testutil::tmp_paths().home.join("archiv");
fs::create_dir_all(&dir).unwrap();
let erste = free_path(&dir, "2026-07-19_2118", "notiz");
assert_eq!(erste.file_name().unwrap(), "2026-07-19_2118-notiz.md");
fs::write(&erste, "alt").unwrap();
let zweite = free_path(&dir, "2026-07-19_2118", "notiz");
assert_eq!(zweite.file_name().unwrap(), "2026-07-19_2118-notiz-2.md");
fs::write(&zweite, "neu").unwrap();
assert_eq!(fs::read_to_string(&erste).unwrap(), "alt");
let dritte = free_path(&dir, "2026-07-19_2118", "notiz");
assert_eq!(dritte.file_name().unwrap(), "2026-07-19_2118-notiz-3.md");
}
#[test]
fn civil_from_days_referenz() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
+85 -2
View File
@@ -28,6 +28,12 @@ pub(crate) fn search(
tag: Option<&str>,
limit: usize,
) -> Result<Vec<Hit>, String> {
// Eingabe ohne jedes indexierbare Zeichen (`!?`, ein einzelnes `"`) findet
// nichts — das ist kein Fehler, sondern ein leeres Ergebnis. Nur die von
// vornherein leere Anfrage ohne Tag bleibt ein Fehler.
if !query.trim().is_empty() && sanitize_query(query).is_empty() && tag.is_none() {
return Ok(Vec::new());
}
let conn = build_index(home)?;
let expr = match_expr(query, tag)?;
let mut stmt = conn
@@ -46,16 +52,64 @@ pub(crate) fn search(
/// MATCH-Ausdruck aus Query und optionalem Tag-Filter.
fn match_expr(query: &str, tag: Option<&str>) -> Result<String, String> {
let q = query.trim();
let q = sanitize_query(query);
let t = tag.map(|t| format!("tags:\"{}\"", t.replace('"', "")));
match (q.is_empty(), t) {
(false, Some(t)) => Ok(format!("({q}) AND {t}")),
(false, None) => Ok(q.to_string()),
(false, None) => Ok(q),
(true, Some(t)) => Ok(t),
(true, None) => Err("leere Suchanfrage".into()),
}
}
/// Übersetzt die Nutzereingabe in einen FTS5-Ausdruck, der nicht scheitern kann.
///
/// Roh durchgereicht ist fast jede natürliche Eingabe ein Syntaxfehler: `ai-control`
/// liest FTS5 als Spaltenfilter (`no such column: control`), `C++` und eine offene
/// Klammer brechen den Parser. Die Live-Suche schickt zudem jeden Zwischenstand beim
/// Tippen ab, also auch das halbe `"Phrase`. Darum wird jedes Wort als Phrase
/// gequotet — die Tokenizer-Regeln bleiben dieselben, nur die Operatorzeichen
/// verlieren ihre Sonderbedeutung. Erhalten bleiben die zwei Formen, die Nutzer
/// bewusst tippen: "Phrasen in Anführungszeichen" und Präfix*.
fn sanitize_query(query: &str) -> String {
let mut out: Vec<String> = Vec::new();
let mut chars = query.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
if c == '"' {
// Phrase bis zum schließenden Anführungszeichen — fehlt es (Tippzwischen-
// stand), gilt der Rest der Eingabe als Phrase.
let phrase: String = chars.by_ref().take_while(|&c| c != '"').collect();
push_term(&mut out, &phrase, false);
continue;
}
let mut term: String = c.to_string();
while let Some(&n) = chars.peek() {
if n.is_whitespace() {
break;
}
term.push(n);
chars.next();
}
let prefix = term.ends_with('*');
push_term(&mut out, term.trim_end_matches('*'), prefix);
}
out.join(" ")
}
/// Hängt `term` als gequotete Phrase an, sofern überhaupt etwas Indexierbares
/// darin steht. Reine Satzzeichen ergäben die leere Phrase `""` — für FTS5 ein
/// Syntaxfehler.
fn push_term(out: &mut Vec<String>, term: &str, prefix: bool) {
if !term.chars().any(char::is_alphanumeric) {
return;
}
let quoted = term.replace('"', "");
out.push(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") });
}
/// Baut den FTS5-Index in-memory aus dem Archiv-Baum.
fn build_index(home: &Path) -> Result<Connection, String> {
let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
@@ -136,4 +190,33 @@ mod tests {
let home = archiv();
assert!(search(&home, " ", None, 10).is_err());
}
/// Eingaben, die roh durchgereicht einen FTS5-Syntaxfehler warfen. Die
/// Bindestrich-Fälle sind die wichtigsten: Archiv-Dokumente heißen selbst so.
#[test]
fn sonderzeichen_werfen_keinen_syntaxfehler() {
let home = archiv();
for q in ["ai-control", "adr-log", "TODO: fix", "C++", "wiki (", "\"halbe Phrase", "!?"] {
assert!(search(&home, q, None, 10).is_ok(), "Suche scheiterte an „{q}“");
}
}
#[test]
fn bindestrich_wort_findet_dokument() {
let home = archiv();
let hits = search(&home, "adr-logging", None, 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "ADR Logging");
}
/// Die Live-Suche schickt jeden Tippzwischenstand ab — keiner darf scheitern.
#[test]
fn tippzwischenstaende_scheitern_nie() {
let home = archiv();
let ziel = "\"Strukturiertes Logging\"";
for n in 1..=ziel.chars().count() {
let teil: String = ziel.chars().take(n).collect();
assert!(search(&home, &teil, None, 10).is_ok(), "Suche scheiterte an „{teil}“");
}
}
}
+13 -4
View File
@@ -257,10 +257,19 @@ fn register_mcp_server(pool_dir: &std::path::Path) {
"alwaysLoad": true,
});
let cfg = pool_dir.join(".claude.json");
let mut v = fs::read_to_string(&cfg)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
// Nur eine fehlende Datei rechtfertigt ein frisches Objekt. Ist sie da, aber
// unlesbar oder kaputtes JSON (abgebrochener Schreibvorgang, volle Platte),
// wird sie in Ruhe gelassen: Sie ist claudes Live-State mit Projekt-Zustand
// und MCP-Einträgen Dritter. Sie hier zu ersetzen macht aus einem
// reparierbaren Schaden einen endgültigen.
let mut v = match fs::read_to_string(&cfg) {
Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(v) => v,
Err(_) => return,
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
Err(_) => return,
};
let Some(obj) = v.as_object_mut() else { return };
let Some(servers) = obj
.entry("mcpServers")
+36 -1
View File
@@ -39,6 +39,12 @@ pub(crate) struct ProjectConfig {
skip_serializing_if = "Option::is_none"
)]
pub(crate) archive_home: Option<String>,
/// Alle Keys, die dieser Build nicht kennt — unverändert durchgereicht.
/// Ohne das verliert jeder read-modify-write (Pool-Zuweisung, Terminal-
/// Einstellungen) still die Felder neuerer Versionen: serde verwirft
/// Unbekanntes beim Deserialisieren, und geschrieben wird die ganze Datei.
#[serde(flatten)]
pub(crate) rest: serde_json::Map<String, serde_json::Value>,
}
#[derive(Serialize, Deserialize, Default, Clone)]
@@ -226,7 +232,11 @@ pub(crate) fn create_project_full_in(
reg.insert(name.to_string(), dir.clone());
save_registry(paths, &reg)?;
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_home: None };
let cfg = ProjectConfig {
pool: pool.map(str::to_string),
terminal,
..ProjectConfig::default()
};
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?;
}
@@ -489,6 +499,31 @@ mod tests {
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
/// Der read-modify-write darf keine Keys verlieren, die dieser Build nicht
/// kennt. Genau daran ging `archiveHome` verloren: Ein älterer Build ohne das
/// Feld hat bei der Pool-Zuweisung die ganze Datei neu geschrieben.
#[test]
fn pool_zuweisen_erhaelt_fremde_keys() {
let p = tmp_paths();
let pool = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
let cfg_path = project_config_path(&p, "proj").unwrap();
fs::write(
&cfg_path,
r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1},"terminal":{"theme":"monokai"}}"#,
)
.unwrap();
assign_pool_in(&p, "proj", &pool).unwrap();
let raw = fs::read_to_string(&cfg_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(v["archiveHome"], "~/archiv");
assert_eq!(v["zukunftsfeld"]["a"], 1);
assert_eq!(v["terminal"]["theme"], "monokai");
assert_eq!(v["pool"], serde_json::Value::String(pool));
}
#[test]
fn projekt_wizard_scaffold() {
let p = tmp_paths();