@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
+11
-24
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user