feat(vcs): copy file names and paths from the changes tree (#1877) (#1879)

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.
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-07 03:35:51 +08:00
1 джерело c3e8f20f53
коміт 6c813d501e
2 files changed
+102 -3

No files matched your search

+25 -3
Переглянути файл
@@ -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");
}
}
}}
>
+77
Переглянути файл
@@ -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();
});
});