From e245600235f6d1f40f9fea6580004aaf91134306 Mon Sep 17 00:00:00 2001 From: marcusH Date: Sun, 19 Jul 2026 23:22:25 +0200 Subject: [PATCH] =?UTF-8?q?Aufr=C3=A4umrunde=20zur=20Bugfix-Runde:=20Suche?= =?UTF-8?q?=20entzerrt,=20Flatten-Fix=20vervollst=C3=A4ndigt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - archive_search: match_expr in search() aufgelöst. sanitize_query lief zweimal pro Anfrage, und die Leer-Entscheidung stand an zwei Stellen mit leicht abweichender Bedingung. - Der Tag-Filter geht jetzt durch dieselbe Quoting-Regel wie der Volltext. Ein leerer Tag ergab tags:"" und damit einen Syntaxfehler — erreichbar durch das angetippte #, den ersten Tastendruck jeder Tag-Suche. - sanitize_query von 26 auf 12 Zeilen: split('"') und split_whitespace statt handgeschriebenem Tokenizer. Gleiches Ergebnis fuer alle Testeingaben. - TerminalConfig bekommt ebenfalls serde flatten. Der Block wird als Ganzes mitgeschrieben, ein terminal.fontSize waere also weiterhin verloren gegangen; set_terminal_config_in traegt die unbekannten Keys jetzt herueber. - markdown.test.ts enthielt ein literales NUL-Byte, wodurch git die Datei als binaer behandelte. Der Test erzeugt das Zeichen jetzt selbst. - Kleineres: commands_delete-Bedingung als direkter Vergleich, eine Verschachtelungsebene in pool.rs, zwei redundante Kommentarbloecke. commands_delete benennt sich in der Doku als Provisorium. --- src-tauri/src/commands.rs | 6 +- src-tauri/src/domain/archive_search.rs | 119 +++++++++++++------------ src-tauri/src/domain/pool.rs | 10 +-- src-tauri/src/domain/project.rs | 38 +++++++- src-tauri/src/terminal.rs | 17 ++-- src/archive-form.ts | 5 +- src/markdown.test.ts | Bin 2561 -> 2585 bytes src/markdown.ts | 4 - 8 files changed, 119 insertions(+), 80 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 25405bd..ab5ce07 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -118,7 +118,11 @@ pub(crate) fn set_terminal_config( icon: Option, title: Option, ) -> Result<(), String> { - set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title }) + set_terminal_config_in( + &Paths::real(), + &project, + TerminalConfig { theme, icon, title, ..Default::default() }, + ) } /// Icon eines Projekts als data-URL für die Übersicht (PNG oder SVG). diff --git a/src-tauri/src/domain/archive_search.rs b/src-tauri/src/domain/archive_search.rs index 6616e68..261dcad 100644 --- a/src-tauri/src/domain/archive_search.rs +++ b/src-tauri/src/domain/archive_search.rs @@ -28,14 +28,21 @@ pub(crate) fn search( tag: Option<&str>, limit: usize, ) -> Result, 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 q = sanitize_query(query); + let t = tag.and_then(|t| quote_phrase(t, false)).map(|t| format!("tags:{t}")); + let expr = match (q.is_empty(), t) { + (false, Some(t)) => format!("({q}) AND {t}"), + (false, None) => q, + (true, Some(t)) => t, + // Von vornherein leere Anfrage ist ein Bedienfehler. Eine Eingabe, die nur + // aus Satzzeichen bestand (`!?`, ein einzelnes `"`, das angetippte `#`), + // findet dagegen schlicht nichts — und spart sich den Index-Aufbau. + (true, None) if query.trim().is_empty() && tag.is_none() => { + return Err("leere Suchanfrage".into()) + } + (true, None) => return Ok(Vec::new()), + }; let conn = build_index(home)?; - let expr = match_expr(query, tag)?; let mut stmt = conn .prepare( "SELECT relpath, title, snippet(docs, 5, '**', '**', ' … ', 12) \ @@ -50,18 +57,6 @@ pub(crate) fn search( rows.collect::, _>>().map_err(|e| e.to_string()) } -/// MATCH-Ausdruck aus Query und optionalem Tag-Filter. -fn match_expr(query: &str, tag: Option<&str>) -> Result { - 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), - (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` @@ -73,41 +68,34 @@ fn match_expr(query: &str, tag: Option<&str>) -> Result { /// bewusst tippen: "Phrasen in Anführungszeichen" und Präfix*. fn sanitize_query(query: &str) -> String { let mut out: Vec = Vec::new(); - let mut chars = query.chars().peekable(); - while let Some(c) = chars.next() { - if c.is_whitespace() { - continue; + // An `"` aufteilen: ungerade Segmente standen in Anführungszeichen und + // bleiben als Ganzes eine Phrase, gerade zerfallen in Wörter. Ein fehlendes + // schließendes Anführungszeichen (Tippzwischenstand) fällt damit von selbst + // richtig heraus — der Rest der Eingabe ist das letzte ungerade Segment. + for (i, teil) in query.split('"').enumerate() { + if i % 2 == 1 { + out.extend(quote_phrase(teil, false)); + } else { + out.extend( + teil + .split_whitespace() + .filter_map(|w| quote_phrase(w.trim_end_matches('*'), w.ends_with('*'))), + ); } - 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, term: &str, prefix: bool) { +/// `term` als gequotete FTS5-Phrase, sofern überhaupt etwas Indexierbares darin +/// steht. Reine Satzzeichen ergäben die leere Phrase `""` — für FTS5 ein +/// Syntaxfehler. Gilt auch für den Tag-Filter: Das angetippte `#` liefert über +/// `panel-wiring.ts` den leeren Tag. +fn quote_phrase(term: &str, prefix: bool) -> Option { if !term.chars().any(char::is_alphanumeric) { - return; + return None; } let quoted = term.replace('"', ""); - out.push(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") }); + Some(if prefix { format!("\"{quoted}\"*") } else { format!("\"{quoted}\"") }) } /// Baut den FTS5-Index in-memory aus dem Archiv-Baum. @@ -193,14 +181,41 @@ mod tests { /// Eingaben, die roh durchgereicht einen FTS5-Syntaxfehler warfen. Die /// Bindestrich-Fälle sind die wichtigsten: Archiv-Dokumente heißen selbst so. + /// Die angefangenen Phrasen stehen für die Tippzwischenstände, die die + /// Live-Suche abschickt. #[test] fn sonderzeichen_werfen_keinen_syntaxfehler() { let home = archiv(); - for q in ["ai-control", "adr-log", "TODO: fix", "C++", "wiki (", "\"halbe Phrase", "!?"] { + let eingaben = [ + "ai-control", + "adr-log", + "TODO: fix", + "C++", + "wiki (", + "\"", + "\"Strukturiertes", + "\"Strukturiertes Logging", + "!?", + ]; + for q in eingaben { assert!(search(&home, q, None, 10).is_ok(), "Suche scheiterte an „{q}“"); } } + /// Der Tag-Filter lief früher an der Quoting-Regel vorbei. `#` allein ist der + /// erste Tastendruck jeder Tag-Suche und kommt als leerer Tag an. + #[test] + fn leerer_tag_wirft_keinen_syntaxfehler() { + let home = archiv(); + // Das angetippte `#`: kein Fehler-Toast, sondern schlicht kein Treffer. + assert_eq!(search(&home, "", Some(""), 10).unwrap().len(), 0); + assert_eq!(search(&home, "", Some("!?"), 10).unwrap().len(), 0); + // Mit Volltext daneben zählt nur dieser, der leere Tag engt nichts ein. + assert_eq!(search(&home, "tracing", Some(""), 10).unwrap().len(), 1); + // Fehler bleibt allein die komplett leere Anfrage ohne jeden Tag. + assert!(search(&home, "", None, 10).is_err()); + } + #[test] fn bindestrich_wort_findet_dokument() { let home = archiv(); @@ -209,14 +224,4 @@ mod tests { 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}“"); - } - } } diff --git a/src-tauri/src/domain/pool.rs b/src-tauri/src/domain/pool.rs index 5c87c36..e5ecda2 100644 --- a/src-tauri/src/domain/pool.rs +++ b/src-tauri/src/domain/pool.rs @@ -263,10 +263,10 @@ fn register_mcp_server(pool_dir: &std::path::Path) { // 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::(&s) { - Ok(v) => v, - Err(_) => return, - }, + Ok(s) => { + let Ok(v) = serde_json::from_str::(&s) else { return }; + v + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}), Err(_) => return, }; @@ -820,7 +820,7 @@ mod tests { set_terminal_config_in( &p, "proj", - TerminalConfig { theme: Some("dracula".into()), icon: None, title: None }, + TerminalConfig { theme: Some("dracula".into()), ..Default::default() }, ) .unwrap(); delete_pool_in(&p, &store, &kunde).unwrap(); diff --git a/src-tauri/src/domain/project.rs b/src-tauri/src/domain/project.rs index 7a7fd46..0f8303f 100644 --- a/src-tauri/src/domain/project.rs +++ b/src-tauri/src/domain/project.rs @@ -55,11 +55,15 @@ pub struct TerminalConfig { pub icon: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, + /// Unbekannte Keys, wie bei `ProjectConfig` — der verlustbehaftete + /// Round-Trip gilt hier genauso, weil dieser Block mitgeschrieben wird. + #[serde(flatten)] + pub rest: serde_json::Map, } impl TerminalConfig { pub(crate) fn is_empty(&self) -> bool { - self.theme.is_none() && self.icon.is_none() && self.title.is_none() + self.theme.is_none() && self.icon.is_none() && self.title.is_none() && self.rest.is_empty() } } @@ -477,6 +481,9 @@ pub(crate) fn set_terminal_config_in( terminal.icon = Some(name); } } + // Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder; + // unbekannte Keys der bestehenden Datei würden sonst hier verloren gehen. + terminal.rest = std::mem::take(&mut cfg.terminal.rest); cfg.terminal = terminal; write_project_config_in(paths, project, &cfg)?; crate::platform::write_terminal_desktop(paths, project, &cfg.terminal); @@ -510,7 +517,8 @@ mod tests { let cfg_path = project_config_path(&p, "proj").unwrap(); fs::write( &cfg_path, - r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1},"terminal":{"theme":"monokai"}}"#, + r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1}, + "terminal":{"theme":"monokai","fontSize":13}}"#, ) .unwrap(); @@ -521,9 +529,33 @@ mod tests { assert_eq!(v["archiveHome"], "~/archiv"); assert_eq!(v["zukunftsfeld"]["a"], 1); assert_eq!(v["terminal"]["theme"], "monokai"); + // Auch im verschachtelten terminal-Block, der als Ganzes mitgeschrieben wird. + assert_eq!(v["terminal"]["fontSize"], 13); assert_eq!(v["pool"], serde_json::Value::String(pool)); } + /// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur + /// theme/icon/title — unbekannte Keys müssen trotzdem stehen bleiben. + #[test] + fn terminal_config_setzen_erhaelt_fremde_keys() { + let p = tmp_paths(); + create_project(&p, "proj").unwrap(); + let cfg_path = project_config_path(&p, "proj").unwrap(); + fs::write(&cfg_path, r#"{"terminal":{"theme":"monokai","fontSize":13}}"#).unwrap(); + + set_terminal_config_in( + &p, + "proj", + TerminalConfig { theme: Some("dracula".into()), ..Default::default() }, + ) + .unwrap(); + + let v: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&cfg_path).unwrap()).unwrap(); + assert_eq!(v["terminal"]["theme"], "dracula"); + assert_eq!(v["terminal"]["fontSize"], 13); + } + #[test] fn projekt_wizard_scaffold() { let p = tmp_paths(); @@ -537,8 +569,8 @@ mod tests { true, TerminalConfig { theme: Some("dracula".into()), - icon: None, title: Some("Neu".into()), + ..Default::default() }, true, ) diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 0072c24..9f48d20 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -250,11 +250,16 @@ pub fn commands_read(project: String) -> String { /// Index im commands-Array des Records; ein leer gewordener Record fällt mit /// weg. Der Watcher meldet den neuen Stand als `commands-update`. /// -/// `cmd` ist der angezeigte Befehlstext und dient als Identität: Die Indizes -/// des Panels sind der Datei um bis zu ein Watcher-Intervall hinterher, und -/// ein leer gewordener Record verschiebt alle folgenden. Ohne Abgleich löscht -/// ein Doppelklick — oder das zweite Fenster auf derselben Liste — die falsche -/// Kachel oder greift ins Leere. +/// `cmd` ist der angezeigte Befehlstext und wird vor dem Löschen abgeglichen: +/// Die Indizes des Panels sind der Datei um bis zu ein Watcher-Intervall +/// hinterher, und ein leer gewordener Record verschiebt alle folgenden. Ohne +/// Abgleich löscht ein Doppelklick — oder das zweite Fenster auf derselben +/// Liste — die falsche Kachel oder greift ins Leere. +/// +/// Bewusst ein Provisorium, keine echte Identität: Zwei gleiche Befehle im +/// selben Record sind so nicht unterscheidbar. Sauber wäre eine stabile ID, die +/// beim Schreiben der JSONL vergeben wird, statt Positionen über die +/// Fenstergrenze zu reichen. #[tauri::command] pub fn commands_delete( project: String, @@ -274,7 +279,7 @@ pub fn commands_delete( .ok_or("Befehl bereits entfernt")?["commands"] .as_array_mut() .ok_or("Record ohne commands")?; - if cmds.get(entry).map(|c| c["cmd"].as_str() != Some(cmd.as_str())).unwrap_or(true) { + if cmds.get(entry).and_then(|c| c["cmd"].as_str()) != Some(cmd.as_str()) { return Err("Befehl bereits entfernt".into()); } cmds.remove(entry); diff --git a/src/archive-form.ts b/src/archive-form.ts index cf4db0b..e83fd3f 100644 --- a/src/archive-form.ts +++ b/src/archive-form.ts @@ -40,10 +40,7 @@ export function initArchiveForm( .map((t) => t.trim()) .filter(Boolean), }); - // Beim Schließen leeren: Das Formular gehört zum Fenster, nicht zum Dokument. - // Stehengebliebene Werte würden beim nächsten Öffnen unbemerkt für ein anderes - // Dokument gelten — wer direkt Enter drückt, archiviert es mit dem Ordner und - // den Schlagwörtern des vorigen. + // Leeren, weil das Formular zum Fenster gehört und nicht zum Dokument. const close = () => { form.hidden = true; folder.value = ""; diff --git a/src/markdown.test.ts b/src/markdown.test.ts index 68975427090120b270cb8afc81dbab2a989f00f4..1b4be35674624a0709af86ddc2d4ff1024d9c645 100644 GIT binary patch delta 59 zcmZn^nJKd28>?PIR$^JAN_B8aQD$DcURqIpu5(6Wk#l}Zs)m7PZE^U7R$^HqLveCZW