From 6c813d501e53693e3c6c20d2ff647981e188fb16 Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 03:35:51 +0800 Subject: [PATCH] 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. --- src/features/vcs/CommitView.tsx | 28 ++++++- .../vcs/__tests__/changesCopy.test.tsx | 77 +++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/features/vcs/__tests__/changesCopy.test.tsx diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 04d296747..bda64cb79 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -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"); + } } }} > diff --git a/src/features/vcs/__tests__/changesCopy.test.tsx b/src/features/vcs/__tests__/changesCopy.test.tsx new file mode 100644 index 000000000..905470f9f --- /dev/null +++ b/src/features/vcs/__tests__/changesCopy.test.tsx @@ -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(); + 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(); + 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(); + // 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(); + }); +});