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(); + }); +});