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:
Dzmitry Bachko
2026-07-29 21:43:45 -07:00
committed by GitHub
co-authored by Dzmitry Bachko OrcaWin
parent 38e9581758
commit 561e2d32cd
10 changed files with 780 additions and 15 deletions
@@ -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)
}
}
}
+67
View File
@@ -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')
+2 -4
View File
@@ -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)
}
)
+2 -3
View File
@@ -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)
+27 -8
View File
@@ -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'
})
})
})
+295
View File
@@ -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()
}
})