From 6682040cc0d4255b62e9e475098a262df7e7ad7a Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Mon, 20 Jul 2026 13:37:02 +0200 Subject: [PATCH] Command-History: stabile ID je Eintrag statt Index+Text-Abgleich MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_commands vergibt jedem Eintrag eine UUID; commands_delete löscht darüber. Doppelklick oder ein zweites Fenster laufen ins 'bereits entfernt' statt auf verschobene Indizes. --- src-tauri/src/mcp.rs | 12 ++++++++++- src-tauri/src/terminal.rs | 45 +++++++++++++-------------------------- src/commands-view.test.ts | 10 ++++----- src/commands-view.ts | 10 +++++---- src/panel-wiring.ts | 2 +- 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 7c602e2..2f99ef0 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -276,7 +276,17 @@ fn call_write(req: &Value) -> Value { static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn call_write_commands(req: &Value) -> Value { - let commands = req["params"]["arguments"]["commands"].clone(); + // Jeder Eintrag bekommt beim Schreiben eine stabile ID — das Panel löscht + // darüber, statt Positionen über Watcher-Latenz und Fenstergrenzen zu + // reichen (der frühere Index+Text-Abgleich war ein Provisorium). + let mut commands = req["params"]["arguments"]["commands"].clone(); + if let Some(arr) = commands.as_array_mut() { + for c in arr { + if let Some(obj) = c.as_object_mut() { + obj.insert("id".into(), json!(uuid::Uuid::new_v4().to_string())); + } + } + } let count = commands.as_array().map(Vec::len).unwrap_or(0); let path = match env_path("AI_CONTROL_COMMANDS") { Ok(path) => path, diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index c0afd50..d0a274c 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -270,27 +270,12 @@ pub fn commands_read(project: String) -> String { } /// Entfernt einen Befehl aus der Command-History (Löschen einer Kachel im -/// Panel). `line` ist der Index der nicht-leeren JSONL-Zeile, `entry` der -/// 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 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. +/// Panel) über seine stabile ID, die write_commands beim Schreiben vergibt. +/// Ein leer gewordener Record fällt mit weg; der Watcher meldet den neuen +/// Stand als `commands-update`. Doppelklick oder ein zweites Fenster auf +/// derselben Liste laufen ins „bereits entfernt" statt auf falsche Indizes. #[tauri::command] -pub fn commands_delete( - project: String, - line: usize, - entry: usize, - cmd: String, -) -> Result<(), String> { +pub fn commands_delete(project: String, id: String) -> Result<(), String> { let path = commands_file(&project); let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let mut records: Vec = text @@ -298,18 +283,18 @@ pub fn commands_delete( .filter(|l| !l.trim().is_empty()) .map(|l| serde_json::from_str(l).map_err(|e| e.to_string())) .collect::>()?; - let cmds = records - .get_mut(line) - .ok_or("Befehl bereits entfernt")?["commands"] - .as_array_mut() - .ok_or("Record ohne commands")?; - if cmds.get(entry).and_then(|c| c["cmd"].as_str()) != Some(cmd.as_str()) { + let mut found = false; + for rec in records.iter_mut() { + if let Some(cmds) = rec["commands"].as_array_mut() { + let before = cmds.len(); + cmds.retain(|c| c["id"].as_str() != Some(id.as_str())); + found = found || cmds.len() != before; + } + } + if !found { return Err("Befehl bereits entfernt".into()); } - cmds.remove(entry); - if cmds.is_empty() { - records.remove(line); - } + records.retain(|r| r["commands"].as_array().is_none_or(|c| !c.is_empty())); let mut out = String::new(); for rec in &records { out.push_str(&rec.to_string()); diff --git a/src/commands-view.test.ts b/src/commands-view.test.ts index 79a1875..ecfb22b 100644 --- a/src/commands-view.test.ts +++ b/src/commands-view.test.ts @@ -79,7 +79,7 @@ describe("initCommandsView", () => { JSON.stringify({ ts: 1700000000, session: true }), JSON.stringify({ ts: 1700000100, - commands: [{ cmd: "ls -la", note: "Liste" }, { cmd: "pwd" }], + commands: [{ cmd: "ls -la", note: "Liste", id: "id-1" }, { cmd: "pwd", id: "id-2" }], }), ].join("\n"); @@ -94,14 +94,14 @@ describe("initCommandsView", () => { expect(view.empty()).toBe(true); }); - /// Der Befehlstext geht mit, weil die Indizes der Datei hinterherhinken - /// können; das Backend gleicht damit ab, bevor es löscht. - it("meldet Löschen mit Record-Index, Eintrags-Index und Befehl", () => { + /// Gelöscht wird über die stabile ID aus write_commands — keine Indizes, + /// die der Datei hinterherhinken könnten. + it("meldet Löschen mit der Eintrags-ID", () => { const onDelete = vi.fn(); const view = initCommandsView(document.getElementById("c")!, onDelete); view.set(jsonl); const dels = document.querySelectorAll(".cmd-del"); dels[1].click(); // zweite Kachel im Record 1 - expect(onDelete).toHaveBeenCalledWith(1, 1, "pwd"); + expect(onDelete).toHaveBeenCalledWith("id-2"); }); }); diff --git a/src/commands-view.ts b/src/commands-view.ts index eba1bb2..634130b 100644 --- a/src/commands-view.ts +++ b/src/commands-view.ts @@ -1,7 +1,7 @@ /// Kachel-Ansicht der Command-History: rendert die JSONL-Datei /// (write_commands im MCP-Server) als kopierbare Kacheln, Neuestes oben, /// mit Zeitmarken und Session-Trennern. Löschen einer Kachel geht als -/// onDelete(line, entry) an den Aufrufer; der neue Stand kommt über den +/// onDelete(id) an den Aufrufer; der neue Stand kommt über den /// Watcher zurück. DOM wird per createElement gebaut — Befehle sind /// Fremdtext und gehen nie durch innerHTML. @@ -17,6 +17,8 @@ function stripInvisibles(s: string): string { interface CommandEntry { cmd: string; note?: string; + /// Stabile ID aus write_commands; Grundlage fürs Löschen. + id?: string; } interface Record { @@ -79,7 +81,7 @@ function deleteBtn(onClick: () => void): HTMLButtonElement { export function initCommandsView( container: HTMLElement, - onDelete: (line: number, entry: number, cmd: string) => void, + onDelete: (id: string) => void, ): CommandsView { let count = 0; @@ -120,7 +122,7 @@ export function initCommandsView( } block.append(head); - cmds.forEach((entry, j) => { + cmds.forEach((entry) => { // Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND // Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als // die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über @@ -140,7 +142,7 @@ export function initCommandsView( note.textContent = entry.note; body.append(note); } - tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(i, j, entry.cmd))); + tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(entry.id ?? ""))); block.append(tile); }); container.append(block); diff --git a/src/panel-wiring.ts b/src/panel-wiring.ts index e5489a7..1b03fec 100644 --- a/src/panel-wiring.ts +++ b/src/panel-wiring.ts @@ -77,7 +77,7 @@ export async function wirePanel( }); const cmdView = initCommandsView( document.getElementById("commands-content")!, - (line, entry, cmd) => invoke("commands_delete", { project, line, entry, cmd }), + (id) => invoke("commands_delete", { project, id }), ); // Treffer-Klick lädt das Dokument in den Dokument-Tab (dort editier- und // archivierbar); der Sprung ins Wiki geht von dort aus.