дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Compare With… was only in the Project view menu and needed the OS file dialog. The editor context menu (with Compare with Clipboard) and the editor tab menu now offer it too, and outside the desktop app (remote development, browser) a browser file picker supplies the file, compared as text. Co-authored-by: a-rookie-of-C-language <[email protected]>
284 рядки
9.8 KiB
TypeScript
284 рядки
9.8 KiB
TypeScript
/**
|
|
* Compare entry points (IDEA's Compare With…, Compare Files, Compare with
|
|
* Clipboard) and the diff viewer's own actions (F7 / ⇧F7, F4).
|
|
*/
|
|
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 { focusedDiffNavigator } from "./diffNavigator";
|
|
import { openDiff, type DiffData } from "./diffInput";
|
|
import { baseName } from "./model";
|
|
import { tx } from "@/platform/i18n";
|
|
|
|
function argPath(arg: unknown): string | undefined {
|
|
if (typeof arg === "string") return arg;
|
|
if (arg && typeof arg === "object" && "path" in arg && typeof (arg as { path: unknown }).path === "string")
|
|
return (arg as { path: string }).path;
|
|
return undefined;
|
|
}
|
|
|
|
function argPaths(arg: unknown): string[] {
|
|
if (Array.isArray(arg)) return arg.filter((x): x is string => typeof x === "string");
|
|
if (arg && typeof arg === "object" && "paths" in arg && Array.isArray((arg as { paths: unknown }).paths))
|
|
return ((arg as { paths: unknown[] }).paths ?? []).filter((x): x is string => typeof x === "string");
|
|
const p = argPath(arg);
|
|
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 {
|
|
path,
|
|
title: selection ? tx`Clipboard ↔ ${baseName(path)} (selection)` : tx`Clipboard ↔ ${baseName(path)}`,
|
|
left: { type: "text", text: clipboard, label: "Clipboard" },
|
|
right: selection
|
|
? { type: "text", text: selection, label: "Selection" }
|
|
: { type: "worktree", label: displayPath(path) },
|
|
};
|
|
}
|
|
|
|
export interface TextsArg {
|
|
path: string;
|
|
title?: string;
|
|
left: { text: string; label: string };
|
|
right: { text: string; label: string };
|
|
line?: number;
|
|
}
|
|
|
|
export function parseTextsArg(arg: unknown): TextsArg | null {
|
|
if (!arg || typeof arg !== "object") return null;
|
|
const a = arg as Record<string, unknown>;
|
|
const side = (v: unknown) =>
|
|
v && typeof v === "object" && typeof (v as { text?: unknown }).text === "string"
|
|
? { text: (v as { text: string }).text, label: String((v as { label?: unknown }).label ?? "") }
|
|
: null;
|
|
const left = side(a.left);
|
|
const right = side(a.right);
|
|
if (typeof a.path !== "string" || !left || !right) return null;
|
|
return {
|
|
path: a.path,
|
|
title: typeof a.title === "string" ? a.title : undefined,
|
|
left,
|
|
right,
|
|
line: typeof a.line === "number" ? a.line : undefined,
|
|
};
|
|
}
|
|
|
|
function info(title: string) {
|
|
notifications().notify({ group: "vcs", level: "info", title });
|
|
}
|
|
|
|
async function pickFile(near: string): Promise<string | null> {
|
|
if (!isTauri()) return null;
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
const dir = near.slice(0, near.lastIndexOf("/")) || undefined;
|
|
const picked = await open({ multiple: false, directory: false, title: "Compare With", defaultPath: dir });
|
|
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The file to compare `path` against: the OS file dialog on the desktop; a
|
|
* browser file picker's contents elsewhere (remote development / `pnpm dev`),
|
|
* compared as text since no absolute path is available to the backend.
|
|
*/
|
|
async function pickComparison(
|
|
path: string,
|
|
): Promise<{ type: "file"; path: string } | { type: "text"; text: string; label: string } | null> {
|
|
if (isTauri()) {
|
|
const picked = await pickFile(path);
|
|
return picked ? { type: "file", path: picked } : null;
|
|
}
|
|
if (typeof document === "undefined") {
|
|
info("Compare With needs the desktop app or a browser file picker");
|
|
return null;
|
|
}
|
|
const chosen = await new Promise<File | null>((resolve) => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.onchange = () => resolve(input.files?.[0] ?? null);
|
|
// Chrome fires `cancel` (or focus returns) when the dialog is dismissed.
|
|
input.addEventListener("cancel", () => resolve(null));
|
|
input.click();
|
|
});
|
|
if (!chosen) return null;
|
|
return { type: "text", text: await chosen.text(), label: chosen.name };
|
|
}
|
|
|
|
const contextPath = (arg: unknown) => argPath(arg) ?? maybeEditorService()?.activePath() ?? undefined;
|
|
|
|
export function registerCompareActions(): () => void {
|
|
return registerActions([
|
|
{
|
|
id: "vcs.openDiff",
|
|
title: "Open Diff",
|
|
description: "Opens the diff viewer for a `DiffData` argument (e.g. a pull request file).",
|
|
category: "Diff",
|
|
icon: "diff",
|
|
when: "projectOpen",
|
|
hidden: true,
|
|
run: ({ arg }) => {
|
|
const data = arg as DiffData | undefined;
|
|
if (data && typeof data.path === "string" && data.left && data.right) return openDiff(data);
|
|
},
|
|
},
|
|
{
|
|
id: "diff.nextDifference",
|
|
title: "Next Difference",
|
|
category: "Diff",
|
|
icon: "arrow-down",
|
|
when: "diffEditorFocus",
|
|
run: () => focusedDiffNavigator()?.next(),
|
|
},
|
|
{
|
|
id: "diff.prevDifference",
|
|
title: "Previous Difference",
|
|
category: "Diff",
|
|
icon: "arrow-up",
|
|
when: "diffEditorFocus",
|
|
run: () => focusedDiffNavigator()?.prev(),
|
|
},
|
|
{
|
|
id: "diff.showSettings",
|
|
title: "Diff Settings",
|
|
category: "Diff",
|
|
icon: "settings",
|
|
keywords: ["ignore whitespace", "highlighting", "side-by-side", "unified"],
|
|
when: "diffEditorFocus",
|
|
run: () => focusedDiffNavigator()?.showSettings(),
|
|
},
|
|
{
|
|
id: "diff.nextFile",
|
|
title: "Compare Next File",
|
|
category: "Diff",
|
|
icon: "arrow-right",
|
|
when: "diffEditorFocus",
|
|
run: () => focusedDiffNavigator()?.file(1),
|
|
},
|
|
{
|
|
id: "diff.prevFile",
|
|
title: "Compare Previous File",
|
|
category: "Diff",
|
|
icon: "arrow-left",
|
|
when: "diffEditorFocus",
|
|
run: () => focusedDiffNavigator()?.file(-1),
|
|
},
|
|
{
|
|
id: "diff.applyLeftSide",
|
|
title: "Accept Left Side",
|
|
description: "Copies the change at the caret from the left side to the right one",
|
|
category: "Diff",
|
|
icon: "arrow-right",
|
|
when: "diffEditorFocus",
|
|
run: () => void focusedDiffNavigator()?.applyChange("a"),
|
|
},
|
|
{
|
|
id: "diff.applyRightSide",
|
|
title: "Accept Right Side",
|
|
description: "Copies the change at the caret from the right side to the left one",
|
|
category: "Diff",
|
|
icon: "arrow-left",
|
|
when: "diffEditorFocus",
|
|
run: () => void focusedDiffNavigator()?.applyChange("b"),
|
|
},
|
|
{
|
|
id: "diff.jumpToSource",
|
|
title: "Jump to Source",
|
|
category: "Diff",
|
|
icon: "external-link",
|
|
when: "diffEditorFocus",
|
|
run: () => focusedDiffNavigator()?.jumpToSource(),
|
|
},
|
|
{
|
|
id: "diff.compareWith",
|
|
title: "Compare With…",
|
|
category: "Diff",
|
|
icon: "diff",
|
|
when: "projectOpen",
|
|
keywords: ["diff", "compare files"],
|
|
run: async ({ arg }) => {
|
|
const path = contextPath(arg);
|
|
if (!path) return info("Open or select a file to compare");
|
|
const other = await pickComparison(path);
|
|
if (!other) return;
|
|
if (other.type === "file") {
|
|
if (other.path === path) return;
|
|
await openDiff(compareFilesData(path, other.path));
|
|
} else {
|
|
await openDiff({
|
|
path,
|
|
left: { type: "text", text: other.text, label: other.label },
|
|
right: { type: "worktree", label: displayPath(path) },
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: "diff.compareFiles",
|
|
title: "Compare Files",
|
|
category: "Diff",
|
|
icon: "diff",
|
|
when: "projectOpen",
|
|
run: async ({ arg }) => {
|
|
const paths = argPaths(arg).filter((p) => !p.endsWith("/"));
|
|
if (paths.length !== 2) return info("Select two files to compare");
|
|
await openDiff(compareFilesData(paths[0]!, paths[1]!));
|
|
},
|
|
},
|
|
{
|
|
id: "diff.compareWithClipboard",
|
|
title: "Compare with Clipboard",
|
|
category: "Diff",
|
|
icon: "diff",
|
|
when: "projectOpen",
|
|
run: async ({ arg }) => {
|
|
const path = contextPath(arg);
|
|
if (!path) return info("Open or select a file to compare with the clipboard");
|
|
let clipboard: string;
|
|
try {
|
|
clipboard = (await navigator.clipboard?.readText()) ?? "";
|
|
} catch {
|
|
return info("The clipboard could not be read");
|
|
}
|
|
const ed = activeEditor();
|
|
const sel = ed && ed.ctx.path === path ? ed.view.state.selection.main : null;
|
|
const selection = sel && !sel.empty ? ed!.view.state.sliceDoc(sel.from, sel.to) : null;
|
|
await openDiff(clipboardDiffData(path, clipboard, selection));
|
|
},
|
|
},
|
|
{
|
|
// Parameterised: { path, title?, left: { text, label }, right: { text, label }, line? }.
|
|
id: "diff.compareTexts",
|
|
title: "Compare Texts",
|
|
category: "Diff",
|
|
hidden: true,
|
|
run: async ({ arg }) => {
|
|
const a = parseTextsArg(arg);
|
|
if (!a) return;
|
|
await openDiff({
|
|
path: a.path,
|
|
title: a.title,
|
|
line: a.line,
|
|
left: { type: "text", text: a.left.text, label: a.left.label },
|
|
right: { type: "text", text: a.right.text, label: a.right.label },
|
|
});
|
|
},
|
|
},
|
|
]);
|
|
}
|