Release Diff Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 04:43:16 +02:00
parent a1380cfba2
commit 46fc10f745
37 changed files with 2722 additions and 455 deletions
+278
View File
@@ -0,0 +1,278 @@
import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { useMemo, useRef, useState, type ChangeEvent } from "react";
import {
compareDirectoryManifests,
createDirectoryManifest,
parseDirectoryManifest,
serializeDirectoryManifest,
type DirectoryManifest,
} from "../core/directory-manifest";
type Side = "left" | "right";
const directoryAttributes = {
webkitdirectory: "",
directory: "",
} as Record<string, string>;
export function DirectoryWorkspace() {
const [files, setFiles] = useState<Record<Side, File[]>>({
left: [],
right: [],
});
const [manifests, setManifests] = useState<
Partial<Record<Side, DirectoryManifest>>
>({});
const [status, setStatus] = useState(
"Choose two directories or import saved manifests.",
);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [changesOnly, setChangesOnly] = useState(true);
const controller = useRef<AbortController | undefined>(undefined);
const changes = useMemo(() => {
if (!manifests.left || !manifests.right) return [];
return compareDirectoryManifests(manifests.left, manifests.right);
}, [manifests]);
const visible = changes.filter(
(change) => !changesOnly || change.status !== "same",
);
async function build(): Promise<void> {
if (
(!files.left.length && !manifests.left) ||
(!files.right.length && !manifests.right)
)
return;
controller.current?.abort();
const next = new AbortController();
controller.current = next;
setBusy(true);
setError("");
try {
const left =
manifests.left ??
(await createDirectoryManifest(files.left, {
signal: next.signal,
onProgress: (progress) =>
setStatus(
`Hashing left: ${progress.completed} / ${progress.total} · ${progress.path}`,
),
}));
const right =
manifests.right ??
(await createDirectoryManifest(files.right, {
signal: next.signal,
onProgress: (progress) =>
setStatus(
`Hashing right: ${progress.completed} / ${progress.total} · ${progress.path}`,
),
}));
if (next.signal.aborted) return;
setManifests({ left, right });
const result = compareDirectoryManifests(left, right);
setStatus(
`Compared ${result.length.toLocaleString()} relative paths; ${result.filter((item) => item.status !== "same").length.toLocaleString()} changed.`,
);
} catch (reason) {
if (!next.signal.aborted)
setError(
reason instanceof Error
? reason.message
: "Directory comparison failed.",
);
} finally {
if (controller.current === next) setBusy(false);
}
}
async function importManifest(
side: Side,
event: ChangeEvent<HTMLInputElement>,
) {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file) return;
try {
const manifest = parseDirectoryManifest(await file.text());
setManifests((current) => ({ ...current, [side]: manifest }));
setFiles((current) => ({ ...current, [side]: [] }));
setError("");
setStatus(
`Imported ${side} manifest with ${manifest.entries.length.toLocaleString()} files.`,
);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Invalid directory manifest.",
);
}
}
function selectedDirectory(side: Side, event: ChangeEvent<HTMLInputElement>) {
const selected = [...(event.currentTarget.files ?? [])];
event.currentTarget.value = "";
setFiles((current) => ({ ...current, [side]: selected }));
setManifests((current) => ({ ...current, [side]: undefined }));
setStatus(`Selected ${selected.length.toLocaleString()} ${side} files.`);
}
function downloadManifest(side: Side): void {
const manifest = manifests[side];
if (!manifest) return;
triggerBlobDownload(
new Blob([serializeDirectoryManifest(manifest)], {
type: "application/json;charset=utf-8",
}),
`${side}-directory-manifest.json`,
);
}
function cancelBuild(): void {
controller.current?.abort();
setStatus(
"Directory hashing cancelled. Existing manifests were left unchanged.",
);
}
return (
<section
className="panel directory-workspace"
aria-labelledby="directory-title"
>
<div className="panel-heading">
<div>
<p className="eyebrow">Relative paths · SHA-256</p>
<h2 id="directory-title">Directory manifests</h2>
</div>
{busy ? (
<button type="button" onClick={cancelBuild}>
Cancel
</button>
) : null}
</div>
<p className="option-note">
File contents are hashed locally with bounded two-file concurrency.
Empty directories are not exposed by browser file selection and
therefore cannot appear in a manifest.
</p>
<div className="directory-inputs">
{(["left", "right"] as const).map((side) => (
<article key={side}>
<h3>{side === "left" ? "Before directory" : "After directory"}</h3>
<div className="button-row">
<label className="button file-button">
Choose directory
<input
type="file"
multiple
{...directoryAttributes}
onChange={(event) => selectedDirectory(side, event)}
data-testid={`${side}-directory-input`}
/>
</label>
<label className="button file-button">
Import manifest
<input
type="file"
accept=".json,application/json"
onChange={(event) => void importManifest(side, event)}
/>
</label>
<button
type="button"
disabled={!manifests[side]}
onClick={() => downloadManifest(side)}
>
Download manifest
</button>
</div>
<p>
{manifests[side]
? `${manifests[side]!.entries.length.toLocaleString()} manifested files · ${formatBytes(manifests[side]!.totals.bytes)}`
: `${files[side].length.toLocaleString()} selected files`}
</p>
</article>
))}
</div>
<div className="compare-bar">
<button
type="button"
className="primary-button"
disabled={
busy ||
(!files.left.length && !manifests.left) ||
(!files.right.length && !manifests.right)
}
onClick={() => void build()}
>
Build & compare manifests
</button>
<p role="status" aria-live="polite">
{status}
</p>
</div>
{error ? (
<p className="diagnostic diagnostic--error" role="alert">
{error}
</p>
) : null}
{changes.length ? (
<>
<label className="toggle manifest-filter">
<input
type="checkbox"
checked={changesOnly}
onChange={(event) => setChangesOnly(event.target.checked)}
/>
<span>
<strong>Show changes only</strong>
</span>
</label>
<div
className="table-scroll"
tabIndex={0}
role="region"
aria-label="Directory manifest comparison"
>
<table>
<thead>
<tr>
<th>Path</th>
<th>Status</th>
<th>Before</th>
<th>After</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
{visible.map((change) => (
<tr key={change.path}>
<th scope="row">
<code>{change.path}</code>
</th>
<td>
<span
className={`kind-badge kind-badge--${change.status}`}
>
{change.status}
</span>
</td>
<td>
{change.left ? formatBytes(change.left.bytes) : "—"}
</td>
<td>
{change.right ? formatBytes(change.right.bytes) : "—"}
</td>
<td>{change.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</section>
);
}
+11 -1
View File
@@ -32,7 +32,9 @@ export function HelpDialog({
</button>
</div>
<p>
Compare exact text or the structure of JSON, XML, CSV and TSV locally.
Compare exact text or the structure of JSON, XML, CSV and TSV locally,
compare bounded directory manifests, or merge two variants against an
explicit base.
</p>
<ul>
<li>Text keeps CRLF, LF, CR and final-newline state exact.</li>
@@ -41,6 +43,14 @@ export function HelpDialog({
<li>
CSV rows are matched by unique key columns and fields remain strings.
</li>
<li>
Directory comparison hashes selected files locally with SHA-256; empty
directories cannot be observed through browser file selection.
</li>
<li>
Three-way merge combines non-overlapping line edits and emits
ours/base/theirs markers plus a report for overlaps.
</li>
</ul>
<p>
Ignored and normalized differences remain visible. All processing is
+123
View File
@@ -0,0 +1,123 @@
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { useState } from "react";
import { mergeThreeWay, type MergeResult } from "../core/merge";
const SAMPLE = {
base: "title\nshared\nfooter\n",
ours: "title from ours\nshared\nfooter\n",
theirs: "title\nshared\nfooter from theirs\n",
};
export function MergeWorkspace() {
const [base, setBase] = useState(SAMPLE.base);
const [ours, setOurs] = useState(SAMPLE.ours);
const [theirs, setTheirs] = useState(SAMPLE.theirs);
const [error, setError] = useState("");
const [result, setResult] = useState<MergeResult>();
function merge(): void {
try {
setResult(mergeThreeWay({ base, ours, theirs }));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Three-way merge failed.",
);
setResult(undefined);
}
}
function download(text: string, name: string, type: string): void {
triggerBlobDownload(new Blob([text], { type }), name);
}
return (
<section className="panel merge-workspace" aria-labelledby="merge-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Bounded line merge</p>
<h2 id="merge-title">Three-way merge</h2>
</div>
</div>
<p className="option-note">
Changes are derived independently from the base. Non-overlapping edits
merge automatically; overlapping results get explicit ours/base/theirs
conflict markers. This is text merge, not a semantic JSON/XML merge.
</p>
<div className="merge-inputs">
{(
[
["Base", base, setBase],
["Ours", ours, setOurs],
["Theirs", theirs, setTheirs],
] as const
).map(([label, value, setter]) => (
<label className="field" key={label}>
<span>{label}</span>
<textarea
value={value}
onChange={(event) => setter(event.target.value)}
spellCheck={false}
/>
</label>
))}
</div>
<button type="button" className="primary-button" onClick={merge}>
Merge locally
</button>
{error ? (
<p className="diagnostic diagnostic--error" role="alert">
{error}
</p>
) : null}
{result ? (
<div className="merge-result">
<div className="artifact-heading">
<div>
<p className="eyebrow">
{result.clean ? "Clean merge" : "Review required"}
</p>
<h3>{result.conflicts.length} conflict(s)</h3>
</div>
<div>
<button
type="button"
onClick={() =>
download(
result.report,
"merge-report.json",
"application/json",
)
}
>
Download report
</button>
<button
type="button"
className="primary-button"
onClick={() =>
download(
result.text,
"merged.txt",
"text/plain;charset=utf-8",
)
}
>
Download merged text
</button>
</div>
</div>
<label className="field output-field">
<span>Merged text</span>
<textarea
readOnly
value={result.text}
data-testid="merge-output"
spellCheck={false}
/>
</label>
</div>
) : null}
</section>
);
}
+333 -305
View File
@@ -1,3 +1,4 @@
import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
useCallback,
useEffect,
@@ -8,6 +9,8 @@ import {
} from "react";
import { DIFF_LIMITS } from "../core/limits";
import { createCompareTask, type CompareTask } from "../core/worker-client";
import { DirectoryWorkspace } from "./DirectoryWorkspace";
import { MergeWorkspace } from "./MergeWorkspace";
import {
DEFAULT_OPTIONS,
DiffToolsError,
@@ -90,11 +93,7 @@ function visibleText(value: string | undefined): string {
}
function bytes(value: number): string {
return value < 1_024
? `${value} B`
: value < 1_048_576
? `${(value / 1_024).toFixed(1)} KiB`
: `${(value / 1_048_576).toFixed(1)} MiB`;
return formatBytes(value, { fractionDigits: value < 1_024 ? 0 : 1 });
}
function fileExtension(mode: DiffMode, artifact: "report" | "patch"): string {
@@ -103,20 +102,10 @@ function fileExtension(mode: DiffMode, artifact: "report" | "patch"): string {
}
function download(value: string, name: string): void {
const blob = new Blob([value], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.hidden = true;
anchor.rel = "noopener";
document.body.append(anchor);
try {
anchor.click();
} finally {
anchor.remove();
queueMicrotask(() => URL.revokeObjectURL(url));
}
triggerBlobDownload(
new Blob([value], { type: "text/plain;charset=utf-8" }),
name,
);
}
function Toggle({
@@ -516,6 +505,9 @@ export function Workbench() {
);
const [statusText, setStatusText] = useState("Ready to compare locally.");
const [notice, setNotice] = useState("");
const [workspace, setWorkspace] = useState<
"compare" | "directories" | "merge"
>("compare");
const task = useRef<CompareTask | undefined>(undefined);
const sequence = useRef(0);
const input = inputs[mode];
@@ -680,320 +672,356 @@ export function Workbench() {
<span className="privacy-pill">Browser-local</span>
</header>
<nav className="mode-tabs" aria-label="Comparison modes">
<div role="tablist" aria-label="Comparison modes">
{MODES.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-label={`${item.label} ${item.hint}`}
aria-selected={mode === item.id}
onClick={() => setRoute(item.id, view)}
>
<span>{item.label}</span>
<small>{item.hint}</small>
</button>
))}
</div>
<nav className="workspace-tabs" aria-label="Diff workspaces">
{(
[
["compare", "Two-way compare"],
["directories", "Directories"],
["merge", "Three-way merge"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
aria-current={workspace === id ? "page" : undefined}
onClick={() => setWorkspace(id)}
>
{label}
</button>
))}
</nav>
<section className="panel options-panel" aria-labelledby="options-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Visible semantics</p>
<h2 id="options-title">Comparison options</h2>
</div>
</div>
<Options mode={mode} options={options} setOptions={setOptions} />
</section>
<section className="input-grid" aria-label="Comparison inputs">
{(["left", "right"] as const).map((side) => (
<article className="panel input-panel" key={side}>
<div className="panel-heading">
<div>
<p className="eyebrow">
{side === "left" ? "Before" : "After"}
</p>
<h2>
{input[`${side}Name`] ??
(side === "left" ? "Original" : "Changed")}
</h2>
</div>
<label className="button file-button">
Open file
<input
type="file"
onChange={(event) => fileChanged(side, event)}
data-testid={`${side}-file-input`}
/>
</label>
</div>
<label className="field">
<span>{side === "left" ? "Before text" : "After text"}</span>
<textarea
value={input[side]}
onChange={(event) => updateInput(side, event.target.value)}
spellCheck={false}
data-testid={`${side}-editor`}
/>
</label>
</article>
))}
</section>
<div className="compare-bar panel">
<button
type="button"
onClick={() =>
setInputs((current) => ({
...current,
[mode]: {
left: current[mode].right,
right: current[mode].left,
leftName: current[mode].rightName,
rightName: current[mode].leftName,
},
}))
}
>
Swap sides
</button>
<button
type="button"
className="primary-button"
onClick={() => compare(request)}
>
Compare now
</button>
<p role="status" aria-live="polite" data-status={status}>
{statusText}
</p>
</div>
<Diagnostics diagnostics={diagnostics} />
{result ? (
<section className="results" aria-label="Comparison result">
<div className="summary-grid">
<article className="summary-card summary-card--verdict">
<span>Verdict</span>
<strong>
{result.exactlyEqual
? "Exactly equal"
: result.semanticallyEqual
? "Semantically equal"
: "Different"}
</strong>
<small>
{result.exactlyEqual
? "Source text matches"
: "Exact source differs"}
</small>
</article>
{(["added", "removed", "modified", "normalized"] as const).map(
(kind) => (
<article
className={`summary-card summary-card--${kind}`}
key={kind}
>
<span>{kind}</span>
<strong>{result.stats[kind]}</strong>
<small>
display row{result.stats[kind] === 1 ? "" : "s"}
</small>
</article>
),
)}
</div>
<section className="metadata panel" aria-labelledby="metadata-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Exact source state</p>
<h2 id="metadata-title">Newlines and size</h2>
</div>
</div>
<div className="metadata-grid">
{[result.left, result.right].map((item, index) => (
<dl key={index}>
<div>
<dt>Side</dt>
<dd>{index === 0 ? "Before" : "After"}</dd>
</div>
<div>
<dt>Size</dt>
<dd>{bytes(item.bytes)}</dd>
</div>
<div>
<dt>Lines</dt>
<dd>{item.lines}</dd>
</div>
<div>
<dt>LF</dt>
<dd>{item.newlines.lf}</dd>
</div>
<div>
<dt>CRLF</dt>
<dd>{item.newlines.crlf}</dd>
</div>
<div>
<dt>CR</dt>
<dd>{item.newlines.cr}</dd>
</div>
<div>
<dt>Final newline</dt>
<dd>{item.newlines.final}</dd>
</div>
</dl>
))}
</div>
<div className="normalization-list">
<strong>Applied comparison rules</strong>
{result.appliedNormalizations.length ? (
result.appliedNormalizations.map((item) => (
<span key={item}>{item}</span>
))
) : (
<span>Exact source comparison</span>
)}
</div>
</section>
<nav className="result-tabs" aria-label="Result views">
<div role="tablist" aria-label="Result views">
{RESULT_VIEWS.map((item) => (
{workspace === "compare" ? (
<>
<nav className="mode-tabs" aria-label="Comparison modes">
<div role="group" aria-label="Comparison modes">
{MODES.map((item) => (
<button
type="button"
role="tab"
aria-selected={view === item.id}
key={item.id}
onClick={() => setRoute(mode, item.id)}
type="button"
aria-label={`${item.label} ${item.hint}`}
aria-pressed={mode === item.id}
onClick={() => setRoute(item.id, view)}
>
{item.label}
<span>{item.label}</span>
<small>{item.hint}</small>
</button>
))}
</div>
</nav>
<section className="panel result-panel" role="tabpanel">
{view === "unified" ? <UnifiedRows rows={result.rows} /> : null}
{view === "side-by-side" ? (
<SideBySideRows rows={result.rows} />
) : null}
{view === "report" ? (
<div className="artifact">
<div className="artifact-heading">
<section
className="panel options-panel"
aria-labelledby="options-title"
>
<div className="panel-heading">
<div>
<p className="eyebrow">Visible semantics</p>
<h2 id="options-title">Comparison options</h2>
</div>
</div>
<Options mode={mode} options={options} setOptions={setOptions} />
</section>
<section className="input-grid" aria-label="Comparison inputs">
{(["left", "right"] as const).map((side) => (
<article className="panel input-panel" key={side}>
<div className="panel-heading">
<div>
<p className="eyebrow">Portable artifact</p>
<h2>JSON report</h2>
<p className="eyebrow">
{side === "left" ? "Before" : "After"}
</p>
<h2>
{input[`${side}Name`] ??
(side === "left" ? "Original" : "Changed")}
</h2>
</div>
<label className="button file-button">
Open file
<input
type="file"
onChange={(event) => fileChanged(side, event)}
data-testid={`${side}-file-input`}
/>
</label>
</div>
<label className="field">
<span>{side === "left" ? "Before text" : "After text"}</span>
<textarea
value={input[side]}
onChange={(event) => updateInput(side, event.target.value)}
spellCheck={false}
data-testid={`${side}-editor`}
/>
</label>
</article>
))}
</section>
<div className="compare-bar panel">
<button
type="button"
onClick={() =>
setInputs((current) => ({
...current,
[mode]: {
left: current[mode].right,
right: current[mode].left,
leftName: current[mode].rightName,
rightName: current[mode].leftName,
},
}))
}
>
Swap sides
</button>
<button
type="button"
className="primary-button"
onClick={() => compare(request)}
>
Compare now
</button>
<p role="status" aria-live="polite" data-status={status}>
{statusText}
</p>
</div>
<Diagnostics diagnostics={diagnostics} />
{result ? (
<section className="results" aria-label="Comparison result">
<div className="summary-grid">
<article className="summary-card summary-card--verdict">
<span>Verdict</span>
<strong>
{result.exactlyEqual
? "Exactly equal"
: result.semanticallyEqual
? "Semantically equal"
: "Different"}
</strong>
<small>
{result.exactlyEqual
? "Source text matches"
: "Exact source differs"}
</small>
</article>
{(["added", "removed", "modified", "normalized"] as const).map(
(kind) => (
<article
className={`summary-card summary-card--${kind}`}
key={kind}
>
<span>{kind}</span>
<strong>{result.stats[kind]}</strong>
<small>
display row{result.stats[kind] === 1 ? "" : "s"}
</small>
</article>
),
)}
</div>
<section
className="metadata panel"
aria-labelledby="metadata-title"
>
<div className="panel-heading">
<div>
<button
type="button"
onClick={() => void copy(result.report, "JSON report")}
>
Copy
</button>
<button
type="button"
className="primary-button"
onClick={() =>
download(
result.report,
`diff-report.${fileExtension(mode, "report")}`,
)
}
>
Download
</button>
<p className="eyebrow">Exact source state</p>
<h2 id="metadata-title">Newlines and size</h2>
</div>
</div>
<textarea
readOnly
value={result.report}
data-testid="json-report"
/>
</div>
) : null}
{view === "patch" ? (
<div className="patch-grid">
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Exact source transform</p>
<h2>Unified patch</h2>
</div>
<div>
<button
type="button"
onClick={() =>
void copy(result.unifiedPatch, "Unified patch")
}
disabled={!result.unifiedPatch}
>
Copy
</button>
<button
type="button"
className="primary-button"
disabled={!result.unifiedPatch}
onClick={() =>
result.unifiedPatch &&
download(
result.unifiedPatch,
`changes.${fileExtension(mode, "patch")}`,
)
}
>
Download
</button>
</div>
</div>
{result.unifiedPatch ? (
<textarea
readOnly
value={result.unifiedPatch}
data-testid="unified-patch"
/>
<div className="metadata-grid">
{[result.left, result.right].map((item, index) => (
<dl key={index}>
<div>
<dt>Side</dt>
<dd>{index === 0 ? "Before" : "After"}</dd>
</div>
<div>
<dt>Size</dt>
<dd>{bytes(item.bytes)}</dd>
</div>
<div>
<dt>Lines</dt>
<dd>{item.lines}</dd>
</div>
<div>
<dt>LF</dt>
<dd>{item.newlines.lf}</dd>
</div>
<div>
<dt>CRLF</dt>
<dd>{item.newlines.crlf}</dd>
</div>
<div>
<dt>CR</dt>
<dd>{item.newlines.cr}</dd>
</div>
<div>
<dt>Final newline</dt>
<dd>{item.newlines.final}</dd>
</div>
</dl>
))}
</div>
<div className="normalization-list">
<strong>Applied comparison rules</strong>
{result.appliedNormalizations.length ? (
result.appliedNormalizations.map((item) => (
<span key={item}>{item}</span>
))
) : (
<p className="empty-result">
Patch unavailable at the configured safety limit.
</p>
<span>Exact source comparison</span>
)}
</div>
{mode === "json" ? (
</section>
<nav className="result-tabs" aria-label="Result views">
<div role="group" aria-label="Result views">
{RESULT_VIEWS.map((item) => (
<button
type="button"
aria-pressed={view === item.id}
key={item.id}
onClick={() => setRoute(mode, item.id)}
>
{item.label}
</button>
))}
</div>
</nav>
<section className="panel result-panel">
{view === "unified" ? <UnifiedRows rows={result.rows} /> : null}
{view === "side-by-side" ? (
<SideBySideRows rows={result.rows} />
) : null}
{view === "report" ? (
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Semantic transform</p>
<h2>RFC 6902 JSON Patch</h2>
<p className="eyebrow">Portable artifact</p>
<h2>JSON report</h2>
</div>
<div>
<button
type="button"
onClick={() =>
void copy(result.report, "JSON report")
}
>
Copy
</button>
<button
type="button"
className="primary-button"
onClick={() =>
download(
result.report,
`diff-report.${fileExtension(mode, "report")}`,
)
}
>
Download
</button>
</div>
<button
type="button"
onClick={() =>
void copy(result.jsonPatch, "JSON Patch")
}
>
Copy
</button>
</div>
<textarea
readOnly
value={result.jsonPatch ?? ""}
data-testid="json-patch"
aria-label="JSON comparison report"
value={result.report}
data-testid="json-report"
/>
</div>
) : null}
</div>
) : null}
</section>
</section>
) : null}
<p className="action-notice" role="status" aria-live="polite">
{notice}
</p>
{view === "patch" ? (
<div className="patch-grid">
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Exact source transform</p>
<h2>Unified patch</h2>
</div>
<div>
<button
type="button"
onClick={() =>
void copy(result.unifiedPatch, "Unified patch")
}
disabled={!result.unifiedPatch}
>
Copy
</button>
<button
type="button"
className="primary-button"
disabled={!result.unifiedPatch}
onClick={() =>
result.unifiedPatch &&
download(
result.unifiedPatch,
`changes.${fileExtension(mode, "patch")}`,
)
}
>
Download
</button>
</div>
</div>
{result.unifiedPatch ? (
<textarea
readOnly
aria-label="Unified patch"
value={result.unifiedPatch}
data-testid="unified-patch"
/>
) : (
<p className="empty-result">
Patch unavailable at the configured safety limit.
</p>
)}
</div>
{mode === "json" ? (
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Semantic transform</p>
<h2>RFC 6902 JSON Patch</h2>
</div>
<button
type="button"
onClick={() =>
void copy(result.jsonPatch, "JSON Patch")
}
>
Copy
</button>
</div>
<textarea
readOnly
aria-label="RFC 6902 JSON Patch"
value={result.jsonPatch ?? ""}
data-testid="json-patch"
/>
</div>
) : null}
</div>
) : null}
</section>
</section>
) : null}
<p className="action-notice" role="status" aria-live="polite">
{notice}
</p>
</>
) : workspace === "directories" ? (
<DirectoryWorkspace />
) : (
<MergeWorkspace />
)}
</main>
);
}
+331
View File
@@ -0,0 +1,331 @@
import { DIFF_LIMITS } from "./limits";
export interface DirectoryManifestEntry {
readonly path: string;
readonly bytes: number;
readonly sha256: string;
readonly lastModified?: string;
}
export interface DirectoryManifest {
readonly schema: "de.add-ideas.diff-tools.directory-manifest.v1";
readonly schemaVersion: 1;
readonly generatedLocally: true;
readonly rootLabel?: string;
readonly hashAlgorithm: "SHA-256";
readonly entries: readonly DirectoryManifestEntry[];
readonly totals: { readonly files: number; readonly bytes: number };
}
export interface ManifestProgress {
readonly completed: number;
readonly total: number;
readonly path: string;
}
export interface ManifestChange {
readonly path: string;
readonly status: "same" | "added" | "removed" | "modified";
readonly left?: DirectoryManifestEntry;
readonly right?: DirectoryManifestEntry;
readonly detail: string;
}
export async function createDirectoryManifest(
files: readonly File[],
options: {
readonly signal?: AbortSignal;
readonly onProgress?: (progress: ManifestProgress) => void;
readonly concurrency?: number;
} = {},
): Promise<DirectoryManifest> {
if (!globalThis.crypto?.subtle)
throw new Error("The browser Web Crypto digest API is unavailable.");
if (files.length > DIFF_LIMITS.maxDirectoryFiles)
throw new RangeError(
`Directory contains more than ${DIFF_LIMITS.maxDirectoryFiles.toLocaleString()} files.`,
);
const paths = relativePaths(files);
let totalBytes = 0;
const collisionKeys = new Set<string>();
for (const [index, file] of files.entries()) {
throwIfAborted(options.signal);
if (file.size > DIFF_LIMITS.maxDirectoryFileBytes)
throw new RangeError(
`${paths[index]} exceeds the per-file hashing limit.`,
);
totalBytes += file.size;
if (
!Number.isSafeInteger(totalBytes) ||
totalBytes > DIFF_LIMITS.maxDirectoryBytes
)
throw new RangeError(
"Directory files exceed the aggregate hashing limit.",
);
const key = paths[index]!.normalize("NFC").toLocaleLowerCase("en-US");
if (collisionKeys.has(key))
throw new Error(
`Directory path collision after conservative normalization: ${paths[index]}.`,
);
collisionKeys.add(key);
}
const entries: DirectoryManifestEntry[] = new Array(files.length);
let next = 0;
let completed = 0;
const concurrency = Math.max(
1,
Math.min(
DIFF_LIMITS.directoryHashConcurrency,
Math.floor(options.concurrency ?? DIFF_LIMITS.directoryHashConcurrency),
),
);
const worker = async () => {
while (true) {
const index = next++;
if (index >= files.length) return;
const file = files[index]!;
const path = paths[index]!;
throwIfAborted(options.signal);
const digest = await crypto.subtle.digest(
"SHA-256",
await file.arrayBuffer(),
);
throwIfAborted(options.signal);
entries[index] = {
path,
bytes: file.size,
sha256: hex(new Uint8Array(digest)),
lastModified:
Number.isFinite(file.lastModified) && file.lastModified > 0
? new Date(file.lastModified).toISOString()
: undefined,
};
completed += 1;
options.onProgress?.({ completed, total: files.length, path });
}
};
await Promise.all(
Array.from(
{ length: Math.min(concurrency, Math.max(1, files.length)) },
worker,
),
);
entries.sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const rootLabel = commonRootLabel(files);
return {
schema: "de.add-ideas.diff-tools.directory-manifest.v1",
schemaVersion: 1,
generatedLocally: true,
rootLabel,
hashAlgorithm: "SHA-256",
entries,
totals: { files: entries.length, bytes: totalBytes },
};
}
export function compareDirectoryManifests(
left: DirectoryManifest,
right: DirectoryManifest,
): ManifestChange[] {
validateDirectoryManifest(left);
validateDirectoryManifest(right);
const leftEntries = new Map(left.entries.map((entry) => [entry.path, entry]));
const rightEntries = new Map(
right.entries.map((entry) => [entry.path, entry]),
);
const paths = [
...new Set([...leftEntries.keys(), ...rightEntries.keys()]),
].sort();
return paths.map((path) => {
const before = leftEntries.get(path);
const after = rightEntries.get(path);
if (!before)
return {
path,
status: "added",
right: after,
detail: "File exists only on the right.",
};
if (!after)
return {
path,
status: "removed",
left: before,
detail: "File exists only on the left.",
};
if (before.sha256 === after.sha256 && before.bytes === after.bytes)
return {
path,
status: "same",
left: before,
right: after,
detail: "Size and SHA-256 digest match.",
};
return {
path,
status: "modified",
left: before,
right: after,
detail:
before.bytes === after.bytes
? "SHA-256 digest changed while byte size stayed equal."
: "Byte size and/or SHA-256 digest changed.",
};
});
}
export function serializeDirectoryManifest(
manifest: DirectoryManifest,
): string {
validateDirectoryManifest(manifest);
const text = `${JSON.stringify(manifest, null, 2)}\n`;
if (text.length > DIFF_LIMITS.maxOutputCharacters)
throw new RangeError("Directory manifest exceeds the output limit.");
return text;
}
export function parseDirectoryManifest(source: string): DirectoryManifest {
if (source.length > DIFF_LIMITS.maxOutputCharacters)
throw new RangeError("Directory manifest exceeds the input limit.");
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch (error) {
throw new SyntaxError(
error instanceof Error ? error.message : "Invalid manifest JSON.",
{ cause: error },
);
}
validateDirectoryManifest(value);
return value;
}
export function validateDirectoryManifest(
value: unknown,
): asserts value is DirectoryManifest {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new TypeError("Directory manifest must be an object.");
const record = value as Record<string, unknown>;
if (
record.schema !== "de.add-ideas.diff-tools.directory-manifest.v1" ||
record.schemaVersion !== 1 ||
record.generatedLocally !== true ||
record.hashAlgorithm !== "SHA-256" ||
!Array.isArray(record.entries)
)
throw new TypeError(
"Directory manifest identity or entry list is invalid.",
);
if (record.entries.length > DIFF_LIMITS.maxDirectoryFiles)
throw new RangeError("Directory manifest exceeds the file-count limit.");
let total = 0;
let previous = "";
const seen = new Set<string>();
const collisionKeys = new Set<string>();
for (const candidate of record.entries) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
throw new TypeError("Directory manifest entry is invalid.");
const entry = candidate as Record<string, unknown>;
if (
typeof entry.path !== "string" ||
typeof entry.bytes !== "number" ||
!Number.isSafeInteger(entry.bytes) ||
entry.bytes < 0 ||
typeof entry.sha256 !== "string" ||
!/^[0-9a-f]{64}$/u.test(entry.sha256) ||
(entry.lastModified !== undefined &&
(typeof entry.lastModified !== "string" ||
!Number.isFinite(Date.parse(entry.lastModified))))
)
throw new TypeError("Directory manifest entry fields are invalid.");
validatePath(entry.path);
if (seen.has(entry.path))
throw new Error(`Duplicate manifest path: ${entry.path}.`);
const collisionKey = entry.path.normalize("NFC").toLocaleLowerCase("en-US");
if (collisionKeys.has(collisionKey))
throw new Error(
`Directory path collision after conservative normalization: ${entry.path}.`,
);
if (previous && entry.path < previous)
throw new Error("Directory manifest entries must be sorted by path.");
seen.add(entry.path);
collisionKeys.add(collisionKey);
previous = entry.path;
total += entry.bytes;
if (!Number.isSafeInteger(total) || total > DIFF_LIMITS.maxDirectoryBytes)
throw new RangeError(
"Directory manifest exceeds the aggregate byte limit.",
);
}
const totals = record.totals;
if (
!totals ||
typeof totals !== "object" ||
(totals as Record<string, unknown>).files !== record.entries.length ||
(totals as Record<string, unknown>).bytes !== total
)
throw new Error("Directory manifest totals do not match its entries.");
}
function relativePaths(files: readonly File[]): string[] {
const root = commonRootLabel(files);
return files.map((file) => {
const raw = file.webkitRelativePath || file.name;
const segments = raw.split("/");
if (root && segments[0] === root) segments.shift();
const path = segments.join("/").normalize("NFC");
validatePath(path);
return path;
});
}
function commonRootLabel(files: readonly File[]): string | undefined {
const roots = files
.map((file) => file.webkitRelativePath.split("/")[0])
.filter((value): value is string => Boolean(value));
return roots.length === files.length &&
roots.every((root) => root === roots[0])
? roots[0]
: undefined;
}
function validatePath(path: string): void {
if (
!path ||
path.length > DIFF_LIMITS.maxDirectoryPathCharacters ||
path.includes("\\") ||
path.startsWith("/") ||
/^[a-z]:/iu.test(path) ||
hasControlCharacter(path)
)
throw new Error(
`Unsafe or excessive directory path: ${path || "(empty)"}.`,
);
const segments = path.split("/");
if (
segments.length > DIFF_LIMITS.maxDirectoryPathSegments ||
segments.some((segment) => !segment || segment === "." || segment === "..")
)
throw new Error(`Unsafe directory path segments: ${path}.`);
}
function hasControlCharacter(value: string): boolean {
return [...value].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
});
}
function hex(bytes: Uint8Array): string {
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted)
throw signal.reason instanceof Error
? signal.reason
: new DOMException("Operation cancelled.", "AbortError");
}
+8
View File
@@ -14,7 +14,15 @@ export const DIFF_LIMITS = Object.freeze({
maxOutputCharacters: 16_000_000,
maxRowSnippet: 12_000,
diffTimeoutMilliseconds: 2_000,
workerTimeoutMilliseconds: 15_000,
maxEditLength: 10_000,
maxDirectoryFiles: 5_000,
maxDirectoryBytes: 512 * 1024 * 1024,
maxDirectoryFileBytes: 256 * 1024 * 1024,
maxDirectoryPathCharacters: 4_096,
maxDirectoryPathSegments: 64,
directoryHashConcurrency: 2,
maxMergeConflicts: 1_000,
});
export class DiffLimitError extends RangeError {
+286
View File
@@ -0,0 +1,286 @@
import { diffArrays } from "diff";
import { assertInput, assertOutput, DIFF_LIMITS } from "./limits";
import type { Diagnostic } from "./types";
interface Edit {
readonly start: number;
readonly end: number;
readonly replacement: readonly string[];
readonly side: "ours" | "theirs";
}
export interface MergeRequest {
readonly base: string;
readonly ours: string;
readonly theirs: string;
readonly oursName?: string;
readonly theirsName?: string;
}
export interface MergeConflict {
readonly index: number;
readonly baseStartLine: number;
readonly baseEndLine: number;
readonly ours: string;
readonly base: string;
readonly theirs: string;
}
export interface MergeResult {
readonly text: string;
readonly clean: boolean;
readonly conflicts: readonly MergeConflict[];
readonly diagnostics: readonly Diagnostic[];
readonly report: string;
}
export function mergeThreeWay(request: MergeRequest): MergeResult {
assertInput(request.base, "left");
assertInput(request.ours, "right");
assertInput(request.theirs, "right");
if (request.ours === request.theirs)
return result(
request,
request.ours,
[],
[notice("merge.identical-sides", "Both variants are identical.")],
);
if (request.ours === request.base)
return result(
request,
request.theirs,
[],
[notice("merge.ours-unchanged", "Only the other variant changed.")],
);
if (request.theirs === request.base)
return result(
request,
request.ours,
[],
[notice("merge.theirs-unchanged", "Only our variant changed.")],
);
const base = lineTokens(request.base);
const ours = editsFor(base, lineTokens(request.ours), "ours");
const theirs = editsFor(base, lineTokens(request.theirs), "theirs");
const all = [...ours, ...theirs].sort(
(left, right) =>
left.start - right.start ||
left.end - right.end ||
left.side.localeCompare(right.side),
);
const output: string[] = [];
const conflicts: MergeConflict[] = [];
let cursor = 0;
let index = 0;
while (index < all.length) {
const first = all[index]!;
output.push(...base.slice(cursor, first.start));
const regionStart = first.start;
let regionEnd = first.end;
const group: Edit[] = [first];
index += 1;
while (index < all.length) {
const candidate = all[index]!;
const overlaps =
candidate.start < regionEnd ||
(regionStart === regionEnd && candidate.start === regionStart);
if (!overlaps) break;
group.push(candidate);
regionEnd = Math.max(regionEnd, candidate.end);
index += 1;
}
const oursGroup = group.filter((edit) => edit.side === "ours");
const theirsGroup = group.filter((edit) => edit.side === "theirs");
const baseRegion = base.slice(regionStart, regionEnd);
const oursRegion = oursGroup.length
? applyRegion(base, regionStart, regionEnd, oursGroup)
: baseRegion;
const theirsRegion = theirsGroup.length
? applyRegion(base, regionStart, regionEnd, theirsGroup)
: baseRegion;
if (equalTokens(oursRegion, theirsRegion)) output.push(...oursRegion);
else if (equalTokens(oursRegion, baseRegion)) output.push(...theirsRegion);
else if (equalTokens(theirsRegion, baseRegion)) output.push(...oursRegion);
else {
if (conflicts.length >= DIFF_LIMITS.maxMergeConflicts)
throw new RangeError(
"Three-way merge exceeds the conflict-count limit.",
);
const conflict: MergeConflict = {
index: conflicts.length + 1,
baseStartLine: lineNumberAt(base, regionStart),
baseEndLine: lineNumberAt(base, regionEnd),
ours: oursRegion.join(""),
base: baseRegion.join(""),
theirs: theirsRegion.join(""),
};
conflicts.push(conflict);
output.push(
marker(`<<<<<<< ${safeLabel(request.oursName, "ours")}`),
...withSectionEnding(oursRegion),
marker("||||||| base"),
...withSectionEnding(baseRegion),
marker("======="),
...withSectionEnding(theirsRegion),
marker(`>>>>>>> ${safeLabel(request.theirsName, "theirs")}`),
);
}
cursor = regionEnd;
}
output.push(...base.slice(cursor));
return result(
request,
output.join(""),
conflicts,
conflicts.length
? [
{
code: "merge.conflicts",
message: `${conflicts.length} conflict(s) require review.`,
severity: "warning",
side: "both",
},
]
: [
notice(
"merge.clean",
"Changes were merged without overlapping edits.",
),
],
);
}
function editsFor(
base: string[],
variant: string[],
side: Edit["side"],
): Edit[] {
const changes = diffArrays(base, variant, {
timeout: DIFF_LIMITS.diffTimeoutMilliseconds,
maxEditLength: DIFF_LIMITS.maxEditLength,
});
if (!changes)
throw new RangeError(
"Three-way merge exceeded the edit-distance or time limit.",
);
const edits: Edit[] = [];
let cursor = 0;
let index = 0;
while (index < changes.length) {
const change = changes[index]!;
if (!change.added && !change.removed) {
cursor += change.value.length;
index += 1;
continue;
}
const start = cursor;
const replacement: string[] = [];
while (index < changes.length) {
const part = changes[index]!;
if (!part.added && !part.removed) break;
if (part.removed) cursor += part.value.length;
if (part.added) replacement.push(...part.value);
index += 1;
}
edits.push({ start, end: cursor, replacement, side });
}
return edits;
}
function applyRegion(
base: string[],
start: number,
end: number,
edits: Edit[],
): string[] {
const output: string[] = [];
let cursor = start;
for (const edit of edits.sort(
(left, right) => left.start - right.start || left.end - right.end,
)) {
output.push(...base.slice(cursor, edit.start), ...edit.replacement);
cursor = edit.end;
}
output.push(...base.slice(cursor, end));
return output;
}
function lineTokens(value: string): string[] {
if (!value) return [];
const tokens = value.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/gu) ?? [];
if (tokens.length > DIFF_LIMITS.maxTokensPerSide)
throw new RangeError("Three-way merge exceeds the line-token limit.");
return tokens;
}
function equalTokens(
left: readonly string[],
right: readonly string[],
): boolean {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function marker(value: string): string {
return `${value}\n`;
}
function withSectionEnding(tokens: readonly string[]): string[] {
if (!tokens.length) return [];
const output = [...tokens];
const last = output.at(-1)!;
if (!/(?:\r\n|\r|\n)$/u.test(last)) output[output.length - 1] = `${last}\n`;
return output;
}
function safeLabel(value: string | undefined, fallback: string): string {
return (value || fallback).replace(/[\r\n]/gu, " ").slice(0, 100);
}
function lineNumberAt(tokens: readonly string[], offset: number): number {
return tokens
.slice(0, offset)
.reduce(
(lines, token) => lines + (/(?:\r\n|\r|\n)$/u.test(token) ? 1 : 0),
1,
);
}
function notice(code: string, message: string): Diagnostic {
return { code, message, severity: "info", side: "both" };
}
function result(
request: MergeRequest,
text: string,
conflicts: MergeConflict[],
diagnostics: Diagnostic[],
): MergeResult {
const bounded = assertOutput(text, "Merged output length");
const reportObject = {
schema: "de.add-ideas.diff-tools.merge-report.v1",
schemaVersion: 1,
generatedLocally: true,
clean: conflicts.length === 0,
conflicts,
diagnostics,
inputCharacters: {
base: request.base.length,
ours: request.ours.length,
theirs: request.theirs.length,
},
};
return {
text: bounded,
clean: conflicts.length === 0,
conflicts,
diagnostics,
report: assertOutput(
`${JSON.stringify(reportObject, null, 2)}\n`,
"Merge report length",
),
};
}
+50 -33
View File
@@ -1,4 +1,9 @@
import {
startWorkerJob,
WorkerJobTimeoutError,
} from "@add-ideas/toolbox-helpers";
import { compareInputs } from "./compare";
import { DIFF_LIMITS } from "./limits";
import {
DiffToolsError,
type CompareRequest,
@@ -6,19 +11,17 @@ import {
type DiffResult,
} from "./types";
type WorkerResponse =
| { id: number; ok: true; result: DiffResult }
| { id: number; ok: false; message: string; diagnostics: Diagnostic[] };
interface SerializedCompareError {
message: string;
diagnostics: Diagnostic[];
}
export interface CompareTask {
promise: Promise<DiffResult>;
cancel(): void;
}
let nextId = 0;
export function createCompareTask(request: CompareRequest): CompareTask {
const id = ++nextId;
if (typeof Worker === "undefined") {
let cancelled = false;
return {
@@ -36,42 +39,56 @@ export function createCompareTask(request: CompareRequest): CompareTask {
new URL("../workers/diff.worker.ts", import.meta.url),
{ type: "module", name: "diff-tools-comparator" },
);
let settled = false;
let rejectPromise: ((reason?: unknown) => void) | undefined;
const promise = new Promise<DiffResult>((resolve, reject) => {
rejectPromise = reject;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (event.data.id !== id || settled) return;
settled = true;
worker.terminate();
if (event.data.ok) resolve(event.data.result);
else
reject(new DiffToolsError(event.data.message, event.data.diagnostics));
};
worker.onerror = (event) => {
if (settled) return;
settled = true;
worker.terminate();
reject(
new DiffToolsError(event.message || "The comparison worker failed.", [
const task = startWorkerJob<
CompareRequest,
DiffResult,
never,
SerializedCompareError
>(worker, request, {
timeoutMs: DIFF_LIMITS.workerTimeoutMilliseconds,
deserializeError: (error) =>
new DiffToolsError(error.message, error.diagnostics),
workerFailureMessage: "The comparison worker failed.",
});
const promise = task.promise.catch((error: unknown) => {
if (error instanceof WorkerJobTimeoutError) {
throw new DiffToolsError(
`Comparison exceeded the ${DIFF_LIMITS.workerTimeoutMilliseconds / 1000}-second worker safety limit.`,
[
{
code: "worker.failure",
message: event.message || "The comparison worker failed.",
code: "worker.timeout",
message:
"The disposable comparison worker was terminated before it completed.",
severity: "error",
side: "both",
},
]),
],
);
};
worker.postMessage({ id, request });
}
if (
error instanceof DiffToolsError ||
(error instanceof DOMException && error.name === "AbortError")
)
throw error;
throw new DiffToolsError(
error instanceof Error ? error.message : "The comparison worker failed.",
[
{
code: "worker.failure",
message:
error instanceof Error
? error.message
: "The comparison worker failed.",
severity: "error",
side: "both",
},
],
);
});
return {
promise,
cancel() {
if (settled) return;
settled = true;
worker.terminate();
rejectPromise?.(new DOMException("Comparison cancelled.", "AbortError"));
task.cancel("Comparison cancelled.");
},
};
}
+107 -9
View File
@@ -115,13 +115,27 @@ textarea {
.hero,
.panel,
.mode-tabs,
.result-tabs {
.result-tabs,
.workspace-tabs {
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
}
.workspace-tabs {
display: flex;
gap: 0.35rem;
padding: 0.35rem;
overflow-x: auto;
}
.workspace-tabs button[aria-current="page"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.hero {
display: flex;
justify-content: space-between;
@@ -179,40 +193,122 @@ textarea {
margin-bottom: 0.9rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
}
.directory-inputs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin: 0.9rem 0;
}
.directory-inputs article {
min-width: 0;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-surface-soft);
}
.directory-inputs article p,
.option-note {
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.manifest-filter {
margin: 1rem 0 0.65rem;
}
.table-scroll {
max-height: 34rem;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.table-scroll th,
.table-scroll td {
padding: 0.55rem 0.65rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
.table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--toolbox-surface-soft);
}
.merge-inputs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0.9rem 0;
}
.merge-inputs textarea {
min-height: 14rem;
}
.merge-result {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--toolbox-border);
}
.merge-result textarea {
min-height: 22rem;
}
.mode-tabs,
.result-tabs {
padding: 0.35rem;
overflow-x: auto;
}
.mode-tabs [role="tablist"],
.result-tabs [role="tablist"] {
.mode-tabs [role="group"],
.result-tabs [role="group"] {
display: flex;
gap: 0.35rem;
min-width: max-content;
}
.mode-tabs [role="tab"] {
.mode-tabs button {
min-width: 9.5rem;
flex-direction: column;
align-items: flex-start;
line-height: 1.2;
}
.mode-tabs [role="tab"] small {
.mode-tabs button small {
color: var(--toolbox-muted);
font-size: 0.7rem;
font-weight: 580;
}
.mode-tabs [role="tab"][aria-selected="true"],
.result-tabs [role="tab"][aria-selected="true"] {
.mode-tabs button[aria-pressed="true"],
.result-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.mode-tabs [role="tab"][aria-selected="true"] small {
.mode-tabs button[aria-pressed="true"] small {
color: inherit;
opacity: 0.84;
}
@@ -734,7 +830,9 @@ textarea {
@media (max-width: 52rem) {
.input-grid,
.metadata-grid,
.paired-lines {
.paired-lines,
.directory-inputs,
.merge-inputs {
grid-template-columns: 1fr;
}
.paired-lines > div + div {
+53 -3
View File
@@ -3,12 +3,22 @@
"schemaVersion": 1,
"id": "de.add-ideas.diff-tools",
"name": "Diff Tools",
"version": "0.1.0",
"description": "Compare text and structured data locally in the browser.",
"version": "0.2.0",
"description": "Compare files and directories or perform bounded three-way merges locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "files", "productivity"],
"tags": ["diff", "compare", "json", "xml", "csv", "patch"],
"tags": [
"diff",
"compare",
"json",
"xml",
"csv",
"patch",
"directory",
"manifest",
"merge"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +31,46 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/*",
"extensions": [".txt", ".md", ".csv", ".xml"],
"label": "Text, CSV and XML files"
},
{
"mediaType": "application/json",
"extensions": [".json"],
"label": "JSON documents and directory manifests"
},
{
"mediaType": "*/*",
"extensions": [],
"label": "Files selected for bounded directory hashing"
}
],
"produces": [
{
"mediaType": "text/x-diff",
"extensions": [".diff", ".patch"],
"label": "Unified and JSON patches"
},
{
"mediaType": "text/plain",
"extensions": [".txt"],
"label": "Three-way merge result"
},
{
"mediaType": "application/json",
"extensions": [".json"],
"label": "Diff, merge and directory-manifest reports"
}
]
},
"capabilities": {
"required": ["workers"],
"optional": ["web-crypto"]
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+11 -24
View File
@@ -1,30 +1,17 @@
/// <reference lib="webworker" />
import { createWorkerJobMessageHandler } from "@add-ideas/toolbox-helpers";
import { compareInputs, serializeFailure } from "../core/compare";
import type { CompareRequest, DiffResult } from "../core/types";
interface WorkerRequest {
id: number;
request: CompareRequest;
}
type WorkerResponse =
| { id: number; ok: true; result: DiffResult }
| {
id: number;
ok: false;
message: string;
diagnostics: ReturnType<typeof serializeFailure>["diagnostics"];
};
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
const { id, request } = event.data;
let response: WorkerResponse;
try {
response = { id, ok: true, result: compareInputs(request) };
} catch (error) {
response = { id, ok: false, ...serializeFailure(error) };
}
self.postMessage(response);
};
self.onmessage = createWorkerJobMessageHandler<
CompareRequest,
DiffResult,
never,
ReturnType<typeof serializeFailure>
>(
(request) => compareInputs(request),
(response) => self.postMessage(response),
{ serializeError: serializeFailure },
);
export {};