mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(floating-workspace): persist Markdown tab renames (#11398)
* fix(floating-workspace): route markdown renames locally * test(floating-workspace): strengthen rename regression * test(floating-workspace): verify rename restart persistence * fix(filesystem): serialize local rename destinations * fix(filesystem): serialize Unicode rename aliases * fix(filesystem): align rename locks with native aliases * fix(filesystem): canonicalize rename parent locks --------- Co-authored-by: Dzmitry Bachko <dbachko@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
co-authored by
Dzmitry Bachko
OrcaWin
parent
38e9581758
commit
561e2d32cd
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown>>()
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('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<void>; release: () => void } {
|
||||
let release!: () => void
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { promise, release }
|
||||
}
|
||||
|
||||
async function reachNextMacrotask(): Promise<void> {
|
||||
await new Promise<void>((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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, Promise<void>>()
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
async function destinationParentKey(filePath: string): Promise<string> {
|
||||
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<void> {
|
||||
const key = await destinationParentKey(newPath)
|
||||
const previous = pendingRenamesByParent.get(key) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user