From c779fa7fc5666d142e53aa4962113d4d973ea684 Mon Sep 17 00:00:00 2001 From: Eddy Date: Wed, 22 Apr 2026 08:56:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207=20=E2=80=94=20Accept/Reject?= =?UTF-8?q?=20DiffView,=20@-Mentions,=20Checkpoint/Rewind=20[appimage]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept/Reject DiffView: - Bridge sendet checkpoint-before/after Events bei Edit/Write - Rust speichert Datei-Snapshots in SQLite (content_before/after) - Frontend zeigt interaktive DiffView mit Accept/Reject-Buttons - Bei Reject: Datei wird automatisch auf Zustand VOR der Aenderung zurueckgesetzt - Neues Modul: checkpoint.rs mit Accept/Reject/Rewind Commands @-Mentions (Datei-Referenzen): - @datei.ts im Chat-Input oeffnet Fuzzy-Autocomplete - Fuzzy-Suche scannt Projektverzeichnis (max 5000 Dateien) - Score-basiertes Ranking (Anfang, Separator, konsekutive Matches) - Bei Auswahl: Dateiinhalt wird in Prompt injiziert (als Code-Block) - @datei.ts#5-10 fuer Zeilenbereiche - Neue Komponente: FileMention.svelte Checkpoint/Rewind: - Automatische Snapshots bei jeder Dateiänderung (Edit/Write) - SQLite-Tabelle checkpoints mit content_before/content_after - Rewind: Alle Dateien ab einem Checkpoint zuruecksetzen - Commands: accept_change, reject_change, list_checkpoints, rewind_to_checkpoint Co-Authored-By: Claude Opus 4.6 --- scripts/claude-bridge.js | 31 ++++ src-tauri/src/checkpoint.rs | 125 ++++++++++++++++ src-tauri/src/claude.rs | 51 +++++++ src-tauri/src/context.rs | 202 ++++++++++++++++++++++++++ src-tauri/src/db.rs | 114 +++++++++++++++ src-tauri/src/lib.rs | 9 ++ src/lib/components/ChatPanel.svelte | 152 ++++++++++++++++++- src/lib/components/DiffView.svelte | 186 +++++++++++++++++++++--- src/lib/components/FileMention.svelte | 169 +++++++++++++++++++++ src/lib/stores/app.ts | 12 ++ src/lib/stores/events.ts | 36 ++++- 11 files changed, 1063 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/checkpoint.rs create mode 100644 src/lib/components/FileMention.svelte diff --git a/scripts/claude-bridge.js b/scripts/claude-bridge.js index 2ef77e1..115955a 100644 --- a/scripts/claude-bridge.js +++ b/scripts/claude-bridge.js @@ -43,6 +43,10 @@ let isQueryRunning = false; // So kann der User wie in Claude Code/VS Code Extension weiter tippen. const pendingMessages = []; +// Tool-Changes: Bei Edit/Write wird der Diff an Rust/Frontend gemeldet +// damit der User Accept/Reject entscheiden kann (Post-Execution mit Rewind) +const CHANGE_TOOLS = ['Edit', 'Write']; + // Agent-Modus (solo | handlanger | experten | auto) let agentMode = 'solo'; @@ -500,6 +504,9 @@ async function sendMessage(message, requestId, model = null, contextOverride = n // als auch als standalone tool_use Event. Via toolUseId deduplizieren. const handledTools = new Set(); + // Tool-Info Cache: toolId → { name, filePath } fuer Checkpoint-After + const toolInfoCache = new Map(); + // Tool-Use handhaben — funktioniert sowohl fuer standalone tool_use Events // als auch fuer tool_use Bloecke innerhalb einer assistant-Nachricht function handleToolUse(ev) { @@ -544,6 +551,19 @@ async function sendMessage(message, requestId, model = null, contextOverride = n agentId: currentAgentId, }); + // Bei Edit/Write: Tool-Info cachen + Checkpoint-Event senden + if (CHANGE_TOOLS.includes(toolName) && toolInput.file_path) { + toolInfoCache.set(toolId, { name: toolName, filePath: toolInput.file_path }); + sendEvent('checkpoint-before', { + toolId, + tool: toolName, + filePath: toolInput.file_path, + oldString: toolInput.old_string || null, // Edit: was ersetzt wird + newString: toolInput.new_string || null, // Edit: womit ersetzt wird + content: toolInput.content || null, // Write: neuer Inhalt + }); + } + const toolSummary = summarizeToolInput(toolName, toolInput); sendMonitorEvent('tool', `${toolName} ${toolSummary}`, { toolId, @@ -572,6 +592,17 @@ async function sendMessage(message, requestId, model = null, contextOverride = n success: !ev.is_error, agentId: currentAgentId, }); + + // Bei Edit/Write: Checkpoint-After senden damit Frontend den Diff anzeigen kann + const toolInfo = toolInfoCache.get(toolId); + if (!ev.is_error && toolInfo) { + sendEvent('checkpoint-after', { + toolId, + tool: toolInfo.name, + filePath: toolInfo.filePath, + }); + toolInfoCache.delete(toolId); + } } // Hilfsfunktion: Iteration mit Fallback bei ungueltiger Session-ID. diff --git a/src-tauri/src/checkpoint.rs b/src-tauri/src/checkpoint.rs new file mode 100644 index 0000000..159d89d --- /dev/null +++ b/src-tauri/src/checkpoint.rs @@ -0,0 +1,125 @@ +// Claude Desktop — Checkpoint-System (Accept/Reject + Rewind) +// Ermoeglicht das Rueckgaengigmachen von Datei-Aenderungen + +use std::sync::{Arc, Mutex}; +use tauri::command; + +use crate::db; + +/// Checkpoint-Info fuer Frontend (Serialisierbar) +#[derive(Debug, serde::Serialize)] +pub struct CheckpointInfo { + pub tool_id: String, + pub tool_name: String, + pub file_path: String, + pub status: String, + pub created_at: String, + pub has_diff: bool, +} + +/// Aenderung akzeptieren — Checkpoint als 'accepted' markieren +#[command] +pub fn accept_change( + tool_id: String, + db_state: tauri::State<'_, Arc>>, +) -> Result { + let db = db_state.lock().map_err(|e| e.to_string())?; + db.accept_checkpoint(&tool_id) + .map_err(|e| format!("Checkpoint akzeptieren fehlgeschlagen: {}", e))?; + println!("✅ Aenderung akzeptiert: {}", tool_id); + Ok("akzeptiert".to_string()) +} + +/// Aenderung ablehnen — Datei auf content_before zuruecksetzen +#[command] +pub fn reject_change( + tool_id: String, + db_state: tauri::State<'_, Arc>>, +) -> Result { + let db = db_state.lock().map_err(|e| e.to_string())?; + + let checkpoint = db + .get_checkpoint(&tool_id) + .map_err(|e| format!("Checkpoint laden fehlgeschlagen: {}", e))? + .ok_or_else(|| format!("Checkpoint nicht gefunden: {}", tool_id))?; + + // Datei auf Zustand VOR der Aenderung zuruecksetzen + std::fs::write(&checkpoint.file_path, &checkpoint.content_before) + .map_err(|e| format!("Datei wiederherstellen fehlgeschlagen: {}", e))?; + + db.reject_checkpoint(&tool_id) + .map_err(|e| format!("Checkpoint Status-Update fehlgeschlagen: {}", e))?; + + println!( + "↩️ Aenderung abgelehnt + zurueckgesetzt: {} ({})", + checkpoint.file_path, tool_id + ); + Ok(format!("Datei zurueckgesetzt: {}", checkpoint.file_path)) +} + +/// Alle Checkpoints einer Session auflisten +#[command] +pub fn list_checkpoints( + session_id: String, + db_state: tauri::State<'_, Arc>>, +) -> Result, String> { + let db = db_state.lock().map_err(|e| e.to_string())?; + let checkpoints = db + .list_checkpoints(&session_id) + .map_err(|e| format!("Checkpoints laden fehlgeschlagen: {}", e))?; + + Ok(checkpoints + .into_iter() + .map(|c| CheckpointInfo { + tool_id: c.tool_id, + tool_name: c.tool_name, + file_path: c.file_path, + status: c.status, + created_at: c.created_at, + has_diff: c.content_after.is_some(), + }) + .collect()) +} + +/// Zu einem bestimmten Checkpoint zurueckspulen (alle danach auch revert) +#[command] +pub fn rewind_to_checkpoint( + tool_id: String, + session_id: String, + db_state: tauri::State<'_, Arc>>, +) -> Result, String> { + let db = db_state.lock().map_err(|e| e.to_string())?; + let checkpoints = db + .list_checkpoints(&session_id) + .map_err(|e| format!("Checkpoints laden fehlgeschlagen: {}", e))?; + + // Alle Checkpoints ab dem angegebenen zuruecksetzen (neueste zuerst = richtige Reihenfolge) + let mut reverted_files = Vec::new(); + let mut found = false; + + for cp in &checkpoints { + if cp.tool_id == tool_id { + found = true; + } + if found && (cp.status == "completed" || cp.status == "pending") { + // Datei wiederherstellen + if let Err(e) = std::fs::write(&cp.file_path, &cp.content_before) { + println!("⚠️ Rewind fehlgeschlagen fuer {}: {}", cp.file_path, e); + continue; + } + let _ = db.reject_checkpoint(&cp.tool_id); + reverted_files.push(cp.file_path.clone()); + println!("↩️ Rewind: {}", cp.file_path); + } + } + + if !found { + return Err(format!("Checkpoint nicht gefunden: {}", tool_id)); + } + + println!( + "↩️ Rewind abgeschlossen: {} Dateien zurueckgesetzt", + reverted_files.len() + ); + Ok(reverted_files) +} diff --git a/src-tauri/src/claude.rs b/src-tauri/src/claude.rs index 48351c2..bfeb97a 100644 --- a/src-tauri/src/claude.rs +++ b/src-tauri/src/claude.rs @@ -515,6 +515,57 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) { let mut state = state.lock().unwrap(); state.agents.clear(); } + "checkpoint-before" => { + // Datei VOR der Aenderung lesen und als Snapshot speichern + if let Some(file_path) = payload.get("filePath").and_then(|v| v.as_str()) { + let content_before = std::fs::read_to_string(file_path).unwrap_or_default(); + let tool_id = payload.get("toolId").and_then(|v| v.as_str()).unwrap_or(""); + let tool_name = payload.get("tool").and_then(|v| v.as_str()).unwrap_or(""); + + // In DB speichern + if let Some(db_state) = app.try_state::>>() { + let db_lock = db_state.lock().unwrap(); + if let Ok(Some(session_id)) = db_lock.get_setting("active_session_id") { + let _ = db_lock.save_checkpoint( + tool_id, + &session_id, + tool_name, + file_path, + &content_before, + ); + println!("📸 Checkpoint: {} ({} Bytes)", file_path, content_before.len()); + } + } + } + let _ = app.emit("checkpoint-before", &payload); + } + "checkpoint-after" => { + // Datei NACH der Aenderung lesen und Diff ans Frontend senden + if let Some(file_path) = payload.get("filePath").and_then(|v| v.as_str()) { + let tool_id = payload.get("toolId").and_then(|v| v.as_str()).unwrap_or(""); + let content_after = std::fs::read_to_string(file_path).unwrap_or_default(); + + // content_before aus DB holen + if let Some(db_state) = app.try_state::>>() { + let db_lock = db_state.lock().unwrap(); + if let Ok(Some(checkpoint)) = db_lock.get_checkpoint(tool_id) { + // Erweitertes Event mit Diff-Daten ans Frontend + let change_event = serde_json::json!({ + "toolId": tool_id, + "tool": payload.get("tool"), + "filePath": file_path, + "contentBefore": checkpoint.content_before, + "contentAfter": content_after, + }); + let _ = app.emit("file-change", &change_event); + println!("📝 Datei-Aenderung: {}", file_path); + + // content_after in Checkpoint updaten + let _ = db_lock.update_checkpoint_after(tool_id, &content_after); + } + } + } + } other => { // Generische Weiterleitung aller Bridge-Events ans Frontend // (subagent-started, subagent-stopped, monitor-event, mode-changed, diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 8ddc184..86afae3 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -591,3 +591,205 @@ pub async fn list_sticky_context( db.load_sticky_context().map_err(|e| e.to_string()) } + +// ============ @-Mentions: Fuzzy File Search ============ + +/// Datei-Ergebnis fuer Frontend +#[derive(Debug, Serialize)] +pub struct FileResult { + pub name: String, + pub path: String, // Relativer Pfad + pub full_path: String, // Absoluter Pfad +} + +/// Ignorierte Verzeichnisse beim Datei-Scan +const IGNORE_DIRS: &[&str] = &[ + "node_modules", ".git", "target", "build", "dist", ".svelte-kit", + "__pycache__", ".next", ".nuxt", "vendor", ".cargo", +]; + +/// Relativen Pfad in absoluten aufloesen (relativ zum Projektverzeichnis) +#[tauri::command] +pub async fn resolve_file_path( + relative_path: String, + app: AppHandle, +) -> Result { + let working_dir = { + let state = app.state::(); + let db = state.lock().unwrap(); + db.get_setting("working_dir") + .ok() + .flatten() + .unwrap_or_else(|| ".".to_string()) + }; + + let full_path = std::path::Path::new(&working_dir).join(&relative_path); + if full_path.exists() { + Ok(full_path.to_string_lossy().to_string()) + } else { + Err(format!("Datei nicht gefunden: {}", relative_path)) + } +} + +/// Dateiinhalt lesen (optional mit Zeilenbereich) +#[tauri::command] +pub async fn read_file_content( + file_path: String, + line_range: Option, +) -> Result { + let content = std::fs::read_to_string(&file_path) + .map_err(|e| format!("Datei lesen fehlgeschlagen: {}", e))?; + + // Zeilenbereich anwenden: "5-10" oder "5" + if let Some(range) = line_range { + let lines: Vec<&str> = content.lines().collect(); + let parts: Vec<&str> = range.split('-').collect(); + let start = parts[0].parse::().unwrap_or(1).saturating_sub(1); + let end = if parts.len() > 1 { + parts[1].parse::().unwrap_or(lines.len()) + } else { + start + 1 + }; + let end = end.min(lines.len()); + + Ok(lines[start..end].join("\n")) + } else { + // Max 50KB + if content.len() > 50_000 { + Ok(content[..50_000].to_string() + "\n... (abgeschnitten bei 50KB)") + } else { + Ok(content) + } + } +} + +/// Fuzzy-Suche in Projektdateien +#[tauri::command] +pub async fn fuzzy_search_files( + query: String, + app: AppHandle, +) -> Result, String> { + // Aktives Projektverzeichnis ermitteln + let working_dir = { + let state = app.state::(); + let db = state.lock().unwrap(); + db.get_setting("working_dir") + .ok() + .flatten() + .unwrap_or_else(|| ".".to_string()) + }; + + let query_lower = query.to_lowercase(); + let mut results: Vec<(FileResult, i32)> = Vec::new(); + + // Rekursiv Dateien scannen (max 5000) + let mut stack = vec![std::path::PathBuf::from(&working_dir)]; + let mut file_count = 0; + let max_files = 5000; + + while let Some(dir) = stack.pop() { + if file_count >= max_files { + break; + } + + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + + for entry in entries.flatten() { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + + // Ignorierte Verzeichnisse ueberspringen + if path.is_dir() { + if !IGNORE_DIRS.contains(&file_name.as_str()) && !file_name.starts_with('.') { + stack.push(path); + } + continue; + } + + file_count += 1; + + // Fuzzy-Match: Query-Zeichen muessen in Reihenfolge im Dateinamen vorkommen + let name_lower = file_name.to_lowercase(); + let score = fuzzy_score(&query_lower, &name_lower); + + if score > 0 { + let rel_path = path + .strip_prefix(&working_dir) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + + results.push(( + FileResult { + name: file_name, + path: rel_path, + full_path: path.to_string_lossy().to_string(), + }, + score, + )); + } + } + } + + // Nach Score sortieren (hoechster zuerst), max 10 Ergebnisse + results.sort_by(|a, b| b.1.cmp(&a.1)); + Ok(results.into_iter().take(10).map(|(r, _)| r).collect()) +} + +/// Fuzzy-Score berechnen: Wie gut passt query in target? +/// Hoeher = besser. 0 = kein Match. +fn fuzzy_score(query: &str, target: &str) -> i32 { + let query_chars: Vec = query.chars().collect(); + let target_chars: Vec = target.chars().collect(); + + if query_chars.is_empty() { + return 0; + } + + let mut score = 0i32; + let mut qi = 0; + let mut prev_match = false; + + for (ti, &tc) in target_chars.iter().enumerate() { + if qi < query_chars.len() && tc == query_chars[qi] { + score += 10; // Basis-Punkte fuer Match + + // Bonus fuer aufeinanderfolgende Matches + if prev_match { + score += 5; + } + + // Bonus fuer Match am Anfang + if ti == 0 { + score += 15; + } + + // Bonus fuer Match nach Separator (/, -, _, .) + if ti > 0 { + let prev = target_chars[ti - 1]; + if prev == '/' || prev == '-' || prev == '_' || prev == '.' { + score += 10; + } + } + + qi += 1; + prev_match = true; + } else { + prev_match = false; + } + } + + // Alle Query-Zeichen muessen matchen + if qi < query_chars.len() { + return 0; + } + + // Bonus fuer exakte Laenge-Naehe (kurze Namen bevorzugen) + let length_diff = (target_chars.len() as i32 - query_chars.len() as i32).abs(); + score -= length_diff; + + score.max(1) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index ae97dd9..6f5c27b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -54,6 +54,19 @@ pub struct MonitorEvent { pub error: Option, } +/// Checkpoint-Eintrag (Datei-Snapshot fuer Accept/Reject + Rewind) +#[allow(dead_code)] +pub struct CheckpointEntry { + pub tool_id: String, + pub session_id: String, + pub tool_name: String, + pub file_path: String, + pub content_before: String, + pub content_after: Option, + pub status: String, + pub created_at: String, +} + /// Datenbank-Wrapper pub struct Database { pub(crate) conn: Connection, @@ -230,6 +243,19 @@ impl Database { UNIQUE(error_hash) ); CREATE INDEX IF NOT EXISTS idx_error_tracker_count ON error_tracker(occurrence_count DESC); + + -- Checkpoints: Datei-Snapshots fuer Accept/Reject und Rewind + CREATE TABLE IF NOT EXISTS checkpoints ( + tool_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + file_path TEXT NOT NULL, + content_before TEXT NOT NULL, + content_after TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id, created_at DESC); ", )?; Ok(()) @@ -887,6 +913,94 @@ impl Database { Ok(counts) } + // ============ Checkpoints (Accept/Reject + Rewind) ============ + + /// Checkpoint speichern (VOR der Aenderung) + pub fn save_checkpoint( + &self, + tool_id: &str, + session_id: &str, + tool_name: &str, + file_path: &str, + content_before: &str, + ) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO checkpoints (tool_id, session_id, tool_name, file_path, content_before, status) + VALUES (?1, ?2, ?3, ?4, ?5, 'pending')", + params![tool_id, session_id, tool_name, file_path, content_before], + )?; + Ok(()) + } + + /// Checkpoint After-Content updaten + pub fn update_checkpoint_after(&self, tool_id: &str, content_after: &str) -> SqlResult<()> { + self.conn.execute( + "UPDATE checkpoints SET content_after = ?1, status = 'completed' WHERE tool_id = ?2", + params![content_after, tool_id], + )?; + Ok(()) + } + + /// Checkpoint abrufen + pub fn get_checkpoint(&self, tool_id: &str) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT tool_id, session_id, tool_name, file_path, content_before, content_after, status, created_at + FROM checkpoints WHERE tool_id = ?1" + )?; + let result: Vec = stmt.query_map(params![tool_id], |row| { + Ok(CheckpointEntry { + tool_id: row.get(0)?, + session_id: row.get(1)?, + tool_name: row.get(2)?, + file_path: row.get(3)?, + content_before: row.get(4)?, + content_after: row.get(5)?, + status: row.get(6)?, + created_at: row.get(7)?, + }) + })?.collect::>>()?; + Ok(result.into_iter().next()) + } + + /// Alle Checkpoints einer Session (neueste zuerst) + pub fn list_checkpoints(&self, session_id: &str) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT tool_id, session_id, tool_name, file_path, content_before, content_after, status, created_at + FROM checkpoints WHERE session_id = ?1 ORDER BY created_at DESC LIMIT 100" + )?; + let result = stmt.query_map(params![session_id], |row| { + Ok(CheckpointEntry { + tool_id: row.get(0)?, + session_id: row.get(1)?, + tool_name: row.get(2)?, + file_path: row.get(3)?, + content_before: row.get(4)?, + content_after: row.get(5)?, + status: row.get(6)?, + created_at: row.get(7)?, + }) + })?.collect::>>()?; + Ok(result) + } + + /// Checkpoint-Status auf 'rejected' setzen + pub fn reject_checkpoint(&self, tool_id: &str) -> SqlResult<()> { + self.conn.execute( + "UPDATE checkpoints SET status = 'rejected' WHERE tool_id = ?1", + params![tool_id], + )?; + Ok(()) + } + + /// Checkpoint-Status auf 'accepted' setzen + pub fn accept_checkpoint(&self, tool_id: &str) -> SqlResult<()> { + self.conn.execute( + "UPDATE checkpoints SET status = 'accepted' WHERE tool_id = ?1", + params![tool_id], + )?; + Ok(()) + } + // ============ Phase 2.0: Fehler-Tracking ============ /// Fehler-Occurrence zählen und zurückgeben diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d515d3..39fb7f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ use tauri::{ use webkit2gtk::WebViewExt; mod audit; +mod checkpoint; mod claude; mod clipboard; mod commands; @@ -150,6 +151,9 @@ pub fn run() { context::log_context_failure, context::get_full_context, context::list_sticky_context, + context::fuzzy_search_files, + context::resolve_file_path, + context::read_file_content, // Voice-Interface voice::transcribe_audio, voice::text_to_speech, @@ -196,6 +200,11 @@ pub fn run() { clipboard::clipboard_watch_status, // Slash-Command Registry commands::get_slash_commands, + // Checkpoints (Accept/Reject + Rewind) + checkpoint::accept_change, + checkpoint::reject_change, + checkpoint::list_checkpoints, + checkpoint::rewind_to_checkpoint, ]) .setup(|app| { let handle = app.handle().clone(); diff --git a/src/lib/components/ChatPanel.svelte b/src/lib/components/ChatPanel.svelte index a34f55e..77e87ef 100644 --- a/src/lib/components/ChatPanel.svelte +++ b/src/lib/components/ChatPanel.svelte @@ -1,12 +1,14 @@ -
- {#if filename} -
- {filename} +
+
+
+ {#if filename} + {shortenPath(filename)} + {/if} {#if language} {language} {/if} - +{stats.added} - -{stats.removed} + +{stats.added} + -{stats.removed}
- {/if} +
+ {#if diffLines.length > 20} + + {/if} + {#if interactive} + + + {/if} +
+
- {#each diffLines as line, idx (idx)} + {#each visibleLines as { line, idx } (idx)} + {@const prevIdx = visibleLines[visibleLines.indexOf({ line, idx }) - 1]?.idx} + {#if idx > 0 && prevIdx !== undefined && idx - prevIdx > 1} +
+ {/if}
{line.lineNo.old ?? ''} {line.lineNo.new ?? ''} @@ -153,18 +223,42 @@ overflow: hidden; } + .diff-view.interactive { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent), 0 2px 8px rgba(0, 0, 0, 0.2); + } + .diff-header { display: flex; align-items: center; - gap: var(--spacing-sm); + justify-content: space-between; padding: var(--spacing-xs) var(--spacing-sm); background: var(--bg-secondary); border-bottom: 1px solid var(--border); + gap: var(--spacing-sm); + } + + .diff-header-left { + display: flex; + align-items: center; + gap: var(--spacing-sm); + min-width: 0; + flex: 1; + } + + .diff-header-right { + display: flex; + align-items: center; + gap: var(--spacing-xs); + flex-shrink: 0; } .filename { font-weight: 600; color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .language { @@ -176,23 +270,66 @@ } .stats { - margin-left: auto; display: flex; gap: var(--spacing-xs); font-size: 0.7rem; } - .stats .added { - color: var(--success); + .stat-added { color: var(--success); } + .stat-removed { color: var(--error); } + + .btn-toggle { + padding: 2px 6px; + font-size: 0.6rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; } - .stats .removed { + .btn-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .btn-accept { + padding: 2px 8px; + font-size: 0.65rem; + background: rgba(34, 197, 94, 0.15); + border: 1px solid var(--success); + border-radius: var(--radius-sm); + color: var(--success); + cursor: pointer; + font-weight: 600; + transition: all 0.15s; + } + + .btn-accept:hover { + background: var(--success); + color: white; + } + + .btn-reject { + padding: 2px 8px; + font-size: 0.65rem; + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--error); + border-radius: var(--radius-sm); color: var(--error); + cursor: pointer; + font-weight: 600; + transition: all 0.15s; + } + + .btn-reject:hover { + background: var(--error); + color: white; } .diff-content { overflow-x: auto; - max-height: 300px; + max-height: 400px; overflow-y: auto; } @@ -239,4 +376,13 @@ flex: 1; padding-left: var(--spacing-xs); } + + .diff-separator { + text-align: center; + padding: 2px 0; + color: var(--text-secondary); + background: var(--bg-tertiary); + font-size: 0.6rem; + user-select: none; + } diff --git a/src/lib/components/FileMention.svelte b/src/lib/components/FileMention.svelte new file mode 100644 index 0000000..f58429a --- /dev/null +++ b/src/lib/components/FileMention.svelte @@ -0,0 +1,169 @@ + + +{#if visible && (results.length > 0 || loading)} +
+ {#if loading && results.length === 0} +
Suche...
+ {/if} + {#each results.slice(0, 8) as file, idx (file.full_path)} + + +
onSelect(file)} + > + {getFileIcon(file.name)} + {file.name} + {file.path} +
+ {/each} +
+{/if} + + diff --git a/src/lib/stores/app.ts b/src/lib/stores/app.ts index 07f9637..989c494 100644 --- a/src/lib/stores/app.ts +++ b/src/lib/stores/app.ts @@ -59,6 +59,18 @@ export interface QuickAction { invokeArgs?: Record; } +// Pending File-Changes (Accept/Reject DiffView) +export interface FileChange { + toolId: string; + tool: string; + filePath: string; + contentBefore: string; + contentAfter: string; + timestamp: Date; +} + +export const pendingChanges = writable([]); + // Stores export const agents = writable([]); export const toolCalls = writable([]); diff --git a/src/lib/stores/events.ts b/src/lib/stores/events.ts index 225af5c..6980a1f 100644 --- a/src/lib/stores/events.ts +++ b/src/lib/stores/events.ts @@ -25,11 +25,13 @@ import { loadMonitorEventsFromDb, activeKnowledgeHints, agentMode, + pendingChanges, type Message, type Agent, type MonitorEventType, type KnowledgeHint, - type AgentMode + type AgentMode, + type FileChange, } from './app'; // Aktuell laufendes Tool (für inline Aktivitätsanzeige) @@ -459,6 +461,38 @@ export async function initEventListeners(): Promise { }) ); + // File-Change Events — Accept/Reject DiffView + listeners.push( + await listen<{ + toolId: string; + tool: string; + filePath: string; + contentBefore: string; + contentAfter: string; + }>('file-change', (event) => { + const { toolId, tool, filePath, contentBefore, contentAfter } = event.payload; + console.log('📝 Datei-Aenderung:', filePath); + + const change: FileChange = { + toolId, + tool, + filePath, + contentBefore, + contentAfter, + timestamp: new Date(), + }; + + pendingChanges.update((changes) => [...changes, change]); + + addMonitorEvent('tool', `Datei geaendert: ${filePath.split('/').pop()}`, { + toolId, + tool, + filePath, + addedLines: contentAfter.split('\n').length - contentBefore.split('\n').length, + }); + }) + ); + console.log('✅ Event-Listener initialisiert'); }