Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions desktop/frontend/src/components/InlineDiff.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import { Check, Copy, ChevronDown, ChevronRight } from "lucide-react";
import { diffLines, type DiffRow } from "../lib/diff";

// InlineDiff is a compact, expandable diff view for tool results that
// showed a before/after pair (Edit, MultiEdit, sed-style bash). It
// renders the unified diff from lib/diff's diffLines() and folds at
// 12 visible rows by default — clicking the row count expands the
// full output, and the "Copy diff" button copies a plain-text unified
// diff (without line numbers, which most clipboard targets don't
// parse) to the clipboard.
//
// The diff seam is a pure function: this component is the only
// place that needs to know about row layout, expansion state, and
// clipboard. A future migration to a real editor (Monaco/CodeMirror
// merge) would replace this file and leave the call sites in
// ToolCard untouched.
export function InlineDiff({
before,
after,
filename,
maxRows = 12,
}: {
before: string;
after: string;
filename?: string;
maxRows?: number;
}) {
const rows = useMemo(() => diffLines(before, after), [before, after]);
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);

const visible = open ? rows : rows.slice(0, maxRows);
const hidden = rows.length - visible.length;

const addCount = rows.filter((r) => r.type === "add").length;
const delCount = rows.filter((r) => r.type === "del").length;

const copy = async () => {
const text = rows.map((r) => (r.type === "add" ? "+ " : r.type === "del" ? "- " : " ") + r.text).join("\n");
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
/* clipboard unavailable */
}
};

return (
<div className="inline-diff">
<header className="inline-diff__head">
<span className="inline-diff__file" title={filename}>
{filename ?? "diff"}
</span>
<span className="inline-diff__stats">
<span className="inline-diff__add">+{addCount}</span>
<span className="inline-diff__del">−{delCount}</span>
</span>
<span className="inline-diff__spacer" />
<button type="button" className="inline-diff__btn" onClick={copy} title="Copy diff">
{copied ? <Check size={11} /> : <Copy size={11} />}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
</header>
<pre className="inline-diff__body">
{visible.map((r, i) => (
<div key={i} className={`inline-diff__row inline-diff__row--${r.type}`}>
<span className="inline-diff__sign">{r.type === "add" ? "+" : r.type === "del" ? "−" : " "}</span>
<span className="inline-diff__text">{r.text || " "}</span>
</div>
))}
</pre>
{hidden > 0 && (
<button
type="button"
className="inline-diff__more"
onClick={() => setOpen(true)}
aria-expanded={open}
>
<ChevronRight size={11} />
<span>Show {hidden} more lines</span>
</button>
)}
{open && rows.length > maxRows && (
<button
type="button"
className="inline-diff__more"
onClick={() => setOpen(false)}
aria-expanded={open}
>
<ChevronDown size={11} />
<span>Collapse</span>
</button>
)}
</div>
);
}

// Re-export for callers that want to drive the row coloring themselves.
export type { DiffRow };
113 changes: 113 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -5541,6 +5541,7 @@ body {
color: var(--fg);
background: var(--hover);
}

.composer__pasted {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -5881,4 +5882,116 @@ body {
.onboarding__skip:disabled {
opacity: 0.4;
cursor: not-allowed;


/* InlineDiff: a compact before/after diff card. The header strip carries
the filename, the +/- counts, and a Copy button. The body is a monospace
pre with +/- row coloring. The CSS tokens are the same --add-bg /
--del-bg the existing DiffView uses, so light/dark theme changes apply
uniformly. */
.inline-diff {
margin: 4px 0;
border: 1px solid var(--border-soft);
border-radius: 6px;
background: var(--bg-soft);
overflow: hidden;
}
.inline-diff__head {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 9px;
border-bottom: 1px solid var(--border-soft);
font-size: 11px;
}
.inline-diff__file {
font-family: var(--mono);
color: var(--fg-dim);
max-width: 60%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inline-diff__stats {
display: inline-flex;
gap: 6px;
font-family: var(--mono);
font-size: 10.5px;
}
.inline-diff__add {
color: var(--add-fg);
}
.inline-diff__del {
color: var(--del-fg);
}
.inline-diff__spacer {
flex: 1;
}
.inline-diff__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
font-family: var(--sans);
font-size: 10.5px;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.inline-diff__btn:hover {
color: var(--fg);
background: var(--bg-elev);
}
.inline-diff__body {
margin: 0;
padding: 4px 0;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
max-height: 320px;
overflow: auto;
}
.inline-diff__row {
display: flex;
gap: 6px;
padding: 0 8px;
}
.inline-diff__row--add {
background: var(--add-bg);
}
.inline-diff__row--del {
background: var(--del-bg);
}
.inline-diff__sign {
width: 10px;
flex-shrink: 0;
text-align: center;
opacity: 0.6;
user-select: none;
}
.inline-diff__text {
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
.inline-diff__more {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
padding: 5px 0;
font-size: 11px;
color: var(--fg-dim);
background: transparent;
border: 0;
border-top: 1px solid var(--border-soft);
cursor: pointer;
}
.inline-diff__more:hover {
color: var(--accent);
background: var(--bg-elev);

}
Loading