дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Ctrl+C copies the selected changes' file names, Ctrl+Shift+C their absolute paths, as IDEA does; the context menu gains Copy File Name beside Copy Path. Applies to every changes tree (Commit tool window, Git window's Local Changes, Commit dialog) and to the unversioned group's files too.
Цей коміт міститься в:
1 джерело
c3e8f20f53
коміт
6c813d501e
2 files changed
+102
-3
No files matched your search
@@ -3,12 +3,12 @@
|
||||
* parts also make the Git window's Local Changes / Stash tabs and the Commit dialog.
|
||||
*/
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { formatBinding, isMacHost } from "@/platform/keymap";
|
||||
import { formatBinding, isMacHost, macStroke, normalizeBinding, strokeOf } from "@/platform/keymap";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import clsx from "clsx";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { dirname } from "@/platform/project";
|
||||
import { basename, dirname } from "@/platform/project";
|
||||
import { isTestFile, useEffectiveRoots } from "@/platform/projectRoots";
|
||||
import {
|
||||
Button,
|
||||
@@ -246,6 +246,18 @@ export function deletableFiles(nodes: ChangeNode[]): string[] {
|
||||
return files.length && files.every(onDisk) ? files.map((f) => f.path) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the selected files' absolute paths (`absolute`) or just their names
|
||||
* (`name`) to the clipboard, one per line: Ctrl+C / Ctrl+Shift+C of the changes
|
||||
* tree and the context menu's Copy items (#1877).
|
||||
*/
|
||||
export function copyChangePaths(nodes: ChangeNode[], kind: "absolute" | "name") {
|
||||
const text = filesOf(nodes)
|
||||
.map((f) => (kind === "name" ? basename(f.path) : f.path))
|
||||
.join("\n");
|
||||
if (text) void navigator.clipboard?.writeText(text);
|
||||
}
|
||||
|
||||
export function nodeMenu(
|
||||
nodes: ChangeNode[],
|
||||
repos: readonly string[],
|
||||
@@ -348,7 +360,8 @@ export function nodeMenu(
|
||||
{ kind: "action", action: "vcs.localHistory", arg: first?.path },
|
||||
{ kind: "action", action: "vcs.annotate", arg: first?.path },
|
||||
{ kind: "separator" },
|
||||
{ label: "Copy Path", icon: "copy", run: () => void navigator.clipboard?.writeText(paths.join("\n")) },
|
||||
{ label: "Copy Path", icon: "copy", run: () => copyChangePaths(nodes, "absolute") },
|
||||
{ label: "Copy File Name", icon: "copy", run: () => copyChangePaths(nodes, "name") },
|
||||
);
|
||||
return items;
|
||||
}
|
||||
@@ -628,6 +641,15 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
|
||||
e.preventDefault();
|
||||
void editorService().openFile(n.path, { focus: true });
|
||||
}
|
||||
} else {
|
||||
// IDEA: Ctrl+C copies the file names, Ctrl+Shift+C the absolute paths (#1877).
|
||||
const stroke = strokeOf(e.nativeEvent);
|
||||
const copy = stroke && normalizeBinding(macStroke(stroke));
|
||||
if (copy === "Meta+C" || copy === "Shift+Meta+C") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyChangePaths(selectedNodes(), copy === "Meta+C" ? "name" : "absolute");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileStatus } from "@/bindings";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import type { ChangeNode } from "../changes";
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return { ...real, call: vi.fn() };
|
||||
});
|
||||
|
||||
import { ChangesTree, copyChangePaths, nodeMenu } from "../CommitView";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
const file = (rel: string, unstaged: FileStatus["unstaged"] = "modified"): ChangeNode => {
|
||||
const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged: null, unstaged, renamedFrom: null };
|
||||
return { key: f.path, type: "file", label: rel, group: "changes", files: [f], path: f.path };
|
||||
};
|
||||
|
||||
const item = (menu: MenuItem[], label: string) => {
|
||||
const i = menu.find((m) => m.kind !== "separator" && "label" in m && m.label === label);
|
||||
return i && "run" in i ? i : undefined;
|
||||
};
|
||||
|
||||
const writeText = vi.hoisted(() => vi.fn());
|
||||
|
||||
describe("Copy in the changes tree (#1877)", () => {
|
||||
beforeEach(() => {
|
||||
writeText.mockClear();
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root: "/p",
|
||||
repo: "/p",
|
||||
roots: ["/p"],
|
||||
status: [
|
||||
{ path: "/p/src/a.ts", relPath: "src/a.ts", staged: null, unstaged: "modified", renamedFrom: null },
|
||||
{ path: "/p/new.txt", relPath: "new.txt", staged: null, unstaged: "untracked", renamedFrom: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("copies names with Ctrl+C and absolute paths with Ctrl+Shift+C", async () => {
|
||||
render(<ChangesTree mode="commit" />);
|
||||
const row = (await screen.findByText("a.ts")).closest('[role="treeitem"]')!;
|
||||
act(() => fireEvent.mouseDown(row));
|
||||
// Tests run on the macOS keymap (jsdom UA is Macintosh): ⌘C / ⌘⇧C.
|
||||
act(() => fireEvent.keyDown(row, { key: "c", code: "KeyC", metaKey: true }));
|
||||
expect(writeText).toHaveBeenCalledWith("a.ts");
|
||||
act(() => fireEvent.keyDown(row, { key: "C", code: "KeyC", metaKey: true, shiftKey: true }));
|
||||
expect(writeText).toHaveBeenCalledWith("/p/src/a.ts");
|
||||
});
|
||||
|
||||
it("copies the file names of every selected file of the unversioned group too", async () => {
|
||||
render(<ChangesTree mode="commit" />);
|
||||
// Unversioned Files starts collapsed; expand it via its chevron.
|
||||
const group = (await screen.findByText("Unversioned Files")).closest('[role="treeitem"]')!;
|
||||
act(() => fireEvent.mouseDown(group.querySelector("[data-tree-toggle]")!));
|
||||
const row = (await screen.findByText("new.txt")).closest('[role="treeitem"]')!;
|
||||
act(() => fireEvent.mouseDown(row));
|
||||
act(() => fireEvent.keyDown(row, { key: "c", code: "KeyC", metaKey: true }));
|
||||
expect(writeText).toHaveBeenCalledWith("new.txt");
|
||||
});
|
||||
|
||||
it("offers Copy File Name beside Copy Path in the context menu", () => {
|
||||
const menu = nodeMenu([file("src/a.ts"), file("docs/b.md", "untracked")], ["/p"]);
|
||||
item(menu, "Copy File Name")?.run?.();
|
||||
expect(writeText).toHaveBeenCalledWith("a.ts\nb.md");
|
||||
item(menu, "Copy Path")?.run?.();
|
||||
expect(writeText).toHaveBeenCalledWith("/p/src/a.ts\n/p/docs/b.md");
|
||||
});
|
||||
|
||||
it("copies nothing when the selection has no files", () => {
|
||||
copyChangePaths([], "name");
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача