diff --git a/src/main/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts new file mode 100644 index 00000000000..49fd190e469 --- /dev/null +++ b/src/main/destination-serialized-local-rename.test.ts @@ -0,0 +1,248 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' + +const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({ + copyFileMock: vi.fn(), + handleMock: vi.fn(), + lstatMock: vi.fn(), + realpathMock: vi.fn(), + renameMock: vi.fn() +})) +const handlers = new Map Promise>() + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { + ...actual, + copyFile: copyFileMock, + lstat: lstatMock, + realpath: realpathMock, + rename: renameMock + } +}) + +import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename' +import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations' +import { RuntimeFileCommands } from './runtime/orca-runtime-files' + +const REPO_PATH = path.resolve('/workspace/repo') +const store = { + getRepos: () => [ + { id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], + getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) +} + +function enoent(): Error { + return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) +} + +function mockStats(dev: number, ino: number) { + return { dev, ino, isDirectory: () => false } +} + +function createRuntimeCommands(): RuntimeFileCommands { + return new RuntimeFileCommands({ + requireStore: () => store, + resolveRuntimeFileTarget: async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH } + }) + } as never) +} + +function createGate(): { promise: Promise; release: () => void } { + let release!: () => void + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +async function reachNextMacrotask(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('renameLocalPathSerializedByDestination', () => { + beforeEach(() => { + handlers.clear() + copyFileMock.mockReset() + handleMock.mockReset() + lstatMock.mockReset() + realpathMock.mockReset() + renameMock.mockReset() + handleMock.mockImplementation((channel: string, handler: never) => { + handlers.set(channel, handler) + }) + lstatMock.mockRejectedValue(enoent()) + realpathMock.mockImplementation(async (filePath: string) => filePath) + renameMock.mockResolvedValue(undefined) + registerFilesystemMutationHandlers(store as never) + }) + + it('serializes exact Unicode aliases across IPC and runtime entry points', async () => { + const ipcSource = path.join(REPO_PATH, 'ipc-source.md') + const runtimeSource = path.join(REPO_PATH, 'runtime-source.md') + const sharpSDestination = path.join(REPO_PATH, 'Straße.md') + const expandedDestination = path.join(REPO_PATH, 'STRASSE.md') + const firstRenameGate = createGate() + let destinationExists = false + lstatMock.mockImplementation(async (filePath: string) => { + if ( + destinationExists && + (filePath === sharpSDestination || filePath === expandedDestination) + ) { + return mockStats(1, 30) + } + if (filePath === ipcSource) { + return mockStats(1, 10) + } + if (filePath === runtimeSource) { + return mockStats(1, 20) + } + throw enoent() + }) + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + destinationExists = true + }) + + const ipcRename = handlers.get('fs:rename')!(null, { + oldPath: ipcSource, + newPath: sharpSDestination + }) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const runtimeRename = createRuntimeCommands().renameFileExplorerPath( + 'id:wt-1', + 'runtime-source.md', + 'STRASSE.md', + undefined, + undefined, + 'local' + ) + await reachNextMacrotask() + const callsWhileFirstBlocked = renameMock.mock.calls.length + firstRenameGate.release() + const results = await Promise.allSettled([ipcRename, runtimeRename]) + + expect(callsWhileFirstBlocked).toBe(1) + expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']) + expect(renameMock).toHaveBeenCalledTimes(1) + }) + + it('releases an alias queue after the leading rename errors', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + throw new Error('EACCES') + } + }) + const destination = path.join(REPO_PATH, 'destination.md') + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first.md'), + destination + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second.md'), + destination + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(firstRename).rejects.toThrow('EACCES') + await expect(secondRename).resolves.toBeUndefined() + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + it('does not serialize destinations in unrelated parent directories', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first', 'source.md'), + path.join(REPO_PATH, 'first', 'alpha.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second', 'source.md'), + path.join(REPO_PATH, 'second', 'beta.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2)) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + }) + + it('serializes symlink aliases of one destination parent', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + const firstParent = path.join(REPO_PATH, 'parent-link-a') + const secondParent = path.join(REPO_PATH, 'parent-link-b') + const canonicalParent = path.join(REPO_PATH, 'canonical-parent') + realpathMock.mockImplementation(async (filePath: string) => { + if (filePath === firstParent || filePath === secondParent) { + return canonicalParent + } + return filePath + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(firstParent, 'source.md'), + path.join(firstParent, 'alpha.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(secondParent, 'source.md'), + path.join(secondParent, 'beta.md') + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + expect(renameMock).toHaveBeenCalledTimes(2) + }) + + it('scopes conservative serialization to one destination parent', async () => { + const firstRenameGate = createGate() + renameMock.mockImplementation(async () => { + if (renameMock.mock.calls.length === 1) { + await firstRenameGate.promise + } + }) + + const firstRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'first.md'), + path.join(REPO_PATH, 'dotless-ı.md') + ) + await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1)) + const secondRename = renameLocalPathSerializedByDestination( + path.join(REPO_PATH, 'second.md'), + path.join(REPO_PATH, 'dotless-I.md') + ) + await reachNextMacrotask() + expect(renameMock).toHaveBeenCalledTimes(1) + + firstRenameGate.release() + await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined]) + expect(renameMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/destination-serialized-local-rename.ts b/src/main/destination-serialized-local-rename.ts new file mode 100644 index 00000000000..74e313c8e98 --- /dev/null +++ b/src/main/destination-serialized-local-rename.ts @@ -0,0 +1,50 @@ +import { realpath, rename } from 'node:fs/promises' +import { dirname, normalize } from 'node:path' +import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' + +// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation. +const pendingRenamesByParent = new Map>() + +function isENOENT(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ) +} + +async function destinationParentKey(filePath: string): Promise { + const parentPath = dirname(filePath) + try { + return normalize(await realpath(parentPath)) + } catch (error) { + // A missing parent will fail the rename; retain its path so that failure + // does not prevent unrelated destinations from progressing. + if (isENOENT(error)) { + return normalize(parentPath) + } + throw error + } +} + +export async function renameLocalPathSerializedByDestination( + oldPath: string, + newPath: string +): Promise { + const key = await destinationParentKey(newPath) + const previous = pendingRenamesByParent.get(key) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + pendingRenamesByParent.set(key, current) + + await previous + try { + await assertNoClobberRenameDestinationAvailable(oldPath, newPath) + await rename(oldPath, newPath) + } finally { + release() + if (pendingRenamesByParent.get(key) === current) { + pendingRenamesByParent.delete(key) + } + } +} diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index dcaeb74b624..97f62de565c 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) + it('serializes concurrent local renames targeting the same destination', async () => { + const firstPath = path.resolve('/workspace/repo/first.ts') + const secondPath = path.resolve('/workspace/repo/second.ts') + const destinationPath = path.resolve('/workspace/repo/destination.ts') + let destinationExists = false + lstatMock.mockImplementation(async (filePath: string) => { + if (filePath === destinationPath && destinationExists) { + return mockStats(1, 30) + } + if (filePath === firstPath) { + return mockStats(1, 10) + } + if (filePath === secondPath) { + return mockStats(1, 20) + } + throw enoent() + }) + renameMock.mockImplementation(async () => { + destinationExists = true + }) + + const results = await Promise.allSettled([ + handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }), + handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath }) + ]) + + expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected']) + expect(renameMock).toHaveBeenCalledTimes(1) + }) + it('rejects rename when destination already exists as a true collision', async () => { const oldPath = path.resolve('/workspace/repo/old.ts') const resolvedNewPath = path.resolve('/workspace/repo/new.ts') @@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => { it('allows case-only rename when destination is the same entry in the same parent', async () => { const oldPath = path.resolve('/workspace/repo/README.md') const newPath = path.resolve('/workspace/repo/readme.md') + const canonicalPath = path.resolve('/workspace/repo/README.md') lstatMock.mockImplementation(async (p: string) => { if (p === oldPath || p === newPath) { return mockStats(2, 20) } throw enoent() }) + mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath }) + + await handlers.get('fs:rename')!(null, { oldPath, newPath }) + + expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) + }) + + it('allows a native Unicode alias rename for the same directory entry', async () => { + const oldPath = path.resolve('/workspace/repo/Straße.md') + const newPath = path.resolve('/workspace/repo/STRASSE.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(2, 21) + } + throw enoent() + }) + mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath }) await handlers.get('fs:rename')!(null, { oldPath, newPath }) @@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).not.toHaveBeenCalled() }) + it('fails closed when same-entry realpath encounters a permission error', async () => { + const oldPath = path.resolve('/workspace/repo/README.md') + const newPath = path.resolve('/workspace/repo/readme.md') + lstatMock.mockImplementation(async (p: string) => { + if (p === oldPath || p === newPath) { + return mockStats(3, 30) + } + throw enoent() + }) + realpathMock.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }) + ) + + await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({ + code: 'EACCES' + }) + expect(renameMock).not.toHaveBeenCalled() + }) + it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => { const oldPath = path.resolve('/workspace/repo/src/README.md') const newPath = path.resolve('/workspace/repo/docs/readme.md') diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e89ff761b63..27c29460bd6 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -9,7 +9,6 @@ import { open, readdir, realpath, - rename, rm, unlink, writeFile @@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' -import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' /** * Re-throw filesystem errors with user-friendly messages. @@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { // accidentally write into a symlinked destination name. const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) + await renameLocalPathSerializedByDestination(oldPath, newPath) } ) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index adbae1adf7e..1ea6769ff4c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -80,7 +80,6 @@ import { isWatcherProcessFailure, WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' -import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision' import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' import { rankRuntimeMobileFilePaths, @@ -89,6 +88,7 @@ import { import { beginWatcherInstall } from '../ipc/watcher-removal-gate' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { toSshExecutionHostId } from '../../shared/execution-host' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' const MOBILE_FILE_LIST_LIMIT = 5000 const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 @@ -1728,8 +1728,7 @@ export class RuntimeFileCommands { const store = this.host.requireStore() const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await assertNoClobberRenameDestinationAvailable(oldPath, newPath) - await rename(oldPath, newPath) + await renameLocalPathSerializedByDestination(oldPath, newPath) return { ok: true } } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index 771dbed72b2..0a6c2606b01 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { Worktree } from '../../../../shared/types' import { useAppStore } from '@/store' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { captureFileExplorerOperationGuard, getFileExplorerOperationOwner @@ -26,6 +27,15 @@ afterEach(() => { }) describe('file explorer operation generations', () => { + it('routes floating workspace file mutations to the local host', () => { + const owner = getFileExplorerOperationOwner(FLOATING_TERMINAL_WORKTREE_ID) + const guard = captureFileExplorerOperationGuard(FLOATING_TERMINAL_WORKTREE_ID, owner) + + expect(owner).toEqual({ kind: 'local' }) + expect(guard.route.expectedExecutionHostId).toBe('local') + expect(() => guard.assertCurrent()).not.toThrow() + }) + it('invalidates a nested SSH mutation when that target reconnects', () => { useAppStore.setState({ repos: [], diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 1d301916c4d..cb94f03c978 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -14,6 +14,7 @@ import { type WorktreeOperationRoute } from '@/lib/worktree-operation-route' import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type FileExplorerOperationRoute = { settings: { activeRuntimeEnvironmentId: string | null } @@ -43,6 +44,9 @@ export function getFileExplorerOperationOwnerFromState( state: FileExplorerOwnerState, worktreeId: string | null | undefined ): FileExplorerOperationOwner { + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return { kind: 'local' } + } const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && parsedWorkspace?.type !== 'folder') { const route = resolveWorktreeOperationRoute(state, worktreeId) diff --git a/src/shared/filesystem-rename-collision.ts b/src/shared/filesystem-rename-collision.ts index e2674d0e618..6d79cf6f0d7 100644 --- a/src/shared/filesystem-rename-collision.ts +++ b/src/shared/filesystem-rename-collision.ts @@ -1,4 +1,4 @@ -import { lstat } from 'node:fs/promises' +import { lstat, realpath } from 'node:fs/promises' import type { Stats } from 'node:fs' import { basename, dirname } from 'node:path' @@ -18,14 +18,33 @@ function hasSameFilesystemIdentity(oldStat: Stats, newStat: Stats): boolean { return oldStat.dev === newStat.dev && oldStat.ino === newStat.ino } -function isCaseOnlySameParentRename(oldPath: string, newPath: string): boolean { +async function isSameDirectoryEntryRename( + oldPath: string, + newPath: string, + oldStat: Stats, + newStat: Stats +): Promise { const oldBasename = basename(oldPath) const newBasename = basename(newPath) - return ( - dirname(oldPath) === dirname(newPath) && - oldBasename !== newBasename && - caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename) - ) + if (!hasSameFilesystemIdentity(oldStat, newStat) || dirname(oldPath) !== dirname(newPath)) { + return false + } + if (oldPath === newPath) { + return true + } + try { + const [oldRealPath, newRealPath] = await Promise.all([realpath(oldPath), realpath(newPath)]) + return oldRealPath === newRealPath + } catch (error) { + // Preserve case-only renames for dangling symlinks, which realpath cannot resolve. + if (!isENOENT(error)) { + throw error + } + return ( + oldBasename !== newBasename && + caseFoldFileExplorerBasename(oldBasename) === caseFoldFileExplorerBasename(newBasename) + ) + } } export async function assertNoClobberRenameDestinationAvailable( @@ -43,7 +62,7 @@ export async function assertNoClobberRenameDestinationAvailable( } const oldStat = await lstat(oldPath) - if (hasSameFilesystemIdentity(oldStat, newStat) && isCaseOnlySameParentRename(oldPath, newPath)) { + if (await isSameDirectoryEntryRename(oldPath, newPath, oldStat, newStat)) { return } diff --git a/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts b/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts new file mode 100644 index 00000000000..1ba01d0a59c --- /dev/null +++ b/tests/e2e/floating-tab-rename-filesystem-alias.spec.ts @@ -0,0 +1,75 @@ +import path from 'node:path' +import { expect, test } from './helpers/orca-app' + +test.describe('floating Markdown filesystem aliases', () => { + test.skip(process.platform !== 'darwin', 'Requires native APFS alias behavior') + + test('renames one APFS entry through its Unicode alias', async ({ orcaPage }) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const originalPath = path.join(directory, `floating-alias-${suffix}-straße.md`) + const renamedPath = path.join(directory, `floating-alias-${suffix}-STRASSE.MD`) + const renamedName = path.basename(renamedPath) + + const result = await orcaPage.evaluate( + async ({ directory, originalPath, renamedPath, renamedName }) => { + await window.api.fs.createFile({ filePath: originalPath }) + await window.api.fs.writeFile({ filePath: originalPath, content: 'same entry\n' }) + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: originalPath, newPath: renamedPath }) + ]) + return { + status: settled[0].status, + reason: settled[0].status === 'rejected' ? String(settled[0].reason) : null, + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + renamedEntryExists: (await window.api.fs.readDir({ dirPath: directory })).some( + ({ name }) => name === renamedName + ) + } + }, + { directory, originalPath, renamedPath, renamedName } + ) + + expect(result).toEqual({ + status: 'fulfilled', + reason: null, + content: 'same entry\n', + renamedEntryExists: true + }) + }) + + test('keeps dotless and ASCII I destinations distinct through IPC', async ({ orcaPage }) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-dotless-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-dotless-second-${suffix}.md`) + const dotlessDestination = path.join(directory, `floating-destination-${suffix}-ı.md`) + const asciiDestination = path.join(directory, `floating-destination-${suffix}-I.md`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, dotlessDestination, asciiDestination }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'dotless\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'ascii\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: dotlessDestination }), + window.api.fs.rename({ oldPath: secondPath, newPath: asciiDestination }) + ]) + return { + statuses: settled.map(({ status }) => status), + dotlessContent: (await window.api.fs.readFile({ filePath: dotlessDestination })).content, + asciiContent: (await window.api.fs.readFile({ filePath: asciiDestination })).content + } + }, + { firstPath, secondPath, dotlessDestination, asciiDestination } + ) + + expect(result).toEqual({ + statuses: ['fulfilled', 'fulfilled'], + dotlessContent: 'dotless\n', + asciiContent: 'ascii\n' + }) + }) +}) diff --git a/tests/e2e/floating-tab-rename.spec.ts b/tests/e2e/floating-tab-rename.spec.ts new file mode 100644 index 00000000000..7f9dca4b7b9 --- /dev/null +++ b/tests/e2e/floating-tab-rename.spec.ts @@ -0,0 +1,295 @@ +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { createRestartSession } from './helpers/orca-restart' + +// Why: mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts. +// E2E specs avoid importing renderer/shared modules into the Playwright runner. +const FLOATING_WORKTREE_ID = 'global-floating-terminal' +const OPEN_PANEL_SELECTOR = '[data-floating-terminal-panel][aria-hidden="false"]' +const PANEL_SELECTOR = '[data-floating-terminal-panel]' + +async function seedFloatingMarkdownFile(page: Page): Promise<{ + originalName: string + originalPath: string + intermediateName: string + intermediatePath: string + renamedName: string + renamedPath: string + tabId: string +}> { + const directory = await page.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const originalName = `floating-rename-${suffix}.md` + const intermediateName = `floating-entered-${suffix}.md` + const renamedName = `floating-renamed-${suffix}.md` + const originalPath = path.join(directory, originalName) + const intermediatePath = path.join(directory, intermediateName) + const renamedPath = path.join(directory, renamedName) + const tabId = await page.evaluate( + async ({ filePath, originalName, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + + await store.getState().updateSettings({ floatingTerminalEnabled: true }) + await window.api.fs.createFile({ filePath }) + await window.api.fs.writeFile({ filePath, content: '# Floating rename\n' }) + store.getState().openFile( + { + filePath, + relativePath: originalName, + worktreeId, + language: 'markdown', + mode: 'edit', + runtimeEnvironmentId: null + }, + { preview: false, suppressActiveRuntimeFallback: true } + ) + + const state = store.getState() + const file = state.openFiles.find( + (candidate) => candidate.filePath === filePath && candidate.worktreeId === worktreeId + ) + const tab = state.unifiedTabsByWorktree[worktreeId]?.find( + (candidate) => candidate.contentType === 'editor' && candidate.entityId === file?.id + ) + if (!file || !tab) { + throw new Error('Floating Markdown tab unavailable') + } + return tab.id + }, + { filePath: originalPath, originalName, worktreeId: FLOATING_WORKTREE_ID } + ) + return { + originalName, + originalPath, + intermediateName, + intermediatePath, + renamedName, + renamedPath, + tabId + } +} + +async function openFloatingPanel(page: Page): Promise { + await page.waitForFunction( + (selector) => Boolean(document.querySelector(selector)), + PANEL_SELECTOR, + { timeout: 30_000 } + ) + await page.evaluate(() => window.dispatchEvent(new Event('orca-toggle-floating-terminal'))) + await expect(page.locator(OPEN_PANEL_SELECTOR)).toBeVisible() +} + +test('concurrent floating Markdown renames do not clobber the destination', async ({ + orcaPage +}) => { + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-second-${suffix}.md`) + const destinationPath = path.join(directory, `floating-destination-${suffix}.md`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, destinationPath }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: destinationPath }), + window.api.fs.rename({ oldPath: secondPath, newPath: destinationPath }) + ]) + const firstExists = await window.api.fs.pathExists({ filePath: firstPath }) + const secondExists = await window.api.fs.pathExists({ filePath: secondPath }) + return { + statuses: settled.map(({ status }) => status).sort(), + rejectionMessages: settled.flatMap((outcome) => + outcome.status === 'rejected' ? [String(outcome.reason)] : [] + ), + destinationContent: (await window.api.fs.readFile({ filePath: destinationPath })).content, + firstExists, + secondExists, + firstContent: firstExists + ? (await window.api.fs.readFile({ filePath: firstPath })).content + : null, + secondContent: secondExists + ? (await window.api.fs.readFile({ filePath: secondPath })).content + : null + } + }, + { firstPath, secondPath, destinationPath } + ) + + expect(result.statuses).toEqual(['fulfilled', 'rejected']) + expect(result.rejectionMessages[0]).toContain('already exists') + expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1) + expect( + [result.destinationContent, result.firstContent ?? result.secondContent].toSorted() + ).toEqual(['first\n', 'second\n']) +}) + +test('Electron serializes native Unicode rename aliases', async ({ orcaPage }) => { + test.skip(process.platform !== 'darwin', 'Requires native Unicode aliasing') + const directory = await orcaPage.evaluate(() => window.api.app.getFloatingMarkdownDirectory()) + const suffix = Date.now().toString(36) + const firstPath = path.join(directory, `floating-unicode-first-${suffix}.md`) + const secondPath = path.join(directory, `floating-unicode-second-${suffix}.md`) + const sharpSDestination = path.join(directory, `floating-destination-${suffix}-straße.md`) + const expandedDestination = path.join(directory, `floating-destination-${suffix}-STRASSE.MD`) + + const result = await orcaPage.evaluate( + async ({ firstPath, secondPath, sharpSDestination, expandedDestination }) => { + await window.api.fs.createFile({ filePath: firstPath }) + await window.api.fs.createFile({ filePath: secondPath }) + await window.api.fs.writeFile({ filePath: firstPath, content: 'first\n' }) + await window.api.fs.writeFile({ filePath: secondPath, content: 'second\n' }) + + const settled = await Promise.allSettled([ + window.api.fs.rename({ oldPath: firstPath, newPath: sharpSDestination }), + window.api.fs.rename({ oldPath: secondPath, newPath: expandedDestination }) + ]) + const firstExists = await window.api.fs.pathExists({ filePath: firstPath }) + const secondExists = await window.api.fs.pathExists({ filePath: secondPath }) + return { + statuses: settled.map(({ status }) => status).sort(), + destinationContent: (await window.api.fs.readFile({ filePath: sharpSDestination })).content, + firstExists, + secondExists, + remainingContent: firstExists + ? (await window.api.fs.readFile({ filePath: firstPath })).content + : (await window.api.fs.readFile({ filePath: secondPath })).content + } + }, + { firstPath, secondPath, sharpSDestination, expandedDestination } + ) + + expect(result.statuses).toEqual(['fulfilled', 'rejected']) + expect(Number(result.firstExists) + Number(result.secondExists)).toBe(1) + expect([result.destinationContent, result.remainingContent].toSorted()).toEqual([ + 'first\n', + 'second\n' + ]) +}) + +test('floating workspace Markdown renames survive an app restart', async (// oxlint-disable-next-line no-empty-pattern -- This persistence test owns both Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + await waitForSessionReady(first.page) + const seeded = await seedFloatingMarkdownFile(first.page) + await openFloatingPanel(first.page) + + const panel = first.page.locator(OPEN_PANEL_SELECTOR) + const tab = panel.locator(`[data-tab-id="${seeded.tabId}"]`) + await expect(tab).toContainText(seeded.originalName) + await tab.click({ button: 'right' }) + const renameMenuItem = first.page.getByRole('menuitem').filter({ hasText: 'Rename' }).first() + await expect(renameMenuItem).toBeVisible() + await renameMenuItem.click() + + const enterInput = panel.getByRole('textbox', { + name: `Rename file ${seeded.originalName}`, + exact: true + }) + await enterInput.fill(seeded.intermediateName) + await enterInput.press('Enter') + await expect(tab).toContainText(seeded.intermediateName) + + await tab.getByText(seeded.intermediateName, { exact: true }).dispatchEvent('dblclick') + const blurInput = panel.getByRole('textbox', { + name: `Rename file ${seeded.intermediateName}`, + exact: true + }) + await blurInput.fill(seeded.renamedName) + await panel.getByRole('radio', { name: 'Rich Editor' }).click() + await expect(tab).toContainText(seeded.renamedName) + await expect + .poll(() => + first.page.evaluate( + async ({ renamedPath, originalPath, intermediatePath }) => ({ + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath }) + }), + { + renamedPath: seeded.renamedPath, + originalPath: seeded.originalPath, + intermediatePath: seeded.intermediatePath + } + ) + ) + .toEqual({ + content: '# Floating rename\n', + originalExists: false, + intermediateExists: false + }) + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await openFloatingPanel(second.page) + + const restoredPanel = second.page.locator(OPEN_PANEL_SELECTOR) + const restoredTab = restoredPanel.locator('[data-tab-id]').filter({ + hasText: seeded.renamedName + }) + await expect(restoredTab).toContainText(seeded.renamedName) + await expect + .poll(() => + second.page.evaluate( + async ({ renamedPath, originalPath, intermediatePath, worktreeId }) => { + const file = window.__store + ?.getState() + .openFiles.find( + (candidate) => + candidate.worktreeId === worktreeId && candidate.filePath === renamedPath + ) + return { + restoredPath: file?.filePath ?? null, + content: (await window.api.fs.readFile({ filePath: renamedPath })).content, + originalExists: await window.api.fs.pathExists({ filePath: originalPath }), + intermediateExists: await window.api.fs.pathExists({ filePath: intermediatePath }) + } + }, + { + renamedPath: seeded.renamedPath, + originalPath: seeded.originalPath, + intermediatePath: seeded.intermediatePath, + worktreeId: FLOATING_WORKTREE_ID + } + ) + ) + .toEqual({ + restoredPath: seeded.renamedPath, + content: '# Floating rename\n', + originalExists: false, + intermediateExists: false + }) + } finally { + for (const app of [secondApp, firstApp]) { + if (!app) { + continue + } + try { + await session.close(app) + } catch { + // best-effort cleanup + } + } + await session.dispose() + } +})