All checks were successful
Build AppImage / build (push) Successful in 8m20s
Crash-Fix: - src/db.rs:801 panickte mit "byte index 240 is not a char boundary" mitten in einem ✅-Emoji → SIGABRT. Neues strutil-Modul mit safe_truncate()/safe_truncate_ellipsis() (5 Tests grün), an allen &s[..N]-Stellen in db/claude/knowledge/session/memory.rs eingebaut. - update.rs: Stale Lock-Files vom letzten Crash werden jetzt protokolliert ("🧹 Stale Lock-Datei aus vorherigem Crash gefunden"). Chat-Polish: - Input-Textfeld wird nach Senden zuverlässig geleert (Store-Reset + DOM-Reset + tick — Svelte 5 bind:value mit Auto-Subscription aktualisiert sonst nicht synchron). - ApprovalBar.svelte (NEU): Sticky-Bar überm Input mit klar beschrifteten Buttons "Übernehmen"/"Verwerfen" statt mehrdeutigem "Behalten/Zurueck". Bleibt sichtbar wenn der Chat scrollt. Klick auf Datei-Name scrollt zur Inline-Karte und blinkt sie. Shortcuts Ctrl+Enter/Ctrl+Backspace. - MessageList: Auto-Scroll trackt jetzt auch toolCalls.length und Status-Änderungen, plus ResizeObserver am Container. Smooth bei kleinen Distanzen, instant bei großen. - Streaming-Caret: pulsierender Block-Cursor mit Glow-Shadow. - Tool-Cards: Slide-In-Transition + Shimmer-Animation auf running. - WorkingIndicator: Verb passt sich an processingPhase an.
388 lines
8.9 KiB
Svelte
388 lines
8.9 KiB
Svelte
<script lang="ts">
|
|
// DiffView — Zeigt Unterschiede zwischen altem und neuem Code
|
|
// Mit optionalen Accept/Reject-Buttons fuer interaktiven Modus
|
|
|
|
interface Props {
|
|
oldText: string;
|
|
newText: string;
|
|
filename?: string;
|
|
language?: string;
|
|
interactive?: boolean;
|
|
toolId?: string;
|
|
onAccept?: (toolId: string) => void;
|
|
onReject?: (toolId: string) => void;
|
|
}
|
|
|
|
let {
|
|
oldText,
|
|
newText,
|
|
filename = '',
|
|
language = '',
|
|
interactive = false,
|
|
toolId = '',
|
|
onAccept,
|
|
onReject,
|
|
}: 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]) {
|
|
// Unveraenderte 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 hinzugefuegt
|
|
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 hinzugefuegt
|
|
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 aendern
|
|
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,
|
|
});
|
|
|
|
// Nur geaenderte Zeilen anzeigen mit Kontext (3 Zeilen davor/danach)
|
|
let showFullDiff = $state(false);
|
|
let contextLines = 3;
|
|
|
|
function getVisibleLines(): { line: DiffLine; idx: number }[] {
|
|
if (showFullDiff) return diffLines.map((line, idx) => ({ line, idx }));
|
|
|
|
const changedIndices = new Set<number>();
|
|
diffLines.forEach((line, idx) => {
|
|
if (line.type !== 'unchanged') {
|
|
for (let i = Math.max(0, idx - contextLines); i <= Math.min(diffLines.length - 1, idx + contextLines); i++) {
|
|
changedIndices.add(i);
|
|
}
|
|
}
|
|
});
|
|
|
|
return Array.from(changedIndices).sort((a, b) => a - b).map(idx => ({ line: diffLines[idx], idx }));
|
|
}
|
|
|
|
let visibleLines = $derived(getVisibleLines());
|
|
|
|
function handleAccept() {
|
|
if (onAccept && toolId) onAccept(toolId);
|
|
}
|
|
|
|
function handleReject() {
|
|
if (onReject && toolId) onReject(toolId);
|
|
}
|
|
|
|
// Dateiname kuerzen fuer Anzeige
|
|
function shortenPath(path: string): string {
|
|
const parts = path.split('/');
|
|
if (parts.length > 3) return `.../${parts.slice(-2).join('/')}`;
|
|
return path;
|
|
}
|
|
</script>
|
|
|
|
<div class="diff-view" class:interactive>
|
|
<div class="diff-header">
|
|
<div class="diff-header-left">
|
|
{#if filename}
|
|
<span class="filename" title={filename}>{shortenPath(filename)}</span>
|
|
{/if}
|
|
{#if language}
|
|
<span class="language">{language}</span>
|
|
{/if}
|
|
<span class="stats">
|
|
<span class="stat-added">+{stats.added}</span>
|
|
<span class="stat-removed">-{stats.removed}</span>
|
|
</span>
|
|
</div>
|
|
<div class="diff-header-right">
|
|
{#if diffLines.length > 20}
|
|
<button class="btn-toggle" onclick={() => showFullDiff = !showFullDiff}>
|
|
{showFullDiff ? 'Kompakt' : 'Alles zeigen'}
|
|
</button>
|
|
{/if}
|
|
{#if interactive}
|
|
<button class="btn-accept" onclick={handleAccept} title="Diese Aenderung auf die Datei anwenden (Ctrl+Enter)">
|
|
✓ Übernehmen
|
|
</button>
|
|
<button class="btn-reject" onclick={handleReject} title="Aenderung verwerfen, Datei bleibt unveraendert (Ctrl+Backspace)">
|
|
✕ Verwerfen
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="diff-content">
|
|
{#each visibleLines as { line, idx } (idx)}
|
|
{@const prevIdx = visibleLines[visibleLines.indexOf({ line, idx }) - 1]?.idx}
|
|
{#if idx > 0 && prevIdx !== undefined && idx - prevIdx > 1}
|
|
<div class="diff-separator">⋯</div>
|
|
{/if}
|
|
<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-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;
|
|
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 {
|
|
font-size: 0.65rem;
|
|
padding: 1px 4px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 2px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.stats {
|
|
display: flex;
|
|
gap: var(--spacing-xs);
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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: 400px;
|
|
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);
|
|
}
|
|
|
|
.diff-separator {
|
|
text-align: center;
|
|
padding: 2px 0;
|
|
color: var(--text-secondary);
|
|
background: var(--bg-tertiary);
|
|
font-size: 0.6rem;
|
|
user-select: none;
|
|
}
|
|
</style>
|