Compare commits
2 commits
9c495fa6d2
...
4405979fa5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4405979fa5 | ||
|
|
2653b4fe8f |
5 changed files with 648 additions and 10 deletions
17
ROADMAP.md
17
ROADMAP.md
|
|
@ -121,10 +121,13 @@ Stand: 14.04.2026
|
||||||
- Löscht Antwort, sendet User-Nachricht erneut
|
- Löscht Antwort, sendet User-Nachricht erneut
|
||||||
- Nur wenn nicht isProcessing
|
- Nur wenn nicht isProcessing
|
||||||
|
|
||||||
|
### Nachträglich implementiert
|
||||||
|
|
||||||
|
- ✅ **DiffView.svelte** — Diff-Ansicht für Edit-Tool Ergebnisse (2653b4f)
|
||||||
|
- ✅ **FilePreview.svelte** — Dateivorschau für Read-Tool Ergebnisse (2653b4f)
|
||||||
|
|
||||||
### Noch offen (niedrigere Priorität)
|
### Noch offen (niedrigere Priorität)
|
||||||
|
|
||||||
- [ ] **DiffView.svelte** — Für Edit-Tool Ergebnisse
|
|
||||||
- [ ] **FilePreview.svelte** — Für Read-Tool Ergebnisse
|
|
||||||
- [ ] **Keyboard Shortcuts** — Cmd+K, Cmd+Shift+Enter
|
- [ ] **Keyboard Shortcuts** — Cmd+K, Cmd+Shift+Enter
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -254,9 +257,14 @@ Compacting ist **notwendig** (Token-Limit, Kosten, Latenz), aber dabei geht krit
|
||||||
- ✅ **src/routes/+page.svelte**
|
- ✅ **src/routes/+page.svelte**
|
||||||
- Neuer Tab "📌 Context" im rechten Panel
|
- Neuer Tab "📌 Context" im rechten Panel
|
||||||
|
|
||||||
|
### Nachträglich implementiert
|
||||||
|
|
||||||
|
- ✅ **Bridge-Integration** — Context bei jedem API-Call injizieren (2653b4f)
|
||||||
|
- `claude-bridge.js`: Sticky Context als Prefix zur Nachricht
|
||||||
|
- `claude.rs`: Context automatisch aus DB laden
|
||||||
|
|
||||||
### Noch offen (niedrigere Priorität)
|
### Noch offen (niedrigere Priorität)
|
||||||
|
|
||||||
- [ ] **Bridge-Integration** — Context bei jedem API-Call injizieren
|
|
||||||
- [ ] **Auto-Extraction vor Compacting** — Hook automatisch auslösen
|
- [ ] **Auto-Extraction vor Compacting** — Hook automatisch auslösen
|
||||||
- [ ] **Validation** — Prüfen ob Claude den Context nutzt
|
- [ ] **Validation** — Prüfen ob Claude den Context nutzt
|
||||||
- [ ] **Wissens-Hints** — On-demand aus claude-db laden
|
- [ ] **Wissens-Hints** — On-demand aus claude-db laden
|
||||||
|
|
@ -265,7 +273,7 @@ Compacting ist **notwendig** (Token-Limit, Kosten, Latenz), aber dabei geht krit
|
||||||
```bash
|
```bash
|
||||||
# Context-Panel öffnen → Einträge hinzufügen
|
# Context-Panel öffnen → Einträge hinzufügen
|
||||||
# Vorschau → <critical-context> Tags sichtbar
|
# Vorschau → <critical-context> Tags sichtbar
|
||||||
# Token-Anzeige zeigt ~200 Token
|
# Nachricht senden → Monitor zeigt "+XX ctx" Token
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1196,3 +1204,4 @@ CARGO_TARGET_DIR=/tmp/claude-desktop-target nix-shell --run "npx tauri build"
|
||||||
| 14.04.2026 | abaf4eb | **Phase 6:** Session-Management, Auto-Load, Compacting |
|
| 14.04.2026 | abaf4eb | **Phase 6:** Session-Management, Auto-Load, Compacting |
|
||||||
| 14.04.2026 | e6bd0de | **Phase 8:** Claude-DB Integration, KnowledgePanel |
|
| 14.04.2026 | e6bd0de | **Phase 8:** Claude-DB Integration, KnowledgePanel |
|
||||||
| 14.04.2026 | eb91e54 | **Phase 9:** Context-Management, ContextPanel |
|
| 14.04.2026 | eb91e54 | **Phase 9:** Context-Management, ContextPanel |
|
||||||
|
| 14.04.2026 | 2653b4f | Bridge-Context Integration, DiffView, FilePreview |
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ let activeAbort = null;
|
||||||
let currentAgentId = null;
|
let currentAgentId = null;
|
||||||
let currentModel = process.env.CLAUDE_MODEL || 'opus';
|
let currentModel = process.env.CLAUDE_MODEL || 'opus';
|
||||||
|
|
||||||
|
// Sticky Context (Schicht 1) — wird bei JEDEM API-Call injiziert
|
||||||
|
let stickyContext = '';
|
||||||
|
|
||||||
// Subagent-Tracking
|
// Subagent-Tracking
|
||||||
// Map: toolUseId → { agentId, parentId, type, task, depth }
|
// Map: toolUseId → { agentId, parentId, type, task, depth }
|
||||||
const activeSubagents = new Map();
|
const activeSubagents = new Map();
|
||||||
|
|
@ -116,10 +119,13 @@ function summarizeToolInput(tool, input) {
|
||||||
|
|
||||||
// ============ Claude Agent SDK ============
|
// ============ Claude Agent SDK ============
|
||||||
|
|
||||||
async function sendMessage(message, requestId, model = null) {
|
async function sendMessage(message, requestId, model = null, contextOverride = null) {
|
||||||
// Modell für diese Anfrage (Parameter > State > Default)
|
// Modell für diese Anfrage (Parameter > State > Default)
|
||||||
const useModel = model || currentModel;
|
const useModel = model || currentModel;
|
||||||
|
|
||||||
|
// Context für diese Anfrage (Parameter > State)
|
||||||
|
const useContext = contextOverride || stickyContext;
|
||||||
|
|
||||||
currentAgentId = randomUUID();
|
currentAgentId = randomUUID();
|
||||||
activeAbort = new AbortController();
|
activeAbort = new AbortController();
|
||||||
|
|
||||||
|
|
@ -135,24 +141,32 @@ async function sendMessage(message, requestId, model = null) {
|
||||||
agentId: currentAgentId,
|
agentId: currentAgentId,
|
||||||
model: useModel,
|
model: useModel,
|
||||||
task: message.substring(0, 100),
|
task: message.substring(0, 100),
|
||||||
|
contextTokens: useContext ? Math.ceil(useContext.length / 4) : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monitor: API-Request
|
// Monitor: API-Request
|
||||||
sendMonitorEvent('api', `→ ${useModel}`, {
|
const contextInfo = useContext ? ` +${Math.ceil(useContext.length / 4)} ctx` : '';
|
||||||
|
sendMonitorEvent('api', `→ ${useModel}${contextInfo}`, {
|
||||||
model: useModel,
|
model: useModel,
|
||||||
promptLength: message.length,
|
promptLength: message.length,
|
||||||
|
contextLength: useContext?.length || 0,
|
||||||
maxTurns: 25,
|
maxTurns: 25,
|
||||||
});
|
});
|
||||||
|
|
||||||
sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel });
|
sendResponse(requestId, { agentId: currentAgentId, status: 'gestartet', model: useModel });
|
||||||
|
|
||||||
|
// Nachricht mit Context kombinieren
|
||||||
|
const fullPrompt = useContext
|
||||||
|
? `${useContext}\n\n---\n\n${message}`
|
||||||
|
: message;
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
let usedModel = useModel;
|
let usedModel = useModel;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const conversation = query({
|
const conversation = query({
|
||||||
prompt: message,
|
prompt: fullPrompt,
|
||||||
options: {
|
options: {
|
||||||
model: useModel,
|
model: useModel,
|
||||||
maxTurns: 25,
|
maxTurns: 25,
|
||||||
|
|
@ -328,8 +342,32 @@ function handleCommand(msg) {
|
||||||
sendError(msg.id, 'Keine Nachricht angegeben');
|
sendError(msg.id, 'Keine Nachricht angegeben');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Modell kann pro Anfrage überschrieben werden
|
// Modell und Context können pro Anfrage überschrieben werden
|
||||||
sendMessage(msg.message, msg.id, msg.model);
|
sendMessage(msg.message, msg.id, msg.model, msg.context);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'set-context':
|
||||||
|
// Sticky Context setzen (wird bei allen folgenden Nachrichten verwendet)
|
||||||
|
stickyContext = msg.context || '';
|
||||||
|
const ctxTokens = stickyContext ? Math.ceil(stickyContext.length / 4) : 0;
|
||||||
|
sendResponse(msg.id, { status: 'Context gesetzt', tokens: ctxTokens });
|
||||||
|
sendMonitorEvent('hook', `Sticky Context gesetzt (~${ctxTokens} Token)`, {
|
||||||
|
contextLength: stickyContext.length,
|
||||||
|
estimatedTokens: ctxTokens,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'get-context':
|
||||||
|
sendResponse(msg.id, {
|
||||||
|
context: stickyContext,
|
||||||
|
tokens: stickyContext ? Math.ceil(stickyContext.length / 4) : 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clear-context':
|
||||||
|
stickyContext = '';
|
||||||
|
sendResponse(msg.id, { status: 'Context gelöscht' });
|
||||||
|
sendMonitorEvent('hook', 'Sticky Context gelöscht', {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'stop':
|
case 'stop':
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use std::process::{Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::{AppHandle, Emitter, Manager};
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
|
use crate::context;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
|
||||||
/// Status eines Agents
|
/// Status eines Agents
|
||||||
|
|
@ -258,6 +259,11 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
|
||||||
|
|
||||||
/// Befehl an Bridge senden
|
/// Befehl an Bridge senden
|
||||||
fn send_to_bridge(app: &AppHandle, command: &str, message: &str) -> Result<String, String> {
|
fn send_to_bridge(app: &AppHandle, command: &str, message: &str) -> Result<String, String> {
|
||||||
|
send_to_bridge_with_context(app, command, message, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Befehl an Bridge senden mit optionalem Context
|
||||||
|
fn send_to_bridge_with_context(app: &AppHandle, command: &str, message: &str, context: Option<String>) -> Result<String, String> {
|
||||||
let state = app.state::<Arc<Mutex<ClaudeState>>>();
|
let state = app.state::<Arc<Mutex<ClaudeState>>>();
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
|
|
||||||
|
|
@ -271,6 +277,25 @@ fn send_to_bridge(app: &AppHandle, command: &str, message: &str) -> Result<Strin
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"model": message
|
"model": message
|
||||||
}),
|
}),
|
||||||
|
"message" => {
|
||||||
|
let mut payload = serde_json::json!({
|
||||||
|
"command": command,
|
||||||
|
"id": request_id,
|
||||||
|
"message": message
|
||||||
|
});
|
||||||
|
// Context hinzufügen wenn vorhanden
|
||||||
|
if let Some(ctx) = context {
|
||||||
|
if !ctx.is_empty() {
|
||||||
|
payload["context"] = serde_json::Value::String(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
},
|
||||||
|
"set-context" | "clear-context" => serde_json::json!({
|
||||||
|
"command": command,
|
||||||
|
"id": request_id,
|
||||||
|
"context": message
|
||||||
|
}),
|
||||||
_ => serde_json::json!({
|
_ => serde_json::json!({
|
||||||
"command": command,
|
"command": command,
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
|
|
@ -307,12 +332,70 @@ pub async fn send_message(app: AppHandle, message: String) -> Result<String, Str
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
send_to_bridge(&app, "message", &message)?;
|
// Context aus DB laden (Schicht 1: Sticky Context)
|
||||||
|
let context = load_sticky_context_for_prompt(&app);
|
||||||
|
|
||||||
|
if context.is_some() {
|
||||||
|
println!("📌 Sticky Context geladen (~{} Token)", context.as_ref().map(|c| c.len() / 4).unwrap_or(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
send_to_bridge_with_context(&app, "message", &message, context)?;
|
||||||
|
|
||||||
// Hinweis: Die eigentliche Antwort kommt über Events
|
// Hinweis: Die eigentliche Antwort kommt über Events
|
||||||
Ok("Nachricht gesendet. Antwort folgt über Events.".to_string())
|
Ok("Nachricht gesendet. Antwort folgt über Events.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sticky Context aus DB laden und als Prompt-Text rendern
|
||||||
|
fn load_sticky_context_for_prompt(app: &AppHandle) -> Option<String> {
|
||||||
|
use crate::context;
|
||||||
|
|
||||||
|
// Versuche Context zu laden
|
||||||
|
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
|
||||||
|
let db = db_state.lock().ok()?;
|
||||||
|
|
||||||
|
// Context-Tabellen erstellen falls nicht vorhanden
|
||||||
|
let _ = db.create_context_tables();
|
||||||
|
|
||||||
|
// Sticky Context laden
|
||||||
|
let entries = db.load_sticky_context().ok()?;
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sticky = context::StickyContext::default();
|
||||||
|
|
||||||
|
for (key, value, _priority) in entries {
|
||||||
|
match key.as_str() {
|
||||||
|
"user_info" => sticky.user_info = Some(value),
|
||||||
|
k if k.starts_with("cred:") => {
|
||||||
|
if let Ok(cred) = serde_json::from_str::<context::CredentialHint>(&value) {
|
||||||
|
sticky.active_credentials.push(cred);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k if k.starts_with("project:") => {
|
||||||
|
if let Ok(proj) = serde_json::from_str::<context::ProjectInfo>(&value) {
|
||||||
|
sticky.current_project = Some(proj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k if k.starts_with("rule:") => {
|
||||||
|
sticky.critical_rules.push(value);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = sticky.render();
|
||||||
|
if rendered.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(rendered)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Alle Agents stoppen
|
/// Alle Agents stoppen
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_all_agents(app: AppHandle) -> Result<(), String> {
|
pub async fn stop_all_agents(app: AppHandle) -> Result<(), String> {
|
||||||
|
|
|
||||||
242
src/lib/components/DiffView.svelte
Normal file
242
src/lib/components/DiffView.svelte
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
<script lang="ts">
|
||||||
|
// DiffView — Zeigt Unterschiede zwischen altem und neuem Code
|
||||||
|
// Verwendet für Edit-Tool Ergebnisse
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
oldText: string;
|
||||||
|
newText: string;
|
||||||
|
filename?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { oldText, newText, filename = '', language = '' }: Props = $props();
|
||||||
|
|
||||||
|
// Einfache Diff-Berechnung (zeilenbasiert)
|
||||||
|
interface DiffLine {
|
||||||
|
type: 'unchanged' | 'added' | 'removed';
|
||||||
|
lineNo: { old: number | null; new: number | null };
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDiff(oldStr: string, newStr: string): DiffLine[] {
|
||||||
|
const oldLines = oldStr.split('\n');
|
||||||
|
const newLines = newStr.split('\n');
|
||||||
|
const result: DiffLine[] = [];
|
||||||
|
|
||||||
|
// Einfacher LCS-basierter Diff
|
||||||
|
const lcs = longestCommonSubsequence(oldLines, newLines);
|
||||||
|
let oldIdx = 0;
|
||||||
|
let newIdx = 0;
|
||||||
|
let lcsIdx = 0;
|
||||||
|
|
||||||
|
while (oldIdx < oldLines.length || newIdx < newLines.length) {
|
||||||
|
if (lcsIdx < lcs.length && oldIdx < oldLines.length && oldLines[oldIdx] === lcs[lcsIdx]) {
|
||||||
|
// Unveränderte Zeile
|
||||||
|
if (newIdx < newLines.length && newLines[newIdx] === lcs[lcsIdx]) {
|
||||||
|
result.push({
|
||||||
|
type: 'unchanged',
|
||||||
|
lineNo: { old: oldIdx + 1, new: newIdx + 1 },
|
||||||
|
text: oldLines[oldIdx]
|
||||||
|
});
|
||||||
|
oldIdx++;
|
||||||
|
newIdx++;
|
||||||
|
lcsIdx++;
|
||||||
|
} else {
|
||||||
|
// Neue Zeile hinzugefügt
|
||||||
|
result.push({
|
||||||
|
type: 'added',
|
||||||
|
lineNo: { old: null, new: newIdx + 1 },
|
||||||
|
text: newLines[newIdx]
|
||||||
|
});
|
||||||
|
newIdx++;
|
||||||
|
}
|
||||||
|
} else if (oldIdx < oldLines.length && (lcsIdx >= lcs.length || oldLines[oldIdx] !== lcs[lcsIdx])) {
|
||||||
|
// Zeile entfernt
|
||||||
|
result.push({
|
||||||
|
type: 'removed',
|
||||||
|
lineNo: { old: oldIdx + 1, new: null },
|
||||||
|
text: oldLines[oldIdx]
|
||||||
|
});
|
||||||
|
oldIdx++;
|
||||||
|
} else if (newIdx < newLines.length) {
|
||||||
|
// Zeile hinzugefügt
|
||||||
|
result.push({
|
||||||
|
type: 'added',
|
||||||
|
lineNo: { old: null, new: newIdx + 1 },
|
||||||
|
text: newLines[newIdx]
|
||||||
|
});
|
||||||
|
newIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestCommonSubsequence(a: string[], b: string[]): string[] {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack um LCS zu rekonstruieren
|
||||||
|
const result: string[] = [];
|
||||||
|
let i = m, j = n;
|
||||||
|
while (i > 0 && j > 0) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
result.unshift(a[i - 1]);
|
||||||
|
i--;
|
||||||
|
j--;
|
||||||
|
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||||
|
i--;
|
||||||
|
} else {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff berechnen wenn sich Inputs ändern
|
||||||
|
let diffLines = $derived(computeDiff(oldText, newText));
|
||||||
|
|
||||||
|
// Statistiken
|
||||||
|
let stats = $derived({
|
||||||
|
added: diffLines.filter(l => l.type === 'added').length,
|
||||||
|
removed: diffLines.filter(l => l.type === 'removed').length,
|
||||||
|
unchanged: diffLines.filter(l => l.type === 'unchanged').length,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="diff-view">
|
||||||
|
{#if filename}
|
||||||
|
<div class="diff-header">
|
||||||
|
<span class="filename">{filename}</span>
|
||||||
|
{#if language}
|
||||||
|
<span class="language">{language}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="stats">
|
||||||
|
<span class="added">+{stats.added}</span>
|
||||||
|
<span class="removed">-{stats.removed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="diff-content">
|
||||||
|
{#each diffLines as line, idx (idx)}
|
||||||
|
<div class="diff-line" class:added={line.type === 'added'} class:removed={line.type === 'removed'}>
|
||||||
|
<span class="line-no old">{line.lineNo.old ?? ''}</span>
|
||||||
|
<span class="line-no new">{line.lineNo.new ?? ''}</span>
|
||||||
|
<span class="line-marker">
|
||||||
|
{#if line.type === 'added'}+{:else if line.type === 'removed'}-{:else}{/if}
|
||||||
|
</span>
|
||||||
|
<span class="line-text">{line.text || '\u00A0'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diff-view {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats .added {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats .removed {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-content {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line {
|
||||||
|
display: flex;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.added {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.removed {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-no {
|
||||||
|
width: 32px;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-marker {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.added .line-marker {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.removed .line-marker {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-text {
|
||||||
|
flex: 1;
|
||||||
|
padding-left: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
src/lib/components/FilePreview.svelte
Normal file
266
src/lib/components/FilePreview.svelte
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
<script lang="ts">
|
||||||
|
// FilePreview — Zeigt Dateiinhalt mit Syntax-Highlighting
|
||||||
|
// Verwendet für Read-Tool Ergebnisse
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
startLine?: number;
|
||||||
|
maxLines?: number;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
startLine = 1,
|
||||||
|
maxLines = 50,
|
||||||
|
collapsed = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isCollapsed = $state(collapsed);
|
||||||
|
|
||||||
|
// Sprache aus Dateiendung ermitteln
|
||||||
|
function getLanguage(fname: string): string {
|
||||||
|
const ext = fname.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const langMap: Record<string, string> = {
|
||||||
|
'js': 'javascript',
|
||||||
|
'ts': 'typescript',
|
||||||
|
'jsx': 'javascript',
|
||||||
|
'tsx': 'typescript',
|
||||||
|
'py': 'python',
|
||||||
|
'rs': 'rust',
|
||||||
|
'go': 'go',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'php': 'php',
|
||||||
|
'java': 'java',
|
||||||
|
'c': 'c',
|
||||||
|
'cpp': 'cpp',
|
||||||
|
'h': 'c',
|
||||||
|
'hpp': 'cpp',
|
||||||
|
'cs': 'csharp',
|
||||||
|
'swift': 'swift',
|
||||||
|
'kt': 'kotlin',
|
||||||
|
'scala': 'scala',
|
||||||
|
'r': 'r',
|
||||||
|
'sql': 'sql',
|
||||||
|
'sh': 'bash',
|
||||||
|
'bash': 'bash',
|
||||||
|
'zsh': 'bash',
|
||||||
|
'ps1': 'powershell',
|
||||||
|
'yaml': 'yaml',
|
||||||
|
'yml': 'yaml',
|
||||||
|
'json': 'json',
|
||||||
|
'xml': 'xml',
|
||||||
|
'html': 'html',
|
||||||
|
'htm': 'html',
|
||||||
|
'css': 'css',
|
||||||
|
'scss': 'scss',
|
||||||
|
'sass': 'sass',
|
||||||
|
'less': 'less',
|
||||||
|
'md': 'markdown',
|
||||||
|
'markdown': 'markdown',
|
||||||
|
'toml': 'toml',
|
||||||
|
'ini': 'ini',
|
||||||
|
'cfg': 'ini',
|
||||||
|
'conf': 'ini',
|
||||||
|
'dockerfile': 'dockerfile',
|
||||||
|
'makefile': 'makefile',
|
||||||
|
'svelte': 'svelte',
|
||||||
|
'vue': 'vue',
|
||||||
|
};
|
||||||
|
return langMap[ext] || 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeilen mit Nummern
|
||||||
|
let lines = $derived(content.split('\n'));
|
||||||
|
let displayLines = $derived(
|
||||||
|
maxLines > 0 && lines.length > maxLines
|
||||||
|
? lines.slice(0, maxLines)
|
||||||
|
: lines
|
||||||
|
);
|
||||||
|
let truncated = $derived(maxLines > 0 && lines.length > maxLines);
|
||||||
|
|
||||||
|
let language = $derived(getLanguage(filename));
|
||||||
|
|
||||||
|
// Icon basierend auf Dateityp
|
||||||
|
function getFileIcon(fname: string): string {
|
||||||
|
const ext = fname.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
'js': '📜', 'ts': '📘', 'jsx': '⚛️', 'tsx': '⚛️',
|
||||||
|
'py': '🐍', 'rs': '🦀', 'go': '🐹',
|
||||||
|
'html': '🌐', 'css': '🎨', 'scss': '🎨',
|
||||||
|
'json': '📋', 'yaml': '📋', 'yml': '📋',
|
||||||
|
'md': '📝', 'txt': '📄',
|
||||||
|
'sql': '🗄️', 'db': '🗄️',
|
||||||
|
'sh': '💻', 'bash': '💻',
|
||||||
|
'svelte': '🔶', 'vue': '💚',
|
||||||
|
'dockerfile': '🐳', 'toml': '⚙️',
|
||||||
|
};
|
||||||
|
return iconMap[ext] || '📄';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
isCollapsed = !isCollapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyContent() {
|
||||||
|
await navigator.clipboard.writeText(content);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="file-preview" class:collapsed={isCollapsed}>
|
||||||
|
<div class="file-header" onclick={toggleCollapsed}>
|
||||||
|
<span class="file-icon">{getFileIcon(filename)}</span>
|
||||||
|
<span class="filename">{filename}</span>
|
||||||
|
<span class="language-badge">{language}</span>
|
||||||
|
<span class="line-count">{lines.length} Zeilen</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn-copy" onclick={(e) => { e.stopPropagation(); copyContent(); }} title="Kopieren">
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
|
<button class="btn-toggle" title={isCollapsed ? 'Ausklappen' : 'Einklappen'}>
|
||||||
|
{isCollapsed ? '▶' : '▼'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isCollapsed}
|
||||||
|
<div class="file-content">
|
||||||
|
<div class="line-numbers">
|
||||||
|
{#each displayLines as _, idx}
|
||||||
|
<span class="line-no">{startLine + idx}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<pre class="code language-{language}"><code>{displayLines.join('\n')}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if truncated}
|
||||||
|
<div class="truncated-notice">
|
||||||
|
... {lines.length - maxLines} weitere Zeilen nicht angezeigt
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-preview {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-header:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview.collapsed .file-header {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-count {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy, .btn-toggle {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover, .btn-toggle:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-content {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-no {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-right: var(--spacing-xs);
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: visible;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncated-notice {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue