Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/features/git/components/GitDiffPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const menuNew = vi.hoisted(() =>
vi.fn(async ({ items }) => ({ popup: vi.fn(), items })),
);
const menuItemNew = vi.hoisted(() => vi.fn(async (options) => options));
const clipboardWriteText = vi.hoisted(() => vi.fn());

vi.mock("@tauri-apps/api/menu", () => ({
Menu: { new: menuNew },
Expand Down Expand Up @@ -44,6 +45,11 @@ vi.mock("../../../services/toasts", () => ({
pushErrorToast: vi.fn(),
}));

Object.defineProperty(navigator, "clipboard", {
value: { writeText: (...args: unknown[]) => clipboardWriteText(...args) },
configurable: true,
});

const logEntries: GitLogEntry[] = [];

const baseProps = {
Expand Down Expand Up @@ -82,6 +88,7 @@ describe("GitDiffPanel", () => {
});

it("adds a show in finder option for file context menus", async () => {
clipboardWriteText.mockClear();
const { container } = render(
<GitDiffPanel
{...baseProps}
Expand All @@ -108,6 +115,42 @@ describe("GitDiffPanel", () => {
expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/src/sample.ts");
});

it("copies file name and path from the context menu", async () => {
clipboardWriteText.mockClear();
const { container } = render(
<GitDiffPanel
{...baseProps}
workspacePath="/tmp/repo"
gitRoot="/tmp/repo"
unstagedFiles={[
{ path: "src/sample.ts", status: "M", additions: 1, deletions: 0 },
]}
/>,
);

const row = container.querySelector(".diff-row");
expect(row).not.toBeNull();
fireEvent.contextMenu(row as Element);

await waitFor(() => expect(menuNew).toHaveBeenCalled());
const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0];
const copyNameItem = menuArgs.items.find(
(item: { text: string }) => item.text === "Copy file name",
);
const copyPathItem = menuArgs.items.find(
(item: { text: string }) => item.text === "Copy file path",
);

expect(copyNameItem).toBeDefined();
expect(copyPathItem).toBeDefined();

await copyNameItem.action();
await copyPathItem.action();

expect(clipboardWriteText).toHaveBeenCalledWith("sample.ts");
expect(clipboardWriteText).toHaveBeenCalledWith("src/sample.ts");
});

it("resolves relative git roots against the workspace path", async () => {
revealItemInDir.mockClear();
menuNew.mockClear();
Expand Down Expand Up @@ -136,4 +179,62 @@ describe("GitDiffPanel", () => {
await revealItem.action();
expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/apps/src/sample.ts");
});

it("copies file path relative to the workspace root", async () => {
clipboardWriteText.mockClear();
const { container } = render(
<GitDiffPanel
{...baseProps}
workspacePath="/tmp/repo"
gitRoot="apps"
unstagedFiles={[
{ path: "src/sample.ts", status: "M", additions: 1, deletions: 0 },
]}
/>,
);

const row = container.querySelector(".diff-row");
expect(row).not.toBeNull();
fireEvent.contextMenu(row as Element);

await waitFor(() => expect(menuNew).toHaveBeenCalled());
const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0];
const copyPathItem = menuArgs.items.find(
(item: { text: string }) => item.text === "Copy file path",
);

expect(copyPathItem).toBeDefined();
await copyPathItem.action();

expect(clipboardWriteText).toHaveBeenCalledWith("apps/src/sample.ts");
});

it("does not trim paths when the git root only shares a prefix", async () => {
clipboardWriteText.mockClear();
const { container } = render(
<GitDiffPanel
{...baseProps}
workspacePath="/tmp/repo"
gitRoot="/tmp/repo-tools"
unstagedFiles={[
{ path: "src/sample.ts", status: "M", additions: 1, deletions: 0 },
]}
/>,
);

const row = container.querySelector(".diff-row");
expect(row).not.toBeNull();
fireEvent.contextMenu(row as Element);

await waitFor(() => expect(menuNew).toHaveBeenCalled());
const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0];
const copyPathItem = menuArgs.items.find(
(item: { text: string }) => item.text === "Copy file path",
);

expect(copyPathItem).toBeDefined();
await copyPathItem.action();

expect(clipboardWriteText).toHaveBeenCalledWith("src/sample.ts");
});
});
50 changes: 50 additions & 0 deletions src/features/git/components/GitDiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,29 @@ function normalizeRootPath(value: string | null | undefined) {
return value.replace(/\\/g, "/").replace(/\/+$/, "");
}

function normalizeSegment(segment: string) {
return /^[A-Za-z]:$/.test(segment) ? segment.toLowerCase() : segment;
}

function getRelativePathWithin(base: string, target: string) {
const normalizedBase = normalizeRootPath(base);
const normalizedTarget = normalizeRootPath(target);
if (!normalizedBase || !normalizedTarget) {
return null;
}
const baseSegments = normalizedBase.split("/").filter(Boolean);
const targetSegments = normalizedTarget.split("/").filter(Boolean);
if (baseSegments.length > targetSegments.length) {
return null;
}
for (let index = 0; index < baseSegments.length; index += 1) {
if (normalizeSegment(baseSegments[index]) !== normalizeSegment(targetSegments[index])) {
return null;
}
}
return targetSegments.slice(baseSegments.length).join("/");
}

function isAbsolutePath(value: string) {
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
}
Expand All @@ -170,6 +193,12 @@ function joinRootAndPath(root: string, relativePath: string) {
return `${normalizedRoot}/${normalizedPath}`;
}

function getFileName(value: string) {
const normalized = value.replace(/\\/g, "/");
const segments = normalized.split("/");
return segments[segments.length - 1] || normalized;
}

function getStatusSymbol(status: string) {
switch (status) {
case "A":
Expand Down Expand Up @@ -1046,6 +1075,13 @@ export function GitDiffPanel({
const absolutePath = resolvedRoot
? joinRootAndPath(resolvedRoot, rawPath)
: rawPath;
const relativeRoot =
workspacePath && resolvedRoot
? getRelativePathWithin(workspacePath, resolvedRoot)
: null;
const projectRelativePath =
relativeRoot !== null ? joinRootAndPath(relativeRoot, rawPath) : rawPath;
const fileName = getFileName(rawPath);
items.push(
await MenuItem.new({
text: "Show in Finder",
Expand Down Expand Up @@ -1077,6 +1113,20 @@ export function GitDiffPanel({
},
}),
);
items.push(
await MenuItem.new({
text: "Copy file name",
action: async () => {
await navigator.clipboard.writeText(fileName);
},
}),
await MenuItem.new({
text: "Copy file path",
action: async () => {
await navigator.clipboard.writeText(projectRelativePath);
},
}),
);
}

// Revert action for all selected files
Expand Down