From 337e81a9c6d4551dbcede5930c21408776d06b1b Mon Sep 17 00:00:00 2001 From: Elowen <73627805+Mar-garet@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:56:43 +0800 Subject: [PATCH] fix(files): preserve relative symlinks when duplicating entries (#6771) * fix(files): preserve relative symlinks when duplicating entries * test(files): verify duplicated symlink targets through daemon RPC --- .../server/daemon-e2e/filesystem.e2e.test.ts | 88 ++++++++++++++++++- .../src/server/file-explorer/service.ts | 1 + 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index c0e14d72b8..5a6187db71 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -1,5 +1,13 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + rmSync, + mkdirSync, + symlinkSync, + readdirSync, + readlinkSync, +} from "fs"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -387,6 +395,84 @@ describe("file explorer", () => { } }); + test.skipIf(process.platform === "win32")( + "duplicating a folder keeps relative symlinks inside the copy", + async () => { + const cwd = tmpCwd(); + try { + mkdirSync(path.join(cwd, "template")); + writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n"); + symlinkSync("data.txt", path.join(cwd, "template", "alias.txt")); + + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" }); + expect(duplicated).toMatchObject({ + duplicatedPath: "template copy", + success: true, + error: null, + }); + expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt"); + + const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt"); + const targetWrite = await ctx.client.writeFile({ + cwd, + path: "template copy/data.txt", + content: "copy payload\n", + expectedModifiedAt: copiedTarget.modifiedAt, + expectedRevision: copiedTarget.revision, + }); + expect(targetWrite.status).toBe("written"); + const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt"); + expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n"); + + const aliasWrite = await ctx.client.writeFile({ + cwd, + path: "template copy/alias.txt", + content: "edited through copied alias\n", + expectedModifiedAt: copiedAlias.modifiedAt, + expectedRevision: copiedAlias.revision, + }); + expect(aliasWrite.status).toBe("written"); + const original = await ctx.client.readFile(cwd, "template/data.txt"); + expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n"); + const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt"); + expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n"); + + const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" }); + expect(deleted).toMatchObject({ success: true, error: null }); + const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt"); + expect(new TextDecoder().decode(survivingAlias.bytes)).toBe( + "edited through copied alias\n", + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "duplicating a file symlink preserves an explicit absolute target", + async () => { + const cwd = tmpCwd(); + const targetPath = path.join(cwd, "data.txt"); + try { + writeFileSync(targetPath, "absolute target payload\n"); + symlinkSync(targetPath, path.join(cwd, "alias.txt")); + + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" }); + expect(duplicated).toMatchObject({ + duplicatedPath: "alias copy.txt", + success: true, + error: null, + }); + expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath); + const copy = await ctx.client.readFile(cwd, "alias copy.txt"); + expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + test("lists directory contents", async () => { const cwd = tmpCwd(); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index b371a5e4af..d99c97616b 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({ errorOnExist: true, force: false, preserveTimestamps: true, + verbatimSymlinks: true, }); return { status: "ok", path: normalizeRelativePath({ root, targetPath }) }; } catch (error) {