дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(diff): compare directories and synchronize entries (#661)
Two folders can now be compared from the project view (Compare Directories with two folders selected, or Compare With… on one folder, also in Find Action). The result tree marks entries as only on left, only on right, contents differ or identical, with a name filter and a show-identical toggle; double-click opens the existing diff editor, and files or whole subdirectories can be copied to either side (overwrite and delete ask first, extra files in the target are kept). Comparison runs in the background with progress and cancel, honours the project's exclusions, and works for remote projects through the same file commands. The comparison tree and diff preview are shared with the Git folder/revision comparison. Verified on macOS (subdirectories, Chinese names, binary files, copy both ways, delete, cancel on large files); Windows and Linux not run. Fixes #520
Цей коміт міститься в:
1 джерело
482241ebb9
коміт
1716f3e062
23 files changed
+1552
-87
No files matched your search
@@ -40,6 +40,7 @@ const OTHER_ACTIONS = [
|
||||
"localHistory.putLabel",
|
||||
"diff.compareWith",
|
||||
"diff.compareFiles",
|
||||
"diff.compareDirectories",
|
||||
"vcs.commit",
|
||||
"vcs.addToVcs",
|
||||
"vcs.annotate",
|
||||
@@ -75,6 +76,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("project view context menu", () => {
|
||||
it("offers directory comparison only for two folders and Compare With for a single folder", () => {
|
||||
expect(labels(contextMenuItems([node("/p/left", true), node("/p/right", true)]))).toContain("Compare Directories");
|
||||
expect(labels(contextMenuItems([node("/p/left", true)]))).toContain("Compare With…");
|
||||
const mixed = labels(contextMenuItems([node("/p/left", true), node("/p/a.txt")]));
|
||||
for (const label of ["Compare Directories", "Compare Files", "Compare With…"]) expect(mixed).not.toContain(label);
|
||||
expect(labels(contextMenuItems([node("/p/a.txt"), node("/p/b.txt")]))).toContain("Compare Files");
|
||||
});
|
||||
|
||||
it("follows IDEA's order for a file", () => {
|
||||
const items = contextMenuItems([node(`${ROOT}/src/a.py`)]);
|
||||
expect(labels(items)).toEqual([
|
||||
@@ -161,7 +170,7 @@ describe("project view context menu", () => {
|
||||
const l = labels(contextMenuItems([node(`${ROOT}/src`, true)]));
|
||||
expect(l).toContain("Mark Directory as");
|
||||
expect(sub(contextMenuItems([node(`${ROOT}/src`, true)]), "Refactor")).toContain("Move Directory…");
|
||||
for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Compare With…", "Run 'a'"])
|
||||
for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Run 'a'"])
|
||||
expect(l).not.toContain(fileOnly);
|
||||
});
|
||||
|
||||
|
||||
@@ -361,7 +361,17 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
);
|
||||
}
|
||||
|
||||
// IDEA: Compare With… (one file) / Compare Files (two files).
|
||||
// Compare selections of the same kind; mixed file/folder selections cannot apply.
|
||||
const compareArg = single?.dir ? { path: single.path, directory: true } : single?.path;
|
||||
if (nodes.length === 2 && nodes.every((n) => n.dir && !n.placeholder))
|
||||
push(
|
||||
delegated(
|
||||
"diff.compareDirectories",
|
||||
nodes.map((n) => n.path),
|
||||
"Compare Directories",
|
||||
{ icon: "diff" },
|
||||
),
|
||||
);
|
||||
if (files.length === 2 && nodes.length === 2)
|
||||
push(
|
||||
delegated(
|
||||
@@ -371,9 +381,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
||||
{ icon: "diff" },
|
||||
),
|
||||
);
|
||||
else if (singleFile)
|
||||
else if (single && !single.placeholder)
|
||||
push(
|
||||
delegated("diff.compareWith", singleFile.path, "Compare With…", {
|
||||
delegated("diff.compareWith", compareArg, "Compare With…", {
|
||||
icon: "diff",
|
||||
shortcut: keyLabel("project.compareWith"),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Icon, Tree, fileIconName } from "@/ui";
|
||||
import { DiffEditor } from "./DiffEditor";
|
||||
import { diffInput, openDiff, type DiffData } from "./diffInput";
|
||||
import { dirKeys, type FileTreeNode } from "./fileTree";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
/** Shared changes tree and on-demand diff surface for revisions and disk folders. */
|
||||
export function ComparisonTree<F>({
|
||||
tree,
|
||||
current,
|
||||
onSelect,
|
||||
diffFor,
|
||||
renderDetails,
|
||||
ariaLabel,
|
||||
openOnActivate = false,
|
||||
getColor,
|
||||
onOpen,
|
||||
}: {
|
||||
tree: FileTreeNode<F>[];
|
||||
current: F | null;
|
||||
onSelect: (entry: F | null) => void;
|
||||
diffFor: (entry: F) => DiffData | null;
|
||||
renderDetails: (node: FileTreeNode<F>) => ReactNode;
|
||||
ariaLabel: string;
|
||||
openOnActivate?: boolean;
|
||||
getColor?: (entry: F) => string | undefined;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string> | null>(null);
|
||||
const data = current && diffFor(current);
|
||||
const input = data ? diffInput(data) : null;
|
||||
return (
|
||||
<div className={`${styles.comparison} ${input ? styles.comparisonWithDiff : ""}`}>
|
||||
<Tree<FileTreeNode<F>>
|
||||
roots={tree}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => (n.file ? undefined : n.children)}
|
||||
isExpandable={(n) => !n.file}
|
||||
expanded={expanded ?? new Set(dirKeys(tree))}
|
||||
onExpandedChange={setExpanded}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
onSelect(lead?.file ?? lead?.directory ?? null);
|
||||
}}
|
||||
onActivate={(n) => {
|
||||
const entry = n.file ?? n.directory;
|
||||
if (!entry) return;
|
||||
onSelect(entry);
|
||||
const diff = diffFor(entry);
|
||||
if (openOnActivate && diff) {
|
||||
void openDiff(diff);
|
||||
onOpen?.();
|
||||
}
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
className={styles.comparisonTree}
|
||||
renderNode={(n) => (
|
||||
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
|
||||
<Icon name={n.file ? fileIconName(n.name) : "folder"} size={14} />
|
||||
<span className={styles.fileName} style={{ color: n.file ? getColor?.(n.file) : undefined }}>
|
||||
{n.name}
|
||||
</span>
|
||||
{renderDetails(n)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{input && (
|
||||
<div className={styles.comparisonDiff}>
|
||||
<DiffEditor key={input.key} input={input} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
const data = input.data as DiffData;
|
||||
const { path, left, right } = data;
|
||||
const [attached, setAttached] = useState<Attached | null>(null);
|
||||
const [binary, setBinary] = useState<boolean | null>(null);
|
||||
/** Text of the sides that are not live documents. */
|
||||
const [statics, setStatics] = useState<{ a: string | null; b: string | null } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -263,15 +264,18 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
const reload = useCallback(async () => {
|
||||
if (!attached) return;
|
||||
try {
|
||||
const [a, b] = await Promise.all([
|
||||
const paths = [sideFile(left, path), sideFile(right, path)].filter((p): p is string => !!p);
|
||||
const [a, b, probes] = await Promise.all([
|
||||
attached.a ? null : loadSide(path, left),
|
||||
attached.b ? null : loadSide(path, right),
|
||||
Promise.all(paths.map((p) => call(commands.fsProbe(p)).catch(() => null))),
|
||||
]);
|
||||
const prev = staticsRef.current;
|
||||
if (!prev || prev.a !== a || prev.b !== b) {
|
||||
staticsRef.current = { a, b };
|
||||
setStatics({ a, b });
|
||||
}
|
||||
setBinary(probes.some((p) => p?.binary));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
@@ -311,9 +315,9 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
left.rev === "HEAD" &&
|
||||
!left.path &&
|
||||
isWorktree(right);
|
||||
const editableA = !!attached?.a && !attached.a.readOnly;
|
||||
const editableB = !!attached?.b && !attached.b.readOnly;
|
||||
const ready = !!attached && !!statics && lang !== null;
|
||||
const editableA = !binary && !!attached?.a && !attached.a.readOnly;
|
||||
const editableB = !binary && !!attached?.b && !attached.b.readOnly;
|
||||
const ready = !!attached && !!statics && lang !== null && binary !== null;
|
||||
|
||||
// Read when the views are built; toggling it later goes through `setSync`.
|
||||
const syncScroll = useRef(opts.syncScroll);
|
||||
@@ -983,7 +987,8 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reload()} />
|
||||
<span className={styles.count}>
|
||||
{!ready
|
||||
{binary && tx("Binary content")}
|
||||
{!ready || binary
|
||||
? ""
|
||||
: chunkInfo.count === 0
|
||||
? identical
|
||||
@@ -1047,7 +1052,11 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
{binary ? (
|
||||
<div className={styles.message}>
|
||||
<T>Binary files cannot be shown as text</T>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className={styles.message}>{error}</div>
|
||||
) : !ready ? (
|
||||
<div className={styles.message}>
|
||||
@@ -1061,7 +1070,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
opts.highlight === "none" && styles.noHighlight,
|
||||
opts.mode === "split" && (opts.alignChanges ? !opts.syncScroll && styles.unsynced : styles.unaligned),
|
||||
)}
|
||||
hidden={!!error || !ready}
|
||||
hidden={!!binary || !!error || !ready}
|
||||
aria-label={diffTitle(data)}
|
||||
role="region"
|
||||
onContextMenu={onContextMenu}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type { DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus } from "@/bindings";
|
||||
import { call, commands, emitLocalIdeEvent, IpcError, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { notifyFileOperation } from "@/platform/fileOps";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, Checkbox, Dialog, Input, Spinner, T } from "@/ui";
|
||||
import { upsertTask } from "@/workbench/tasks";
|
||||
import { ComparisonTree } from "./ComparisonTree";
|
||||
import { buildFileTree } from "./fileTree";
|
||||
import { confirmDialog, openDialog } from "./dialogs";
|
||||
import { openDiff, type DiffData, type DiffSide } from "./diffInput";
|
||||
import { compareFilesData } from "./compareData";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
const STATUS: Record<DirectoryCompareStatus, string> = {
|
||||
leftOnly: "Only on left",
|
||||
rightOnly: "Only on right",
|
||||
different: "Contents differ",
|
||||
equal: "Identical",
|
||||
};
|
||||
const join = (root: string, path: string) => `${root.replace(/\/$/, "")}/${path}`;
|
||||
const directory = (e: DirectoryCompareEntry) => e.leftKind === "dir" || e.rightKind === "dir";
|
||||
|
||||
export function directoryDiff(left: string, right: string, entry: DirectoryCompareEntry): DiffData | null {
|
||||
if (directory(entry)) return null;
|
||||
const data = compareFilesData(join(left, entry.path), join(right, entry.path));
|
||||
const missing = (): DiffSide => ({ type: "text", text: "", label: tx("(does not exist)") });
|
||||
return {
|
||||
...data,
|
||||
title: entry.path,
|
||||
left:
|
||||
entry.leftKind === "symlink"
|
||||
? { type: "text", text: entry.leftLink ?? "", label: data.left.label ?? join(left, entry.path) }
|
||||
: entry.leftKind
|
||||
? data.left
|
||||
: missing(),
|
||||
right:
|
||||
entry.rightKind === "symlink"
|
||||
? { type: "text", text: entry.rightLink ?? "", label: data.right.label ?? join(right, entry.path) }
|
||||
: entry.rightKind
|
||||
? data.right
|
||||
: missing(),
|
||||
};
|
||||
}
|
||||
|
||||
export function showDirectoryComparison(left: string, right: string) {
|
||||
if (left === right) return;
|
||||
openDialog((close) => <DirectoryCompareDialog left={left} right={right} onClose={close} />);
|
||||
}
|
||||
|
||||
export function DirectoryCompareDialog({ left, right, onClose }: { left: string; right: string; onClose: () => void }) {
|
||||
const [entries, setEntries] = useState<DirectoryCompareEntry[]>([]);
|
||||
const [current, setCurrent] = useState<DirectoryCompareEntry | null>(null);
|
||||
const [showEqual, setShowEqual] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [operating, setOperating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [operationError, setOperationError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DirectoryCompareProgress | null>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
const cancel = useRef<() => void>(() => {});
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let pending = true;
|
||||
const request = nextRequestId();
|
||||
const task = {
|
||||
id: `directory-compare-${request}`,
|
||||
title: tx("Comparing directories"),
|
||||
message: null as string | null,
|
||||
fraction: null as number | null,
|
||||
cancel: () => {
|
||||
void commands.cancelRequest(request);
|
||||
},
|
||||
};
|
||||
cancel.current = task.cancel;
|
||||
upsertTask({ ...task, done: false });
|
||||
const channel = new Channel<DirectoryCompareProgress>();
|
||||
channel.onmessage = (p) => {
|
||||
if (!alive) return;
|
||||
setProgress(p);
|
||||
task.message = p.path;
|
||||
task.fraction = p.total ? p.compared / p.total : null;
|
||||
upsertTask({ ...task, done: false });
|
||||
};
|
||||
void call(commands.fsCompareDirectories(left, right, request, channel))
|
||||
.then((rows) => {
|
||||
if (!alive) return;
|
||||
setEntries(rows);
|
||||
setCurrent((prev) => rows.find((e) => e.path === prev?.path) ?? null);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
if (isCancelled(e)) setCancelled(true);
|
||||
else setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
pending = false;
|
||||
upsertTask({ ...task, done: true });
|
||||
if (alive) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
if (pending) task.cancel();
|
||||
upsertTask({ ...task, done: true });
|
||||
};
|
||||
}, [left, right, generation]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const paths = new Set<string>();
|
||||
const query = filter.trim().toLocaleLowerCase();
|
||||
for (const e of entries) {
|
||||
if ((!showEqual && e.status === "equal") || !e.path.toLocaleLowerCase().includes(query)) continue;
|
||||
let path = e.path;
|
||||
while (path) {
|
||||
paths.add(path);
|
||||
const slash = path.lastIndexOf("/");
|
||||
path = slash < 0 ? "" : path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
return entries.filter((e) => paths.has(e.path));
|
||||
}, [entries, showEqual, filter]);
|
||||
const tree = useMemo(() => buildFileTree(visible, (e) => e.path, directory), [visible]);
|
||||
const selected = cancelled || error ? null : (visible.find((e) => e.path === current?.path) ?? null);
|
||||
const diff = selected && directoryDiff(left, right, selected);
|
||||
const refresh = () => {
|
||||
setBusy(true);
|
||||
setCancelled(false);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setGeneration((g) => g + 1);
|
||||
};
|
||||
|
||||
const operate = async (side: "left" | "right" | "delete") => {
|
||||
if (!selected || busy || operating) return;
|
||||
setOperating(true);
|
||||
setOperationError(null);
|
||||
let attempted = false;
|
||||
const targetRoot = side === "left" ? left : right;
|
||||
try {
|
||||
if (side === "delete") {
|
||||
if (!!selected.leftKind === !!selected.rightKind) return;
|
||||
const path = join(selected.leftKind ? left : right, selected.path);
|
||||
if (!(await confirmDialog({ title: "Delete", message: tx`Delete ${path}?`, okLabel: "Delete", danger: true })))
|
||||
return;
|
||||
attempted = true;
|
||||
await call(commands.fsDelete(path));
|
||||
notifyFileOperation({ kind: "deleted", paths: [path] });
|
||||
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [path] });
|
||||
} else {
|
||||
const sourceRoot = side === "left" ? right : left;
|
||||
const to = join(targetRoot, selected.path);
|
||||
try {
|
||||
attempted = true;
|
||||
await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, false));
|
||||
} catch (e) {
|
||||
if (!(e instanceof IpcError) || e.kind !== "alreadyExists") throw e;
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: "Overwrite files?",
|
||||
message: tx`Copy to ${to} and overwrite existing files?`,
|
||||
okLabel: "Overwrite",
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, true));
|
||||
}
|
||||
notifyFileOperation({ kind: "created", paths: [to] });
|
||||
emitLocalIdeEvent({ type: "fsChanged", created: [to], modified: [to], removed: [] });
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted.current) setOperationError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (mounted.current) {
|
||||
setOperating(false);
|
||||
if (attempted) refresh();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Compare Directories"
|
||||
onClose={onClose}
|
||||
width={diff ? 1100 : 780}
|
||||
buttons={[
|
||||
{
|
||||
label: "Show Diff",
|
||||
disabled: !diff || busy || operating,
|
||||
onClick: () => {
|
||||
if (diff) {
|
||||
void openDiff(diff);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ label: "Close", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.compareNotice}>{tx`Left: ${left}`}</div>
|
||||
<div className={styles.compareNotice}>{tx`Right: ${right}`}</div>
|
||||
<div className={styles.compareToolbar}>
|
||||
<Input
|
||||
aria-label="Filter by name"
|
||||
placeholder="Filter by name"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<Checkbox label="Show identical files" checked={showEqual} onChange={setShowEqual} />
|
||||
<Button size="small" disabled={busy || operating} onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.compareToolbar}>
|
||||
<Button size="small" disabled={!selected?.rightKind || busy || operating} onClick={() => void operate("left")}>
|
||||
Copy to Left
|
||||
</Button>
|
||||
<Button size="small" disabled={!selected?.leftKind || busy || operating} onClick={() => void operate("right")}>
|
||||
Copy to Right
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!selected || !!selected.leftKind === !!selected.rightKind || busy || operating}
|
||||
onClick={() => void operate("delete")}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{operating && <Spinner size={14} />}
|
||||
</div>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{tx(error)}
|
||||
</div>
|
||||
)}
|
||||
{operationError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{tx(operationError)}
|
||||
</div>
|
||||
)}
|
||||
{busy && (
|
||||
<div className={styles.compareToolbar} role="status">
|
||||
<Spinner size={14} />
|
||||
<span>
|
||||
{progress?.total != null
|
||||
? tx`Compared ${progress.compared} of ${progress.total} entries`
|
||||
: tx`Scanned ${progress?.scanned ?? 0} entries`}
|
||||
</span>
|
||||
<Button size="small" onClick={() => cancel.current()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{cancelled && (
|
||||
<div className={styles.compareNotice} role="status">
|
||||
<T>Comparison cancelled</T>
|
||||
</div>
|
||||
)}
|
||||
{!busy && !cancelled && !error && !visible.length && (
|
||||
<div className={styles.empty} role="status">
|
||||
<T>No matching differences</T>
|
||||
</div>
|
||||
)}
|
||||
{!!tree.length && (
|
||||
<ComparisonTree
|
||||
tree={tree}
|
||||
current={busy || operating ? null : selected}
|
||||
onSelect={setCurrent}
|
||||
diffFor={(e) => directoryDiff(left, right, e)}
|
||||
openOnActivate={!busy && !operating}
|
||||
onOpen={onClose}
|
||||
ariaLabel="Compared files"
|
||||
renderDetails={(n) => {
|
||||
const entry = n.file ?? n.directory;
|
||||
return <span className={styles.fileDir}>{entry ? tx(STATUS[entry.status]) : n.count}</span>;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,21 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("Diff toolbar", () => {
|
||||
it("shows a binary notice for disk comparisons instead of identical empty text", async () => {
|
||||
probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: true, tooLarge: false } });
|
||||
const binaryInput = {
|
||||
...input,
|
||||
data: { path: "/p/right.bin", left: { type: "file", path: "/p/left.bin" }, right: { type: "worktree" } },
|
||||
} as EditorInput;
|
||||
render(<DiffEditor input={binaryInput} />);
|
||||
expect(await screen.findByText("Binary files cannot be shown as text")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Contents are identical")).toBeNull();
|
||||
expect(screen.queryByRole("region", { name: /left.bin/ })).toBeNull();
|
||||
probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(screen.queryByText("Binary files cannot be shown as text")).toBeNull());
|
||||
});
|
||||
|
||||
it("puts Jump to Source right after Next Difference, in the navigation group", async () => {
|
||||
render(<DiffEditor input={input} />);
|
||||
await screen.findByRole("button", { name: "Jump to Source" });
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { DirectoryCompareEntry } from "@/bindings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
compare: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
open: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
fsCompareDirectories: mocks.compare,
|
||||
fsCopyMerge: mocks.copy,
|
||||
fsDelete: mocks.remove,
|
||||
cancelRequest: mocks.cancel,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
...(await original<typeof import("@tauri-apps/api/core")>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
DiffEditor: ({ input }: { input: unknown }) => <div data-testid="diff">{JSON.stringify(input)}</div>,
|
||||
}));
|
||||
vi.mock("../dialogs", () => ({ confirmDialog: mocks.confirm, openDialog: vi.fn(), promptText: vi.fn() }));
|
||||
vi.mock("../diffInput", async (original) => ({
|
||||
...(await original<typeof import("../diffInput")>()),
|
||||
openDiff: mocks.open,
|
||||
}));
|
||||
import { DirectoryCompareDialog, directoryDiff } from "../DirectoryCompare";
|
||||
|
||||
const row = (
|
||||
path: string,
|
||||
status: DirectoryCompareEntry["status"],
|
||||
leftKind: DirectoryCompareEntry["leftKind"] = "file",
|
||||
rightKind: DirectoryCompareEntry["rightKind"] = "file",
|
||||
): DirectoryCompareEntry => ({ path, status, leftKind, rightKind });
|
||||
const rows = [
|
||||
row("nested", "different", "dir", "dir"),
|
||||
row("nested/中文.txt", "different"),
|
||||
row("only-left.txt", "leftOnly", "file", null),
|
||||
row("only-right.txt", "rightOnly", null, "file"),
|
||||
row("same.txt", "equal"),
|
||||
row("empty", "leftOnly", "dir", null),
|
||||
];
|
||||
const ok = (data: unknown = null) => ({ status: "ok", data });
|
||||
const setup = () => render(<DirectoryCompareDialog left="/p/left" right="/p/right" onClose={() => {}} />);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.compare.mockResolvedValue(ok(rows));
|
||||
mocks.copy.mockResolvedValue(ok());
|
||||
mocks.remove.mockResolvedValue(ok());
|
||||
mocks.confirm.mockResolvedValue(true);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("disk directory comparison", () => {
|
||||
it("reuses the changes tree, filters names and toggles identical files", async () => {
|
||||
setup();
|
||||
expect(await screen.findByText("中文.txt")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Only on left")).toHaveLength(2);
|
||||
expect(screen.getByText("Only on right")).toBeInTheDocument();
|
||||
expect(screen.queryByText("same.txt")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Show identical files" }));
|
||||
expect(screen.getByText("same.txt")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Filter by name" }), { target: { value: "中文" } });
|
||||
expect(screen.queryByText("same.txt")).toBeNull();
|
||||
expect(screen.getByText("nested")).toBeInTheDocument();
|
||||
fireEvent.mouseDown(screen.getByText("nested"), { button: 0 });
|
||||
expect(screen.getByRole("button", { name: "Copy to Right" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("previews on selection and opens the existing diff on double click, including missing sides", async () => {
|
||||
setup();
|
||||
fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 });
|
||||
expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt");
|
||||
fireEvent.doubleClick(screen.getByText("中文.txt"));
|
||||
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" }));
|
||||
expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text");
|
||||
expect(directoryDiff("/l", "/r", rows[3]!)?.left.type).toBe("text");
|
||||
expect(directoryDiff("/l", "/r", row("type", "different", "file", "dir"))).toBeNull();
|
||||
expect(
|
||||
directoryDiff("/l", "/r", {
|
||||
...row("link", "different", "symlink", "symlink"),
|
||||
leftLink: "one",
|
||||
rightLink: "two",
|
||||
}),
|
||||
).toMatchObject({
|
||||
left: { type: "text", text: "one" },
|
||||
right: { type: "text", text: "two" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["Copy to Left", "Copy to Right"])(
|
||||
"confirms before overwriting, refreshes and restores selection after %s",
|
||||
async (label) => {
|
||||
mocks.copy
|
||||
.mockResolvedValueOnce({ status: "error", error: { kind: "alreadyExists", message: "exists" } })
|
||||
.mockResolvedValueOnce(ok());
|
||||
setup();
|
||||
fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 });
|
||||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Overwrite files?" })),
|
||||
);
|
||||
await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.copy.mock.calls[0]![3]).toBe(false);
|
||||
expect(mocks.copy.mock.calls[1]![3]).toBe(true);
|
||||
expect(mocks.copy.mock.calls[0]!.slice(0, 3)).toEqual(
|
||||
label === "Copy to Left"
|
||||
? ["/p/right", "/p/left", "nested/中文.txt"]
|
||||
: ["/p/left", "/p/right", "nested/中文.txt"],
|
||||
);
|
||||
expect(screen.getByTestId("diff")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not overwrite when confirmation is declined and reports operation errors", async () => {
|
||||
mocks.copy.mockResolvedValue({ status: "error", error: { kind: "alreadyExists", message: "exists" } });
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
setup();
|
||||
fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy to Right" }));
|
||||
await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.copy).toHaveBeenCalledTimes(1);
|
||||
mocks.copy.mockResolvedValue({ status: "error", error: { kind: "permissionDenied", message: "Cannot write" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy to Right" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot write");
|
||||
});
|
||||
|
||||
it("copies empty and nonempty subdirectories and confirms deletion only for one-sided entries", async () => {
|
||||
setup();
|
||||
fireEvent.mouseDown(await screen.findByText("nested"), { button: 0 });
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy to Right" }));
|
||||
await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "nested", false));
|
||||
await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2));
|
||||
fireEvent.mouseDown(screen.getByText("empty"), { button: 0 });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy to Right" }));
|
||||
await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "empty", false));
|
||||
await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(3));
|
||||
fireEvent.mouseDown(screen.getByText("only-left.txt"), { button: 0 });
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalled());
|
||||
expect(mocks.remove).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith("/p/left/only-left.txt"));
|
||||
});
|
||||
|
||||
it("streams progress, cancels on demand and on close, and ignores stale results after refresh", async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
mocks.compare.mockImplementationOnce((_l, _r, _request, channel) => {
|
||||
channel.onmessage({ scanned: 20, compared: 5, total: 10, bytesRead: 100, path: "big" });
|
||||
return new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
});
|
||||
const view = setup();
|
||||
expect(await screen.findByText("Compared 5 of 10 entries")).toBeInTheDocument();
|
||||
const request = mocks.compare.mock.calls[0]![2];
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(mocks.cancel).toHaveBeenCalledWith(request);
|
||||
view.unmount();
|
||||
expect(mocks.cancel).toHaveBeenCalledTimes(2);
|
||||
resolve(ok(rows));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
show: vi.fn(),
|
||||
read: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
remote: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
}));
|
||||
vi.mock("../DirectoryCompare", () => ({ showDirectoryComparison: mocks.show }));
|
||||
vi.mock("../dialogs", () => ({ promptText: mocks.prompt }));
|
||||
vi.mock("../diffInput", () => ({ openDiff: mocks.openDiff }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.pick }));
|
||||
vi.mock("@/platform/remote", () => ({ isRemoteWindow: mocks.remote }));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
isTauri: () => true,
|
||||
commands: { fsEntryKind: mocks.read },
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", async (original) => ({
|
||||
...(await original<typeof import("@/workbench/contributions")>()),
|
||||
notifications: () => ({ notify: mocks.notify }),
|
||||
}));
|
||||
import { getAction, isActionEnabled } from "@/platform/actions";
|
||||
import { provideProjectViewSelection } from "@/workbench/contributions";
|
||||
import { registerCompareActions } from "../compare";
|
||||
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dispose = registerCompareActions();
|
||||
mocks.remote.mockReturnValue(false);
|
||||
mocks.read.mockImplementation((path: string) =>
|
||||
Promise.resolve({ status: "ok", data: path.endsWith(".txt") ? "file" : "dir" }),
|
||||
);
|
||||
});
|
||||
afterEach(() => dispose());
|
||||
|
||||
describe("directory compare actions", () => {
|
||||
it("has a stable searchable action and resolves the selection for Find Action", async () => {
|
||||
const unselect = provideProjectViewSelection(() => ["/p/left", "/p/right"]);
|
||||
try {
|
||||
const action = getAction("diff.compareDirectories")!;
|
||||
expect(action.title).toBe("Compare Directories");
|
||||
expect(action.hidden).not.toBe(true);
|
||||
expect(isActionEnabled(action, { projectOpen: true })).toBe(true);
|
||||
await action.run({ ctx: { projectOpen: true } });
|
||||
expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right");
|
||||
await action.run({ ctx: { projectOpen: true }, arg: { paths: ["/x/left", "/x/right"] } });
|
||||
expect(mocks.show).toHaveBeenLastCalledWith("/x/left", "/x/right");
|
||||
} finally {
|
||||
unselect();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects mixed selections and does not start a directory comparison", async () => {
|
||||
await getAction("diff.compareDirectories")!.run({ ctx: {}, arg: ["/p/left", "/p/file.txt"] });
|
||||
expect(mocks.show).not.toHaveBeenCalled();
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Select two directories to compare" }));
|
||||
});
|
||||
|
||||
it("uses the native folder picker for Compare With on a local directory", async () => {
|
||||
mocks.pick.mockResolvedValue("/p/right");
|
||||
await getAction("diff.compareWith")!.run({ ctx: {}, arg: { path: "/p/left", directory: true } });
|
||||
expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: true }));
|
||||
expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right");
|
||||
expect(mocks.openDiff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses remote paths and validates the selected directory via the routed filesystem", async () => {
|
||||
mocks.remote.mockReturnValue(true);
|
||||
mocks.prompt.mockResolvedValue({ value: "/remote/right" });
|
||||
const unselect = provideProjectViewSelection(() => ["/remote/left"]);
|
||||
try {
|
||||
await getAction("diff.compareWith")!.run({ ctx: {} });
|
||||
expect(mocks.prompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: "Directory path", initial: "/remote/left" }),
|
||||
);
|
||||
expect(mocks.pick).not.toHaveBeenCalled();
|
||||
expect(mocks.read).toHaveBeenCalledWith("/remote/right");
|
||||
expect(mocks.show).toHaveBeenCalledWith("/remote/left", "/remote/right");
|
||||
} finally {
|
||||
unselect();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves file Compare With on the existing diff flow", async () => {
|
||||
mocks.pick.mockResolvedValue("/p/b.txt");
|
||||
await getAction("diff.compareWith")!.run({ ctx: {}, arg: "/p/a.txt" });
|
||||
expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: false }));
|
||||
expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/b.txt" }));
|
||||
expect(mocks.show).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,4 +9,21 @@ describe("buildFileTree", () => {
|
||||
expect(tree[1]!.children.map((c) => c.name)).toEqual(["a.ts", "b.ts"]);
|
||||
expect(dirKeys(tree)).toEqual(["d:docs/x/y", "d:src/app"]);
|
||||
});
|
||||
|
||||
it("retains explicit empty directories and their entries for subtree operations", () => {
|
||||
const entries = [
|
||||
{ path: "empty", dir: true },
|
||||
{ path: "src", dir: true },
|
||||
{ path: "src/nested", dir: true },
|
||||
{ path: "src/nested/a", dir: false },
|
||||
];
|
||||
const tree = buildFileTree(
|
||||
entries,
|
||||
(e) => e.path,
|
||||
(e) => e.dir,
|
||||
);
|
||||
expect(tree[0]).toMatchObject({ name: "empty", directory: entries[0], children: [], count: 0 });
|
||||
expect(tree[1]).toMatchObject({ name: "src", directory: entries[1] });
|
||||
expect(tree[1]!.children[0]).toMatchObject({ name: "nested", directory: entries[2] });
|
||||
});
|
||||
});
|
||||
@@ -310,6 +310,17 @@
|
||||
color: var(--fg-subtle);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.compareToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
.compareToolbar > input {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
.compareAdded {
|
||||
color: var(--vcs-added);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
*/
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { maybeEditorService, notifications, projectViewSelection } from "@/workbench/contributions";
|
||||
import { promptText } from "./dialogs";
|
||||
import { focusedDiffNavigator } from "./diffNavigator";
|
||||
import { compareFilesData, displayPath } from "./compareData";
|
||||
export { compareFilesData, displayPath } from "./compareData";
|
||||
import { openDiff, type DiffData } from "./diffInput";
|
||||
import { baseName } from "./model";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -27,20 +30,6 @@ function argPaths(arg: unknown): string[] {
|
||||
return p ? [p] : [];
|
||||
}
|
||||
|
||||
/** Project-relative path for labels. */
|
||||
export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string {
|
||||
return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
|
||||
}
|
||||
|
||||
/** Diff of two files on disk; both sides are live, editable documents. */
|
||||
export function compareFilesData(leftPath: string, rightPath: string): DiffData {
|
||||
return {
|
||||
path: rightPath,
|
||||
left: { type: "file", path: leftPath, label: displayPath(leftPath) },
|
||||
right: { type: "worktree", label: displayPath(rightPath) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Clipboard ↔ the file (editable) or ↔ the editor selection. */
|
||||
export function clipboardDiffData(path: string, clipboard: string, selection: string | null): DiffData {
|
||||
return {
|
||||
@@ -122,6 +111,29 @@ async function pickComparison(
|
||||
|
||||
const contextPath = (arg: unknown) => argPath(arg) ?? maybeEditorService()?.activePath() ?? undefined;
|
||||
|
||||
async function showDirectories(left: string, right: string) {
|
||||
const { showDirectoryComparison } = await import("./DirectoryCompare");
|
||||
showDirectoryComparison(left, right);
|
||||
}
|
||||
|
||||
async function isDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await call(commands.fsEntryKind(path))) === "dir";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pickDirectory(near: string): Promise<string | null> {
|
||||
if (isRemoteWindow() || !isTauri()) {
|
||||
const picked = await promptText({ title: "Compare With", label: "Directory path", initial: near, mono: true });
|
||||
return picked?.value ?? null;
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({ multiple: false, directory: true, title: tx("Compare With"), defaultPath: near });
|
||||
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
||||
}
|
||||
|
||||
export function registerCompareActions(): () => void {
|
||||
return registerActions([
|
||||
{
|
||||
@@ -210,10 +222,18 @@ export function registerCompareActions(): () => void {
|
||||
category: "Diff",
|
||||
icon: "diff",
|
||||
when: "projectOpen",
|
||||
keywords: ["diff", "compare files"],
|
||||
keywords: ["diff", "compare files", "compare folders"],
|
||||
run: async ({ arg }) => {
|
||||
const path = contextPath(arg);
|
||||
const path = argPath(arg) ?? projectViewSelection()[0] ?? contextPath(arg);
|
||||
if (!path) return info("Open or select a file to compare");
|
||||
const directoryArg = !!arg && typeof arg === "object" && "directory" in arg && arg.directory === true;
|
||||
if (directoryArg || (isTauri() && (await isDirectory(path)))) {
|
||||
const other = await pickDirectory(path);
|
||||
if (!other || other === path) return;
|
||||
if (!(await isDirectory(other))) return info("Select a directory to compare");
|
||||
await showDirectories(path, other);
|
||||
return;
|
||||
}
|
||||
const other = await pickComparison(path);
|
||||
if (!other) return;
|
||||
if (other.type === "file") {
|
||||
@@ -228,6 +248,20 @@ export function registerCompareActions(): () => void {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff.compareDirectories",
|
||||
title: "Compare Directories",
|
||||
category: "Diff",
|
||||
icon: "diff",
|
||||
when: "projectOpen",
|
||||
keywords: ["diff", "folders", "compare folders"],
|
||||
run: async ({ arg }) => {
|
||||
const paths = arg === undefined ? projectViewSelection() : argPaths(arg);
|
||||
if (paths.length !== 2 || !(await isDirectory(paths[0]!)) || !(await isDirectory(paths[1]!)))
|
||||
return info("Select two directories to compare");
|
||||
await showDirectories(paths[0]!, paths[1]!);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff.compareFiles",
|
||||
title: "Compare Files",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { DiffData } from "./diffInput";
|
||||
|
||||
/** Project-relative path for labels. */
|
||||
export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string {
|
||||
return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
|
||||
}
|
||||
|
||||
/** Diff of two files on disk; both sides are live, editable documents. */
|
||||
export function compareFilesData(leftPath: string, rightPath: string): DiffData {
|
||||
return {
|
||||
path: rightPath,
|
||||
left: { type: "file", path: leftPath, label: displayPath(leftPath) },
|
||||
right: { type: "worktree", label: displayPath(rightPath) },
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,22 @@ export interface FileTreeNode<F> {
|
||||
/** Directory label (`src/app`) or file name. */
|
||||
name: string;
|
||||
file: F | null;
|
||||
/** An explicit directory entry, including empty directories. */
|
||||
directory?: F;
|
||||
children: FileTreeNode<F>[];
|
||||
/** Number of files below (1 for a file). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function buildFileTree<F>(files: readonly F[], relPath: (f: F) => string): FileTreeNode<F>[] {
|
||||
export function buildFileTree<F>(
|
||||
files: readonly F[],
|
||||
relPath: (f: F) => string,
|
||||
isDirectory?: (f: F) => boolean,
|
||||
): FileTreeNode<F>[] {
|
||||
interface Dir {
|
||||
dirs: Map<string, Dir>;
|
||||
files: { name: string; file: F; rel: string }[];
|
||||
entry?: F;
|
||||
}
|
||||
const root: Dir = { dirs: new Map(), files: [] };
|
||||
for (const f of files) {
|
||||
@@ -26,7 +33,11 @@ export function buildFileTree<F>(files: readonly F[], relPath: (f: F) => string)
|
||||
if (!next) d.dirs.set(p, (next = { dirs: new Map(), files: [] }));
|
||||
d = next;
|
||||
}
|
||||
d.files.push({ name, file: f, rel });
|
||||
if (isDirectory?.(f)) {
|
||||
let sub = d.dirs.get(name);
|
||||
if (!sub) d.dirs.set(name, (sub = { dirs: new Map(), files: [] }));
|
||||
sub.entry = f;
|
||||
} else d.files.push({ name, file: f, rel });
|
||||
}
|
||||
|
||||
const convert = (d: Dir, prefix: string): FileTreeNode<F>[] => {
|
||||
@@ -35,7 +46,7 @@ export function buildFileTree<F>(files: readonly F[], relPath: (f: F) => string)
|
||||
let label = name;
|
||||
let path = prefix ? `${prefix}/${name}` : name;
|
||||
let cur = sub;
|
||||
while (cur.files.length === 0 && cur.dirs.size === 1) {
|
||||
while (!cur.entry && cur.files.length === 0 && cur.dirs.size === 1) {
|
||||
const [n, only] = [...cur.dirs][0]!;
|
||||
label += `/${n}`;
|
||||
path += `/${n}`;
|
||||
@@ -46,6 +57,7 @@ export function buildFileTree<F>(files: readonly F[], relPath: (f: F) => string)
|
||||
key: `d:${path}`,
|
||||
name: label,
|
||||
file: null,
|
||||
directory: cur.entry,
|
||||
children,
|
||||
count: children.reduce((n, c) => n + c.count, 0),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, nextRequestId } from "@/platform/ipc";
|
||||
import { Checkbox, Dialog, Icon, Input, Tree, Spinner, fileIconName, T } from "@/ui";
|
||||
import { Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput";
|
||||
import { openDialog, promptText } from "./dialogs";
|
||||
@@ -17,10 +17,10 @@ import { runGitOperation, runOp } from "./ops";
|
||||
import { reloadLog, useGitLog } from "./logStore";
|
||||
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store";
|
||||
import styles from "./common.module.css";
|
||||
import { DiffEditor } from "./DiffEditor";
|
||||
import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree";
|
||||
import { ComparisonTree } from "./ComparisonTree";
|
||||
import { buildFileTree, type FileTreeNode } from "./fileTree";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { displayPath } from "./compare";
|
||||
import { displayPath } from "./compareData";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation in progress
|
||||
@@ -528,8 +528,6 @@ export function ChangesBetweenDialog({
|
||||
const [files, setFiles] = useState<ComparisonFile[] | null>(null);
|
||||
const [result, setResult] = useState<CompareResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string> | null>(null);
|
||||
const [current, setCurrent] = useState<ComparisonFile | null>(null);
|
||||
const fromRev = from.rev;
|
||||
const toRev = to?.rev ?? null;
|
||||
@@ -665,52 +663,25 @@ export function ChangesBetweenDialog({
|
||||
</div>
|
||||
) : null}
|
||||
{files && files.length > 0 ? (
|
||||
<div className={`${styles.comparison} ${input ? styles.comparisonWithDiff : ""}`}>
|
||||
<Tree<FileTreeNode<ComparisonFile>>
|
||||
roots={tree}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => (n.file ? undefined : n.children)}
|
||||
isExpandable={(n) => !n.file}
|
||||
expanded={expanded ?? new Set(dirKeys(tree))}
|
||||
onExpandedChange={setExpanded}
|
||||
selected={selected}
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelected(sel);
|
||||
if (lead?.file) setCurrent(lead.file);
|
||||
}}
|
||||
onActivate={(n) => n.file && setCurrent(n.file)}
|
||||
aria-label="Changed files"
|
||||
className={styles.comparisonTree}
|
||||
renderNode={(n) => (
|
||||
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }} title={n.file ? n.file.path : n.name}>
|
||||
<Icon name={n.file ? fileIconName(n.name) : "folder"} size={14} />
|
||||
<span
|
||||
className={styles.fileName}
|
||||
style={
|
||||
n.file ? { color: KIND_COLOR[n.file.kind === "untracked" ? "added" : n.file.kind] } : undefined
|
||||
}
|
||||
>
|
||||
{n.name}
|
||||
</span>
|
||||
{n.file ? (
|
||||
<>
|
||||
<span className={styles.fileDir}>{tx(n.file.kind)}</span>
|
||||
{n.file.added !== undefined && <span className={styles.compareAdded}>+{n.file.added}</span>}
|
||||
{n.file.removed !== undefined && <span className={styles.compareRemoved}>−{n.file.removed}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.fileDir}>{n.count}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{input && (
|
||||
<div className={styles.comparisonDiff}>
|
||||
<DiffEditor key={input.key} input={input} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ComparisonTree
|
||||
tree={tree}
|
||||
current={current}
|
||||
onSelect={setCurrent}
|
||||
diffFor={diffFor}
|
||||
getColor={(f) => KIND_COLOR[f.kind === "untracked" ? "added" : f.kind]}
|
||||
ariaLabel="Changed files"
|
||||
renderDetails={(n) =>
|
||||
n.file ? (
|
||||
<>
|
||||
<span className={styles.fileDir}>{tx(n.file.kind)}</span>
|
||||
{n.file.added !== undefined && <span className={styles.compareAdded}>+{n.file.added}</span>}
|
||||
{n.file.removed !== undefined && <span className={styles.compareRemoved}>−{n.file.removed}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.fileDir}>{n.count}</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in new issue
Заблокувати користувача