feat: add file manager context actions (#3027)
* feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
a98d154b6b
commit
ca2b633286
@@ -1,8 +1,10 @@
|
|||||||
import { unlink, writeFile } from "node:fs/promises";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Page } from "@playwright/test";
|
import { type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||||
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
|
|||||||
|
|
||||||
interface WorkspaceFixtureOptions {
|
interface WorkspaceFixtureOptions {
|
||||||
includeDeletedFile?: boolean;
|
includeDeletedFile?: boolean;
|
||||||
|
includeNestedFolders?: boolean;
|
||||||
|
includeRenamedFile?: boolean;
|
||||||
|
includeUntrackedFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CleanupTask {
|
interface CleanupTask {
|
||||||
@@ -23,6 +28,39 @@ interface CleanupTask {
|
|||||||
|
|
||||||
const cleanupTasks: CleanupTask[] = [];
|
const cleanupTasks: CleanupTask[] = [];
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
|
async function failNextDiscardRequest(page: Page): Promise<void> {
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||||
|
const serverSocket = browserSocket.connectToServer();
|
||||||
|
browserSocket.onMessage((message) => {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
const envelope = JSON.parse(message) as {
|
||||||
|
message?: { type?: string; cwd?: string; requestId?: string };
|
||||||
|
};
|
||||||
|
if (envelope.message?.type === "checkout.discard_changes.request") {
|
||||||
|
browserSocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: {
|
||||||
|
cwd: envelope.message.cwd,
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNKNOWN", message: "Injected revert failure" },
|
||||||
|
requestId: envelope.message.requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverSocket.send(message);
|
||||||
|
});
|
||||||
|
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||||
|
|
||||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -209,9 +247,15 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await openWorkspaceChanges(page, workspace);
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
|
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
||||||
|
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||||
|
await deletedFileName.dblclick();
|
||||||
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||||
await expect(page.getByText("Copy path")).toBeVisible();
|
await expect(page.getByText("Copy path")).toBeVisible();
|
||||||
|
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||||
|
await expect(page.getByText("Copy path")).toBeVisible();
|
||||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
@@ -230,6 +274,183 @@ test("changes file actions open below the right-click without a reserved kebab",
|
|||||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
await page.getByTestId("diff-file-0-duplicate").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
|
||||||
|
.toBe(AFTER);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
|
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
|
||||||
|
.toBe(AFTER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
|
await page.getByTestId("diff-folder-src-collapse-folder").click();
|
||||||
|
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("diff-folder-src-toggle").click();
|
||||||
|
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||||
|
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
|
||||||
|
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||||
|
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
|
||||||
|
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changes context menus expose folder revert and restore a file after confirmation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||||
|
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||||
|
await expect(folderRevert).toBeVisible();
|
||||||
|
const revertLabelColor = await folderRevert
|
||||||
|
.getByText("Discard changes", { exact: true })
|
||||||
|
.evaluate((element) => getComputedStyle(element).color);
|
||||||
|
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.dismiss();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByTestId("diff-file-0-revert").click();
|
||||||
|
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||||
|
.toBe(AFTER);
|
||||||
|
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
const confirmation = new Promise<string>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByTestId("diff-file-0-revert").click();
|
||||||
|
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||||
|
.toBe(BEFORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discarding a staged rename restores its source path", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const renamedToggle = page
|
||||||
|
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||||
|
.filter({ hasText: "zz-renamed.ts" });
|
||||||
|
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||||
|
expect(toggleTestId).not.toBeNull();
|
||||||
|
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||||
|
await renamedToggle.click({ button: "right" });
|
||||||
|
const confirmation = new Promise<void>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||||
|
await confirmation;
|
||||||
|
|
||||||
|
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
|
||||||
|
.toBe("export const renamed = true;\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const untrackedToggle = page
|
||||||
|
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||||
|
.filter({ hasText: "zz-untracked.txt" });
|
||||||
|
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
|
||||||
|
expect(toggleTestId).not.toBeNull();
|
||||||
|
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
|
||||||
|
await untrackedToggle.click({ button: "right" });
|
||||||
|
const confirmation = new Promise<void>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByTestId(`${rowTestId}-revert`).click();
|
||||||
|
await confirmation;
|
||||||
|
|
||||||
|
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a revert error returned by the daemon", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await failNextDiscardRequest(page);
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
const confirmation = new Promise<void>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByTestId("diff-file-0-revert").click();
|
||||||
|
await confirmation;
|
||||||
|
|
||||||
|
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||||
|
.toBe(AFTER);
|
||||||
|
});
|
||||||
|
|
||||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
@@ -327,8 +548,37 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
await scrollToLowerUnwrappedDiffRows(page);
|
await scrollToLowerUnwrappedDiffRows(page);
|
||||||
await page.getByTestId("changes-toggle-view-mode").click();
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||||
|
"user-select",
|
||||||
|
"none",
|
||||||
|
);
|
||||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
|
||||||
|
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
||||||
|
const folderLabelBounds = await page
|
||||||
|
.getByTestId("diff-folder-src")
|
||||||
|
.getByText("src", { exact: true })
|
||||||
|
.boundingBox();
|
||||||
|
const fileLabelBounds = await page
|
||||||
|
.getByTestId("diff-file-0")
|
||||||
|
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||||
|
.boundingBox();
|
||||||
|
expect(folderLabelBounds).not.toBeNull();
|
||||||
|
expect(fileLabelBounds).not.toBeNull();
|
||||||
|
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
|
||||||
|
|
||||||
|
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||||
|
await folderToggle.click();
|
||||||
|
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||||
|
await folderToggle.click();
|
||||||
|
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
|
|
||||||
|
const fileToggle = page.getByTestId("diff-file-0-toggle");
|
||||||
|
await fileToggle.click({ button: "right" });
|
||||||
|
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||||
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
|
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
@@ -488,6 +738,15 @@ async function createWorkspaceWithMountedTabDiff(
|
|||||||
if (options.includeDeletedFile) {
|
if (options.includeDeletedFile) {
|
||||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||||
}
|
}
|
||||||
|
if (options.includeRenamedFile) {
|
||||||
|
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
|
||||||
|
}
|
||||||
|
if (options.includeNestedFolders) {
|
||||||
|
files.push(
|
||||||
|
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
|
||||||
|
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
|
||||||
|
);
|
||||||
|
}
|
||||||
const repo = await createTempGitRepo("changes-pane-", { files });
|
const repo = await createTempGitRepo("changes-pane-", { files });
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
cleanupTasks.push({
|
cleanupTasks.push({
|
||||||
@@ -498,9 +757,24 @@ async function createWorkspaceWithMountedTabDiff(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
|
if (options.includeUntrackedFile) {
|
||||||
|
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
|
||||||
|
}
|
||||||
if (options.includeDeletedFile) {
|
if (options.includeDeletedFile) {
|
||||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||||
}
|
}
|
||||||
|
if (options.includeRenamedFile) {
|
||||||
|
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
|
||||||
|
cwd: repo.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.includeNestedFolders) {
|
||||||
|
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
|
||||||
|
await writeFile(
|
||||||
|
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
|
||||||
|
"export const nested = 2;\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
const createdWorkspace = await client.createWorkspace({
|
const createdWorkspace = await client.createWorkspace({
|
||||||
source: { kind: "directory", path: repo.path },
|
source: { kind: "directory", path: repo.path },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
|
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||||
|
|
||||||
|
let workspace: SeededWorkspace;
|
||||||
|
|
||||||
|
async function failNextDeleteRequest(page: Page): Promise<void> {
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||||
|
const serverSocket = browserSocket.connectToServer();
|
||||||
|
browserSocket.onMessage((message) => {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
const envelope = JSON.parse(message) as {
|
||||||
|
message?: { type?: string; cwd?: string; path?: string; requestId?: string };
|
||||||
|
};
|
||||||
|
if (envelope.message?.type === "fs.entry.delete.request") {
|
||||||
|
browserSocket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "fs.entry.delete.response",
|
||||||
|
payload: {
|
||||||
|
cwd: envelope.message.cwd,
|
||||||
|
path: envelope.message.path,
|
||||||
|
success: false,
|
||||||
|
error: "Injected delete failure",
|
||||||
|
requestId: envelope.message.requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverSocket.send(message);
|
||||||
|
});
|
||||||
|
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hideContextActionCapabilities(page: Page): Promise<void> {
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||||
|
const serverSocket = browserSocket.connectToServer();
|
||||||
|
browserSocket.onMessage((message) => serverSocket.send(message));
|
||||||
|
serverSocket.onMessage((message) => {
|
||||||
|
if (typeof message !== "string") {
|
||||||
|
browserSocket.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const envelope = JSON.parse(message) as {
|
||||||
|
message?: {
|
||||||
|
type?: string;
|
||||||
|
payload?: { status?: string; features?: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
envelope.message?.type === "status" &&
|
||||||
|
envelope.message.payload?.status === "server_info"
|
||||||
|
) {
|
||||||
|
envelope.message.payload.features = {
|
||||||
|
...envelope.message.payload.features,
|
||||||
|
fsEntryOps: false,
|
||||||
|
fsEntryDuplicate: false,
|
||||||
|
checkoutDiscardChanges: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
browserSocket.send(JSON.stringify(envelope));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise<void> }> {
|
||||||
|
let resolveRequest: (() => void) | undefined;
|
||||||
|
const requestSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
|
||||||
|
const serverSocket = browserSocket.connectToServer();
|
||||||
|
browserSocket.onMessage((message) => {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
const envelope = JSON.parse(message) as { message?: { type?: string } };
|
||||||
|
const type = envelope.message?.type;
|
||||||
|
if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") {
|
||||||
|
resolveRequest?.();
|
||||||
|
void browserSocket.close({
|
||||||
|
code: 1008,
|
||||||
|
reason: "Context action connection failed",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverSocket.send(message);
|
||||||
|
});
|
||||||
|
serverSocket.onMessage((message) => browserSocket.send(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { wait: () => requestSeen };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await workspace?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||||
|
context,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||||
|
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||||
|
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||||
|
|
||||||
|
await page.getByTestId("files-new-folder").click();
|
||||||
|
await expect(nameInput).toBeVisible();
|
||||||
|
await nameInput.press("Tab");
|
||||||
|
await expect(nameInput).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByTestId("files-new-file").click();
|
||||||
|
await nameInput.fill("README.md");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const emptyArea = page.getByTestId("files-empty-area");
|
||||||
|
await expect(emptyArea).toBeVisible();
|
||||||
|
const emptyAreaBounds = await emptyArea.boundingBox();
|
||||||
|
expect(emptyAreaBounds).not.toBeNull();
|
||||||
|
await emptyArea.click({
|
||||||
|
button: "right",
|
||||||
|
position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 },
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible();
|
||||||
|
await page.getByTestId("files-empty-area-new-folder").click();
|
||||||
|
await nameInput.fill("folder");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await expect(entry("folder")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("folder").click({ button: "right" });
|
||||||
|
await page.getByText("New file", { exact: true }).click();
|
||||||
|
await nameInput.fill("child.txt");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await expect(entry("child.txt")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("folder").click({ button: "right" });
|
||||||
|
await page.getByText("New folder", { exact: true }).click();
|
||||||
|
await nameInput.fill("nested");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await entry("nested").click({ button: "right" });
|
||||||
|
await page.getByText("New folder", { exact: true }).click();
|
||||||
|
await nameInput.fill("deep");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await expect(entry("deep")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("folder").click({ button: "right" });
|
||||||
|
await page.getByText("Collapse folder", { exact: true }).click();
|
||||||
|
await expect(entry("child.txt")).toBeHidden();
|
||||||
|
await expect(entry("nested")).toBeHidden();
|
||||||
|
await expect(entry("deep")).toBeHidden();
|
||||||
|
|
||||||
|
await entry("folder").click();
|
||||||
|
await expect(entry("child.txt")).toBeVisible();
|
||||||
|
await expect(entry("nested")).toBeVisible();
|
||||||
|
await expect(entry("deep")).toBeHidden();
|
||||||
|
await entry("nested").click();
|
||||||
|
await expect(entry("deep")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("files-new-file").click();
|
||||||
|
await nameInput.fill("created.txt");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await expect(entry("created.txt")).toBeVisible();
|
||||||
|
await expect(entry("created.txt")).toHaveCSS("user-select", "none");
|
||||||
|
await entry("created.txt").dblclick();
|
||||||
|
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||||
|
|
||||||
|
const folderRow = entry("folder").locator(
|
||||||
|
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||||
|
);
|
||||||
|
const fileRow = entry("created.txt").locator(
|
||||||
|
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||||
|
);
|
||||||
|
await expect(folderRow.locator("svg")).toHaveCount(2);
|
||||||
|
await expect(fileRow.locator("svg")).toHaveCount(1);
|
||||||
|
const gitRow = entry(".git").locator(
|
||||||
|
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||||
|
);
|
||||||
|
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
||||||
|
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
|
||||||
|
expect(collapsedChevronBounds).not.toBeNull();
|
||||||
|
expect(expandedChevronBounds).not.toBeNull();
|
||||||
|
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
||||||
|
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
||||||
|
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
|
||||||
|
const folderLabelBounds = await entry("folder").boundingBox();
|
||||||
|
const fileLabelBounds = await entry("created.txt").boundingBox();
|
||||||
|
expect(folderLabelBounds).not.toBeNull();
|
||||||
|
expect(fileLabelBounds).not.toBeNull();
|
||||||
|
expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0);
|
||||||
|
|
||||||
|
await folderRow.click();
|
||||||
|
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(entry("child.txt")).toBeHidden();
|
||||||
|
await folderRow.click();
|
||||||
|
await expect(folderRow).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(entry("child.txt")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("folder").click({ button: "right" });
|
||||||
|
await page.getByText("Rename", { exact: true }).click();
|
||||||
|
await nameInput.fill("renamed-folder");
|
||||||
|
await nameInput.press("Tab");
|
||||||
|
await expect(entry("renamed-folder")).toBeVisible();
|
||||||
|
await expect(entry("folder")).toBeHidden();
|
||||||
|
await expect(entry("child.txt")).toBeVisible();
|
||||||
|
const renamedFolderRow = entry("renamed-folder").locator(
|
||||||
|
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||||
|
);
|
||||||
|
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true");
|
||||||
|
|
||||||
|
await entry("created.txt").click({ button: "right" });
|
||||||
|
await expect(fileRow).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false");
|
||||||
|
const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
|
||||||
|
const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/);
|
||||||
|
await backdrop.click({ button: "right" });
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
|
||||||
|
await entry("created.txt").click({ button: "right" });
|
||||||
|
await page.getByText("Rename", { exact: true }).click();
|
||||||
|
await expect(nameInput).toHaveValue("created.txt");
|
||||||
|
await nameInput.press("Escape");
|
||||||
|
await expect(entry("created.txt")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("created.txt").click({ button: "right" });
|
||||||
|
await page.getByText("Rename", { exact: true }).click();
|
||||||
|
await nameInput.fill("README.md");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
|
||||||
|
await expect(entry("created.txt")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("created.txt").click({ button: "right" });
|
||||||
|
await page.getByText("Rename", { exact: true }).click();
|
||||||
|
await nameInput.fill("renamed.txt");
|
||||||
|
await nameInput.press("Tab");
|
||||||
|
await expect(entry("renamed.txt")).toBeVisible();
|
||||||
|
await expect(entry("created.txt")).toBeHidden();
|
||||||
|
|
||||||
|
await entry("renamed.txt").click({ button: "right" });
|
||||||
|
await page.getByText("Copy relative path", { exact: true }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt");
|
||||||
|
|
||||||
|
await entry("renamed.txt").click({ button: "right" });
|
||||||
|
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||||
|
const deleteLabelColor = await deleteAction
|
||||||
|
.getByText("Delete", { exact: true })
|
||||||
|
.evaluate((element) => getComputedStyle(element).color);
|
||||||
|
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||||
|
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.dismiss();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByText("Delete", { exact: true }).click();
|
||||||
|
expect(await cancelledConfirmation).toContain("renamed.txt");
|
||||||
|
await expect(entry("renamed.txt")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("renamed.txt").click({ button: "right" });
|
||||||
|
const confirmation = new Promise<string>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByText("Delete", { exact: true }).click();
|
||||||
|
expect(await confirmation).toContain("renamed.txt");
|
||||||
|
await expect(entry("renamed.txt")).toBeHidden();
|
||||||
|
|
||||||
|
await entry("renamed-folder").click({ button: "right" });
|
||||||
|
const folderConfirmation = new Promise<string>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByText("Delete", { exact: true }).click();
|
||||||
|
expect(await folderConfirmation).toContain("renamed-folder");
|
||||||
|
await expect(entry("renamed-folder")).toBeHidden();
|
||||||
|
await expect(entry("child.txt")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicates files and folders with collision-free names", async ({ page }) => {
|
||||||
|
const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt");
|
||||||
|
const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder");
|
||||||
|
await writeFile(sourceFilePath, "duplicate contents", "utf8");
|
||||||
|
await mkdir(sourceFolderPath);
|
||||||
|
await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||||
|
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
|
||||||
|
|
||||||
|
await entry("duplicate-source.txt").click({ button: "right" });
|
||||||
|
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||||
|
await expect(entry("duplicate-source copy.txt")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8"))
|
||||||
|
.toBe("duplicate contents");
|
||||||
|
|
||||||
|
await entry("duplicate-source.txt").click({ button: "right" });
|
||||||
|
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||||
|
await expect(entry("duplicate-source copy 2.txt")).toBeVisible();
|
||||||
|
|
||||||
|
await entry("duplicate-folder").click({ button: "right" });
|
||||||
|
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
|
||||||
|
await expect(entry("duplicate-folder copy")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"),
|
||||||
|
)
|
||||||
|
.toBe("nested contents");
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
rm(sourceFilePath, { force: true }),
|
||||||
|
rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }),
|
||||||
|
rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }),
|
||||||
|
rm(sourceFolderPath, { recursive: true, force: true }),
|
||||||
|
rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows an actionable error when the connection drops during creation", async ({ page }) => {
|
||||||
|
const disconnect = await disconnectNextContextAction(page);
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
await page.getByTestId("files-new-file").click();
|
||||||
|
const nameInput = page.getByTestId("file-explorer-name-input");
|
||||||
|
await nameInput.fill("disconnected.txt");
|
||||||
|
await nameInput.press("Enter");
|
||||||
|
await disconnect.wait();
|
||||||
|
|
||||||
|
await expect(page.getByText(/Context action connection failed/i)).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an entry visible when deletion fails", async ({ page }) => {
|
||||||
|
await failNextDeleteRequest(page);
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
const readme = page
|
||||||
|
.getByTestId("file-explorer-tree-scroll")
|
||||||
|
.getByText("README.md", { exact: true });
|
||||||
|
await readme.click({ button: "right" });
|
||||||
|
const confirmation = new Promise<void>((resolve) => {
|
||||||
|
page.once("dialog", async (dialog) => {
|
||||||
|
await dialog.accept();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByText("Delete", { exact: true }).click();
|
||||||
|
await confirmation;
|
||||||
|
|
||||||
|
await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible();
|
||||||
|
await expect(readme).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides unsupported file operations and revert actions", async ({ page }) => {
|
||||||
|
await hideContextActionCapabilities(page);
|
||||||
|
await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n");
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("files-new-file")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("files-new-folder")).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.getByTestId("file-explorer-tree-scroll")
|
||||||
|
.getByText("README.md", { exact: true })
|
||||||
|
.click({ button: "right" });
|
||||||
|
const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
|
||||||
|
await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await page.getByTestId("explorer-tab-changes").click();
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
|
import {
|
||||||
|
Copy,
|
||||||
|
CopyPlus,
|
||||||
|
Download,
|
||||||
|
FilePlus,
|
||||||
|
FileText,
|
||||||
|
FolderMinus,
|
||||||
|
FolderOpen,
|
||||||
|
FolderPlus,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import {
|
import {
|
||||||
@@ -10,11 +24,14 @@ import {
|
|||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||||
interface FileAction {
|
interface FileAction {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
separatorBefore?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +40,18 @@ interface FileActionsContextMenuContentProps {
|
|||||||
fileExists?: boolean;
|
fileExists?: boolean;
|
||||||
onOpenFile?: () => void;
|
onOpenFile?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
|
onCopyRelativePath?: () => void;
|
||||||
|
onReveal?: () => void;
|
||||||
|
revealTargetName?: string;
|
||||||
onDownload?: () => void;
|
onDownload?: () => void;
|
||||||
onAddToChat?: () => void;
|
onAddToChat?: () => void;
|
||||||
|
onNewFile?: () => void;
|
||||||
|
onNewFolder?: () => void;
|
||||||
|
onCollapseFolder?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onDuplicate?: () => void;
|
||||||
|
onRevert?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
testIDPrefix?: string;
|
testIDPrefix?: string;
|
||||||
@@ -39,51 +66,161 @@ export function FileActionsContextMenuContent({
|
|||||||
fileExists = true,
|
fileExists = true,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
onCopyRelativePath,
|
||||||
|
onReveal,
|
||||||
|
revealTargetName,
|
||||||
onDownload,
|
onDownload,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
|
onNewFile,
|
||||||
|
onNewFolder,
|
||||||
|
onCollapseFolder,
|
||||||
|
onRename,
|
||||||
|
onDuplicate,
|
||||||
|
onRevert,
|
||||||
|
onDelete,
|
||||||
header,
|
header,
|
||||||
testIDPrefix,
|
testIDPrefix,
|
||||||
}: FileActionsContextMenuContentProps): ReactElement | null {
|
}: FileActionsContextMenuContentProps): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actions = useMemo<FileAction[]>(() => {
|
const actions = useMemo<FileAction[]>(() => {
|
||||||
const availableFile = fileKind === "file" && fileExists;
|
const availableFile = fileKind === "file" && fileExists;
|
||||||
const next: FileAction[] = [];
|
const specs: Array<FileAction | null> = [
|
||||||
if (availableFile && onOpenFile) {
|
onNewFile
|
||||||
next.push({
|
? {
|
||||||
key: "open-file",
|
key: "new-file",
|
||||||
label: t("workspace.fileActions.openFile"),
|
label: t("workspace.fileActions.newFile"),
|
||||||
icon: FileText,
|
icon: FilePlus,
|
||||||
onSelect: onOpenFile,
|
onSelect: onNewFile,
|
||||||
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
|
}
|
||||||
});
|
: null,
|
||||||
}
|
onNewFolder
|
||||||
if (onCopyPath) {
|
? {
|
||||||
next.push({
|
key: "new-folder",
|
||||||
key: "copy-path",
|
label: t("workspace.fileActions.newFolder"),
|
||||||
label: t("workspace.fileActions.copyPath"),
|
icon: FolderPlus,
|
||||||
icon: Copy,
|
onSelect: onNewFolder,
|
||||||
onSelect: onCopyPath,
|
}
|
||||||
});
|
: null,
|
||||||
}
|
onCollapseFolder
|
||||||
if (availableFile && onDownload) {
|
? {
|
||||||
next.push({
|
key: "collapse-folder",
|
||||||
key: "download",
|
label: t("workspace.fileActions.collapseFolder"),
|
||||||
label: t("workspace.fileActions.download"),
|
icon: FolderMinus,
|
||||||
icon: Download,
|
onSelect: onCollapseFolder,
|
||||||
onSelect: onDownload,
|
}
|
||||||
});
|
: null,
|
||||||
}
|
availableFile && onOpenFile
|
||||||
if (availableFile && onAddToChat) {
|
? {
|
||||||
next.push({
|
key: "open-file",
|
||||||
key: "add-to-chat",
|
label: t("workspace.fileActions.openFile"),
|
||||||
label: t("workspace.fileActions.addToChat"),
|
icon: FileText,
|
||||||
icon: MessageSquarePlus,
|
onSelect: onOpenFile,
|
||||||
onSelect: onAddToChat,
|
}
|
||||||
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
|
: null,
|
||||||
});
|
onRename
|
||||||
}
|
? {
|
||||||
return next;
|
key: "rename",
|
||||||
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
|
label: t("workspace.fileActions.rename"),
|
||||||
|
icon: Pencil,
|
||||||
|
onSelect: onRename,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onDuplicate
|
||||||
|
? {
|
||||||
|
key: "duplicate",
|
||||||
|
label: t("workspace.fileActions.duplicate"),
|
||||||
|
icon: CopyPlus,
|
||||||
|
onSelect: onDuplicate,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onCopyPath
|
||||||
|
? {
|
||||||
|
key: "copy-path",
|
||||||
|
label: t("workspace.fileActions.copyPath"),
|
||||||
|
icon: Copy,
|
||||||
|
onSelect: onCopyPath,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onCopyRelativePath
|
||||||
|
? {
|
||||||
|
key: "copy-relative-path",
|
||||||
|
label: t("workspace.fileActions.copyRelativePath"),
|
||||||
|
icon: Copy,
|
||||||
|
onSelect: onCopyRelativePath,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onReveal && revealTargetName
|
||||||
|
? {
|
||||||
|
key: "reveal",
|
||||||
|
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
|
||||||
|
icon: FolderOpen,
|
||||||
|
onSelect: onReveal,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
availableFile && onDownload
|
||||||
|
? {
|
||||||
|
key: "download",
|
||||||
|
label: t("workspace.fileActions.download"),
|
||||||
|
icon: Download,
|
||||||
|
onSelect: onDownload,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
availableFile && onAddToChat
|
||||||
|
? {
|
||||||
|
key: "add-to-chat",
|
||||||
|
label: t("workspace.fileActions.addToChat"),
|
||||||
|
icon: MessageSquarePlus,
|
||||||
|
onSelect: onAddToChat,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onRevert
|
||||||
|
? {
|
||||||
|
key: "revert",
|
||||||
|
label: t("workspace.fileActions.revert"),
|
||||||
|
icon: Undo2,
|
||||||
|
onSelect: onRevert,
|
||||||
|
destructive: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onDelete
|
||||||
|
? {
|
||||||
|
key: "delete",
|
||||||
|
label: t("workspace.fileActions.delete"),
|
||||||
|
icon: Trash2,
|
||||||
|
onSelect: onDelete,
|
||||||
|
destructive: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
const availableActions = specs.filter((action): action is FileAction => action !== null);
|
||||||
|
return availableActions.map((action, index) =>
|
||||||
|
Object.assign(action, {
|
||||||
|
separatorBefore: Boolean(
|
||||||
|
action.destructive && index > 0 && !availableActions[index - 1].destructive,
|
||||||
|
),
|
||||||
|
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
fileExists,
|
||||||
|
fileKind,
|
||||||
|
onAddToChat,
|
||||||
|
onCollapseFolder,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyRelativePath,
|
||||||
|
onDelete,
|
||||||
|
onDownload,
|
||||||
|
onDuplicate,
|
||||||
|
onNewFile,
|
||||||
|
onNewFolder,
|
||||||
|
onOpenFile,
|
||||||
|
onRename,
|
||||||
|
onReveal,
|
||||||
|
onRevert,
|
||||||
|
revealTargetName,
|
||||||
|
t,
|
||||||
|
testIDPrefix,
|
||||||
|
]);
|
||||||
|
|
||||||
if (actions.length === 0) {
|
if (actions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -101,21 +238,32 @@ export function FileActionsContextMenuContent({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<FileActionMenuItem key={action.key} action={action} />
|
<Fragment key={action.key}>
|
||||||
|
{action.separatorBefore ? <ContextMenuSeparator /> : null}
|
||||||
|
<FileActionMenuItem action={action} />
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||||
const Icon = action.icon;
|
const leading = useMemo(() => {
|
||||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
const ThemedIcon = withUnistyles(action.icon);
|
||||||
const leading = useMemo(
|
return (
|
||||||
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
|
<ThemedIcon
|
||||||
[ThemedIcon],
|
size={ICON_SIZE.sm}
|
||||||
);
|
uniProps={action.destructive ? destructiveColorMapping : foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [action.destructive, action.icon]);
|
||||||
return (
|
return (
|
||||||
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
<ContextMenuItem
|
||||||
|
leading={leading}
|
||||||
|
onSelect={action.onSelect}
|
||||||
|
destructive={action.destructive}
|
||||||
|
testID={action.testID}
|
||||||
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|||||||
// paths) render different data, but their ROWS should look identical — same
|
// paths) render different data, but their ROWS should look identical — same
|
||||||
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||||
// drift apart.
|
// drift apart.
|
||||||
export const TREE_INDENT_PER_LEVEL = 16;
|
export const TREE_INDENT_PER_LEVEL = 14;
|
||||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
|
||||||
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
export const WORKSPACE_TREE_ICON_SIZE = 14;
|
||||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
|
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
|
||||||
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
|
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
|
||||||
/**
|
/**
|
||||||
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
||||||
* The extra 2px is optical: text ink ends inside its layout box, while the
|
* The extra 2px is optical: text ink ends inside its layout box, while the
|
||||||
* header icons' strokes extend to theirs.
|
* header icons' strokes extend to theirs.
|
||||||
*/
|
*/
|
||||||
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
|
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2;
|
||||||
|
|
||||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||||
* in the Changes tree so their indentation can't drift apart. */
|
* in the Changes tree so their indentation can't drift apart. */
|
||||||
export function treeRowPaddingLeft(depth: number): number {
|
export function treeRowPaddingLeft(depth: number): number {
|
||||||
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
|
return SPACING[2] + depth * TREE_INDENT_PER_LEVEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
|
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
|
||||||
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
|||||||
key: index,
|
key: index,
|
||||||
style: [
|
style: [
|
||||||
styles.indentGuide,
|
styles.indentGuide,
|
||||||
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
|
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
[depth],
|
[depth],
|
||||||
@@ -63,13 +63,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
|||||||
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||||
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
|
||||||
style={
|
|
||||||
expanded
|
|
||||||
? [styles.chevron, styles.chevronExpanded]
|
|
||||||
: [styles.chevron, styles.chevronCollapsed]
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThemedChevronRight
|
<ThemedChevronRight
|
||||||
size={WORKSPACE_TREE_ICON_SIZE}
|
size={WORKSPACE_TREE_ICON_SIZE}
|
||||||
uniProps={foregroundExtraMutedIconColorMapping}
|
uniProps={foregroundExtraMutedIconColorMapping}
|
||||||
@@ -93,13 +87,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
// Lucide leaves space around the chevron path. Align its painted right edge inside the fixed
|
|
||||||
// slot; the rotated glyph is wider, so it needs half the optical offset.
|
|
||||||
chevronCollapsed: {
|
|
||||||
left: 4,
|
|
||||||
},
|
|
||||||
chevronExpanded: {
|
chevronExpanded: {
|
||||||
left: 2,
|
|
||||||
transform: [{ rotate: "90deg" }],
|
transform: [{ rotate: "90deg" }],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export function ContextMenuTrigger({
|
|||||||
enabledOnMobile = true,
|
enabledOnMobile = true,
|
||||||
enabledOnWeb = true,
|
enabledOnWeb = true,
|
||||||
longPressDelayMs,
|
longPressDelayMs,
|
||||||
|
onContextMenu,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
@@ -115,6 +116,7 @@ export function ContextMenuTrigger({
|
|||||||
enabledOnMobile?: boolean;
|
enabledOnMobile?: boolean;
|
||||||
enabledOnWeb?: boolean;
|
enabledOnWeb?: boolean;
|
||||||
longPressDelayMs?: number;
|
longPressDelayMs?: number;
|
||||||
|
onContextMenu?: (event: unknown) => void;
|
||||||
triggerRef?: Ref<View | null>;
|
triggerRef?: Ref<View | null>;
|
||||||
}
|
}
|
||||||
>): ReactElement {
|
>): ReactElement {
|
||||||
@@ -170,9 +172,10 @@ export function ContextMenuTrigger({
|
|||||||
if (isCallable(preventDefault)) preventDefault.call(event);
|
if (isCallable(preventDefault)) preventDefault.call(event);
|
||||||
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
||||||
}
|
}
|
||||||
|
onContextMenu?.(event);
|
||||||
openAtEvent(event);
|
openAtEvent(event);
|
||||||
},
|
},
|
||||||
[openAtEvent],
|
[onContextMenu, openAtEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ export function AnchoredSurface({
|
|||||||
<>
|
<>
|
||||||
{backdrop ? (
|
{backdrop ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
{...{ onContextMenu: onClose }}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("menu.backdrop")}
|
accessibilityLabel={t("menu.backdrop")}
|
||||||
style={styles.backdrop}
|
style={styles.backdrop}
|
||||||
@@ -413,6 +414,9 @@ export function MenuOverlay({
|
|||||||
const overlay = (
|
const overlay = (
|
||||||
<OverlayLayerProvider layer={floatingLayer}>
|
<OverlayLayerProvider layer={floatingLayer}>
|
||||||
<View
|
<View
|
||||||
|
{...{
|
||||||
|
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||||
|
}}
|
||||||
ref={setWebOverlayScope}
|
ref={setWebOverlayScope}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -183,6 +183,31 @@ describe("checkout-git-actions-store", () => {
|
|||||||
).toBe("idle");
|
).toBe("idle");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards selected paths through the shared checkout action workflow", async () => {
|
||||||
|
const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null }));
|
||||||
|
const client = { checkoutDiscardChanges };
|
||||||
|
useSessionStore.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await useCheckoutGitActionsStore
|
||||||
|
.getState()
|
||||||
|
.discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] });
|
||||||
|
|
||||||
|
expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, {
|
||||||
|
paths: ["renamed.ts", "original.ts"],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
useCheckoutGitActionsStore
|
||||||
|
.getState()
|
||||||
|
.getStatus({ serverId, cwd, actionId: "discard-changes" }),
|
||||||
|
).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
for (const rpc of [
|
for (const rpc of [
|
||||||
{
|
{
|
||||||
label: "forge",
|
label: "forge",
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export type CheckoutGitAsyncActionId =
|
|||||||
| "enable-pr-auto-merge-rebase"
|
| "enable-pr-auto-merge-rebase"
|
||||||
| "disable-pr-auto-merge"
|
| "disable-pr-auto-merge"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
| "merge-from-base";
|
| "merge-from-base"
|
||||||
|
| "discard-changes";
|
||||||
|
|
||||||
type CheckoutKey = string;
|
type CheckoutKey = string;
|
||||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||||
@@ -119,6 +120,7 @@ interface CheckoutGitActionsStoreState {
|
|||||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||||
|
discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCheckoutAction({
|
async function runCheckoutAction({
|
||||||
@@ -366,6 +368,23 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
discardChanges: async ({ serverId, cwd, paths }) => {
|
||||||
|
await runCheckoutAction({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
actionId: "discard-changes",
|
||||||
|
run: async () => {
|
||||||
|
const client = resolveClient(serverId);
|
||||||
|
const payload = await client.checkoutDiscardChanges(cwd, { paths });
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(
|
||||||
|
payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function __resetCheckoutGitActionsStoreForTests() {
|
export function __resetCheckoutGitActionsStoreForTests() {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
|
||||||
View,
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
Text,
|
import { Folder } from "lucide-react-native";
|
||||||
Pressable,
|
|
||||||
type LayoutChangeEvent,
|
|
||||||
type PressableStateCallbackType,
|
|
||||||
} from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
TreeChevron,
|
TreeChevron,
|
||||||
@@ -15,9 +10,16 @@ import {
|
|||||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||||
|
WORKSPACE_TREE_ICON_SIZE,
|
||||||
} from "@/components/tree-primitives";
|
} from "@/components/tree-primitives";
|
||||||
import { type Theme } from "@/styles/theme";
|
import { type Theme } from "@/styles/theme";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||||
|
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
const ThemedFolder = withUnistyles(Folder);
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
|
||||||
interface DiffFolderRowProps {
|
interface DiffFolderRowProps {
|
||||||
/** full uncompressed directory path — the collapse identity */
|
/** full uncompressed directory path — the collapse identity */
|
||||||
@@ -25,20 +27,27 @@ interface DiffFolderRowProps {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
onToggle: (dirPath: string) => void;
|
onToggle: (dirPath: string) => void;
|
||||||
|
onCollapse: (dirPath: string) => void;
|
||||||
|
onSelect: (dirPath: string) => void;
|
||||||
onHeightChange?: (height: number) => void;
|
onHeightChange?: (height: number) => void;
|
||||||
|
onCopyPath?: (path: string) => void;
|
||||||
|
onCopyRelativePath?: (path: string) => void;
|
||||||
|
onReveal?: (path: string) => void;
|
||||||
|
revealTargetName?: string;
|
||||||
|
onDuplicate?: (path: string) => void;
|
||||||
|
onRevert?: (path: string) => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderRowPressableStyle({
|
function folderRowPressableStyle(
|
||||||
hovered,
|
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||||
pressed,
|
isSelected: boolean,
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
) {
|
||||||
// Subtle background highlight on hover/press, matching the Files explorer rows
|
return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive];
|
||||||
// (entryRowActive) — no opacity darken.
|
|
||||||
return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffFolderRow({
|
export function DiffFolderRow({
|
||||||
@@ -46,15 +55,39 @@ export function DiffFolderRow({
|
|||||||
displayName,
|
displayName,
|
||||||
depth,
|
depth,
|
||||||
collapsed,
|
collapsed,
|
||||||
|
isSelected,
|
||||||
additions,
|
additions,
|
||||||
deletions,
|
deletions,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onCollapse,
|
||||||
|
onSelect,
|
||||||
onHeightChange,
|
onHeightChange,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyRelativePath,
|
||||||
|
onReveal,
|
||||||
|
revealTargetName,
|
||||||
|
onDuplicate,
|
||||||
|
onRevert,
|
||||||
testID,
|
testID,
|
||||||
}: DiffFolderRowProps) {
|
}: DiffFolderRowProps) {
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
onSelect(dirPath);
|
||||||
|
}, [dirPath, onSelect]);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
|
const selection = isWeb ? window.getSelection() : null;
|
||||||
|
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSelect();
|
||||||
onToggle(dirPath);
|
onToggle(dirPath);
|
||||||
}, [dirPath, onToggle]);
|
}, [dirPath, handleSelect, onToggle]);
|
||||||
|
|
||||||
|
const pressableStyle = useCallback(
|
||||||
|
(state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||||
|
folderRowPressableStyle(state, isSelected),
|
||||||
|
[isSelected],
|
||||||
|
);
|
||||||
|
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
@@ -63,39 +96,88 @@ export function DiffFolderRow({
|
|||||||
[onHeightChange],
|
[onHeightChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCollapse = useCallback(() => {
|
||||||
|
onCollapse(dirPath);
|
||||||
|
}, [dirPath, onCollapse]);
|
||||||
|
|
||||||
|
const handleCopyPath = useCallback(() => {
|
||||||
|
onCopyPath?.(dirPath);
|
||||||
|
}, [dirPath, onCopyPath]);
|
||||||
|
|
||||||
|
const handleCopyRelativePath = useCallback(() => {
|
||||||
|
onCopyRelativePath?.(dirPath);
|
||||||
|
}, [dirPath, onCopyRelativePath]);
|
||||||
|
|
||||||
|
const handleReveal = useCallback(() => {
|
||||||
|
onReveal?.(dirPath);
|
||||||
|
}, [dirPath, onReveal]);
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
onDuplicate?.(dirPath);
|
||||||
|
}, [dirPath, onDuplicate]);
|
||||||
|
|
||||||
|
const handleRevert = useCallback(() => {
|
||||||
|
onRevert?.(dirPath);
|
||||||
|
}, [dirPath, onRevert]);
|
||||||
|
|
||||||
const leftStyle = useMemo(
|
const leftStyle = useMemo(
|
||||||
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
|
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
|
||||||
[depth],
|
[depth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
const accessibilityState = useMemo(
|
||||||
|
() => ({ expanded: !collapsed, selected: isSelected }),
|
||||||
|
[collapsed, isSelected],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} onLayout={handleLayout} testID={testID}>
|
<View style={styles.container} onLayout={handleLayout} testID={testID}>
|
||||||
<TreeIndentGuides depth={depth} />
|
<TreeIndentGuides depth={depth} />
|
||||||
<Pressable
|
<ContextMenu>
|
||||||
onPress={handlePress}
|
<ContextMenuTrigger
|
||||||
style={folderRowPressableStyle}
|
onPress={handlePress}
|
||||||
accessibilityRole="button"
|
onLongPress={handleSelect}
|
||||||
accessibilityState={accessibilityState}
|
onContextMenu={handleSelect}
|
||||||
testID={testID ? `${testID}-toggle` : undefined}
|
style={pressableStyle}
|
||||||
>
|
accessibilityRole="button"
|
||||||
<View style={leftStyle}>
|
accessibilityState={accessibilityState}
|
||||||
<View style={styles.chevronOpticalOffset}>
|
aria-selected={isSelected}
|
||||||
<TreeChevron expanded={!collapsed} />
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
|
>
|
||||||
|
<View style={leftStyle}>
|
||||||
|
<View style={styles.chevronOpticalOffset}>
|
||||||
|
<TreeChevron expanded={!collapsed} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.folderIcon}>
|
||||||
|
<ThemedFolder
|
||||||
|
size={WORKSPACE_TREE_ICON_SIZE}
|
||||||
|
uniProps={foregroundMutedColorMapping}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.folderName} numberOfLines={1}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.folderName} numberOfLines={1}>
|
<View style={styles.right}>
|
||||||
{displayName}
|
<DiffStat
|
||||||
</Text>
|
additions={additions}
|
||||||
</View>
|
deletions={deletions}
|
||||||
<View style={styles.right}>
|
testID={testID ? `${testID}-stat` : undefined}
|
||||||
<DiffStat
|
/>
|
||||||
additions={additions}
|
</View>
|
||||||
deletions={deletions}
|
</ContextMenuTrigger>
|
||||||
testID={testID ? `${testID}-stat` : undefined}
|
<FileActionsContextMenuContent
|
||||||
/>
|
fileKind="directory"
|
||||||
</View>
|
onCollapseFolder={!collapsed ? handleCollapse : undefined}
|
||||||
</Pressable>
|
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
||||||
|
onCopyRelativePath={onCopyRelativePath ? handleCopyRelativePath : undefined}
|
||||||
|
onReveal={onReveal ? handleReveal : undefined}
|
||||||
|
revealTargetName={revealTargetName}
|
||||||
|
onDuplicate={onDuplicate ? handleDuplicate : undefined}
|
||||||
|
onRevert={onRevert ? handleRevert : undefined}
|
||||||
|
testIDPrefix={testID}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -123,8 +205,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
chevronOpticalOffset: {
|
chevronOpticalOffset: {
|
||||||
// The Changes directory chevron reads high beside the folder label.
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
transform: [{ translateY: 2 }],
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
folderIcon: {
|
||||||
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -138,5 +228,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||||
import { useFileDownload } from "@/hooks/use-file-download";
|
import { useFileDownload } from "@/hooks/use-file-download";
|
||||||
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
@@ -94,6 +95,7 @@ import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
|||||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
@@ -119,6 +121,7 @@ import {
|
|||||||
} from "@/review";
|
} from "@/review";
|
||||||
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||||
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
import { DiffTooLargeState } from "@/git/diff-too-large-state";
|
||||||
|
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||||
|
|
||||||
@@ -129,11 +132,14 @@ export function resolveDiffLayout(
|
|||||||
return canUseSplitLayout ? layout : "unified";
|
return canUseSplitLayout ? layout : "unified";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileHeaderPressableStyle({
|
function fileHeaderPressableStyle(
|
||||||
hovered,
|
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||||
pressed,
|
isSelected: boolean,
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
) {
|
||||||
return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive];
|
return [
|
||||||
|
styles.fileHeader,
|
||||||
|
(Boolean(hovered) || pressed || isSelected) && styles.fileHeaderActive,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HighlightedTextProps {
|
interface HighlightedTextProps {
|
||||||
@@ -208,16 +214,23 @@ interface DiffFileSectionProps {
|
|||||||
file: ParsedDiffFile;
|
file: ParsedDiffFile;
|
||||||
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
/** Tree indentation level (0 on the flat/mobile path). */
|
/** Tree indentation level (0 on the flat/mobile path). */
|
||||||
depth?: number;
|
depth?: number;
|
||||||
/** Show the muted directory suffix (flat list); false inside the folder tree. */
|
/** Show the muted directory suffix (flat list); false inside the folder tree. */
|
||||||
showDir?: boolean;
|
showDir?: boolean;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
onToggle?: (path: string) => void;
|
onToggle?: (path: string) => void;
|
||||||
|
onSelect?: (path: string) => void;
|
||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
onAddToChat?: (path: string) => void;
|
onAddToChat?: (path: string) => void;
|
||||||
onCopyPath?: (path: string) => void;
|
onCopyPath?: (path: string) => void;
|
||||||
|
onCopyRelativePath?: (path: string) => void;
|
||||||
|
onReveal?: (path: string) => void;
|
||||||
|
revealTargetName?: string;
|
||||||
onDownload?: (path: string) => void;
|
onDownload?: (path: string) => void;
|
||||||
|
onDuplicate?: (path: string) => void;
|
||||||
|
onRevert?: (path: string, oldPath?: string) => void;
|
||||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
@@ -226,6 +239,57 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
|||||||
|
|
||||||
function noopStartComment(): void {}
|
function noopStartComment(): void {}
|
||||||
|
|
||||||
|
function useDiscardChangesAction({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
diffMode,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
diffMode: "uncommitted" | "base";
|
||||||
|
}): ((path: string, oldPath?: string) => void) | undefined {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
|
||||||
|
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||||
|
const discardSupported = useSessionStore(
|
||||||
|
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true,
|
||||||
|
);
|
||||||
|
const discardPath = useCallback(
|
||||||
|
async (path: string, oldPath?: string) => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: t("workspace.fileActions.confirmRevert.title"),
|
||||||
|
message: t("workspace.fileActions.confirmRevert.message", { name: path }),
|
||||||
|
confirmLabel: t("workspace.fileActions.confirmRevert.confirm"),
|
||||||
|
cancelLabel: t("workspace.fileActions.confirmRevert.cancel"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await discardChanges({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
paths: oldPath ? [path, oldPath] : [path],
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
toast.error(
|
||||||
|
cause instanceof Error ? cause.message : t("workspace.fileActions.confirmRevert.failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cwd, discardChanges, serverId, t, toast],
|
||||||
|
);
|
||||||
|
const handleDiscardPath = useCallback(
|
||||||
|
(path: string, oldPath?: string) => {
|
||||||
|
void discardPath(path, oldPath);
|
||||||
|
},
|
||||||
|
[discardPath],
|
||||||
|
);
|
||||||
|
return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
||||||
|
|
||||||
function LongPressableLine({
|
function LongPressableLine({
|
||||||
@@ -247,6 +311,10 @@ function LongPressableLine({
|
|||||||
}) {
|
}) {
|
||||||
const onStartComment = reviewActions?.onStartComment;
|
const onStartComment = reviewActions?.onStartComment;
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
|
const selection = isWeb ? window.getSelection() : null;
|
||||||
|
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (reviewTarget && onStartComment) {
|
if (reviewTarget && onStartComment) {
|
||||||
onStartComment(reviewTarget);
|
onStartComment(reviewTarget);
|
||||||
}
|
}
|
||||||
@@ -912,18 +980,84 @@ function SplitDiffColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiffFileActionsContextMenuContent({
|
||||||
|
file,
|
||||||
|
onOpenFile,
|
||||||
|
onAddToChat,
|
||||||
|
onCopyPath,
|
||||||
|
onCopyRelativePath,
|
||||||
|
onReveal,
|
||||||
|
revealTargetName,
|
||||||
|
onDownload,
|
||||||
|
onDuplicate,
|
||||||
|
onRevert,
|
||||||
|
testID,
|
||||||
|
}: Pick<
|
||||||
|
DiffFileSectionProps,
|
||||||
|
| "file"
|
||||||
|
| "onOpenFile"
|
||||||
|
| "onAddToChat"
|
||||||
|
| "onCopyPath"
|
||||||
|
| "onCopyRelativePath"
|
||||||
|
| "onReveal"
|
||||||
|
| "revealTargetName"
|
||||||
|
| "onDownload"
|
||||||
|
| "onDuplicate"
|
||||||
|
| "onRevert"
|
||||||
|
| "testID"
|
||||||
|
>) {
|
||||||
|
const handleOpenFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]);
|
||||||
|
const handleAddToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]);
|
||||||
|
const handleCopyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]);
|
||||||
|
const handleCopyRelativePath = useCallback(
|
||||||
|
() => onCopyRelativePath?.(file.path),
|
||||||
|
[file.path, onCopyRelativePath],
|
||||||
|
);
|
||||||
|
const handleReveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]);
|
||||||
|
const handleDownload = useCallback(() => onDownload?.(file.path), [file.path, onDownload]);
|
||||||
|
const handleDuplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]);
|
||||||
|
const handleRevert = useCallback(
|
||||||
|
() => onRevert?.(file.path, file.oldPath),
|
||||||
|
[file.oldPath, file.path, onRevert],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileActionsContextMenuContent
|
||||||
|
fileKind="file"
|
||||||
|
fileExists={!file.isDeleted}
|
||||||
|
onOpenFile={onOpenFile ? handleOpenFile : undefined}
|
||||||
|
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
||||||
|
onCopyRelativePath={onCopyRelativePath ? handleCopyRelativePath : undefined}
|
||||||
|
onReveal={onReveal ? handleReveal : undefined}
|
||||||
|
revealTargetName={revealTargetName}
|
||||||
|
onDownload={onDownload ? handleDownload : undefined}
|
||||||
|
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||||
|
onDuplicate={!file.isDeleted && onDuplicate ? handleDuplicate : undefined}
|
||||||
|
onRevert={onRevert ? handleRevert : undefined}
|
||||||
|
testIDPrefix={testID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DiffFileHeader = memo(function DiffFileHeader({
|
const DiffFileHeader = memo(function DiffFileHeader({
|
||||||
file,
|
file,
|
||||||
workspaceFileDragScope,
|
workspaceFileDragScope,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
isSelected = false,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
showDir = true,
|
showDir = true,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onSelect,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
onCopyRelativePath,
|
||||||
|
onReveal,
|
||||||
|
revealTargetName,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onDuplicate,
|
||||||
|
onRevert,
|
||||||
onHeaderHeightChange,
|
onHeaderHeightChange,
|
||||||
testID,
|
testID,
|
||||||
}: DiffFileSectionProps) {
|
}: DiffFileSectionProps) {
|
||||||
@@ -938,29 +1072,24 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
const pressHandledRef = useRef(false);
|
const pressHandledRef = useRef(false);
|
||||||
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
|
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
if (interactive) {
|
||||||
|
onSelect?.(file.path);
|
||||||
|
}
|
||||||
|
}, [file.path, interactive, onSelect]);
|
||||||
|
|
||||||
const toggleExpanded = useCallback(() => {
|
const toggleExpanded = useCallback(() => {
|
||||||
if (!interactive) {
|
if (!interactive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selection = isWeb ? window.getSelection() : null;
|
||||||
|
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
pressHandledRef.current = true;
|
pressHandledRef.current = true;
|
||||||
|
handleSelect();
|
||||||
onToggle?.(file.path);
|
onToggle?.(file.path);
|
||||||
}, [file.path, interactive, onToggle]);
|
}, [file.path, handleSelect, interactive, onToggle]);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
onOpenFile?.(file.path);
|
|
||||||
}, [file.path, onOpenFile]);
|
|
||||||
|
|
||||||
const handleAddToChat = useCallback(() => {
|
|
||||||
onAddToChat?.(file.path);
|
|
||||||
}, [file.path, onAddToChat]);
|
|
||||||
|
|
||||||
const handleCopyPath = useCallback(() => {
|
|
||||||
onCopyPath?.(file.path);
|
|
||||||
}, [file.path, onCopyPath]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
onDownload?.(file.path);
|
|
||||||
}, [file.path, onDownload]);
|
|
||||||
|
|
||||||
const handleLayout = useCallback(
|
const handleLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
@@ -981,7 +1110,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
|
|
||||||
const handleLongPress = useCallback(() => {
|
const handleLongPress = useCallback(() => {
|
||||||
pressHandledRef.current = true;
|
pressHandledRef.current = true;
|
||||||
}, []);
|
handleSelect();
|
||||||
|
}, [handleSelect]);
|
||||||
|
|
||||||
const handlePressOut = useCallback(
|
const handlePressOut = useCallback(
|
||||||
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||||
@@ -1008,16 +1138,20 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
() => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded],
|
() => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded],
|
||||||
[isExpanded],
|
[isExpanded],
|
||||||
);
|
);
|
||||||
|
const accessibilityState = useMemo(
|
||||||
|
() => ({ expanded: isExpanded, selected: isSelected }),
|
||||||
|
[isExpanded, isSelected],
|
||||||
|
);
|
||||||
|
|
||||||
const headerPressableStyle = useCallback(
|
const headerPressableStyle = useCallback(
|
||||||
(state: PressableStateCallbackType) =>
|
(state: PressableStateCallbackType) =>
|
||||||
depth > 0
|
depth > 0
|
||||||
? [
|
? [
|
||||||
fileHeaderPressableStyle(state),
|
fileHeaderPressableStyle(state, isSelected),
|
||||||
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
|
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
|
||||||
]
|
]
|
||||||
: fileHeaderPressableStyle(state),
|
: fileHeaderPressableStyle(state, isSelected),
|
||||||
[depth],
|
[depth, isSelected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileName = file.path.split("/").pop() ?? file.path;
|
const fileName = file.path.split("/").pop() ?? file.path;
|
||||||
@@ -1027,9 +1161,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
ref={dragSourceRef}
|
ref={dragSourceRef}
|
||||||
style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]}
|
style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]}
|
||||||
>
|
>
|
||||||
|
{showDir ? null : <View style={styles.fileChevronSpacer} />}
|
||||||
{showDir ? null : (
|
{showDir ? null : (
|
||||||
<View style={styles.fileIcon}>
|
<View style={styles.fileIcon}>
|
||||||
<MaterialFileIcon fileName={fileName} size={16} />
|
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.fileName} numberOfLines={1}>
|
<Text style={styles.fileName} numberOfLines={1}>
|
||||||
@@ -1060,7 +1195,14 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
let trigger: ReactElement;
|
let trigger: ReactElement;
|
||||||
if (!interactive) {
|
if (!interactive) {
|
||||||
trigger = (
|
trigger = (
|
||||||
<View style={headerPressableStyle({ hovered: false, pressed: false })}>{headerContent}</View>
|
<View
|
||||||
|
{...{
|
||||||
|
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||||
|
}}
|
||||||
|
style={headerPressableStyle({ hovered: false, pressed: false })}
|
||||||
|
>
|
||||||
|
{headerContent}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trigger = (
|
trigger = (
|
||||||
@@ -1072,7 +1214,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
|
onContextMenu={handleSelect}
|
||||||
onPress={toggleExpanded}
|
onPress={toggleExpanded}
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
aria-selected={isSelected}
|
||||||
>
|
>
|
||||||
{headerContent}
|
{headerContent}
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@@ -1090,14 +1235,18 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{interactive ? (
|
{interactive ? (
|
||||||
<FileActionsContextMenuContent
|
<DiffFileActionsContextMenuContent
|
||||||
fileKind="file"
|
file={file}
|
||||||
fileExists={!file.isDeleted}
|
onOpenFile={onOpenFile}
|
||||||
onOpenFile={onOpenFile ? handleOpenFile : undefined}
|
onAddToChat={onAddToChat}
|
||||||
onCopyPath={onCopyPath ? handleCopyPath : undefined}
|
onCopyPath={onCopyPath}
|
||||||
onDownload={onDownload ? handleDownload : undefined}
|
onCopyRelativePath={onCopyRelativePath}
|
||||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
onReveal={onReveal}
|
||||||
testIDPrefix={testID}
|
revealTargetName={revealTargetName}
|
||||||
|
onDownload={onDownload}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onRevert={onRevert}
|
||||||
|
testID={testID}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -1827,7 +1976,12 @@ interface SharedDiffViewProps {
|
|||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
onAddToChat?: (path: string) => void;
|
onAddToChat?: (path: string) => void;
|
||||||
onCopyPath?: (path: string) => void;
|
onCopyPath?: (path: string) => void;
|
||||||
|
onCopyRelativePath?: (path: string) => void;
|
||||||
|
onReveal?: (path: string) => void;
|
||||||
|
revealTargetName?: string;
|
||||||
onDownload?: (path: string) => void;
|
onDownload?: (path: string) => void;
|
||||||
|
onDuplicate?: (path: string) => void;
|
||||||
|
onRevert?: (path: string, oldPath?: string) => void;
|
||||||
onExpandedPathsChange: (paths: string[]) => void;
|
onExpandedPathsChange: (paths: string[]) => void;
|
||||||
onCollapsedFoldersChange: (paths: string[]) => void;
|
onCollapsedFoldersChange: (paths: string[]) => void;
|
||||||
}
|
}
|
||||||
@@ -1882,10 +2036,28 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
|||||||
const workspaceFileDragScope =
|
const workspaceFileDragScope =
|
||||||
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
|
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
|
||||||
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
|
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
|
||||||
|
const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined;
|
||||||
|
const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined;
|
||||||
|
const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined;
|
||||||
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
|
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
|
||||||
|
const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined;
|
||||||
|
const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined;
|
||||||
|
// Keep selection independent from expansion so future keyboard actions (such as R to rename)
|
||||||
|
// can target the current VCS file or folder without changing its open state.
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
|
||||||
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
||||||
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
||||||
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
selectedPath &&
|
||||||
|
!allFolderPathSet.has(selectedPath) &&
|
||||||
|
!files.some((file) => file.path === selectedPath)
|
||||||
|
) {
|
||||||
|
setSelectedPath(null);
|
||||||
|
}
|
||||||
|
}, [allFolderPathSet, files, selectedPath]);
|
||||||
const effectiveCollapsedFolders = useMemo(
|
const effectiveCollapsedFolders = useMemo(
|
||||||
() => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))),
|
() => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))),
|
||||||
[allFolderPathSet, collapsedFolders],
|
[allFolderPathSet, collapsedFolders],
|
||||||
@@ -2181,6 +2353,40 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
|||||||
[computeItemOffset, effectiveCollapsedFolders, mode],
|
[computeItemOffset, effectiveCollapsedFolders, mode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCollapseFolder = useCallback(
|
||||||
|
(dirPath: string) => {
|
||||||
|
if (mode.kind !== "working_tree") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetOffset = computeItemOffset(
|
||||||
|
(item) => item.type === "folder" && item.dirPath === dirPath,
|
||||||
|
);
|
||||||
|
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
||||||
|
if (
|
||||||
|
targetOffset !== null &&
|
||||||
|
shouldAnchorHeaderBeforeCollapse({
|
||||||
|
headerOffset: targetOffset,
|
||||||
|
headerHeight: folderHeight,
|
||||||
|
viewportOffset: diffListScrollOffsetRef.current,
|
||||||
|
viewportHeight: diffListViewportHeightRef.current,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathPrefix = `${dirPath}/`;
|
||||||
|
mode.onCollapsedFoldersChange([
|
||||||
|
...new Set([
|
||||||
|
...effectiveCollapsedFolders,
|
||||||
|
...allFolderPaths.filter(
|
||||||
|
(folderPath) => folderPath === dirPath || folderPath.startsWith(pathPrefix),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[allFolderPaths, computeItemOffset, effectiveCollapsedFolders, mode],
|
||||||
|
);
|
||||||
|
|
||||||
const renderFlatItem = useCallback(
|
const renderFlatItem = useCallback(
|
||||||
({ item }: { item: DiffFlatItem }) => {
|
({ item }: { item: DiffFlatItem }) => {
|
||||||
if (item.type === "folder") {
|
if (item.type === "folder") {
|
||||||
@@ -2190,10 +2396,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
|||||||
displayName={item.displayName}
|
displayName={item.displayName}
|
||||||
depth={item.depth}
|
depth={item.depth}
|
||||||
collapsed={item.collapsed}
|
collapsed={item.collapsed}
|
||||||
|
isSelected={selectedPath === item.dirPath}
|
||||||
additions={item.additions}
|
additions={item.additions}
|
||||||
deletions={item.deletions}
|
deletions={item.deletions}
|
||||||
onToggle={handleToggleFolder}
|
onToggle={handleToggleFolder}
|
||||||
|
onCollapse={handleCollapseFolder}
|
||||||
|
onSelect={handleSelectPath}
|
||||||
onHeightChange={handleFolderRowHeightChange}
|
onHeightChange={handleFolderRowHeightChange}
|
||||||
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyRelativePath={onCopyRelativePath}
|
||||||
|
onReveal={onReveal}
|
||||||
|
revealTargetName={revealTargetName}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onRevert={onRevert}
|
||||||
testID={`diff-folder-${item.dirPath}`}
|
testID={`diff-folder-${item.dirPath}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2204,14 +2419,21 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
|||||||
file={item.file}
|
file={item.file}
|
||||||
workspaceFileDragScope={workspaceFileDragScope}
|
workspaceFileDragScope={workspaceFileDragScope}
|
||||||
isExpanded={item.isExpanded}
|
isExpanded={item.isExpanded}
|
||||||
|
isSelected={selectedPath === item.file.path}
|
||||||
depth={item.depth}
|
depth={item.depth}
|
||||||
showDir={viewMode === "flat"}
|
showDir={viewMode === "flat"}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined}
|
onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined}
|
||||||
|
onSelect={handleSelectPath}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
onAddToChat={onAddToChat}
|
onAddToChat={onAddToChat}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
|
onCopyRelativePath={onCopyRelativePath}
|
||||||
|
onReveal={onReveal}
|
||||||
|
revealTargetName={revealTargetName}
|
||||||
onDownload={onDownload}
|
onDownload={onDownload}
|
||||||
|
onDuplicate={onDuplicate}
|
||||||
|
onRevert={onRevert}
|
||||||
onHeaderHeightChange={handleHeaderHeightChange}
|
onHeaderHeightChange={handleHeaderHeightChange}
|
||||||
testID={`diff-file-${item.fileIndex}`}
|
testID={`diff-file-${item.fileIndex}`}
|
||||||
/>
|
/>
|
||||||
@@ -2235,6 +2457,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
|||||||
handleBodyHeightChange,
|
handleBodyHeightChange,
|
||||||
handleFolderRowHeightChange,
|
handleFolderRowHeightChange,
|
||||||
handleHeaderHeightChange,
|
handleHeaderHeightChange,
|
||||||
|
handleCollapseFolder,
|
||||||
|
handleSelectPath,
|
||||||
handleToggleExpanded,
|
handleToggleExpanded,
|
||||||
handleToggleFolder,
|
handleToggleFolder,
|
||||||
layout,
|
layout,
|
||||||
@@ -2248,7 +2472,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
onCopyRelativePath,
|
||||||
|
onReveal,
|
||||||
|
revealTargetName,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onDuplicate,
|
||||||
|
onRevert,
|
||||||
|
selectedPath,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2659,6 +2889,11 @@ export function GitDiffPane({
|
|||||||
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
|
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
|
const { targets: desktopOpenTargets } = useDesktopOpenTargets({
|
||||||
|
isLocalExecution: isLocalDaemon,
|
||||||
|
});
|
||||||
|
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
|
||||||
const {
|
const {
|
||||||
changesTabOpen,
|
changesTabOpen,
|
||||||
toggleChanges: handleToggleChangesTab,
|
toggleChanges: handleToggleChangesTab,
|
||||||
@@ -2668,6 +2903,11 @@ export function GitDiffPane({
|
|||||||
const refreshSupported = useSessionStore(
|
const refreshSupported = useSessionStore(
|
||||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||||
);
|
);
|
||||||
|
const client = useSessionStore((state) => state.sessions[serverId]?.client);
|
||||||
|
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||||
|
const fsEntryDuplicateEnabled = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
|
||||||
|
);
|
||||||
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
||||||
@@ -2773,12 +3013,51 @@ export function GitDiffPane({
|
|||||||
},
|
},
|
||||||
[cwd],
|
[cwd],
|
||||||
);
|
);
|
||||||
|
const handleCopyRelativePath = useCallback((path: string) => {
|
||||||
|
void Clipboard.setStringAsync(path);
|
||||||
|
}, []);
|
||||||
|
const handleRevealPath = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (!fileManagerTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await openDesktopTarget({
|
||||||
|
editorId: fileManagerTarget.id,
|
||||||
|
workspacePath: cwd,
|
||||||
|
filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
toast.error(
|
||||||
|
cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cwd, fileManagerTarget, t, toast],
|
||||||
|
);
|
||||||
const handleDownloadPath = useCallback(
|
const handleDownloadPath = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
downloadFile({ fileName: path.split("/").pop() ?? path, path });
|
downloadFile({ fileName: path.split("/").pop() ?? path, path });
|
||||||
},
|
},
|
||||||
[downloadFile],
|
[downloadFile],
|
||||||
);
|
);
|
||||||
|
const handleDuplicatePath = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await client.duplicateFileEntry({ cwd, path });
|
||||||
|
if (!payload.success) {
|
||||||
|
toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed"));
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, cwd, t, toast],
|
||||||
|
);
|
||||||
|
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
|
||||||
const workingTreeMode = useMemo(
|
const workingTreeMode = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
kind: "working_tree" as const,
|
kind: "working_tree" as const,
|
||||||
@@ -2791,7 +3070,12 @@ export function GitDiffPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
onCopyPath: handleCopyPath,
|
onCopyPath: handleCopyPath,
|
||||||
|
onCopyRelativePath: handleCopyRelativePath,
|
||||||
|
onReveal: fileManagerTarget ? handleRevealPath : undefined,
|
||||||
|
revealTargetName: fileManagerTarget?.label,
|
||||||
onDownload: handleDownloadPath,
|
onDownload: handleDownloadPath,
|
||||||
|
onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined,
|
||||||
|
onRevert: onRevertPath,
|
||||||
onExpandedPathsChange: changesTree.updateExpandedPaths,
|
onExpandedPathsChange: changesTree.updateExpandedPaths,
|
||||||
onCollapsedFoldersChange: changesTree.updateCollapsedFolders,
|
onCollapsedFoldersChange: changesTree.updateCollapsedFolders,
|
||||||
}),
|
}),
|
||||||
@@ -2806,7 +3090,13 @@ export function GitDiffPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
handleCopyPath,
|
handleCopyPath,
|
||||||
|
handleCopyRelativePath,
|
||||||
handleDownloadPath,
|
handleDownloadPath,
|
||||||
|
handleDuplicatePath,
|
||||||
|
handleRevealPath,
|
||||||
|
fileManagerTarget,
|
||||||
|
fsEntryDuplicateEnabled,
|
||||||
|
onRevertPath,
|
||||||
changesTree.updateExpandedPaths,
|
changesTree.updateExpandedPaths,
|
||||||
changesTree.updateCollapsedFolders,
|
changesTree.updateCollapsedFolders,
|
||||||
],
|
],
|
||||||
@@ -2861,7 +3151,12 @@ export function GitDiffPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View
|
||||||
|
{...{
|
||||||
|
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
|
||||||
|
}}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
{isGit && (currentBranchName || isMobile) ? (
|
{isGit && (currentBranchName || isMobile) ? (
|
||||||
<View style={styles.header} testID="changes-header">
|
<View style={styles.header} testID="changes-header">
|
||||||
<BranchSwitcher
|
<BranchSwitcher
|
||||||
@@ -3170,6 +3465,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
fileChevronSpacer: {
|
||||||
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
fileIcon: {
|
fileIcon: {
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
@@ -3183,6 +3483,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
fileDir: {
|
fileDir: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -3190,6 +3491,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
fileDirSpacer: {
|
fileDirSpacer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type ExplorerDirectory,
|
type ExplorerDirectory,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
|
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||||
|
|
||||||
function createExplorerState(): AgentFileExplorerState {
|
function createExplorerState(): AgentFileExplorerState {
|
||||||
return {
|
return {
|
||||||
@@ -252,6 +253,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
[client, normalizedWorkspaceRoot, t],
|
[client, normalizedWorkspaceRoot, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createEntry = useCallback(
|
||||||
|
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
|
||||||
|
if (!client || !normalizedWorkspaceRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = await client.createFileEntry({
|
||||||
|
cwd: normalizedWorkspaceRoot,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
if (payload.success) {
|
||||||
|
await requestDirectoryListing(input.parentPath, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameEntry = useCallback(
|
||||||
|
async (input: { path: string; name: string }) => {
|
||||||
|
if (!client || !normalizedWorkspaceRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = await client.renameFileEntry({
|
||||||
|
cwd: normalizedWorkspaceRoot,
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
if (payload.success) {
|
||||||
|
await requestDirectoryListing(parentExplorerPath(input.path), {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateEntry = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (!client || !normalizedWorkspaceRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = await client.duplicateFileEntry({ cwd: normalizedWorkspaceRoot, path });
|
||||||
|
if (payload.success) {
|
||||||
|
await requestDirectoryListing(parentExplorerPath(path), {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteEntry = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
if (!client || !normalizedWorkspaceRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload = await client.deleteFileEntry({ cwd: normalizedWorkspaceRoot, path });
|
||||||
|
if (payload.success) {
|
||||||
|
await requestDirectoryListing(parentExplorerPath(path), {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
[client, normalizedWorkspaceRoot, requestDirectoryListing],
|
||||||
|
);
|
||||||
|
|
||||||
const selectExplorerEntry = useCallback(
|
const selectExplorerEntry = useCallback(
|
||||||
(path: string | null) => {
|
(path: string | null) => {
|
||||||
updateExplorerState((state) => ({
|
updateExplorerState((state) => ({
|
||||||
@@ -267,6 +342,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFilePreview,
|
requestFilePreview,
|
||||||
requestFileDownloadToken,
|
requestFileDownloadToken,
|
||||||
|
createEntry,
|
||||||
|
renameEntry,
|
||||||
|
duplicateEntry,
|
||||||
|
deleteEntry,
|
||||||
selectExplorerEntry,
|
selectExplorerEntry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,9 +393,32 @@ export const ar: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "افتح الملف",
|
openFile: "افتح الملف",
|
||||||
copyPath: "نسخ المسار",
|
copyPath: "نسخ المسار",
|
||||||
|
copyRelativePath: "نسخ المسار النسبي",
|
||||||
|
revealIn: "إظهار في {{target}}",
|
||||||
download: "تحميل",
|
download: "تحميل",
|
||||||
addToChat: "إضافة إلى الدردشة",
|
addToChat: "إضافة إلى الدردشة",
|
||||||
moreActions: "المزيد من الإجراءات",
|
moreActions: "المزيد من الإجراءات",
|
||||||
|
newFile: "ملف جديد",
|
||||||
|
newFolder: "مجلد جديد",
|
||||||
|
collapseFolder: "طي المجلد",
|
||||||
|
rename: "إعادة تسمية",
|
||||||
|
duplicate: "تكرار",
|
||||||
|
revert: "تجاهل التغييرات",
|
||||||
|
delete: "حذف",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "حذف الملف؟",
|
||||||
|
folderTitle: "حذف المجلد؟",
|
||||||
|
message: 'سيتم حذف "{{name}}" نهائيًا.',
|
||||||
|
confirm: "حذف",
|
||||||
|
cancel: "إلغاء",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "تجاهل التغييرات؟",
|
||||||
|
message: 'سيتم تجاهل التغييرات في "{{name}}" نهائيًا.',
|
||||||
|
confirm: "تجاهل",
|
||||||
|
cancel: "إلغاء",
|
||||||
|
failed: "فشل تجاهل التغييرات",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -425,6 +448,15 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "فشل في سرد الدليل",
|
failedToListDirectory: "فشل في سرد الدليل",
|
||||||
|
createFailed: "فشل إنشاء العنصر",
|
||||||
|
renameFailed: "فشل إعادة تسمية العنصر",
|
||||||
|
duplicateFailed: "فشل تكرار العنصر",
|
||||||
|
revealFailed: "فشل إظهار العنصر",
|
||||||
|
deleteFailed: "فشل حذف العنصر",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "اسم الملف",
|
||||||
|
folderPlaceholder: "اسم المجلد",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -392,9 +392,32 @@ export const en = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "Open file",
|
openFile: "Open file",
|
||||||
copyPath: "Copy path",
|
copyPath: "Copy path",
|
||||||
|
copyRelativePath: "Copy relative path",
|
||||||
|
revealIn: "Reveal in {{target}}",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
addToChat: "Add to chat",
|
addToChat: "Add to chat",
|
||||||
moreActions: "More actions",
|
moreActions: "More actions",
|
||||||
|
newFile: "New file",
|
||||||
|
newFolder: "New folder",
|
||||||
|
collapseFolder: "Collapse folder",
|
||||||
|
rename: "Rename",
|
||||||
|
duplicate: "Duplicate",
|
||||||
|
revert: "Discard changes",
|
||||||
|
delete: "Delete",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "Delete file?",
|
||||||
|
folderTitle: "Delete folder?",
|
||||||
|
message: '"{{name}}" will be permanently deleted.',
|
||||||
|
confirm: "Delete",
|
||||||
|
cancel: "Cancel",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "Discard changes?",
|
||||||
|
message: 'Changes to "{{name}}" will be permanently discarded.',
|
||||||
|
confirm: "Discard",
|
||||||
|
cancel: "Cancel",
|
||||||
|
failed: "Failed to discard changes",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -424,6 +447,15 @@ export const en = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "Failed to list directory",
|
failedToListDirectory: "Failed to list directory",
|
||||||
|
createFailed: "Failed to create entry",
|
||||||
|
renameFailed: "Failed to rename entry",
|
||||||
|
duplicateFailed: "Failed to duplicate entry",
|
||||||
|
revealFailed: "Failed to reveal entry",
|
||||||
|
deleteFailed: "Failed to delete entry",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "File name",
|
||||||
|
folderPlaceholder: "Folder name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -397,9 +397,32 @@ export const es: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "Abrir archivo",
|
openFile: "Abrir archivo",
|
||||||
copyPath: "Copiar ruta",
|
copyPath: "Copiar ruta",
|
||||||
|
copyRelativePath: "Copiar ruta relativa",
|
||||||
|
revealIn: "Mostrar en {{target}}",
|
||||||
download: "Descargar",
|
download: "Descargar",
|
||||||
addToChat: "Añadir al chat",
|
addToChat: "Añadir al chat",
|
||||||
moreActions: "Más acciones",
|
moreActions: "Más acciones",
|
||||||
|
newFile: "Nuevo archivo",
|
||||||
|
newFolder: "Nueva carpeta",
|
||||||
|
collapseFolder: "Contraer carpeta",
|
||||||
|
rename: "Renombrar",
|
||||||
|
duplicate: "Duplicar",
|
||||||
|
revert: "Descartar cambios",
|
||||||
|
delete: "Eliminar",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "¿Eliminar archivo?",
|
||||||
|
folderTitle: "¿Eliminar carpeta?",
|
||||||
|
message: '"{{name}}" se eliminará permanentemente.',
|
||||||
|
confirm: "Eliminar",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "¿Descartar cambios?",
|
||||||
|
message: 'Los cambios en "{{name}}" se descartarán permanentemente.',
|
||||||
|
confirm: "Descartar",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
failed: "No se pudieron descartar los cambios",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -429,6 +452,15 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "No se pudo listar el directorio",
|
failedToListDirectory: "No se pudo listar el directorio",
|
||||||
|
createFailed: "No se pudo crear la entrada",
|
||||||
|
renameFailed: "No se pudo renombrar la entrada",
|
||||||
|
duplicateFailed: "No se pudo duplicar la entrada",
|
||||||
|
revealFailed: "No se pudo mostrar la entrada",
|
||||||
|
deleteFailed: "No se pudo eliminar la entrada",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "Nombre del archivo",
|
||||||
|
folderPlaceholder: "Nombre de la carpeta",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -397,9 +397,32 @@ export const fr: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "Ouvrir le fichier",
|
openFile: "Ouvrir le fichier",
|
||||||
copyPath: "Copier le chemin",
|
copyPath: "Copier le chemin",
|
||||||
|
copyRelativePath: "Copier le chemin relatif",
|
||||||
|
revealIn: "Afficher dans {{target}}",
|
||||||
download: "Télécharger",
|
download: "Télécharger",
|
||||||
addToChat: "Ajouter au chat",
|
addToChat: "Ajouter au chat",
|
||||||
moreActions: "Plus de propositions",
|
moreActions: "Plus de propositions",
|
||||||
|
newFile: "Nouveau fichier",
|
||||||
|
newFolder: "Nouveau dossier",
|
||||||
|
collapseFolder: "Replier le dossier",
|
||||||
|
rename: "Renommer",
|
||||||
|
duplicate: "Dupliquer",
|
||||||
|
revert: "Abandonner les modifications",
|
||||||
|
delete: "Supprimer",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "Supprimer le fichier ?",
|
||||||
|
folderTitle: "Supprimer le dossier ?",
|
||||||
|
message: "« {{name}} » sera définitivement supprimé.",
|
||||||
|
confirm: "Supprimer",
|
||||||
|
cancel: "Annuler",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "Abandonner les modifications ?",
|
||||||
|
message: "Les modifications de « {{name}} » seront définitivement abandonnées.",
|
||||||
|
confirm: "Abandonner",
|
||||||
|
cancel: "Annuler",
|
||||||
|
failed: "Échec de l'abandon des modifications",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -429,6 +452,15 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "Échec de la liste du répertoire",
|
failedToListDirectory: "Échec de la liste du répertoire",
|
||||||
|
createFailed: "Échec de la création de l'entrée",
|
||||||
|
renameFailed: "Échec du renommage de l'entrée",
|
||||||
|
duplicateFailed: "Échec de la duplication de l'entrée",
|
||||||
|
revealFailed: "Échec de l'affichage de l'entrée",
|
||||||
|
deleteFailed: "Échec de la suppression de l'entrée",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "Nom du fichier",
|
||||||
|
folderPlaceholder: "Nom du dossier",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -397,9 +397,32 @@ export const ja: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "ファイルを開く",
|
openFile: "ファイルを開く",
|
||||||
copyPath: "パスをコピー",
|
copyPath: "パスをコピー",
|
||||||
|
copyRelativePath: "相対パスをコピー",
|
||||||
|
revealIn: "{{target}}で表示",
|
||||||
download: "ダウンロード",
|
download: "ダウンロード",
|
||||||
addToChat: "チャットに追加",
|
addToChat: "チャットに追加",
|
||||||
moreActions: "その他のアクション",
|
moreActions: "その他のアクション",
|
||||||
|
newFile: "新規ファイル",
|
||||||
|
newFolder: "新規フォルダ",
|
||||||
|
collapseFolder: "フォルダを折りたたむ",
|
||||||
|
rename: "名前を変更",
|
||||||
|
duplicate: "複製",
|
||||||
|
revert: "変更を破棄",
|
||||||
|
delete: "削除",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "ファイルを削除しますか?",
|
||||||
|
folderTitle: "フォルダを削除しますか?",
|
||||||
|
message: "「{{name}}」は完全に削除されます。",
|
||||||
|
confirm: "削除",
|
||||||
|
cancel: "キャンセル",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "変更を破棄しますか?",
|
||||||
|
message: "「{{name}}」への変更は完全に破棄されます。",
|
||||||
|
confirm: "破棄",
|
||||||
|
cancel: "キャンセル",
|
||||||
|
failed: "変更の破棄に失敗しました",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -429,6 +452,15 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "ディレクトリの一覧取得に失敗しました",
|
failedToListDirectory: "ディレクトリの一覧取得に失敗しました",
|
||||||
|
createFailed: "エントリの作成に失敗しました",
|
||||||
|
renameFailed: "エントリの名前変更に失敗しました",
|
||||||
|
duplicateFailed: "エントリの複製に失敗しました",
|
||||||
|
revealFailed: "エントリの表示に失敗しました",
|
||||||
|
deleteFailed: "エントリの削除に失敗しました",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "ファイル名",
|
||||||
|
folderPlaceholder: "フォルダ名",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -394,9 +394,32 @@ export const ko: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "파일 열기",
|
openFile: "파일 열기",
|
||||||
copyPath: "경로 복사",
|
copyPath: "경로 복사",
|
||||||
|
copyRelativePath: "상대 경로 복사",
|
||||||
|
revealIn: "{{target}}에서 보기",
|
||||||
download: "다운로드",
|
download: "다운로드",
|
||||||
addToChat: "채팅에 추가",
|
addToChat: "채팅에 추가",
|
||||||
moreActions: "추가 작업",
|
moreActions: "추가 작업",
|
||||||
|
newFile: "새 파일",
|
||||||
|
newFolder: "새 폴더",
|
||||||
|
collapseFolder: "폴더 접기",
|
||||||
|
rename: "이름 바꾸기",
|
||||||
|
duplicate: "복제",
|
||||||
|
revert: "변경 사항 버리기",
|
||||||
|
delete: "삭제",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "파일을 삭제할까요?",
|
||||||
|
folderTitle: "폴더를 삭제할까요?",
|
||||||
|
message: '"{{name}}"이(가) 영구적으로 삭제됩니다.',
|
||||||
|
confirm: "삭제",
|
||||||
|
cancel: "취소",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "변경 사항을 버릴까요?",
|
||||||
|
message: '"{{name}}"의 변경 사항이 영구적으로 삭제됩니다.',
|
||||||
|
confirm: "버리기",
|
||||||
|
cancel: "취소",
|
||||||
|
failed: "변경 사항을 버리지 못했습니다",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -426,6 +449,15 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "디렉터리 목록을 불러오지 못했습니다",
|
failedToListDirectory: "디렉터리 목록을 불러오지 못했습니다",
|
||||||
|
createFailed: "항목을 만들지 못했습니다",
|
||||||
|
renameFailed: "항목 이름을 바꾸지 못했습니다",
|
||||||
|
duplicateFailed: "항목을 복제하지 못했습니다",
|
||||||
|
revealFailed: "항목을 표시하지 못했습니다",
|
||||||
|
deleteFailed: "항목을 삭제하지 못했습니다",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "파일 이름",
|
||||||
|
folderPlaceholder: "폴더 이름",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -397,9 +397,32 @@ export const ptBR: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "Abrir arquivo",
|
openFile: "Abrir arquivo",
|
||||||
copyPath: "Copiar caminho",
|
copyPath: "Copiar caminho",
|
||||||
|
copyRelativePath: "Copiar caminho relativo",
|
||||||
|
revealIn: "Mostrar no {{target}}",
|
||||||
download: "Baixar",
|
download: "Baixar",
|
||||||
addToChat: "Adicionar ao chat",
|
addToChat: "Adicionar ao chat",
|
||||||
moreActions: "Mais ações",
|
moreActions: "Mais ações",
|
||||||
|
newFile: "Novo arquivo",
|
||||||
|
newFolder: "Nova pasta",
|
||||||
|
collapseFolder: "Recolher pasta",
|
||||||
|
rename: "Renomear",
|
||||||
|
duplicate: "Duplicar",
|
||||||
|
revert: "Descartar alterações",
|
||||||
|
delete: "Excluir",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "Excluir arquivo?",
|
||||||
|
folderTitle: "Excluir pasta?",
|
||||||
|
message: '"{{name}}" será excluído permanentemente.',
|
||||||
|
confirm: "Excluir",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "Descartar alterações?",
|
||||||
|
message: 'As alterações em "{{name}}" serão descartadas permanentemente.',
|
||||||
|
confirm: "Descartar",
|
||||||
|
cancel: "Cancelar",
|
||||||
|
failed: "Falha ao descartar alterações",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -429,6 +452,15 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "Falha ao listar diretório",
|
failedToListDirectory: "Falha ao listar diretório",
|
||||||
|
createFailed: "Falha ao criar entrada",
|
||||||
|
renameFailed: "Falha ao renomear entrada",
|
||||||
|
duplicateFailed: "Falha ao duplicar entrada",
|
||||||
|
revealFailed: "Falha ao mostrar entrada",
|
||||||
|
deleteFailed: "Falha ao excluir entrada",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "Nome do arquivo",
|
||||||
|
folderPlaceholder: "Nome da pasta",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -396,9 +396,32 @@ export const ru: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "Открыть файл",
|
openFile: "Открыть файл",
|
||||||
copyPath: "Копировать путь",
|
copyPath: "Копировать путь",
|
||||||
|
copyRelativePath: "Копировать относительный путь",
|
||||||
|
revealIn: "Показать в {{target}}",
|
||||||
download: "Скачать",
|
download: "Скачать",
|
||||||
addToChat: "Добавить в чат",
|
addToChat: "Добавить в чат",
|
||||||
moreActions: "Дополнительные действия",
|
moreActions: "Дополнительные действия",
|
||||||
|
newFile: "Новый файл",
|
||||||
|
newFolder: "Новая папка",
|
||||||
|
collapseFolder: "Свернуть папку",
|
||||||
|
rename: "Переименовать",
|
||||||
|
duplicate: "Дублировать",
|
||||||
|
revert: "Отменить изменения",
|
||||||
|
delete: "Удалить",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "Удалить файл?",
|
||||||
|
folderTitle: "Удалить папку?",
|
||||||
|
message: "«{{name}}» будет удалён безвозвратно.",
|
||||||
|
confirm: "Удалить",
|
||||||
|
cancel: "Отмена",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "Отменить изменения?",
|
||||||
|
message: "Изменения в «{{name}}» будут безвозвратно отменены.",
|
||||||
|
confirm: "Отменить",
|
||||||
|
cancel: "Отмена",
|
||||||
|
failed: "Не удалось отменить изменения",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -428,6 +451,15 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "Не удалось указать каталог",
|
failedToListDirectory: "Не удалось указать каталог",
|
||||||
|
createFailed: "Не удалось создать элемент",
|
||||||
|
renameFailed: "Не удалось переименовать элемент",
|
||||||
|
duplicateFailed: "Не удалось дублировать элемент",
|
||||||
|
revealFailed: "Не удалось показать элемент",
|
||||||
|
deleteFailed: "Не удалось удалить элемент",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "Имя файла",
|
||||||
|
folderPlaceholder: "Имя папки",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -393,9 +393,32 @@ export const zhCN: TranslationResources = {
|
|||||||
fileActions: {
|
fileActions: {
|
||||||
openFile: "打开文件",
|
openFile: "打开文件",
|
||||||
copyPath: "复制路径",
|
copyPath: "复制路径",
|
||||||
|
copyRelativePath: "复制相对路径",
|
||||||
|
revealIn: "在 {{target}} 中显示",
|
||||||
download: "下载",
|
download: "下载",
|
||||||
addToChat: "添加到聊天",
|
addToChat: "添加到聊天",
|
||||||
moreActions: "更多操作",
|
moreActions: "更多操作",
|
||||||
|
newFile: "新建文件",
|
||||||
|
newFolder: "新建文件夹",
|
||||||
|
collapseFolder: "折叠文件夹",
|
||||||
|
rename: "重命名",
|
||||||
|
duplicate: "复制",
|
||||||
|
revert: "放弃更改",
|
||||||
|
delete: "删除",
|
||||||
|
confirmDelete: {
|
||||||
|
fileTitle: "删除文件?",
|
||||||
|
folderTitle: "删除文件夹?",
|
||||||
|
message: "“{{name}}”将被永久删除。",
|
||||||
|
confirm: "删除",
|
||||||
|
cancel: "取消",
|
||||||
|
},
|
||||||
|
confirmRevert: {
|
||||||
|
title: "放弃更改?",
|
||||||
|
message: "对“{{name}}”的更改将被永久放弃。",
|
||||||
|
confirm: "放弃",
|
||||||
|
cancel: "取消",
|
||||||
|
failed: "放弃更改失败",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
@@ -425,6 +448,15 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
failedToListDirectory: "列出目录失败",
|
failedToListDirectory: "列出目录失败",
|
||||||
|
createFailed: "创建条目失败",
|
||||||
|
renameFailed: "重命名条目失败",
|
||||||
|
duplicateFailed: "复制条目失败",
|
||||||
|
revealFailed: "显示条目失败",
|
||||||
|
deleteFailed: "删除条目失败",
|
||||||
|
},
|
||||||
|
draft: {
|
||||||
|
filePlaceholder: "文件名",
|
||||||
|
folderPlaceholder: "文件夹名称",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@@ -32,3 +32,8 @@ export function buildAbsoluteExplorerPath({
|
|||||||
|
|
||||||
return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`;
|
return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parentExplorerPath(entryPath: string): string {
|
||||||
|
const separatorIndex = entryPath.lastIndexOf("/");
|
||||||
|
return separatorIndex > 0 ? entryPath.slice(0, separatorIndex) : ".";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1727,6 +1727,181 @@ test("a candidate measurement that times out under a heartbeat tick does not cou
|
|||||||
expect(session.teardownCount()).toBe(0);
|
expect(session.teardownCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("file context action RPCs correlate success and error responses", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_context_actions",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const createPromise = client.createFileEntry({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
parentPath: "src",
|
||||||
|
name: "new.ts",
|
||||||
|
kind: "file",
|
||||||
|
});
|
||||||
|
const createRequest = parseSentFrame(mock.sent.at(-1));
|
||||||
|
expect(createRequest).toEqual({
|
||||||
|
type: "fs.entry.create.request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
parentPath: "src",
|
||||||
|
name: "new.ts",
|
||||||
|
kind: "file",
|
||||||
|
requestId: expect.any(String),
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "fs.entry.create.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
parentPath: "src",
|
||||||
|
path: "src/new.ts",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: createRequest.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(createPromise).resolves.toMatchObject({
|
||||||
|
path: "src/new.ts",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renamePromise = client.renameFileEntry({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
name: "existing.ts",
|
||||||
|
});
|
||||||
|
const renameRequest = parseSentFrame(mock.sent.at(-1));
|
||||||
|
expect(renameRequest).toMatchObject({
|
||||||
|
type: "fs.entry.rename.request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
name: "existing.ts",
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "fs.entry.rename.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
renamedPath: null,
|
||||||
|
success: false,
|
||||||
|
error: '"existing.ts" already exists',
|
||||||
|
requestId: renameRequest.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(renamePromise).resolves.toMatchObject({
|
||||||
|
renamedPath: null,
|
||||||
|
success: false,
|
||||||
|
error: '"existing.ts" already exists',
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicatePromise = client.duplicateFileEntry({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
});
|
||||||
|
const duplicateRequest = parseSentFrame(mock.sent.at(-1));
|
||||||
|
expect(duplicateRequest).toMatchObject({
|
||||||
|
type: "fs.entry.duplicate.request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "fs.entry.duplicate.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
duplicatedPath: "src/new copy.ts",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: duplicateRequest.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(duplicatePromise).resolves.toMatchObject({
|
||||||
|
duplicatedPath: "src/new copy.ts",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletePromise = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/new.ts" });
|
||||||
|
const deleteRequest = parseSentFrame(mock.sent.at(-1));
|
||||||
|
expect(deleteRequest).toMatchObject({
|
||||||
|
type: "fs.entry.delete.request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "fs.entry.delete.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "src/new.ts",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: deleteRequest.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(deletePromise).resolves.toMatchObject({ success: true, error: null });
|
||||||
|
|
||||||
|
const discardPromise = client.checkoutDiscardChanges("/tmp/project", { paths: ["src"] });
|
||||||
|
const discardRequest = parseSentFrame(mock.sent.at(-1));
|
||||||
|
expect(discardRequest).toMatchObject({
|
||||||
|
type: "checkout.discard_changes.request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
paths: ["src"],
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
|
||||||
|
requestId: discardRequest.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(discardPromise).resolves.toMatchObject({
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a connection loss rejects an in-flight file context action", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_context_disconnect",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const pending = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/file.ts" });
|
||||||
|
mock.triggerClose({ code: 1006, reason: "network lost" });
|
||||||
|
|
||||||
|
await expect(pending).rejects.toThrow(/network lost|disconnected|closed/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
|
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -4329,6 +4329,54 @@ export class DaemonClient {
|
|||||||
return payload.result;
|
return payload.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createFileEntry(input: {
|
||||||
|
cwd: string;
|
||||||
|
parentPath: string;
|
||||||
|
name: string;
|
||||||
|
kind: "file" | "directory";
|
||||||
|
}): Promise<CorrelatedResponsePayload<"fs.entry.create.response">> {
|
||||||
|
return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.create.response">({
|
||||||
|
message: { type: "fs.entry.create.request", ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameFileEntry(input: {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
}): Promise<CorrelatedResponsePayload<"fs.entry.rename.response">> {
|
||||||
|
return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.rename.response">({
|
||||||
|
message: { type: "fs.entry.rename.request", ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicateFileEntry(input: {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
}): Promise<CorrelatedResponsePayload<"fs.entry.duplicate.response">> {
|
||||||
|
return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.duplicate.response">({
|
||||||
|
message: { type: "fs.entry.duplicate.request", ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFileEntry(input: {
|
||||||
|
cwd: string;
|
||||||
|
path: string;
|
||||||
|
}): Promise<CorrelatedResponsePayload<"fs.entry.delete.response">> {
|
||||||
|
return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.delete.response">({
|
||||||
|
message: { type: "fs.entry.delete.request", ...input },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkoutDiscardChanges(
|
||||||
|
cwd: string,
|
||||||
|
input: { paths: string[] },
|
||||||
|
): Promise<CorrelatedResponsePayload<"checkout.discard_changes.response">> {
|
||||||
|
return this.sendNamespacedCorrelatedSessionRequest<"checkout.discard_changes.response">({
|
||||||
|
message: { type: "checkout.discard_changes.request", cwd, paths: input.paths },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
|
async uploadFile(input: FileUploadInput): Promise<FileUploadResult> {
|
||||||
const bytes = asUint8Array(input.bytes);
|
const bytes = asUint8Array(input.bytes);
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
CheckoutDiscardChangesRequestSchema,
|
||||||
|
CheckoutDiscardChangesResponseSchema,
|
||||||
|
FileEntryCreateRequestSchema,
|
||||||
|
FileEntryCreateResponseSchema,
|
||||||
|
FileEntryDeleteRequestSchema,
|
||||||
|
FileEntryDeleteResponseSchema,
|
||||||
|
FileEntryDuplicateRequestSchema,
|
||||||
|
FileEntryDuplicateResponseSchema,
|
||||||
|
FileEntryRenameRequestSchema,
|
||||||
|
FileEntryRenameResponseSchema,
|
||||||
|
ServerInfoStatusPayloadSchema,
|
||||||
|
} from "./messages.js";
|
||||||
|
|
||||||
|
describe("file context action messages", () => {
|
||||||
|
test("keeps context-action capabilities optional and preserves advertised support", () => {
|
||||||
|
const legacy = ServerInfoStatusPayloadSchema.parse({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "server-1",
|
||||||
|
features: {},
|
||||||
|
});
|
||||||
|
expect(legacy.features?.fsEntryOps).toBeUndefined();
|
||||||
|
expect(legacy.features?.fsEntryDuplicate).toBeUndefined();
|
||||||
|
expect(legacy.features?.checkoutDiscardChanges).toBeUndefined();
|
||||||
|
|
||||||
|
const current = ServerInfoStatusPayloadSchema.parse({
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "server-1",
|
||||||
|
features: {
|
||||||
|
fsEntryOps: true,
|
||||||
|
fsEntryDuplicate: true,
|
||||||
|
checkoutDiscardChanges: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(current.features).toMatchObject({
|
||||||
|
fsEntryOps: true,
|
||||||
|
fsEntryDuplicate: true,
|
||||||
|
checkoutDiscardChanges: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects discard requests without a path", () => {
|
||||||
|
expect(() =>
|
||||||
|
CheckoutDiscardChangesRequestSchema.parse({
|
||||||
|
type: "checkout.discard_changes.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
paths: [],
|
||||||
|
requestId: "discard-empty",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips file entry create requests and responses", () => {
|
||||||
|
const request = {
|
||||||
|
type: "fs.entry.create.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
parentPath: "src/components",
|
||||||
|
name: "button.tsx",
|
||||||
|
kind: "file",
|
||||||
|
requestId: "create-1",
|
||||||
|
};
|
||||||
|
expect(FileEntryCreateRequestSchema.parse(request)).toEqual(request);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: "fs.entry.create.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/workspace",
|
||||||
|
parentPath: "src/components",
|
||||||
|
path: "src/components/button.tsx",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "create-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(FileEntryCreateResponseSchema.parse(response)).toEqual(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips file entry rename requests and responses", () => {
|
||||||
|
const request = {
|
||||||
|
type: "fs.entry.rename.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "src/old.ts",
|
||||||
|
name: "new.ts",
|
||||||
|
requestId: "rename-1",
|
||||||
|
};
|
||||||
|
expect(FileEntryRenameRequestSchema.parse(request)).toEqual(request);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: "fs.entry.rename.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "src/old.ts",
|
||||||
|
renamedPath: "src/new.ts",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "rename-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(FileEntryRenameResponseSchema.parse(response)).toEqual(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips file entry duplicate requests and responses", () => {
|
||||||
|
const request = {
|
||||||
|
type: "fs.entry.duplicate.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "src/button.tsx",
|
||||||
|
requestId: "duplicate-1",
|
||||||
|
};
|
||||||
|
expect(FileEntryDuplicateRequestSchema.parse(request)).toEqual(request);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: "fs.entry.duplicate.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "src/button.tsx",
|
||||||
|
duplicatedPath: "src/button copy.tsx",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "duplicate-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(FileEntryDuplicateResponseSchema.parse(response)).toEqual(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips file entry delete requests and responses", () => {
|
||||||
|
const request = {
|
||||||
|
type: "fs.entry.delete.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "src/old.ts",
|
||||||
|
requestId: "delete-1",
|
||||||
|
};
|
||||||
|
expect(FileEntryDeleteRequestSchema.parse(request)).toEqual(request);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: "fs.entry.delete.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/workspace",
|
||||||
|
path: "src/old.ts",
|
||||||
|
success: false,
|
||||||
|
error: "File or folder no longer exists",
|
||||||
|
requestId: "delete-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(FileEntryDeleteResponseSchema.parse(response)).toEqual(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips checkout discard requests and responses", () => {
|
||||||
|
const request = {
|
||||||
|
type: "checkout.discard_changes.request",
|
||||||
|
cwd: "/workspace",
|
||||||
|
paths: ["src", "README.md"],
|
||||||
|
requestId: "discard-1",
|
||||||
|
};
|
||||||
|
expect(CheckoutDiscardChangesRequestSchema.parse(request)).toEqual(request);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/workspace",
|
||||||
|
success: false,
|
||||||
|
error: { code: "UNKNOWN", message: "discard failed" },
|
||||||
|
requestId: "discard-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(CheckoutDiscardChangesResponseSchema.parse(response)).toEqual(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1777,6 +1777,13 @@ export const CheckoutRefreshRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutDiscardChangesRequestSchema = z.object({
|
||||||
|
type: z.literal("checkout.discard_changes.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
paths: z.array(z.string()).min(1),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutPrCreateRequestSchema = z.object({
|
export const CheckoutPrCreateRequestSchema = z.object({
|
||||||
type: z.literal("checkout_pr_create_request"),
|
type: z.literal("checkout_pr_create_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -2204,6 +2211,8 @@ const DiffHunkSchema = z.object({
|
|||||||
|
|
||||||
const ParsedDiffFileSchema = z.object({
|
const ParsedDiffFileSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
|
// COMPAT(diffOldPath): added in v0.3.0, remove gate after 2027-02-09.
|
||||||
|
oldPath: z.string().optional(),
|
||||||
isNew: z.boolean(),
|
isNew: z.boolean(),
|
||||||
isDeleted: z.boolean(),
|
isDeleted: z.boolean(),
|
||||||
additions: z.number(),
|
additions: z.number(),
|
||||||
@@ -2291,6 +2300,37 @@ export const FileWriteRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FileEntryCreateRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.create.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
parentPath: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
kind: z.enum(["file", "directory"]),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileEntryRenameRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.rename.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileEntryDuplicateRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.duplicate.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileEntryDeleteRequestSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.delete.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ProjectIconRequestSchema = z.object({
|
export const ProjectIconRequestSchema = z.object({
|
||||||
type: z.literal("project_icon_request"),
|
type: z.literal("project_icon_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -2661,6 +2701,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPullRequestSchema,
|
CheckoutPullRequestSchema,
|
||||||
CheckoutPushRequestSchema,
|
CheckoutPushRequestSchema,
|
||||||
CheckoutRefreshRequestSchema,
|
CheckoutRefreshRequestSchema,
|
||||||
|
CheckoutDiscardChangesRequestSchema,
|
||||||
CheckoutPrCreateRequestSchema,
|
CheckoutPrCreateRequestSchema,
|
||||||
CheckoutPrMergeRequestSchema,
|
CheckoutPrMergeRequestSchema,
|
||||||
CheckoutForgeSetAutoMergeRequestSchema,
|
CheckoutForgeSetAutoMergeRequestSchema,
|
||||||
@@ -2699,6 +2740,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FileSubscribeRequestSchema,
|
FileSubscribeRequestSchema,
|
||||||
FileUnsubscribeRequestSchema,
|
FileUnsubscribeRequestSchema,
|
||||||
FileWriteRequestSchema,
|
FileWriteRequestSchema,
|
||||||
|
FileEntryCreateRequestSchema,
|
||||||
|
FileEntryRenameRequestSchema,
|
||||||
|
FileEntryDuplicateRequestSchema,
|
||||||
|
FileEntryDeleteRequestSchema,
|
||||||
ProjectIconRequestSchema,
|
ProjectIconRequestSchema,
|
||||||
ProjectIconGetRequestSchema,
|
ProjectIconGetRequestSchema,
|
||||||
FileDownloadTokenRequestSchema,
|
FileDownloadTokenRequestSchema,
|
||||||
@@ -3004,6 +3049,12 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
workspaceScriptManagement: z.boolean().optional(),
|
workspaceScriptManagement: z.boolean().optional(),
|
||||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||||
projectCustomIcon: z.boolean().optional(),
|
projectCustomIcon: z.boolean().optional(),
|
||||||
|
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
|
||||||
|
fsEntryOps: z.boolean().optional(),
|
||||||
|
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||||
|
fsEntryDuplicate: z.boolean().optional(),
|
||||||
|
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||||
|
checkoutDiscardChanges: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -4493,6 +4544,16 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutDiscardChangesResponseSchema = z.object({
|
||||||
|
type: z.literal("checkout.discard_changes.response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const CheckoutCommitsListResponseSchema = z.object({
|
export const CheckoutCommitsListResponseSchema = z.object({
|
||||||
type: z.literal("checkout.commits.list.response"),
|
type: z.literal("checkout.commits.list.response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -4957,6 +5018,53 @@ export const FileWriteResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FileEntryCreateResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.create.response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
parentPath: z.string(),
|
||||||
|
path: z.string().nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileEntryRenameResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.rename.response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
renamedPath: z.string().nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileEntryDuplicateResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.duplicate.response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
duplicatedPath: z.string().nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileEntryDeleteResponseSchema = z.object({
|
||||||
|
type: z.literal("fs.entry.delete.response"),
|
||||||
|
payload: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const FileUpdateSchema = z.object({
|
export const FileUpdateSchema = z.object({
|
||||||
type: z.literal("fs.file.update"),
|
type: z.literal("fs.file.update"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -5549,6 +5657,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPullResponseSchema,
|
CheckoutPullResponseSchema,
|
||||||
CheckoutPushResponseSchema,
|
CheckoutPushResponseSchema,
|
||||||
CheckoutRefreshResponseSchema,
|
CheckoutRefreshResponseSchema,
|
||||||
|
CheckoutDiscardChangesResponseSchema,
|
||||||
CheckoutPrCreateResponseSchema,
|
CheckoutPrCreateResponseSchema,
|
||||||
CheckoutPrMergeResponseSchema,
|
CheckoutPrMergeResponseSchema,
|
||||||
CheckoutForgeSetAutoMergeResponseSchema,
|
CheckoutForgeSetAutoMergeResponseSchema,
|
||||||
@@ -5576,6 +5685,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
FileSubscribeResponseSchema,
|
FileSubscribeResponseSchema,
|
||||||
FileUnsubscribeResponseSchema,
|
FileUnsubscribeResponseSchema,
|
||||||
FileWriteResponseSchema,
|
FileWriteResponseSchema,
|
||||||
|
FileEntryCreateResponseSchema,
|
||||||
|
FileEntryRenameResponseSchema,
|
||||||
|
FileEntryDuplicateResponseSchema,
|
||||||
|
FileEntryDeleteResponseSchema,
|
||||||
FileUpdateSchema,
|
FileUpdateSchema,
|
||||||
ProjectIconResponseSchema,
|
ProjectIconResponseSchema,
|
||||||
ProjectIconGetResponseSchema,
|
ProjectIconGetResponseSchema,
|
||||||
@@ -5896,6 +6009,8 @@ export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
|
|||||||
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
|
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
|
||||||
export type CheckoutRefreshRequest = z.infer<typeof CheckoutRefreshRequestSchema>;
|
export type CheckoutRefreshRequest = z.infer<typeof CheckoutRefreshRequestSchema>;
|
||||||
export type CheckoutRefreshResponse = z.infer<typeof CheckoutRefreshResponseSchema>;
|
export type CheckoutRefreshResponse = z.infer<typeof CheckoutRefreshResponseSchema>;
|
||||||
|
export type CheckoutDiscardChangesRequest = z.infer<typeof CheckoutDiscardChangesRequestSchema>;
|
||||||
|
export type CheckoutDiscardChangesResponse = z.infer<typeof CheckoutDiscardChangesResponseSchema>;
|
||||||
export type CheckoutCommitFile = z.infer<typeof CheckoutCommitFileSchema>;
|
export type CheckoutCommitFile = z.infer<typeof CheckoutCommitFileSchema>;
|
||||||
export type CheckoutCommit = z.infer<typeof CheckoutCommitSchema>;
|
export type CheckoutCommit = z.infer<typeof CheckoutCommitSchema>;
|
||||||
export type CheckoutCommitsListRequest = z.infer<typeof CheckoutCommitsListRequestSchema>;
|
export type CheckoutCommitsListRequest = z.infer<typeof CheckoutCommitsListRequestSchema>;
|
||||||
@@ -5999,6 +6114,14 @@ export type FileUnsubscribeRequest = z.infer<typeof FileUnsubscribeRequestSchema
|
|||||||
export type FileUnsubscribeResponse = z.infer<typeof FileUnsubscribeResponseSchema>;
|
export type FileUnsubscribeResponse = z.infer<typeof FileUnsubscribeResponseSchema>;
|
||||||
export type FileWriteRequest = z.infer<typeof FileWriteRequestSchema>;
|
export type FileWriteRequest = z.infer<typeof FileWriteRequestSchema>;
|
||||||
export type FileWriteResponse = z.infer<typeof FileWriteResponseSchema>;
|
export type FileWriteResponse = z.infer<typeof FileWriteResponseSchema>;
|
||||||
|
export type FileEntryCreateRequest = z.infer<typeof FileEntryCreateRequestSchema>;
|
||||||
|
export type FileEntryCreateResponse = z.infer<typeof FileEntryCreateResponseSchema>;
|
||||||
|
export type FileEntryRenameRequest = z.infer<typeof FileEntryRenameRequestSchema>;
|
||||||
|
export type FileEntryRenameResponse = z.infer<typeof FileEntryRenameResponseSchema>;
|
||||||
|
export type FileEntryDuplicateRequest = z.infer<typeof FileEntryDuplicateRequestSchema>;
|
||||||
|
export type FileEntryDuplicateResponse = z.infer<typeof FileEntryDuplicateResponseSchema>;
|
||||||
|
export type FileEntryDeleteRequest = z.infer<typeof FileEntryDeleteRequestSchema>;
|
||||||
|
export type FileEntryDeleteResponse = z.infer<typeof FileEntryDeleteResponseSchema>;
|
||||||
export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
|
export type FileWriteResult = z.infer<typeof FileWriteResultSchema>;
|
||||||
export type FileUpdate = z.infer<typeof FileUpdateSchema>;
|
export type FileUpdate = z.infer<typeof FileUpdateSchema>;
|
||||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises";
|
import {
|
||||||
|
appendFile,
|
||||||
|
chmod,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
truncate,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||||
import {
|
import {
|
||||||
|
createExplorerEntry,
|
||||||
|
deleteExplorerEntry,
|
||||||
|
duplicateExplorerEntry,
|
||||||
getExplorerFileVersion,
|
getExplorerFileVersion,
|
||||||
readExplorerFile,
|
readExplorerFile,
|
||||||
|
renameExplorerEntry,
|
||||||
streamExplorerFile,
|
streamExplorerFile,
|
||||||
writeExplorerFile,
|
writeExplorerFile,
|
||||||
} from "./service.js";
|
} from "./service.js";
|
||||||
@@ -373,4 +388,202 @@ describe("file explorer service", () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates files and directories, refusing duplicates and separator names", async () => {
|
||||||
|
const root = await createTempDir("paseo-entry-create-");
|
||||||
|
try {
|
||||||
|
const file = await createExplorerEntry({
|
||||||
|
root,
|
||||||
|
parentPath: ".",
|
||||||
|
name: "notes.txt",
|
||||||
|
kind: "file",
|
||||||
|
});
|
||||||
|
expect(file).toEqual({ status: "ok", path: "notes.txt" });
|
||||||
|
expect((await stat(path.join(root, "notes.txt"))).isFile()).toBe(true);
|
||||||
|
|
||||||
|
const dir = await createExplorerEntry({
|
||||||
|
root,
|
||||||
|
parentPath: ".",
|
||||||
|
name: "docs",
|
||||||
|
kind: "directory",
|
||||||
|
});
|
||||||
|
expect(dir).toEqual({ status: "ok", path: "docs" });
|
||||||
|
const nested = await createExplorerEntry({
|
||||||
|
root,
|
||||||
|
parentPath: "docs",
|
||||||
|
name: "guide.md",
|
||||||
|
kind: "file",
|
||||||
|
});
|
||||||
|
expect(nested).toEqual({ status: "ok", path: "docs/guide.md" });
|
||||||
|
|
||||||
|
const duplicate = await createExplorerEntry({
|
||||||
|
root,
|
||||||
|
parentPath: ".",
|
||||||
|
name: "notes.txt",
|
||||||
|
kind: "file",
|
||||||
|
});
|
||||||
|
expect(duplicate.status).toBe("error");
|
||||||
|
|
||||||
|
const traversal = await createExplorerEntry({
|
||||||
|
root,
|
||||||
|
parentPath: ".",
|
||||||
|
name: "../escape",
|
||||||
|
kind: "directory",
|
||||||
|
});
|
||||||
|
expect(traversal.status).toBe("error");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicates files and folders with collision-free sibling names", async () => {
|
||||||
|
const root = await createTempDir("paseo-entry-duplicate-");
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(root, "notes.txt"), "original", "utf8");
|
||||||
|
const firstFileCopy = await duplicateExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath: "notes.txt",
|
||||||
|
});
|
||||||
|
expect(firstFileCopy).toEqual({ status: "ok", path: "notes copy.txt" });
|
||||||
|
expect(await readFile(path.join(root, "notes copy.txt"), "utf8")).toBe("original");
|
||||||
|
|
||||||
|
const secondFileCopy = await duplicateExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath: "notes.txt",
|
||||||
|
});
|
||||||
|
expect(secondFileCopy).toEqual({ status: "ok", path: "notes copy 2.txt" });
|
||||||
|
|
||||||
|
await mkdir(path.join(root, "docs"));
|
||||||
|
await writeFile(path.join(root, "docs", "guide.md"), "guide", "utf8");
|
||||||
|
const folderCopy = await duplicateExplorerEntry({ root, relativePath: "docs" });
|
||||||
|
expect(folderCopy).toEqual({ status: "ok", path: "docs copy" });
|
||||||
|
expect(await readFile(path.join(root, "docs copy", "guide.md"), "utf8")).toBe("guide");
|
||||||
|
|
||||||
|
await expect(duplicateExplorerEntry({ root, relativePath: "." })).resolves.toMatchObject({
|
||||||
|
status: "error",
|
||||||
|
});
|
||||||
|
await expect(duplicateExplorerEntry({ root, relativePath: "missing.txt" })).resolves.toEqual({
|
||||||
|
status: "error",
|
||||||
|
error: "File or folder no longer exists",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renames tracked entries with Git and untracked entries on the filesystem", async () => {
|
||||||
|
const root = await createTempDir("paseo-entry-rename-");
|
||||||
|
try {
|
||||||
|
await runGitCommand(["init"], { cwd: root });
|
||||||
|
await writeFile(path.join(root, "tracked.txt"), "tracked", "utf8");
|
||||||
|
await mkdir(path.join(root, "tracked-folder"));
|
||||||
|
await writeFile(path.join(root, "tracked-folder", "inside.txt"), "tracked", "utf8");
|
||||||
|
await runGitCommand(["add", "."], { cwd: root });
|
||||||
|
await runGitCommand(
|
||||||
|
["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"],
|
||||||
|
{ cwd: root },
|
||||||
|
);
|
||||||
|
|
||||||
|
const tracked = await renameExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath: "tracked.txt",
|
||||||
|
name: "renamed.txt",
|
||||||
|
});
|
||||||
|
expect(tracked).toEqual({ status: "ok", path: "renamed.txt" });
|
||||||
|
expect((await runGitCommand(["status", "--short"], { cwd: root })).stdout.trim()).toBe(
|
||||||
|
"R tracked.txt -> renamed.txt",
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackedFolder = await renameExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath: "tracked-folder",
|
||||||
|
name: "renamed-folder",
|
||||||
|
});
|
||||||
|
expect(trackedFolder).toEqual({ status: "ok", path: "renamed-folder" });
|
||||||
|
const gitStatus = (await runGitCommand(["status", "--short"], { cwd: root })).stdout;
|
||||||
|
expect(gitStatus).toContain("tracked-folder/inside.txt -> renamed-folder/inside.txt");
|
||||||
|
|
||||||
|
await writeFile(path.join(root, "untracked.txt"), "untracked", "utf8");
|
||||||
|
const untracked = await renameExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath: "untracked.txt",
|
||||||
|
name: "moved.txt",
|
||||||
|
});
|
||||||
|
expect(untracked).toEqual({ status: "ok", path: "moved.txt" });
|
||||||
|
expect((await stat(path.join(root, "moved.txt"))).isFile()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports case-only renames for tracked and untracked files", async () => {
|
||||||
|
const root = await createTempDir("paseo-entry-case-rename-");
|
||||||
|
try {
|
||||||
|
await runGitCommand(["init"], { cwd: root });
|
||||||
|
await writeFile(path.join(root, "Tracked.txt"), "tracked", "utf8");
|
||||||
|
await writeFile(path.join(root, "Loose.txt"), "untracked", "utf8");
|
||||||
|
await runGitCommand(["add", "Tracked.txt"], { cwd: root });
|
||||||
|
await runGitCommand(
|
||||||
|
["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"],
|
||||||
|
{ cwd: root },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
renameExplorerEntry({ root, relativePath: "Tracked.txt", name: "tracked.txt" }),
|
||||||
|
).resolves.toEqual({ status: "ok", path: "tracked.txt" });
|
||||||
|
expect((await stat(path.join(root, "tracked.txt"))).isFile()).toBe(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
renameExplorerEntry({ root, relativePath: "Loose.txt", name: "loose.txt" }),
|
||||||
|
).resolves.toEqual({ status: "ok", path: "loose.txt" });
|
||||||
|
expect((await stat(path.join(root, "loose.txt"))).isFile()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses invalid renames, existing targets, and the workspace root", async () => {
|
||||||
|
const root = await createTempDir("paseo-entry-rename-errors-");
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(root, "source.txt"), "source", "utf8");
|
||||||
|
await writeFile(path.join(root, "existing.txt"), "existing", "utf8");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }),
|
||||||
|
).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' });
|
||||||
|
await expect(
|
||||||
|
renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }),
|
||||||
|
).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" });
|
||||||
|
await expect(
|
||||||
|
renameExplorerEntry({ root, relativePath: ".", name: "renamed-root" }),
|
||||||
|
).resolves.toEqual({ status: "error", error: "Cannot rename the workspace root" });
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes files and directories but never the workspace root or outside paths", async () => {
|
||||||
|
const root = await createTempDir("paseo-entry-delete-");
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(root, "doomed.txt"), "bye", "utf8");
|
||||||
|
const removedFile = await deleteExplorerEntry({ root, relativePath: "doomed.txt" });
|
||||||
|
expect(removedFile).toEqual({ status: "ok", path: "doomed.txt" });
|
||||||
|
await expect(stat(path.join(root, "doomed.txt"))).rejects.toThrow();
|
||||||
|
|
||||||
|
await createExplorerEntry({ root, parentPath: ".", name: "nested", kind: "directory" });
|
||||||
|
await writeFile(path.join(root, "nested", "inner.txt"), "hi", "utf8");
|
||||||
|
const removedDir = await deleteExplorerEntry({ root, relativePath: "nested" });
|
||||||
|
expect(removedDir).toEqual({ status: "ok", path: "nested" });
|
||||||
|
await expect(stat(path.join(root, "nested"))).rejects.toThrow();
|
||||||
|
|
||||||
|
const rootDelete = await deleteExplorerEntry({ root, relativePath: "." });
|
||||||
|
expect(rootDelete.status).toBe("error");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deleteExplorerEntry({ root, relativePath: "../outside" }),
|
||||||
|
).resolves.toMatchObject({ status: "error" });
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FileHandle } from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
|
||||||
|
import { runGitCommand } from "../../utils/run-git-command.js";
|
||||||
|
|
||||||
export type ExplorerEntryKind = "file" | "directory";
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||||
@@ -572,6 +573,215 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExplorerCreateEntryParams {
|
||||||
|
root: string;
|
||||||
|
parentPath: string;
|
||||||
|
name: string;
|
||||||
|
kind: ExplorerEntryKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExplorerRenameEntryParams {
|
||||||
|
root: string;
|
||||||
|
relativePath: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExplorerEntryMutationResult =
|
||||||
|
| { status: "ok"; path: string }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
export async function createExplorerEntry({
|
||||||
|
root,
|
||||||
|
parentPath,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
}: ExplorerCreateEntryParams): Promise<ExplorerEntryMutationResult> {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName || trimmedName === "." || trimmedName === "..") {
|
||||||
|
return { status: "error", error: "Invalid name" };
|
||||||
|
}
|
||||||
|
if (trimmedName.includes("/") || trimmedName.includes("\\")) {
|
||||||
|
return { status: "error", error: "Name cannot contain path separators" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parent = await resolveScopedPath({ root, relativePath: parentPath });
|
||||||
|
const parentStats = await fs.stat(parent.resolvedPath);
|
||||||
|
if (!parentStats.isDirectory()) {
|
||||||
|
return { status: "error", error: "Parent path is not a directory" };
|
||||||
|
}
|
||||||
|
const targetPath = path.join(parent.resolvedPath, trimmedName);
|
||||||
|
if (kind === "directory") {
|
||||||
|
await fs.mkdir(targetPath);
|
||||||
|
} else {
|
||||||
|
const handle = await fs.open(targetPath, "wx", 0o644);
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
path: normalizeRelativePath({
|
||||||
|
root,
|
||||||
|
targetPath: path.join(parent.requestedPath, trimmedName),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isEntryExistsError(error)) {
|
||||||
|
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||||
|
}
|
||||||
|
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
}: ReadFileParams): Promise<ExplorerEntryMutationResult> {
|
||||||
|
try {
|
||||||
|
const source = await resolveScopedPath({ root, relativePath });
|
||||||
|
const realRoot = await fs.realpath(expandUserPath(root));
|
||||||
|
if (source.resolvedPath === realRoot) {
|
||||||
|
return { status: "error", error: "Cannot duplicate the workspace root" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fs.lstat(source.requestedPath);
|
||||||
|
const sourceName = path.basename(source.requestedPath);
|
||||||
|
const extension = stats.isDirectory() ? "" : path.extname(sourceName);
|
||||||
|
const baseName = extension ? sourceName.slice(0, -extension.length) : sourceName;
|
||||||
|
let targetPath = "";
|
||||||
|
for (let copyNumber = 1; ; copyNumber += 1) {
|
||||||
|
const suffix = copyNumber === 1 ? " copy" : ` copy ${copyNumber}`;
|
||||||
|
targetPath = path.join(
|
||||||
|
path.dirname(source.requestedPath),
|
||||||
|
`${baseName}${suffix}${extension}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await fs.lstat(targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.cp(source.requestedPath, targetPath, {
|
||||||
|
recursive: stats.isDirectory(),
|
||||||
|
errorOnExist: true,
|
||||||
|
force: false,
|
||||||
|
preserveTimestamps: true,
|
||||||
|
});
|
||||||
|
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
return { status: "error", error: "File or folder no longer exists" };
|
||||||
|
}
|
||||||
|
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
name,
|
||||||
|
}: ExplorerRenameEntryParams): Promise<ExplorerEntryMutationResult> {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName || trimmedName === "." || trimmedName === "..") {
|
||||||
|
return { status: "error", error: "Invalid name" };
|
||||||
|
}
|
||||||
|
if (trimmedName.includes("/") || trimmedName.includes("\\")) {
|
||||||
|
return { status: "error", error: "Name cannot contain path separators" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = await resolveScopedPath({ root, relativePath });
|
||||||
|
const realRoot = await fs.realpath(expandUserPath(root));
|
||||||
|
if (source.resolvedPath === realRoot) {
|
||||||
|
return { status: "error", error: "Cannot rename the workspace root" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.join(path.dirname(source.requestedPath), trimmedName);
|
||||||
|
const sourceStats = await fs.lstat(source.requestedPath);
|
||||||
|
const targetStats = await fs.lstat(targetPath).catch((error: unknown) => {
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
targetStats &&
|
||||||
|
(targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino)
|
||||||
|
) {
|
||||||
|
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath });
|
||||||
|
const renamedPath = normalizeRelativePath({ root, targetPath });
|
||||||
|
if (sourcePath === renamedPath) {
|
||||||
|
return { status: "ok", path: renamedPath };
|
||||||
|
}
|
||||||
|
const repository = await runGitCommand(["rev-parse", "--is-inside-work-tree"], {
|
||||||
|
cwd: realRoot,
|
||||||
|
acceptExitCodes: [0, 128],
|
||||||
|
});
|
||||||
|
const tracked =
|
||||||
|
repository.exitCode === 0
|
||||||
|
? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot })
|
||||||
|
: null;
|
||||||
|
if (tracked?.stdout) {
|
||||||
|
await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot });
|
||||||
|
} else {
|
||||||
|
await fs.rename(source.requestedPath, targetPath);
|
||||||
|
}
|
||||||
|
return { status: "ok", path: renamedPath };
|
||||||
|
} catch (error) {
|
||||||
|
if (isEntryExistsError(error)) {
|
||||||
|
return { status: "error", error: `"${trimmedName}" already exists` };
|
||||||
|
}
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
return { status: "error", error: "File or folder no longer exists" };
|
||||||
|
}
|
||||||
|
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExplorerEntry({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
}: ReadFileParams): Promise<ExplorerEntryMutationResult> {
|
||||||
|
try {
|
||||||
|
const scoped = await resolveScopedPath({ root, relativePath });
|
||||||
|
const realRoot = await fs.realpath(expandUserPath(root));
|
||||||
|
if (scoped.resolvedPath === realRoot) {
|
||||||
|
return { status: "error", error: "Cannot delete the workspace root" };
|
||||||
|
}
|
||||||
|
// Remove the requested path, not the realpath: deleting a symlink must
|
||||||
|
// remove the link, never its target. lstat keeps the same guarantee.
|
||||||
|
const stats = await fs.lstat(scoped.requestedPath);
|
||||||
|
await fs.rm(scoped.requestedPath, {
|
||||||
|
recursive: stats.isDirectory(),
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
path: normalizeRelativePath({ root, targetPath: scoped.requestedPath }),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingEntryError(error)) {
|
||||||
|
return { status: "error", error: "File or folder no longer exists" };
|
||||||
|
}
|
||||||
|
return { status: "error", error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntryExistsError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as NodeJS.ErrnoException).code === "EEXIST"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveScopedPath({
|
async function resolveScopedPath({
|
||||||
root,
|
root,
|
||||||
relativePath = ".",
|
relativePath = ".",
|
||||||
|
|||||||
@@ -2106,6 +2106,8 @@ export class Session {
|
|||||||
return this.checkoutSession.handleCheckoutPushRequest(msg);
|
return this.checkoutSession.handleCheckoutPushRequest(msg);
|
||||||
case "checkout.refresh.request":
|
case "checkout.refresh.request":
|
||||||
return this.checkoutSession.handleRefreshRequest(msg);
|
return this.checkoutSession.handleRefreshRequest(msg);
|
||||||
|
case "checkout.discard_changes.request":
|
||||||
|
return this.checkoutSession.handleCheckoutDiscardChangesRequest(msg);
|
||||||
case "checkout_pr_create_request":
|
case "checkout_pr_create_request":
|
||||||
return this.checkoutSession.handleCheckoutPrCreateRequest(msg);
|
return this.checkoutSession.handleCheckoutPrCreateRequest(msg);
|
||||||
case "checkout_pr_merge_request":
|
case "checkout_pr_merge_request":
|
||||||
@@ -2196,6 +2198,14 @@ export class Session {
|
|||||||
return undefined;
|
return undefined;
|
||||||
case "fs.file.write.request":
|
case "fs.file.write.request":
|
||||||
return this.workspaceFilesSession.handleFileWriteRequest(msg);
|
return this.workspaceFilesSession.handleFileWriteRequest(msg);
|
||||||
|
case "fs.entry.create.request":
|
||||||
|
return this.workspaceFilesSession.handleFileEntryCreateRequest(msg);
|
||||||
|
case "fs.entry.rename.request":
|
||||||
|
return this.workspaceFilesSession.handleFileEntryRenameRequest(msg);
|
||||||
|
case "fs.entry.duplicate.request":
|
||||||
|
return this.workspaceFilesSession.handleFileEntryDuplicateRequest(msg);
|
||||||
|
case "fs.entry.delete.request":
|
||||||
|
return this.workspaceFilesSession.handleFileEntryDeleteRequest(msg);
|
||||||
case "project_icon_request":
|
case "project_icon_request":
|
||||||
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
||||||
case "project.icon.get.request":
|
case "project.icon.get.request":
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import {
|
import {
|
||||||
@@ -439,6 +443,88 @@ describe("CheckoutSession", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("discard changes", () => {
|
||||||
|
it("discards the paths, notifies git mutation, refreshes diffs, and confirms success", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-"));
|
||||||
|
const cwd = realpathSync(tempDir);
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["init", "-q"], { cwd });
|
||||||
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd });
|
||||||
|
execFileSync("git", ["config", "user.name", "Test User"], { cwd });
|
||||||
|
writeFileSync(join(cwd, "file.txt"), "original\n");
|
||||||
|
execFileSync("git", ["add", "file.txt"], { cwd });
|
||||||
|
execFileSync("git", ["commit", "-qm", "initial"], { cwd });
|
||||||
|
writeFileSync(join(cwd, "file.txt"), "changed\n");
|
||||||
|
|
||||||
|
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
|
||||||
|
cwd: "",
|
||||||
|
files: [],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber });
|
||||||
|
|
||||||
|
await checkout.handleCheckoutDiscardChangesRequest({
|
||||||
|
type: "checkout.discard_changes.request",
|
||||||
|
cwd,
|
||||||
|
paths: ["file.txt"],
|
||||||
|
requestId: "discard-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readFileSync(join(cwd, "file.txt"), "utf8").replaceAll("\r\n", "\n")).toBe(
|
||||||
|
"original\n",
|
||||||
|
);
|
||||||
|
expect(gitMutationCalls.notifyGitMutation).toEqual([
|
||||||
|
{ cwd, reason: "discard-changes", options: undefined },
|
||||||
|
]);
|
||||||
|
expect(refreshedCwds).toEqual([cwd]);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: { cwd, success: true, error: null, requestId: "discard-1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the checkout error without notifying git or refreshing diffs", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-error-"));
|
||||||
|
const cwd = realpathSync(tempDir);
|
||||||
|
try {
|
||||||
|
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
|
||||||
|
cwd: "",
|
||||||
|
files: [],
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber });
|
||||||
|
|
||||||
|
await checkout.handleCheckoutDiscardChangesRequest({
|
||||||
|
type: "checkout.discard_changes.request",
|
||||||
|
cwd,
|
||||||
|
paths: ["file.txt"],
|
||||||
|
requestId: "discard-error",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gitMutationCalls.notifyGitMutation).toEqual([]);
|
||||||
|
expect(refreshedCwds).toEqual([]);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: false,
|
||||||
|
error: { code: "NOT_GIT_REPO", message: `Not a git repository: ${cwd}` },
|
||||||
|
requestId: "discard-error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("diff subscriptions", () => {
|
describe("diff subscriptions", () => {
|
||||||
it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => {
|
it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => {
|
||||||
const { subscriber, subscriptions } = createFakeDiffSubscriber({
|
const { subscriber, subscriptions } = createFakeDiffSubscriber({
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
commitChanges,
|
commitChanges,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
|
discardChanges,
|
||||||
forgeAuthStateFromError,
|
forgeAuthStateFromError,
|
||||||
isForgeAuthError,
|
isForgeAuthError,
|
||||||
mergeFromBase,
|
mergeFromBase,
|
||||||
@@ -605,6 +606,26 @@ export class CheckoutSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleCheckoutDiscardChangesRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "checkout.discard_changes.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { cwd, paths, requestId } = msg;
|
||||||
|
try {
|
||||||
|
await discardChanges(cwd, paths);
|
||||||
|
await this.gitMutation.notifyGitMutation(cwd, "discard-changes");
|
||||||
|
this.scheduleDiffRefresh(cwd);
|
||||||
|
this.host.emit({
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: { cwd, success: true, error: null, requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.host.emit({
|
||||||
|
type: "checkout.discard_changes.response",
|
||||||
|
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleStashSaveRequest(
|
async handleStashSaveRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
@@ -74,6 +81,227 @@ function uploadFrame(args: Parameters<typeof encodeFileTransferFrame>[0]): FileT
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("WorkspaceFilesSession", () => {
|
describe("WorkspaceFilesSession", () => {
|
||||||
|
test("creates an entry and emits the complete success response", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-create-");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryCreateRequest({
|
||||||
|
type: "fs.entry.create.request",
|
||||||
|
cwd,
|
||||||
|
parentPath: ".",
|
||||||
|
name: "notes.txt",
|
||||||
|
kind: "file",
|
||||||
|
requestId: "req-create",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(join(cwd, "notes.txt"))).toBe(true);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.create.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
parentPath: ".",
|
||||||
|
path: "notes.txt",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "req-create",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes entry creation errors through in the response", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-create-error-");
|
||||||
|
writeFileSync(join(cwd, "notes.txt"), "existing");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryCreateRequest({
|
||||||
|
type: "fs.entry.create.request",
|
||||||
|
cwd,
|
||||||
|
parentPath: ".",
|
||||||
|
name: "notes.txt",
|
||||||
|
kind: "file",
|
||||||
|
requestId: "req-create-error",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.create.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
parentPath: ".",
|
||||||
|
path: null,
|
||||||
|
success: false,
|
||||||
|
error: '"notes.txt" already exists',
|
||||||
|
requestId: "req-create-error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renames an entry and emits the resulting path", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-rename-");
|
||||||
|
writeFileSync(join(cwd, "notes.txt"), "rename me");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryRenameRequest({
|
||||||
|
type: "fs.entry.rename.request",
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
name: "renamed.txt",
|
||||||
|
requestId: "req-rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(join(cwd, "notes.txt"))).toBe(false);
|
||||||
|
expect(existsSync(join(cwd, "renamed.txt"))).toBe(true);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.rename.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
renamedPath: "renamed.txt",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "req-rename",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes entry rename errors through in the response", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-rename-error-");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryRenameRequest({
|
||||||
|
type: "fs.entry.rename.request",
|
||||||
|
cwd,
|
||||||
|
path: "missing.txt",
|
||||||
|
name: "renamed.txt",
|
||||||
|
requestId: "req-rename-error",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.rename.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: "missing.txt",
|
||||||
|
renamedPath: null,
|
||||||
|
success: false,
|
||||||
|
error: "File or folder no longer exists",
|
||||||
|
requestId: "req-rename-error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicates an entry and emits the resulting path", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-duplicate-");
|
||||||
|
writeFileSync(join(cwd, "notes.txt"), "duplicate me");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryDuplicateRequest({
|
||||||
|
type: "fs.entry.duplicate.request",
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
requestId: "req-duplicate",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readFileSync(join(cwd, "notes copy.txt"), "utf8")).toBe("duplicate me");
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.duplicate.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
duplicatedPath: "notes copy.txt",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "req-duplicate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes entry duplication errors through in the response", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-duplicate-error-");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryDuplicateRequest({
|
||||||
|
type: "fs.entry.duplicate.request",
|
||||||
|
cwd,
|
||||||
|
path: "missing.txt",
|
||||||
|
requestId: "req-duplicate-error",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.duplicate.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: "missing.txt",
|
||||||
|
duplicatedPath: null,
|
||||||
|
success: false,
|
||||||
|
error: "File or folder no longer exists",
|
||||||
|
requestId: "req-duplicate-error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes an entry and emits the complete success response", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-delete-");
|
||||||
|
writeFileSync(join(cwd, "notes.txt"), "delete me");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryDeleteRequest({
|
||||||
|
type: "fs.entry.delete.request",
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
requestId: "req-delete",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(join(cwd, "notes.txt"))).toBe(false);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.delete.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
requestId: "req-delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes entry deletion errors through in the response", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-delete-error-");
|
||||||
|
const { subsystem, emitted } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleFileEntryDeleteRequest({
|
||||||
|
type: "fs.entry.delete.request",
|
||||||
|
cwd,
|
||||||
|
path: "missing.txt",
|
||||||
|
requestId: "req-delete-error",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
{
|
||||||
|
type: "fs.entry.delete.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
path: "missing.txt",
|
||||||
|
success: false,
|
||||||
|
error: "File or folder no longer exists",
|
||||||
|
requestId: "req-delete-error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("lists directory entries", async () => {
|
test("lists directory entries", async () => {
|
||||||
const cwd = makeDir("workspace-files-list-");
|
const cwd = makeDir("workspace-files-list-");
|
||||||
writeFileSync(join(cwd, "a.txt"), "alpha");
|
writeFileSync(join(cwd, "a.txt"), "alpha");
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
} from "@getpaseo/protocol/binary-frames/index";
|
} from "@getpaseo/protocol/binary-frames/index";
|
||||||
import type {
|
import type {
|
||||||
FileDownloadTokenRequest,
|
FileDownloadTokenRequest,
|
||||||
|
FileEntryCreateRequest,
|
||||||
|
FileEntryDeleteRequest,
|
||||||
|
FileEntryDuplicateRequest,
|
||||||
|
FileEntryRenameRequest,
|
||||||
FileExplorerRequest,
|
FileExplorerRequest,
|
||||||
FileUploadRequest,
|
FileUploadRequest,
|
||||||
FileSubscribeRequest,
|
FileSubscribeRequest,
|
||||||
@@ -18,9 +22,13 @@ import type {
|
|||||||
import { FileUploadStore } from "../../file-upload/index.js";
|
import { FileUploadStore } from "../../file-upload/index.js";
|
||||||
import type { DownloadTokenStore } from "../../file-download/token-store.js";
|
import type { DownloadTokenStore } from "../../file-download/token-store.js";
|
||||||
import {
|
import {
|
||||||
|
createExplorerEntry,
|
||||||
|
deleteExplorerEntry,
|
||||||
|
duplicateExplorerEntry,
|
||||||
getDownloadableFileInfo,
|
getDownloadableFileInfo,
|
||||||
listDirectoryEntries,
|
listDirectoryEntries,
|
||||||
readExplorerFile,
|
readExplorerFile,
|
||||||
|
renameExplorerEntry,
|
||||||
streamExplorerFile,
|
streamExplorerFile,
|
||||||
writeExplorerFile,
|
writeExplorerFile,
|
||||||
} from "../../file-explorer/service.js";
|
} from "../../file-explorer/service.js";
|
||||||
@@ -131,6 +139,80 @@ export class WorkspaceFilesSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleFileEntryCreateRequest(request: FileEntryCreateRequest): Promise<void> {
|
||||||
|
const result = await createExplorerEntry({
|
||||||
|
root: request.cwd,
|
||||||
|
parentPath: request.parentPath,
|
||||||
|
name: request.name,
|
||||||
|
kind: request.kind,
|
||||||
|
});
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.entry.create.response",
|
||||||
|
payload: {
|
||||||
|
cwd: request.cwd,
|
||||||
|
parentPath: request.parentPath,
|
||||||
|
path: result.status === "ok" ? result.path : null,
|
||||||
|
success: result.status === "ok",
|
||||||
|
error: result.status === "ok" ? null : result.error,
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileEntryRenameRequest(request: FileEntryRenameRequest): Promise<void> {
|
||||||
|
const result = await renameExplorerEntry({
|
||||||
|
root: request.cwd,
|
||||||
|
relativePath: request.path,
|
||||||
|
name: request.name,
|
||||||
|
});
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.entry.rename.response",
|
||||||
|
payload: {
|
||||||
|
cwd: request.cwd,
|
||||||
|
path: request.path,
|
||||||
|
renamedPath: result.status === "ok" ? result.path : null,
|
||||||
|
success: result.status === "ok",
|
||||||
|
error: result.status === "ok" ? null : result.error,
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileEntryDuplicateRequest(request: FileEntryDuplicateRequest): Promise<void> {
|
||||||
|
const result = await duplicateExplorerEntry({
|
||||||
|
root: request.cwd,
|
||||||
|
relativePath: request.path,
|
||||||
|
});
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.entry.duplicate.response",
|
||||||
|
payload: {
|
||||||
|
cwd: request.cwd,
|
||||||
|
path: request.path,
|
||||||
|
duplicatedPath: result.status === "ok" ? result.path : null,
|
||||||
|
success: result.status === "ok",
|
||||||
|
error: result.status === "ok" ? null : result.error,
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileEntryDeleteRequest(request: FileEntryDeleteRequest): Promise<void> {
|
||||||
|
const result = await deleteExplorerEntry({
|
||||||
|
root: request.cwd,
|
||||||
|
relativePath: request.path,
|
||||||
|
});
|
||||||
|
this.host.emit({
|
||||||
|
type: "fs.entry.delete.response",
|
||||||
|
payload: {
|
||||||
|
cwd: request.cwd,
|
||||||
|
path: request.path,
|
||||||
|
success: result.status === "ok",
|
||||||
|
error: result.status === "ok" ? null : result.error,
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
|
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
|
||||||
this.fileSubscriptions.clear();
|
this.fileSubscriptions.clear();
|
||||||
|
|||||||
@@ -1638,6 +1638,12 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
workspaceScriptManagement: true,
|
workspaceScriptManagement: true,
|
||||||
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
|
||||||
projectCustomIcon: true,
|
projectCustomIcon: true,
|
||||||
|
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
|
||||||
|
fsEntryOps: true,
|
||||||
|
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||||
|
fsEntryDuplicate: true,
|
||||||
|
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||||
|
checkoutDiscardChanges: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
__resetPullRequestStatusCacheForTests,
|
__resetPullRequestStatusCacheForTests,
|
||||||
__setPullRequestStatusCacheTtlForTests,
|
__setPullRequestStatusCacheTtlForTests,
|
||||||
commitAll,
|
commitAll,
|
||||||
|
discardChanges,
|
||||||
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES,
|
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
getCachedCheckoutShortstat,
|
getCachedCheckoutShortstat,
|
||||||
@@ -108,6 +109,10 @@ function initRepo(): { tempDir: string; repoDir: string } {
|
|||||||
return { tempDir, repoDir };
|
return { tempDir, repoDir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTextFile(path: string): string {
|
||||||
|
return readFileSync(path, "utf8").replaceAll("\r\n", "\n");
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
@@ -516,6 +521,19 @@ describe("checkout git utilities", () => {
|
|||||||
expect(message).toBe("update file");
|
expect(message).toBe("update file");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes both paths for a staged rename in structured diffs", async () => {
|
||||||
|
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
||||||
|
|
||||||
|
const diff = await getCheckoutDiff(repoDir, {
|
||||||
|
mode: "uncommitted",
|
||||||
|
includeStructured: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diff.structured).toContainEqual(
|
||||||
|
expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => {
|
it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => {
|
||||||
setupRemoteTrackingMain(repoDir, tempDir);
|
setupRemoteTrackingMain(repoDir, tempDir);
|
||||||
|
|
||||||
@@ -3698,3 +3716,132 @@ const x = 1;
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("discardChanges", () => {
|
||||||
|
it("discards staged and unstaged modifications, deletions, and untracked files", async () => {
|
||||||
|
const { tempDir, repoDir } = initRepo();
|
||||||
|
try {
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "changed\n");
|
||||||
|
writeFileSync(join(repoDir, "staged.txt"), "staged\n");
|
||||||
|
execFileSync("git", ["add", "staged.txt"], { cwd: repoDir });
|
||||||
|
mkdirSync(join(repoDir, "junk"), { recursive: true });
|
||||||
|
writeFileSync(join(repoDir, "junk", "scratch.txt"), "scratch\n");
|
||||||
|
|
||||||
|
await discardChanges(repoDir, ["file.txt", "staged.txt", "junk"]);
|
||||||
|
|
||||||
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||||
|
expect(existsSync(join(repoDir, "staged.txt"))).toBe(false);
|
||||||
|
expect(existsSync(join(repoDir, "junk"))).toBe(false);
|
||||||
|
const status = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
expect(status).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores both sides of a staged rename", async () => {
|
||||||
|
const { tempDir, repoDir } = initRepo();
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir });
|
||||||
|
|
||||||
|
await discardChanges(repoDir, ["file.txt", "renamed.txt"]);
|
||||||
|
|
||||||
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||||
|
expect(existsSync(join(repoDir, "renamed.txt"))).toBe(false);
|
||||||
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unstages and removes files in a repository with an unborn HEAD", async () => {
|
||||||
|
const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-unborn-")));
|
||||||
|
const repoDir = join(tempDir, "repo");
|
||||||
|
mkdirSync(repoDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir });
|
||||||
|
writeFileSync(join(repoDir, "new.txt"), "new\n");
|
||||||
|
execFileSync("git", ["add", "new.txt"], { cwd: repoDir });
|
||||||
|
|
||||||
|
await discardChanges(repoDir, ["new.txt"]);
|
||||||
|
|
||||||
|
expect(existsSync(join(repoDir, "new.txt"))).toBe(false);
|
||||||
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards a nested folder pathspec without touching sibling changes", async () => {
|
||||||
|
const { tempDir, repoDir } = initRepo();
|
||||||
|
try {
|
||||||
|
mkdirSync(join(repoDir, "nested"));
|
||||||
|
writeFileSync(join(repoDir, "nested", "tracked.txt"), "original\n");
|
||||||
|
execFileSync("git", ["add", "nested/tracked.txt"], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
writeFileSync(join(repoDir, "nested", "tracked.txt"), "changed\n");
|
||||||
|
writeFileSync(join(repoDir, "nested", "untracked.txt"), "remove\n");
|
||||||
|
writeFileSync(join(repoDir, "outside.txt"), "keep\n");
|
||||||
|
|
||||||
|
await discardChanges(repoDir, ["nested"]);
|
||||||
|
|
||||||
|
expect(readTextFile(join(repoDir, "nested", "tracked.txt"))).toBe("original\n");
|
||||||
|
expect(existsSync(join(repoDir, "nested", "untracked.txt"))).toBe(false);
|
||||||
|
expect(readFileSync(join(repoDir, "outside.txt"), "utf8")).toBe("keep\n");
|
||||||
|
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(
|
||||||
|
"?? outside.txt\n",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a staged deletion", async () => {
|
||||||
|
const { tempDir, repoDir } = initRepo();
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rm", "file.txt"], { cwd: repoDir });
|
||||||
|
await discardChanges(repoDir, ["file.txt"]);
|
||||||
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves files outside the given pathspecs untouched", async () => {
|
||||||
|
const { tempDir, repoDir } = initRepo();
|
||||||
|
try {
|
||||||
|
writeFileSync(join(repoDir, "file.txt"), "changed\n");
|
||||||
|
writeFileSync(join(repoDir, "other.txt"), "keep\n");
|
||||||
|
await discardChanges(repoDir, ["file.txt"]);
|
||||||
|
expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n");
|
||||||
|
expect(readFileSync(join(repoDir, "other.txt"), "utf8")).toBe("keep\n");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats requested filenames as literal pathspecs", async () => {
|
||||||
|
const { tempDir, repoDir } = initRepo();
|
||||||
|
try {
|
||||||
|
writeFileSync(join(repoDir, "foo[ab].txt"), "literal original\n");
|
||||||
|
writeFileSync(join(repoDir, "fooa.txt"), "sibling original\n");
|
||||||
|
execFileSync("git", ["add", "foo[ab].txt", "fooa.txt"], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add pathspec files"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
writeFileSync(join(repoDir, "foo[ab].txt"), "literal changed\n");
|
||||||
|
writeFileSync(join(repoDir, "fooa.txt"), "sibling changed\n");
|
||||||
|
|
||||||
|
await discardChanges(repoDir, ["foo[ab].txt"]);
|
||||||
|
|
||||||
|
expect(readTextFile(join(repoDir, "foo[ab].txt"))).toBe("literal original\n");
|
||||||
|
expect(readTextFile(join(repoDir, "fooa.txt"))).toBe("sibling changed\n");
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export type GitMutationRefreshReason =
|
|||||||
| "create-branch"
|
| "create-branch"
|
||||||
| "stash-push"
|
| "stash-push"
|
||||||
| "stash-pop"
|
| "stash-pop"
|
||||||
|
| "discard-changes"
|
||||||
| "create-worktree";
|
| "create-worktree";
|
||||||
|
|
||||||
|
const DISCARD_CHANGES_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
||||||
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
||||||
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;
|
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;
|
||||||
@@ -2084,6 +2086,7 @@ function buildPlaceholderParsedDiffFile(
|
|||||||
): ParsedDiffFile {
|
): ParsedDiffFile {
|
||||||
return {
|
return {
|
||||||
path: change.path,
|
path: change.path,
|
||||||
|
...(change.oldPath ? { oldPath: change.oldPath } : {}),
|
||||||
isNew: change.isNew,
|
isNew: change.isNew,
|
||||||
isDeleted: change.isDeleted,
|
isDeleted: change.isDeleted,
|
||||||
additions: options.stat?.additions ?? 0,
|
additions: options.stat?.additions ?? 0,
|
||||||
@@ -2918,6 +2921,7 @@ async function buildHighlightedTrackedDiffFile(input: {
|
|||||||
return {
|
return {
|
||||||
...highlightedFile,
|
...highlightedFile,
|
||||||
path: change.path,
|
path: change.path,
|
||||||
|
...(change.oldPath ? { oldPath: change.oldPath } : {}),
|
||||||
isNew: change.isNew,
|
isNew: change.isNew,
|
||||||
isDeleted: change.isDeleted,
|
isDeleted: change.isDeleted,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
@@ -2993,6 +2997,7 @@ async function appendStructuredTrackedDiffs(
|
|||||||
|
|
||||||
const file = {
|
const file = {
|
||||||
path: change.path,
|
path: change.path,
|
||||||
|
...(change.oldPath ? { oldPath: change.oldPath } : {}),
|
||||||
isNew: change.isNew,
|
isNew: change.isNew,
|
||||||
isDeleted: change.isDeleted,
|
isDeleted: change.isDeleted,
|
||||||
additions: stat?.additions ?? 0,
|
additions: stat?.additions ?? 0,
|
||||||
@@ -3321,6 +3326,71 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
|
|||||||
await commitChanges(cwd, { message, addAll: true });
|
await commitChanges(cwd, { message, addAll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function discardChanges(cwd: string, pathspecs: string[]): Promise<void> {
|
||||||
|
await requireGitRepo(cwd);
|
||||||
|
if (pathspecs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], {
|
||||||
|
cwd,
|
||||||
|
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Why: unborn HEAD has no commit for reset, so remove the paths directly from the index.
|
||||||
|
await runGitCommand(
|
||||||
|
["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// With everything unstaged, the remaining state is only worktree
|
||||||
|
// modifications/deletions (restore from the index) and untracked files
|
||||||
|
// (clean). Classify from porcelain so each path gets the command that
|
||||||
|
// actually applies to it.
|
||||||
|
const status = await runGitCommand(
|
||||||
|
["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs],
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const tracked: string[] = [];
|
||||||
|
const untracked: string[] = [];
|
||||||
|
const tokens = status.stdout.split("\0");
|
||||||
|
for (let index = 0; index < tokens.length; index++) {
|
||||||
|
const token = tokens[index];
|
||||||
|
if (token.length < 4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const state = token.slice(0, 2);
|
||||||
|
const filePath = token.slice(3);
|
||||||
|
if (state.startsWith("R") || state.startsWith("C")) {
|
||||||
|
// Rename/copy entries carry the source path as the next NUL token.
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if (state === "??") {
|
||||||
|
untracked.push(filePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tracked.push(filePath);
|
||||||
|
}
|
||||||
|
if (tracked.length > 0) {
|
||||||
|
await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], {
|
||||||
|
cwd,
|
||||||
|
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (untracked.length > 0) {
|
||||||
|
await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], {
|
||||||
|
cwd,
|
||||||
|
timeout: DISCARD_CHANGES_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface DetectMergeToBaseConflictInput {
|
interface DetectMergeToBaseConflictInput {
|
||||||
operationCwd: string;
|
operationCwd: string;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user