mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(runtime): a phone reads and saves Markdown tabs on a headless server (#31101)
* fix(runtime): a phone reads and saves Markdown tabs on a headless server With no window to relay through, markdown.readTab and markdown.saveTab answered renderer_unavailable. Main now reads the tab's file (or its saved draft) from disk and saves with a version compare plus the disk write precondition, in the shapes the window's editor relay returns. With a window attached the relay is unchanged. * Headless Markdown save: keep the draft's disk baseline, honour read-only tabs, re-find the record to clear * Use the disk baseline only for a draft * ci: run the phone Markdown spec's orcad case in the orca serve mode-switch job * Headless Markdown: answer from disk only; a window's saved draft stays its view state Clearing a saved draft wrote workspace layout from main, which no layout command carries. Main now never shows or edits a draft; the desktop's restore check reports a disk change. * Phone Markdown: one path through main and the disk, window or not A paired server's workspace is read and saved on that server with files.read / files.writeIfUnchanged, so it answers from its own disk and keeps the precondition. The window relay (ui:mobileMarkdownRequest and the renderer bridge) is removed. * e2e: a phone saves a Markdown tab the desktop holds unsaved edits in The phone reads the disk with the tab marked dirty; its save lands on disk, and the desktop keeps its draft and shows the changed-on-disk banner. * Group the Markdown disk host's imports * Drop an unneeded store cast from the dirty-readers test * Phone Markdown: refuse a save on a server too old to check the disk; cover the untitled tab A server before #30605 has no files.writeIfUnchanged, so the save is refused with the existing update-required message rather than written unchecked. * Phone Markdown: refuse a file an SSH host holds outside the workspace; pin the untitled answer * Phone Markdown: untitled tabs stay read-only; older servers save like the desktop editor; a closed-source preview still reads - The desktop lists an untitled document's tab with isUntitled (optional, additive); main answers it read-only with unsupported_untitled and refuses its save before touching the disk. - A server without the files.write.disk-precondition capability gets the desktop editor's guarded save: compare from here, then a plain files.write, or a conflict. Every call of a save is pinned to the pairing it probed. - A preview whose source tab was closed has no saved record; it reads its own file, read-only. * e2e: read the untitled tab's answer without a cast * Phone Markdown: pin a save's pairing before its first read A save captures the server's pairing before it reads, and every call of the save, the read included, is pinned to it, so a re-pair between the read and the write is refused. * Test: with no window, a phone saves an existing untitled.md and never creates a deleted one --------- Co-authored-by: E2E Test <e2e@test.local>
This commit is contained in:
@@ -490,6 +490,15 @@ jobs:
|
||||
ORCA_E2E_ORCAD_SERVE: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1
|
||||
# Only this job builds out/orcad, so the spec's orcad case runs here; the e2e shards run its Electron case.
|
||||
- name: Phone Markdown on headless orcad serve
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
(inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/headless-serve-mobile-markdown.spec.ts'))
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1'
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/headless-serve-mobile-markdown.spec.ts --grep "headless orcad server" --project=electron-headless --workers=1
|
||||
# The layout oracle's headless scenarios run on both serve hosts, so they need this job's orcad.
|
||||
- name: Layout oracle on headless serve (orcad and Electron)
|
||||
if: >-
|
||||
|
||||
@@ -173,15 +173,29 @@ export class RuntimeFileReads {
|
||||
}
|
||||
|
||||
async function readLocalMobileFile(filePath: string, store: RuntimeStore): Promise<string> {
|
||||
// Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views).
|
||||
const { bytes } = await readLocalRuntimeFilePrefix(
|
||||
filePath,
|
||||
store,
|
||||
MOBILE_FILE_READ_MAX_BYTES + 1
|
||||
)
|
||||
return bytes.toString('utf8')
|
||||
}
|
||||
|
||||
/** Up to `maxBytes` from the start of an authorized local file, and the file's full size. */
|
||||
export async function readLocalRuntimeFilePrefix(
|
||||
filePath: string,
|
||||
store: RuntimeStore,
|
||||
maxBytes: number
|
||||
): Promise<{ bytes: Buffer; size: number }> {
|
||||
const authorizedPath = await resolveRuntimeAuthorizedPath(filePath, store)
|
||||
const fileStat = await stat(authorizedPath)
|
||||
// Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views).
|
||||
const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1)
|
||||
const readLimit = Math.min(fileStat.size, maxBytes)
|
||||
const handle = await open(authorizedPath, 'r')
|
||||
try {
|
||||
const buffer = Buffer.alloc(readLimit)
|
||||
const { bytesRead } = await handle.read(buffer, 0, readLimit, 0)
|
||||
return buffer.subarray(0, bytesRead).toString('utf8')
|
||||
return { bytes: buffer.subarray(0, bytesRead), size: fileStat.size }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { isBinaryBuffer } from '../../../shared/binary-buffer'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
import {
|
||||
hashMarkdownContent,
|
||||
isMarkdownContentByteLengthOverLimit,
|
||||
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
|
||||
MOBILE_MARKDOWN_READ_MAX_BYTES,
|
||||
truncateMobileMarkdownRead,
|
||||
type RuntimeMarkdownReadTabResult,
|
||||
type RuntimeMarkdownSaveTabResult
|
||||
} from '../../../shared/mobile-markdown-document'
|
||||
import type {
|
||||
PersistedOpenFile,
|
||||
WorkspaceSessionState
|
||||
} from '../../../shared/workspace-session-state-types'
|
||||
import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue'
|
||||
import {
|
||||
openLocalRegularFile,
|
||||
readLocalFilePrefix
|
||||
} from '../../ipc/filesystem/local-regular-file-read'
|
||||
import { buildHeadlessMobileSessionEditorTabs } from '../mobile-session-editor-projection'
|
||||
import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root'
|
||||
import { requireRuntimeFileProvider } from './file-target'
|
||||
import type { RuntimeFileCommandHost } from './file-command-host'
|
||||
|
||||
type Host = Pick<RuntimeFileCommandHost, 'requireStore' | 'resolveRuntimeFileTarget'> & {
|
||||
getSession(worktreeId: string): WorkspaceSessionState | null | undefined
|
||||
updateSession(
|
||||
worktreeId: string,
|
||||
update: (session: WorkspaceSessionState) => WorkspaceSessionState
|
||||
): void
|
||||
}
|
||||
|
||||
export class HeadlessMarkdownDocuments {
|
||||
private readonly saves = new Map<string, Promise<void>>()
|
||||
|
||||
constructor(private readonly host: Host) {}
|
||||
|
||||
private target(worktreeId: string, tabId: string): PersistedOpenFile {
|
||||
const session = this.host.getSession(worktreeId)
|
||||
const tab =
|
||||
session &&
|
||||
buildHeadlessMobileSessionEditorTabs(worktreeId, session).find(
|
||||
(candidate) => candidate.id === tabId && candidate.type === 'markdown'
|
||||
)
|
||||
const file =
|
||||
tab &&
|
||||
session.openFilesByWorktree?.[worktreeId]?.find(
|
||||
(candidate) =>
|
||||
candidate.filePath === tab.filePath &&
|
||||
!candidate.runtimeEnvironmentId &&
|
||||
!candidate.externalSshTargetId
|
||||
)
|
||||
if (!file) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private async disk(worktreeId: string, file: PersistedOpenFile) {
|
||||
const target = await this.host.resolveRuntimeFileTarget(worktreeId)
|
||||
const provider = requireRuntimeFileProvider(target, this.host)
|
||||
const path = await resolveRuntimeAuthorizedPath(file.filePath, this.host.requireStore())
|
||||
const { handle, stats } = await openLocalRegularFile(path)
|
||||
try {
|
||||
const bytes = await readLocalFilePrefix(
|
||||
handle,
|
||||
Math.min(stats.size, MOBILE_MARKDOWN_READ_MAX_BYTES + 4)
|
||||
)
|
||||
if (isBinaryBuffer(bytes)) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return { content: bytes.toString('utf8'), byteLength: stats.size, provider }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async read(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult> {
|
||||
const file = this.target(worktreeId, tabId)
|
||||
const disk = file.dirtyDraftContent === undefined ? await this.disk(worktreeId, file) : null
|
||||
const content = file.dirtyDraftContent ?? disk?.content ?? ''
|
||||
const preview = truncateMobileMarkdownRead(content)
|
||||
const tooLarge = isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)
|
||||
const readOnlyReason = file.readOnly
|
||||
? 'unsupported_tab'
|
||||
: tooLarge
|
||||
? 'file_too_large'
|
||||
: undefined
|
||||
return {
|
||||
tabId,
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
content: preview.content,
|
||||
version: hashMarkdownContent(preview.content),
|
||||
isDirty: file.dirtyDraftContent !== undefined,
|
||||
source: file.dirtyDraftContent === undefined ? 'file' : 'draft',
|
||||
editable: !readOnlyReason,
|
||||
...(readOnlyReason ? { readOnlyReason } : {}),
|
||||
...(preview.truncated
|
||||
? { truncated: true, byteLength: disk?.byteLength ?? preview.byteLength }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
async save(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
baseVersion: string,
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult> {
|
||||
const file = this.target(worktreeId, tabId)
|
||||
const key = `${worktreeId}:${file.filePath}`
|
||||
return runKeyedSerializedOperation(this.saves, key, async () => {
|
||||
const current = this.target(worktreeId, tabId)
|
||||
if (current.readOnly) {
|
||||
throw new Error('unsupported_tab')
|
||||
}
|
||||
if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const disk = await this.disk(worktreeId, current)
|
||||
const baseline = current.dirtyDraftContent ?? disk.content
|
||||
if (isMarkdownContentByteLengthOverLimit(baseline, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
if (hashMarkdownContent(baseline) !== baseVersion && baseline !== content) {
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const expectedSignature =
|
||||
current.dirtyDraftContent !== undefined
|
||||
? current.lastKnownDiskSignature
|
||||
: getDiskBaselineSignature(disk.content)
|
||||
// A restored draft without its disk baseline cannot safely replace external edits.
|
||||
if (!expectedSignature) {
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const written = await disk.provider.writeFileWithDiskPrecondition(current.filePath, content, {
|
||||
expectedSignature
|
||||
})
|
||||
if (!written.ok) {
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const verified = await this.disk(worktreeId, current)
|
||||
if (verified.content !== content) {
|
||||
throw new Error('save_verification_failed')
|
||||
}
|
||||
this.host.updateSession(worktreeId, (session) => ({
|
||||
...session,
|
||||
openFilesByWorktree: {
|
||||
...session.openFilesByWorktree,
|
||||
[worktreeId]: (session.openFilesByWorktree?.[worktreeId] ?? []).map((candidate) => {
|
||||
if (
|
||||
candidate.filePath !== current.filePath ||
|
||||
candidate.runtimeEnvironmentId ||
|
||||
candidate.externalSshTargetId ||
|
||||
candidate.dirtyDraftContent !== current.dirtyDraftContent
|
||||
) {
|
||||
return candidate
|
||||
}
|
||||
const { dirtyDraftContent: _draft, ...saved } = candidate
|
||||
return { ...saved, lastKnownDiskSignature: getDiskBaselineSignature(content) }
|
||||
})
|
||||
}
|
||||
}))
|
||||
return { tabId, version: hashMarkdownContent(content), isDirty: false as const, content }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
hashMarkdownContent,
|
||||
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
|
||||
MOBILE_MARKDOWN_READ_MAX_BYTES
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { getDiskBaselineSignature } from '../../shared/disk-baseline-signature'
|
||||
import { compareDiskWritePrecondition } from '../../shared/file-write-disk-precondition'
|
||||
import type {
|
||||
PersistedOpenFile,
|
||||
WorkspaceSessionState
|
||||
} from '../../shared/workspace-session-state-types'
|
||||
import {
|
||||
readMarkdownTabFromDisk,
|
||||
saveMarkdownTabToDisk,
|
||||
type ListedMarkdownTab,
|
||||
type MarkdownTabDiskHost
|
||||
} from './markdown-tab-disk-documents'
|
||||
|
||||
const WORKTREE = 'repo::/srv/repo'
|
||||
const FILE_PATH = '/srv/repo/README.md'
|
||||
const TAB: ListedMarkdownTab = {
|
||||
id: FILE_PATH,
|
||||
mode: 'edit',
|
||||
filePath: FILE_PATH,
|
||||
sourceFilePath: FILE_PATH,
|
||||
sourceRelativePath: 'README.md',
|
||||
isDirty: false
|
||||
}
|
||||
|
||||
function makeHost(disk: string, draft?: string, saved: Partial<PersistedOpenFile> = {}) {
|
||||
const file: PersistedOpenFile = {
|
||||
filePath: FILE_PATH,
|
||||
relativePath: 'README.md',
|
||||
worktreeId: WORKTREE,
|
||||
language: 'markdown',
|
||||
runtimeEnvironmentId: null,
|
||||
...(draft === undefined
|
||||
? {}
|
||||
: { dirtyDraftContent: draft, lastKnownDiskSignature: getDiskBaselineSignature(disk) }),
|
||||
...saved
|
||||
}
|
||||
const session: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
openFilesByWorktree: { [WORKTREE]: [file] }
|
||||
}
|
||||
const state = { disk }
|
||||
const io = {
|
||||
readFile: vi.fn(async (_worktreeId: string, _file: { filePath: string }, maxBytes: number) => {
|
||||
const bytes = Buffer.from(state.disk)
|
||||
return { content: bytes.subarray(0, maxBytes).toString('utf8'), byteLength: bytes.length }
|
||||
}),
|
||||
writeFileIfUnchanged: vi.fn(
|
||||
async (
|
||||
_worktreeId: string,
|
||||
_file: { filePath: string },
|
||||
content: string,
|
||||
precondition: { expectedSignature: string }
|
||||
) => {
|
||||
if (compareDiskWritePrecondition(precondition, state.disk)) {
|
||||
return { ok: false as const, diskConflict: 'changed' as const }
|
||||
}
|
||||
state.disk = content
|
||||
return { ok: true as const }
|
||||
}
|
||||
)
|
||||
}
|
||||
const host = {
|
||||
...io,
|
||||
getWorkspaceSessionForWorktree: () => session,
|
||||
forSave: async () => io
|
||||
} satisfies MarkdownTabDiskHost
|
||||
return { host, state }
|
||||
}
|
||||
|
||||
describe('Markdown tab documents on disk', () => {
|
||||
it('reads the file from disk when the tab has no saved draft', async () => {
|
||||
const { host } = makeHost('# Notes\n')
|
||||
|
||||
await expect(readMarkdownTabFromDisk(host, WORKTREE, TAB)).resolves.toEqual({
|
||||
tabId: FILE_PATH,
|
||||
filePath: FILE_PATH,
|
||||
relativePath: 'README.md',
|
||||
content: '# Notes\n',
|
||||
isDirty: false,
|
||||
version: hashMarkdownContent('# Notes\n'),
|
||||
source: 'file',
|
||||
editable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the disk, never unsaved desktop text, and reports the tab dirty as it was listed', async () => {
|
||||
const { host } = makeHost('# Disk\n', '# Draft\n')
|
||||
|
||||
await expect(
|
||||
readMarkdownTabFromDisk(host, WORKTREE, { ...TAB, isDirty: true })
|
||||
).resolves.toMatchObject({
|
||||
content: '# Disk\n',
|
||||
isDirty: true,
|
||||
source: 'file',
|
||||
editable: true,
|
||||
version: hashMarkdownContent('# Disk\n')
|
||||
})
|
||||
})
|
||||
|
||||
it("reads a preview tab's source file, read-only", async () => {
|
||||
const { host } = makeHost('# Notes\n')
|
||||
const preview: ListedMarkdownTab = {
|
||||
...TAB,
|
||||
id: `markdown-preview::${FILE_PATH}`,
|
||||
mode: 'markdown-preview'
|
||||
}
|
||||
|
||||
await expect(readMarkdownTabFromDisk(host, WORKTREE, preview)).resolves.toMatchObject({
|
||||
tabId: preview.id,
|
||||
filePath: FILE_PATH,
|
||||
content: '# Notes\n',
|
||||
editable: false,
|
||||
readOnlyReason: 'unsupported_preview'
|
||||
})
|
||||
await expect(
|
||||
saveMarkdownTabToDisk(host, WORKTREE, preview, hashMarkdownContent('# Notes\n'), '# Edit\n')
|
||||
).rejects.toThrow('unsupported_preview')
|
||||
expect(host.writeFileIfUnchanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks a read truncated when the host sent less than the whole file', async () => {
|
||||
const { host } = makeHost('# Notes\n')
|
||||
host.readFile.mockResolvedValueOnce({ content: 'a'.repeat(10), byteLength: 600_000 })
|
||||
|
||||
await expect(readMarkdownTabFromDisk(host, WORKTREE, TAB)).resolves.toMatchObject({
|
||||
truncated: true,
|
||||
byteLength: 600_000,
|
||||
editable: false,
|
||||
readOnlyReason: 'file_too_large'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a file over the edit cap read-only, and a file over the read cap truncated', async () => {
|
||||
const editable = makeHost('a'.repeat(MOBILE_MARKDOWN_EDIT_MAX_BYTES + 1))
|
||||
await expect(readMarkdownTabFromDisk(editable.host, WORKTREE, TAB)).resolves.toMatchObject({
|
||||
editable: false,
|
||||
readOnlyReason: 'file_too_large'
|
||||
})
|
||||
|
||||
const size = MOBILE_MARKDOWN_READ_MAX_BYTES + 10
|
||||
const large = makeHost('a'.repeat(size))
|
||||
const read = await readMarkdownTabFromDisk(large.host, WORKTREE, TAB)
|
||||
expect(read).toMatchObject({ truncated: true, byteLength: size, editable: false })
|
||||
expect(Buffer.byteLength(read.content)).toBe(MOBILE_MARKDOWN_READ_MAX_BYTES)
|
||||
expect(read.version).toBe(hashMarkdownContent(read.content))
|
||||
})
|
||||
|
||||
it('saves over the version the phone read, refusing any change on disk since', async () => {
|
||||
const { host, state } = makeHost('# Notes\n')
|
||||
|
||||
await expect(
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# New\n')
|
||||
).resolves.toEqual({
|
||||
tabId: FILE_PATH,
|
||||
version: hashMarkdownContent('# New\n'),
|
||||
isDirty: false,
|
||||
content: '# New\n'
|
||||
})
|
||||
expect(state.disk).toBe('# New\n')
|
||||
expect(host.writeFileIfUnchanged).toHaveBeenCalledWith(
|
||||
WORKTREE,
|
||||
expect.objectContaining({ filePath: FILE_PATH }),
|
||||
'# New\n',
|
||||
{
|
||||
expectedSignature: getDiskBaselineSignature('# Notes\n')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a save from a stale version and leaves the disk alone', async () => {
|
||||
const { host, state } = makeHost('# Changed on the server\n')
|
||||
|
||||
await expect(
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# Phone\n')
|
||||
).rejects.toThrow('conflict')
|
||||
expect(state.disk).toBe('# Changed on the server\n')
|
||||
expect(host.writeFileIfUnchanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers a repeated save that already landed as saved', async () => {
|
||||
const { host } = makeHost('# New\n')
|
||||
|
||||
await expect(
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# New\n')
|
||||
).resolves.toMatchObject({ content: '# New\n', isDirty: false })
|
||||
expect(host.writeFileIfUnchanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses when the disk changes between the read and the write', async () => {
|
||||
const { host, state } = makeHost('# Notes\n')
|
||||
host.readFile.mockImplementationOnce(async () => {
|
||||
const read = { content: state.disk, byteLength: Buffer.byteLength(state.disk) }
|
||||
state.disk = '# Raced\n'
|
||||
return read
|
||||
})
|
||||
|
||||
await expect(
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# Phone\n')
|
||||
).rejects.toThrow('conflict')
|
||||
expect(state.disk).toBe('# Raced\n')
|
||||
})
|
||||
|
||||
it("saves the disk, leaving a window's saved draft for that window", async () => {
|
||||
const { host, state } = makeHost('# Disk\n', '# Draft\n')
|
||||
|
||||
await saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Disk\n'), '# Phone\n')
|
||||
|
||||
expect(state.disk).toBe('# Phone\n')
|
||||
})
|
||||
|
||||
it('runs two saves of one file one after the other', async () => {
|
||||
const { host, state } = makeHost('# Notes\n')
|
||||
const base = hashMarkdownContent('# Notes\n')
|
||||
|
||||
// Run together, the second would pass the same version check and fail the disk precondition.
|
||||
const results = await Promise.all([
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# Same\n'),
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# Same\n')
|
||||
])
|
||||
|
||||
expect(results.map((result) => result.content)).toEqual(['# Same\n', '# Same\n'])
|
||||
expect(host.writeFileIfUnchanged).toHaveBeenCalledTimes(1)
|
||||
expect(state.disk).toBe('# Same\n')
|
||||
})
|
||||
|
||||
it('keeps saving after a failed save of the same file', async () => {
|
||||
const { host, state } = makeHost('# Notes\n')
|
||||
host.writeFileIfUnchanged.mockRejectedValueOnce(new Error('EACCES'))
|
||||
const base = hashMarkdownContent('# Notes\n')
|
||||
|
||||
await expect(saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# First\n')).rejects.toThrow(
|
||||
'EACCES'
|
||||
)
|
||||
await saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# Second\n')
|
||||
|
||||
expect(state.disk).toBe('# Second\n')
|
||||
})
|
||||
|
||||
it('never saves a read-only tab', async () => {
|
||||
const { host } = makeHost('# Log\n', undefined, { readOnly: true })
|
||||
|
||||
await expect(readMarkdownTabFromDisk(host, WORKTREE, TAB)).resolves.toMatchObject({
|
||||
editable: false,
|
||||
readOnlyReason: 'unsupported_tab'
|
||||
})
|
||||
await expect(
|
||||
saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Log\n'), '# Edit\n')
|
||||
).rejects.toThrow('unsupported_tab')
|
||||
expect(host.writeFileIfUnchanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a tab the saved session no longer holds', async () => {
|
||||
const { host } = makeHost('# Notes\n')
|
||||
|
||||
await expect(
|
||||
readMarkdownTabFromDisk(host, WORKTREE, {
|
||||
...TAB,
|
||||
id: 'gone',
|
||||
sourceFilePath: '/srv/repo/GONE.md'
|
||||
})
|
||||
).rejects.toThrow('tab_not_found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* A phone's Markdown read and save, window or not: main answers from the file on disk, through the
|
||||
* workspace's own host. The desktop's unsaved text stays the desktop's: the phone sees the disk,
|
||||
* with the listed tab's `isDirty` as its "changed on desktop" signal, and the desktop's editor
|
||||
* reports a phone save as a change on disk.
|
||||
*/
|
||||
import {
|
||||
hashMarkdownContent,
|
||||
isMarkdownContentByteLengthOverLimit,
|
||||
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
|
||||
MOBILE_MARKDOWN_READ_MAX_BYTES,
|
||||
truncateMobileMarkdownRead,
|
||||
utf8ByteLength,
|
||||
type RuntimeMarkdownReadTabResult,
|
||||
type RuntimeMarkdownSaveTabResult
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
import type { RuntimeMobileSessionMarkdownTab } from '../../shared/runtime-types'
|
||||
import type {
|
||||
FileWriteDiskPrecondition,
|
||||
FileWriteResult
|
||||
} from '../../shared/file-write-disk-precondition'
|
||||
import { getDiskBaselineSignature } from '../../shared/disk-baseline-signature'
|
||||
import type {
|
||||
PersistedOpenFile,
|
||||
WorkspaceSessionState
|
||||
} from '../../shared/workspace-session-state-types'
|
||||
import { findListedEditorFile } from './mobile-session-editor-projection'
|
||||
|
||||
export type MarkdownDiskFile = { content: string; byteLength: number }
|
||||
|
||||
type MarkdownFileAddress = Pick<
|
||||
PersistedOpenFile,
|
||||
'filePath' | 'relativePath' | 'externalSshTargetId'
|
||||
>
|
||||
|
||||
export type MarkdownTabDiskHost = Pick<MarkdownFileIo, 'readFile'> & {
|
||||
getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null | undefined
|
||||
/** The file calls of one save, all reaching the host its first call reached. */
|
||||
forSave(worktreeId: string, file: MarkdownFileAddress): Promise<MarkdownFileIo>
|
||||
}
|
||||
|
||||
export type MarkdownFileIo = {
|
||||
/** A prefix of the file decoded (at most `maxBytes`, the host may send less), and its full
|
||||
* size; throws `binary_file` for binary. */
|
||||
readFile(
|
||||
worktreeId: string,
|
||||
file: MarkdownFileAddress,
|
||||
maxBytes: number
|
||||
): Promise<MarkdownDiskFile>
|
||||
writeFileIfUnchanged(
|
||||
worktreeId: string,
|
||||
file: MarkdownFileAddress,
|
||||
content: string,
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteResult>
|
||||
}
|
||||
|
||||
/** The tab as the phone was shown it; a preview names its source file in `sourceFilePath`. */
|
||||
export type ListedMarkdownTab = Pick<
|
||||
RuntimeMobileSessionMarkdownTab,
|
||||
'id' | 'mode' | 'filePath' | 'sourceFilePath' | 'sourceRelativePath' | 'isDirty' | 'isUntitled'
|
||||
>
|
||||
|
||||
// Why up to 4 bytes over: a character straddling the cap still decodes whole before the cut.
|
||||
const READ_SLACK_BYTES = 4
|
||||
|
||||
const saveQueues = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Why the saved session: it names the file behind the listed tab, window or not.
|
||||
function savedEditorFile(
|
||||
host: MarkdownTabDiskHost,
|
||||
worktreeId: string,
|
||||
tab: ListedMarkdownTab
|
||||
): PersistedOpenFile {
|
||||
const session = host.getWorkspaceSessionForWorktree(worktreeId)
|
||||
const files = session?.openFilesByWorktree?.[worktreeId] ?? []
|
||||
const sourceFilePath = tab.sourceFilePath || tab.filePath
|
||||
const file = session
|
||||
? findListedEditorFile(files, session, worktreeId, { id: tab.id, filePath: sourceFilePath })
|
||||
: undefined
|
||||
if (!file && tab.mode === 'markdown-preview') {
|
||||
// Why: a preview outlives its closed source tab, and only edit tabs are saved; it stays read-only.
|
||||
return {
|
||||
filePath: sourceFilePath,
|
||||
relativePath: tab.sourceRelativePath,
|
||||
worktreeId,
|
||||
language: 'markdown'
|
||||
}
|
||||
}
|
||||
if (!file || (file.language !== 'markdown' && tab.mode !== 'markdown-preview')) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
/** Why a tab is read-only on the phone whatever the file holds; checked before any disk access. */
|
||||
function tabReadOnlyReason(
|
||||
tab: ListedMarkdownTab,
|
||||
file: PersistedOpenFile
|
||||
): RuntimeMarkdownReadTabResult['readOnlyReason'] {
|
||||
if (tab.mode === 'markdown-preview') {
|
||||
return 'unsupported_preview'
|
||||
}
|
||||
// Why: a phone save must never make a file of a document the user has not named and saved.
|
||||
if (tab.isUntitled) {
|
||||
return 'unsupported_untitled'
|
||||
}
|
||||
// Why: the editor never saves a read-only tab (a log view), so the phone may not either.
|
||||
return file.readOnly ? 'unsupported_tab' : undefined
|
||||
}
|
||||
|
||||
function readOnlyReason(
|
||||
tab: ListedMarkdownTab,
|
||||
file: PersistedOpenFile,
|
||||
byteLength: number
|
||||
): RuntimeMarkdownReadTabResult['readOnlyReason'] {
|
||||
return (
|
||||
tabReadOnlyReason(tab, file) ??
|
||||
(byteLength > MOBILE_MARKDOWN_EDIT_MAX_BYTES ? 'file_too_large' : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export async function readMarkdownTabFromDisk(
|
||||
host: MarkdownTabDiskHost,
|
||||
worktreeId: string,
|
||||
tab: ListedMarkdownTab
|
||||
): Promise<RuntimeMarkdownReadTabResult> {
|
||||
const file = savedEditorFile(host, worktreeId, tab)
|
||||
const disk = await host.readFile(
|
||||
worktreeId,
|
||||
file,
|
||||
MOBILE_MARKDOWN_READ_MAX_BYTES + READ_SLACK_BYTES
|
||||
)
|
||||
const preview = truncateMobileMarkdownRead(disk.content)
|
||||
const reason = readOnlyReason(tab, file, disk.byteLength)
|
||||
return {
|
||||
tabId: tab.id,
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
content: preview.content,
|
||||
// Why the listed flag: unsaved desktop text is the desktop's; the phone only learns it exists.
|
||||
isDirty: tab.isDirty,
|
||||
version: hashMarkdownContent(preview.content),
|
||||
source: 'file',
|
||||
editable: reason === undefined,
|
||||
...(reason ? { readOnlyReason: reason } : {}),
|
||||
...(disk.byteLength > utf8ByteLength(preview.content)
|
||||
? { truncated: true, byteLength: disk.byteLength }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMarkdownTabToDisk(
|
||||
host: MarkdownTabDiskHost,
|
||||
worktreeId: string,
|
||||
tab: ListedMarkdownTab,
|
||||
baseVersion: string,
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult> {
|
||||
if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const key = `${worktreeId}\0${savedEditorFile(host, worktreeId, tab).filePath}`
|
||||
const previous = saveQueues.get(key) ?? Promise.resolve()
|
||||
const save = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => saveOnce(host, worktreeId, tab, baseVersion, content))
|
||||
saveQueues.set(key, save)
|
||||
try {
|
||||
return await save
|
||||
} finally {
|
||||
if (saveQueues.get(key) === save) {
|
||||
saveQueues.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOnce(
|
||||
host: MarkdownTabDiskHost,
|
||||
worktreeId: string,
|
||||
tab: ListedMarkdownTab,
|
||||
baseVersion: string,
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult> {
|
||||
const file = savedEditorFile(host, worktreeId, tab)
|
||||
const refusal = tabReadOnlyReason(tab, file)
|
||||
if (refusal) {
|
||||
throw new Error(refusal)
|
||||
}
|
||||
const io = await host.forSave(worktreeId, file)
|
||||
const disk = await io.readFile(worktreeId, file, MOBILE_MARKDOWN_EDIT_MAX_BYTES + 1)
|
||||
const reason = readOnlyReason(tab, file, disk.byteLength)
|
||||
if (reason) {
|
||||
throw new Error(reason)
|
||||
}
|
||||
const saved = { tabId: tab.id, isDirty: false as const, version: hashMarkdownContent(content) }
|
||||
if (hashMarkdownContent(disk.content) !== baseVersion) {
|
||||
// Why: a repeated save tap that already landed is a success, not a conflict.
|
||||
if (disk.content === content) {
|
||||
return { ...saved, content }
|
||||
}
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const written = await io.writeFileIfUnchanged(worktreeId, file, content, {
|
||||
expectedSignature: getDiskBaselineSignature(disk.content)
|
||||
})
|
||||
if (!written.ok) {
|
||||
throw new Error('conflict')
|
||||
}
|
||||
return { ...saved, content }
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Where a phone's Markdown tab file is read and written: this machine's disk for a local workspace,
|
||||
* and for a paired server's (`runtime:<env>`) the same `files.read` / `files.writeIfUnchanged` calls
|
||||
* the desktop editor makes, so that server reads its own disk and does the compare-and-write itself.
|
||||
* A raw `ssh:` host is refused on both, by the route every local file command takes.
|
||||
*/
|
||||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||||
import { parseExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
compareDiskWritePrecondition,
|
||||
diskConflictFromReadFailure,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IF_UNCHANGED_METHOD,
|
||||
parseFileWriteResult,
|
||||
type FileWriteDiskConflict,
|
||||
type FileWriteDiskPrecondition,
|
||||
type FileWriteResult
|
||||
} from '../../shared/file-write-disk-precondition'
|
||||
import { utf8ByteLength } from '../../shared/mobile-markdown-document'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { assertFileMutationOwnershipCapability } from '../../shared/file-mutation-ownership'
|
||||
import type { PersistedOpenFile } from '../../shared/workspace-session-state-types'
|
||||
import { readLocalRuntimeFilePrefix } from './files/file-reads'
|
||||
import {
|
||||
requireLocalRuntimeFileRoute,
|
||||
requireRuntimeFileProvider,
|
||||
type ResolvedRuntimeFileTarget,
|
||||
type RuntimeLocalFileHost
|
||||
} from './files/file-target'
|
||||
import type {
|
||||
MarkdownDiskFile,
|
||||
MarkdownFileIo,
|
||||
MarkdownTabDiskHost
|
||||
} from './markdown-tab-disk-documents'
|
||||
import { OrchestrationError } from './orchestration/orchestration-error'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
|
||||
type EnvironmentCall = (
|
||||
environmentId: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
expectedPairingRevision?: number
|
||||
) => Promise<unknown>
|
||||
|
||||
export type MarkdownTabDiskRuntime = RuntimeLocalFileHost &
|
||||
Pick<MarkdownTabDiskHost, 'getWorkspaceSessionForWorktree'> & {
|
||||
resolveRuntimeFileTarget(selector: string): Promise<ResolvedRuntimeFileTarget>
|
||||
callEnvironment: EnvironmentCall
|
||||
/** The server's current pairing, so every call of one save reaches the server it probed. */
|
||||
environmentPairingRevision(environmentId: string): number | undefined
|
||||
}
|
||||
|
||||
export function createMarkdownTabDiskHost(runtime: MarkdownTabDiskRuntime): MarkdownTabDiskHost {
|
||||
const routeOf = async (worktreeId: string, file: MarkdownFileAddress) => {
|
||||
// Why: a file outside the workspace on an SSH host is that host's; no process here serves it.
|
||||
if (file.externalSshTargetId) {
|
||||
throw new SshHostNotServedError(file.externalSshTargetId)
|
||||
}
|
||||
const target = await runtime.resolveRuntimeFileTarget(`id:${worktreeId}`)
|
||||
const host = parseExecutionHostId(target.executionHostId)
|
||||
if (host?.kind === 'runtime') {
|
||||
return { environmentId: host.environmentId, target }
|
||||
}
|
||||
requireLocalRuntimeFileRoute(target)
|
||||
return { environmentId: null, target }
|
||||
}
|
||||
// `pairingRevision` is the pairing a save captured before its first call; a phone read leaves it unset.
|
||||
const fileIo = (pairingRevision: number | undefined): MarkdownFileIo => ({
|
||||
readFile: async (worktreeId, file, maxBytes) => {
|
||||
const { environmentId } = await routeOf(worktreeId, file)
|
||||
if (environmentId) {
|
||||
return readFileOnEnvironment(
|
||||
runtime.callEnvironment,
|
||||
environmentId,
|
||||
worktreeId,
|
||||
file,
|
||||
pairingRevision
|
||||
)
|
||||
}
|
||||
const { bytes, size } = await readLocalRuntimeFilePrefix(
|
||||
file.filePath,
|
||||
runtime.requireStore(),
|
||||
maxBytes
|
||||
)
|
||||
if (isBinaryBuffer(bytes)) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return { content: bytes.toString('utf8'), byteLength: size }
|
||||
},
|
||||
writeFileIfUnchanged: async (worktreeId, file, content, precondition) => {
|
||||
const { environmentId, target } = await routeOf(worktreeId, file)
|
||||
if (environmentId) {
|
||||
return writeFileOnEnvironment(
|
||||
runtime.callEnvironment,
|
||||
environmentId,
|
||||
pairingRevision,
|
||||
worktreeId,
|
||||
file,
|
||||
content,
|
||||
precondition
|
||||
)
|
||||
}
|
||||
return requireRuntimeFileProvider(target, runtime).writeFileWithDiskPrecondition(
|
||||
file.filePath,
|
||||
content,
|
||||
precondition
|
||||
)
|
||||
}
|
||||
})
|
||||
return {
|
||||
getWorkspaceSessionForWorktree: (worktreeId) =>
|
||||
runtime.getWorkspaceSessionForWorktree(worktreeId),
|
||||
readFile: fileIo(undefined).readFile,
|
||||
forSave: async (worktreeId, file) => {
|
||||
const { environmentId } = await routeOf(worktreeId, file)
|
||||
return fileIo(environmentId ? pairingRevisionOf(runtime, environmentId) : undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pairingRevisionOf(
|
||||
runtime: Pick<MarkdownTabDiskRuntime, 'environmentPairingRevision'>,
|
||||
environmentId: string
|
||||
): number | undefined {
|
||||
try {
|
||||
return runtime.environmentPairingRevision(environmentId)
|
||||
} catch (error) {
|
||||
throw new RemoteRuntimeClientError(
|
||||
'remote_runtime_unavailable',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type MarkdownFileAddress = Pick<PersistedOpenFile, 'relativePath' | 'externalSshTargetId'>
|
||||
|
||||
/** The server's answer passes through; losing it is `remote_runtime_unavailable`, never a disk verdict. */
|
||||
async function callEnvironment(
|
||||
call: EnvironmentCall,
|
||||
environmentId: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
expectedPairingRevision?: number
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await call(environmentId, method, params, expectedPairingRevision)
|
||||
} catch (error) {
|
||||
if (error instanceof OrchestrationError && error.code !== 'server_required') {
|
||||
throw error
|
||||
}
|
||||
throw new RemoteRuntimeClientError(
|
||||
'remote_runtime_unavailable',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileOnEnvironment(
|
||||
call: EnvironmentCall,
|
||||
environmentId: string,
|
||||
worktreeId: string,
|
||||
file: MarkdownFileAddress,
|
||||
pairingRevision: number | undefined
|
||||
): Promise<MarkdownDiskFile> {
|
||||
const address = { worktree: `id:${worktreeId}`, relativePath: file.relativePath }
|
||||
const read = await callEnvironment(call, environmentId, 'files.read', address, pairingRevision)
|
||||
if (!isRecord(read) || typeof read.content !== 'string') {
|
||||
throw new Error('invalid_runtime_response')
|
||||
}
|
||||
const cut = utf8ByteLength(read.content)
|
||||
if (read.truncated !== true) {
|
||||
return { content: read.content, byteLength: cut }
|
||||
}
|
||||
// Why: a truncated `files.read` reports where it cut, not the file size the phone labels.
|
||||
const stat = await callEnvironment(call, environmentId, 'files.stat', address, pairingRevision)
|
||||
const size = isRecord(stat) && typeof stat.size === 'number' ? stat.size : 0
|
||||
return { content: read.content, byteLength: Math.max(size, cut + 1) }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop editor's guarded remote save (`writeRemoteFileIfUnchanged`): a server that advertises
|
||||
* the disk check compares at write time; an older one is compared from here before a plain write.
|
||||
* Every call is pinned to the pairing the save captured before its first read, so a re-pair never
|
||||
* receives the write.
|
||||
*/
|
||||
async function writeFileOnEnvironment(
|
||||
call: EnvironmentCall,
|
||||
environmentId: string,
|
||||
pairingRevision: number | undefined,
|
||||
worktreeId: string,
|
||||
file: MarkdownFileAddress,
|
||||
content: string,
|
||||
diskPrecondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteResult> {
|
||||
const pinned = (method: string, params: unknown): Promise<unknown> =>
|
||||
callEnvironment(call, environmentId, method, params, pairingRevision)
|
||||
const status = await pinned('status.get', undefined)
|
||||
const capabilities =
|
||||
isRecord(status) && Array.isArray(status.capabilities) ? status.capabilities : []
|
||||
assertFileMutationOwnershipCapability({ capabilities })
|
||||
const params = {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath: file.relativePath,
|
||||
content,
|
||||
// Why `local`: the server writes only a workspace it runs itself, so the write never hops on.
|
||||
expectedExecutionHostId: 'local'
|
||||
}
|
||||
if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) {
|
||||
return parseFileWriteResult(
|
||||
await pinned(FILE_WRITE_IF_UNCHANGED_METHOD, { ...params, diskPrecondition })
|
||||
)
|
||||
}
|
||||
const diskConflict = await diskConflictOnServer(pinned, params, diskPrecondition)
|
||||
if (diskConflict) {
|
||||
return { ok: false, diskConflict }
|
||||
}
|
||||
return parseFileWriteResult(await pinned('files.write', params))
|
||||
}
|
||||
|
||||
/** For a server that would write unguarded: a read that cannot prove the disk fails the save. */
|
||||
async function diskConflictOnServer(
|
||||
pinned: (method: string, params: unknown) => Promise<unknown>,
|
||||
address: { worktree: string; relativePath: string },
|
||||
precondition: FileWriteDiskPrecondition
|
||||
): Promise<FileWriteDiskConflict | null> {
|
||||
let read: unknown
|
||||
try {
|
||||
read = await pinned('files.read', {
|
||||
worktree: address.worktree,
|
||||
relativePath: address.relativePath
|
||||
})
|
||||
} catch (error) {
|
||||
return diskConflictFromReadFailure(precondition, error)
|
||||
}
|
||||
if (!isRecord(read) || typeof read.content !== 'string' || read.truncated === true) {
|
||||
return compareDiskWritePrecondition(precondition, undefined)
|
||||
}
|
||||
return compareDiskWritePrecondition(precondition, read.content)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function buildHeadlessMobileSessionEditorTabs(
|
||||
}
|
||||
|
||||
/** The record the phone's tab names: two owners can hold one path, so by the id it was listed under. */
|
||||
function listedFile(
|
||||
export function findListedEditorFile(
|
||||
files: readonly PersistedOpenFile[],
|
||||
session: WorkspaceSessionState,
|
||||
worktreeId: string,
|
||||
@@ -117,7 +117,7 @@ export function retireHeadlessEditorTab(
|
||||
): WorkspaceSessionState | null {
|
||||
const files = session.openFilesByWorktree?.[worktreeId] ?? []
|
||||
const file = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? listedFile(files, session, worktreeId, tab)
|
||||
? findListedEditorFile(files, session, worktreeId, tab)
|
||||
: files.find((candidate) => candidate.filePath === tab.filePath)
|
||||
if (!file) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { hashMarkdownContent } from '../../shared/mobile-markdown-document'
|
||||
import type { RuntimeMobileSessionMarkdownTab } from '../../shared/runtime-types'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_IF_UNCHANGED_METHOD
|
||||
} from '../../shared/file-write-disk-precondition'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import { OrchestrationError } from './orchestration/orchestration-error'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import { FILE_METHODS } from './rpc/methods/files'
|
||||
import { SESSION_TAB_MARKDOWN_METHODS } from './rpc/methods/session-tab-markdown-methods'
|
||||
|
||||
// Why: authorization is not under test; both runtimes read a temp workspace the store never registered.
|
||||
vi.mock('./files/chat-scratch-file-root', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
resolveRuntimeAuthorizedPath: async (path: string) => path
|
||||
}))
|
||||
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
|
||||
await import('./orca-runtime-test-lifecycle.spec')
|
||||
const { TEST_WORKTREE_ID, makeRuntimeStoreWithWorkspaceSession } =
|
||||
await import('./orca-runtime-test-fixtures.spec')
|
||||
|
||||
type Runtime = InstanceType<typeof OrcaRuntimeService>
|
||||
|
||||
let root = ''
|
||||
let readme = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-markdown-tab-routes-'))
|
||||
readme = join(root, 'README.md')
|
||||
await writeFile(readme, '# Notes\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionWithFile(filePath: string, relativePath: string): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
openFilesByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{ filePath, relativePath, worktreeId: TEST_WORKTREE_ID, language: 'markdown' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sessionWithReadme(externalSshTargetId?: string): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
openFilesByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
filePath: readme,
|
||||
relativePath: 'README.md',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
language: 'markdown',
|
||||
runtimeEnvironmentId: null,
|
||||
...(externalSshTargetId ? { externalSshTargetId } : {})
|
||||
}
|
||||
]
|
||||
},
|
||||
activeTabTypeByWorktree: { [TEST_WORKTREE_ID]: 'editor' },
|
||||
activeFileIdByWorktree: { [TEST_WORKTREE_ID]: readme }
|
||||
}
|
||||
}
|
||||
|
||||
async function runtimeOnHost(
|
||||
executionHostId: ExecutionHostId,
|
||||
session = sessionWithReadme()
|
||||
): Promise<Runtime> {
|
||||
const harness = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const runtime = new OrcaRuntimeService(harness.runtimeStore)
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const target = await runtime.resolveRuntimeFileTarget(`id:${TEST_WORKTREE_ID}`)
|
||||
vi.spyOn(runtime, 'resolveRuntimeFileTarget').mockResolvedValue({
|
||||
...target,
|
||||
worktree: { ...target.worktree, path: root },
|
||||
executionHostId
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
const CURRENT_SERVER = [
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY
|
||||
]
|
||||
/** A server from before #30605: it advertises no disk check and serves no `files.writeIfUnchanged`. */
|
||||
const PRE_DISK_CHECK_SERVER = [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY]
|
||||
const PAIRING_REVISION = 7
|
||||
|
||||
/** A desktop whose workspace runs on `env-a`, wired to that server's runtime the way the transport is. */
|
||||
async function desktopOnServer(
|
||||
serverHost: ExecutionHostId = 'local',
|
||||
capabilities: string[] = CURRENT_SERVER
|
||||
) {
|
||||
const server = await runtimeOnHost(serverHost)
|
||||
const serverDispatch = new RpcDispatcher({
|
||||
runtime: server,
|
||||
methods: [...FILE_METHODS, ...SESSION_TAB_MARKDOWN_METHODS].filter(
|
||||
(method) =>
|
||||
method.name !== FILE_WRITE_IF_UNCHANGED_METHOD ||
|
||||
capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)
|
||||
)
|
||||
})
|
||||
const serverCalls: string[] = []
|
||||
const pins: (number | undefined)[] = []
|
||||
const serverForwards = vi.spyOn(server, 'callOrchestrationWorkerServer')
|
||||
const desktop = await runtimeOnHost('runtime:env-a')
|
||||
const pairing = { revision: PAIRING_REVISION }
|
||||
vi.spyOn(desktop, 'resolveOrchestrationWorkerServer').mockImplementation(() => ({
|
||||
environmentId: 'env-a',
|
||||
name: 'env-a',
|
||||
peerFingerprint: 'peer',
|
||||
pairingRevision: pairing.revision
|
||||
}))
|
||||
const call = vi
|
||||
.spyOn(desktop, 'callOrchestrationWorkerServer')
|
||||
.mockImplementation(async (environmentId, method, params, _timeout, _envelope, internal) => {
|
||||
expect(environmentId).toBe('env-a')
|
||||
serverCalls.push(method)
|
||||
const pin = internal?.expectedEnvironmentPairingRevision
|
||||
pins.push(pin)
|
||||
// As `callRuntimeEnvironment` refuses a call pinned to a pairing that has since changed.
|
||||
if (pin !== undefined && pin !== pairing.revision) {
|
||||
throw new OrchestrationError(
|
||||
'runtime_environment_changed',
|
||||
'Runtime environment pairing changed; refresh and try again'
|
||||
)
|
||||
}
|
||||
if (method === 'status.get') {
|
||||
return { runtimeId: 'server-runtime', capabilities }
|
||||
}
|
||||
const response = await serverDispatch.dispatch({
|
||||
id: `call-${serverCalls.length}`,
|
||||
authToken: 'tok',
|
||||
method,
|
||||
params
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new OrchestrationError(response.error.code, response.error.message)
|
||||
}
|
||||
return response.result
|
||||
})
|
||||
return { desktop, call, serverCalls, serverForwards, pins, pairing }
|
||||
}
|
||||
|
||||
describe("a phone's Markdown tab on a paired server's workspace", () => {
|
||||
it('reads the file on that server, which answers from its own disk', async () => {
|
||||
const { desktop, serverCalls, serverForwards } = await desktopOnServer()
|
||||
|
||||
await expect(
|
||||
desktop.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme)
|
||||
).resolves.toMatchObject({
|
||||
tabId: readme,
|
||||
content: '# Notes\n',
|
||||
version: hashMarkdownContent('# Notes\n'),
|
||||
isDirty: false,
|
||||
editable: true
|
||||
})
|
||||
expect(serverCalls).toEqual(['files.read'])
|
||||
expect(serverForwards).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never hops on from a server that does not run the workspace itself', async () => {
|
||||
const { desktop, serverCalls, serverForwards } = await desktopOnServer('runtime:env-b')
|
||||
|
||||
await expect(desktop.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme)).rejects.toThrow(
|
||||
'runtime:env-b is not dispatched by this process'
|
||||
)
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(serverCalls).toEqual(['files.read', 'files.read'])
|
||||
expect(serverForwards).not.toHaveBeenCalled()
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Notes\n')
|
||||
})
|
||||
|
||||
it('saves through the server, which checks the precondition and writes', async () => {
|
||||
const { desktop, serverCalls, serverForwards, pins } = await desktopOnServer()
|
||||
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).resolves.toMatchObject({ content: '# Phone\n', isDirty: false })
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Phone\n')
|
||||
expect(serverCalls).toEqual(['files.read', 'status.get', 'files.writeIfUnchanged'])
|
||||
expect(pins).toEqual([PAIRING_REVISION, PAIRING_REVISION, PAIRING_REVISION])
|
||||
expect(serverForwards).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("refuses a save the server's disk moved under", async () => {
|
||||
const { desktop, call } = await desktopOnServer()
|
||||
const forward = call.getMockImplementation()!
|
||||
call.mockImplementation(async (environmentId, method, params) => {
|
||||
if (method === 'files.writeIfUnchanged') {
|
||||
await writeFile(readme, '# Changed on the server\n')
|
||||
}
|
||||
return forward(environmentId, method, params)
|
||||
})
|
||||
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow('conflict')
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Changed on the server\n')
|
||||
})
|
||||
|
||||
it('saves an unchanged file on a server too old to check the disk, comparing here first', async () => {
|
||||
const { desktop, serverCalls, pins } = await desktopOnServer('local', PRE_DISK_CHECK_SERVER)
|
||||
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).resolves.toMatchObject({ content: '# Phone\n', isDirty: false })
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Phone\n')
|
||||
expect(serverCalls).toEqual(['files.read', 'status.get', 'files.read', 'files.write'])
|
||||
expect(pins).toEqual([PAIRING_REVISION, PAIRING_REVISION, PAIRING_REVISION, PAIRING_REVISION])
|
||||
})
|
||||
|
||||
it('refuses a changed file on a server too old to check the disk, and never writes', async () => {
|
||||
const { desktop, call, serverCalls } = await desktopOnServer('local', PRE_DISK_CHECK_SERVER)
|
||||
const forward = call.getMockImplementation()!
|
||||
call.mockImplementation(async (environmentId, method, params, ...rest) => {
|
||||
if (method === 'status.get') {
|
||||
await writeFile(readme, '# Changed on the server\n')
|
||||
}
|
||||
return forward(environmentId, method, params, ...rest)
|
||||
})
|
||||
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow('conflict')
|
||||
expect(serverCalls).not.toContain('files.write')
|
||||
expect(serverCalls).not.toContain(FILE_WRITE_IF_UNCHANGED_METHOD)
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Changed on the server\n')
|
||||
})
|
||||
|
||||
it('refuses a save whose server was re-paired after its first read, writing nothing', async () => {
|
||||
const { desktop, call, serverCalls, pairing } = await desktopOnServer()
|
||||
const forward = call.getMockImplementation()!
|
||||
call.mockImplementation(async (environmentId, method, params, ...rest) => {
|
||||
const answer = await forward(environmentId, method, params, ...rest)
|
||||
if (method === 'files.read') {
|
||||
pairing.revision = PAIRING_REVISION + 1
|
||||
}
|
||||
return answer
|
||||
})
|
||||
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow('pairing changed')
|
||||
expect(serverCalls).not.toContain(FILE_WRITE_IF_UNCHANGED_METHOD)
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Notes\n')
|
||||
})
|
||||
|
||||
it('reports a lost server as unavailable, never as a missing or empty file', async () => {
|
||||
const { desktop, call } = await desktopOnServer()
|
||||
call.mockRejectedValue(new Error('socket closed'))
|
||||
|
||||
const read = desktop.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme)
|
||||
await expect(read).rejects.toBeInstanceOf(RemoteRuntimeClientError)
|
||||
await expect(read).rejects.toMatchObject({ code: 'remote_runtime_unavailable' })
|
||||
await expect(
|
||||
desktop.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'remote_runtime_unavailable' })
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Notes\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe("a phone's Markdown tab on a raw ssh: host", () => {
|
||||
it('refuses it with the same error on read and save', async () => {
|
||||
const runtime = await runtimeOnHost('ssh:target-1')
|
||||
const forwards = vi.spyOn(runtime, 'callOrchestrationWorkerServer')
|
||||
|
||||
await expect(
|
||||
runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme)
|
||||
).rejects.toBeInstanceOf(SshHostNotServedError)
|
||||
await expect(
|
||||
runtime.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Changed\n'
|
||||
)
|
||||
).rejects.toBeInstanceOf(SshHostNotServedError)
|
||||
expect(forwards).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("a phone's Markdown tab on a file an SSH host holds outside the workspace", () => {
|
||||
it("refuses it on read and save without touching this machine's file at that path", async () => {
|
||||
const runtime = await runtimeOnHost('local', sessionWithReadme('target-1'))
|
||||
|
||||
await expect(
|
||||
runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme)
|
||||
).rejects.toBeInstanceOf(SshHostNotServedError)
|
||||
await expect(
|
||||
runtime.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
readme,
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Changed\n'
|
||||
)
|
||||
).rejects.toBeInstanceOf(SshHostNotServedError)
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Notes\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe("a phone's Markdown tab with a desktop window attached", () => {
|
||||
function windowTab(
|
||||
overrides: Partial<RuntimeMobileSessionMarkdownTab>
|
||||
): RuntimeMobileSessionMarkdownTab {
|
||||
return {
|
||||
type: 'markdown',
|
||||
id: 'tab-readme',
|
||||
title: 'README.md',
|
||||
filePath: readme,
|
||||
relativePath: 'README.md',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
isActive: true,
|
||||
sourceFileId: readme,
|
||||
sourceFilePath: readme,
|
||||
sourceRelativePath: 'README.md',
|
||||
documentVersion: `file:${readme}`,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeWithWindowTabs(
|
||||
tabs: RuntimeMobileSessionMarkdownTab[],
|
||||
session = sessionWithReadme()
|
||||
): Runtime {
|
||||
const harness = makeRuntimeStoreWithWorkspaceSession(session)
|
||||
const runtime = new OrcaRuntimeService(harness.runtimeStore)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: tabs[0]?.id ?? null,
|
||||
activeTabType: 'markdown',
|
||||
tabs
|
||||
}
|
||||
]
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
|
||||
it("reads the disk, not the desktop's unsaved text, and says the desktop has some", async () => {
|
||||
const runtime = runtimeWithWindowTabs([windowTab({ isDirty: true })])
|
||||
|
||||
await expect(
|
||||
runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme')
|
||||
).resolves.toMatchObject({
|
||||
tabId: 'tab-readme',
|
||||
content: '# Notes\n',
|
||||
isDirty: true,
|
||||
source: 'file',
|
||||
editable: true
|
||||
})
|
||||
})
|
||||
|
||||
it("reads a preview tab's source file, read-only", async () => {
|
||||
const runtime = runtimeWithWindowTabs([
|
||||
windowTab({ id: `markdown-preview::${readme}`, mode: 'markdown-preview' })
|
||||
])
|
||||
|
||||
await expect(
|
||||
runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, `markdown-preview::${readme}`)
|
||||
).resolves.toMatchObject({
|
||||
content: '# Notes\n',
|
||||
editable: false,
|
||||
readOnlyReason: 'unsupported_preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a listed tab whose file the saved session does not hold', async () => {
|
||||
const unsaved = join(root, 'not-in-session.md')
|
||||
const runtime = runtimeWithWindowTabs([
|
||||
windowTab({ id: unsaved, filePath: unsaved, sourceFilePath: unsaved })
|
||||
])
|
||||
|
||||
await expect(runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, unsaved)).rejects.toThrow(
|
||||
'tab_not_found'
|
||||
)
|
||||
})
|
||||
|
||||
it('reads a preview whose source tab was closed from its own file, read-only', async () => {
|
||||
const other = join(root, 'other.md')
|
||||
await writeFile(other, '# Other\n')
|
||||
const runtime = runtimeWithWindowTabs([
|
||||
windowTab({
|
||||
id: `markdown-preview::${other}`,
|
||||
mode: 'markdown-preview',
|
||||
filePath: other,
|
||||
relativePath: 'other.md',
|
||||
sourceFileId: `markdown-preview::${other}`,
|
||||
sourceFilePath: other,
|
||||
sourceRelativePath: 'other.md'
|
||||
})
|
||||
])
|
||||
|
||||
await expect(
|
||||
runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, `markdown-preview::${other}`)
|
||||
).resolves.toMatchObject({
|
||||
filePath: other,
|
||||
content: '# Other\n',
|
||||
editable: false,
|
||||
readOnlyReason: 'unsupported_preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('reads an untitled tab read-only and never saves it', async () => {
|
||||
const runtime = runtimeWithWindowTabs([windowTab({ isUntitled: true, isDirty: true })])
|
||||
|
||||
await expect(
|
||||
runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme')
|
||||
).resolves.toMatchObject({
|
||||
content: '# Notes\n',
|
||||
isDirty: true,
|
||||
editable: false,
|
||||
readOnlyReason: 'unsupported_untitled'
|
||||
})
|
||||
await expect(
|
||||
runtime.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
'tab-readme',
|
||||
hashMarkdownContent('# Notes\n'),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow('unsupported_untitled')
|
||||
expect(await readFile(readme, 'utf8')).toBe('# Notes\n')
|
||||
})
|
||||
|
||||
it('never makes the file of an untitled tab that has none on disk', async () => {
|
||||
const untitled = join(root, 'untitled.md')
|
||||
const runtime = runtimeWithWindowTabs(
|
||||
[
|
||||
windowTab({
|
||||
id: untitled,
|
||||
filePath: untitled,
|
||||
relativePath: 'untitled.md',
|
||||
sourceFilePath: untitled,
|
||||
sourceRelativePath: 'untitled.md',
|
||||
isUntitled: true,
|
||||
isDirty: true
|
||||
})
|
||||
],
|
||||
sessionWithFile(untitled, 'untitled.md')
|
||||
)
|
||||
|
||||
await expect(runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, untitled)).rejects.toThrow(
|
||||
'ENOENT'
|
||||
)
|
||||
await expect(
|
||||
runtime.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
untitled,
|
||||
hashMarkdownContent(''),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow('unsupported_untitled')
|
||||
expect(existsSync(untitled)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("a phone's Markdown tab with no window, on a desktop's untitled document", () => {
|
||||
it('saves it as an ordinary file, because the saved session does not record untitled', async () => {
|
||||
const untitled = join(root, 'untitled.md')
|
||||
await writeFile(untitled, '')
|
||||
const runtime = await runtimeOnHost('local', sessionWithFile(untitled, 'untitled.md'))
|
||||
|
||||
const read = await runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, untitled)
|
||||
expect(read).toMatchObject({ content: '', editable: true })
|
||||
|
||||
await expect(
|
||||
runtime.saveMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, untitled, read.version, '# Phone\n')
|
||||
).resolves.toMatchObject({ content: '# Phone\n', isDirty: false })
|
||||
expect(await readFile(untitled, 'utf8')).toBe('# Phone\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe("a phone's Markdown tab with no window, on a file missing from disk", () => {
|
||||
it('fails the read and never makes the file on save', async () => {
|
||||
const missing = join(root, 'untitled.md')
|
||||
const runtime = await runtimeOnHost('local', sessionWithFile(missing, 'untitled.md'))
|
||||
|
||||
await expect(runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, missing)).rejects.toThrow(
|
||||
'ENOENT'
|
||||
)
|
||||
await expect(
|
||||
runtime.saveMobileMarkdownTab(
|
||||
`id:${TEST_WORKTREE_ID}`,
|
||||
missing,
|
||||
hashMarkdownContent(''),
|
||||
'# Phone\n'
|
||||
)
|
||||
).rejects.toThrow('ENOENT')
|
||||
expect(existsSync(missing)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,6 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMarkdownReadTabResult,
|
||||
RuntimeMarkdownSaveTabResult,
|
||||
RuntimeMobileSessionTabMove,
|
||||
RuntimeTerminalDriverState,
|
||||
RuntimeTerminalPresentation
|
||||
@@ -152,13 +150,6 @@ export type RuntimeNotifier = {
|
||||
runtimeEnvironmentId?: string | null,
|
||||
navigation?: RuntimeNavigationTarget
|
||||
): void
|
||||
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult>
|
||||
saveMobileMarkdownTab?(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
baseVersion: string,
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult>
|
||||
/** Closes the whole tab. */
|
||||
closeTerminal(tabId: string): void
|
||||
/** Drops one split pane main already closed; never closes its tab. */
|
||||
|
||||
@@ -19,16 +19,16 @@ import {
|
||||
import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options'
|
||||
import { resolveWorktreeHostRouting } from '../worktree-launch-host-repo'
|
||||
import { isFloatingWorkspaceSelector } from '../../../shared/floating-workspace-worktree'
|
||||
import { HeadlessMarkdownDocuments } from '../files/headless-markdown-document'
|
||||
import {
|
||||
readMarkdownTabFromDisk,
|
||||
saveMarkdownTabToDisk,
|
||||
type ListedMarkdownTab,
|
||||
type MarkdownTabDiskHost
|
||||
} from '../markdown-tab-disk-documents'
|
||||
import { createMarkdownTabDiskHost } from '../markdown-tab-disk-host'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
|
||||
export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends RuntimeHeadlessTabGroups {
|
||||
private readonly headlessMarkdown = new HeadlessMarkdownDocuments({
|
||||
requireStore: () => this.requireStore(),
|
||||
resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector),
|
||||
getSession: (id) => this.getWorkspaceSessionForWorktree(id),
|
||||
updateSession: (id, update) => this.updateHeadlessWorkspaceContent(id, update)
|
||||
})
|
||||
protected abstract getListedBrowserTabsByPageId(worktreeId: string): Map<string, BrowserTabInfo>
|
||||
protected abstract resolveMobileMarkdownWorktreeId(
|
||||
...args: Parameters<
|
||||
@@ -197,10 +197,11 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim
|
||||
tabId: string
|
||||
): Promise<RuntimeMarkdownReadTabResult> {
|
||||
const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId)
|
||||
if (!this.notifier?.readMobileMarkdownTab) {
|
||||
return this.headlessMarkdown.read(worktreeId, tabId)
|
||||
}
|
||||
return await this.notifier.readMobileMarkdownTab(worktreeId, tabId)
|
||||
return await readMarkdownTabFromDisk(
|
||||
this.markdownTabDiskHost(),
|
||||
worktreeId,
|
||||
this.listedMarkdownTab(worktreeId, tabId)
|
||||
)
|
||||
}
|
||||
|
||||
async saveMobileMarkdownTab(
|
||||
@@ -210,10 +211,45 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult> {
|
||||
const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId)
|
||||
if (!this.notifier?.saveMobileMarkdownTab) {
|
||||
return this.headlessMarkdown.save(worktreeId, tabId, baseVersion, content)
|
||||
return await saveMarkdownTabToDisk(
|
||||
this.markdownTabDiskHost(),
|
||||
worktreeId,
|
||||
this.listedMarkdownTab(worktreeId, tabId),
|
||||
baseVersion,
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
private listedMarkdownTab(worktreeId: string, tabId: string): ListedMarkdownTab {
|
||||
const tab = this.mobileSessionTabsByWorktree
|
||||
.get(worktreeId)
|
||||
?.tabs.find((candidate) => candidate.type === 'markdown' && candidate.id === tabId)
|
||||
if (tab?.type !== 'markdown') {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content)
|
||||
return tab
|
||||
}
|
||||
|
||||
private markdownTabDiskHost(): MarkdownTabDiskHost {
|
||||
return createMarkdownTabDiskHost({
|
||||
requireStore: () => this.requireStore(),
|
||||
getWorkspaceSessionForWorktree: (worktreeId) =>
|
||||
this.getWorkspaceSessionForWorktree(worktreeId),
|
||||
resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector),
|
||||
callEnvironment: (environmentId, method, params, expectedPairingRevision) =>
|
||||
this.callOrchestrationWorkerServer(
|
||||
environmentId,
|
||||
method,
|
||||
params,
|
||||
undefined,
|
||||
undefined,
|
||||
expectedPairingRevision === undefined
|
||||
? undefined
|
||||
: { expectedEnvironmentPairingRevision: expectedPairingRevision }
|
||||
),
|
||||
environmentPairingRevision: (environmentId) =>
|
||||
this.resolveOrchestrationWorkerServer(environmentId).pairingRevision
|
||||
})
|
||||
}
|
||||
|
||||
// Why: `getRepo(id)` is host-blind and never read `worktree.hostId`, which outranks every repo
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcEmitter = new EventEmitter()
|
||||
const ipcMainMock = {
|
||||
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
|
||||
ipcEmitter.on(channel, listener)
|
||||
}),
|
||||
removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
|
||||
ipcEmitter.removeListener(channel, listener)
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: ipcMainMock
|
||||
}))
|
||||
|
||||
describe('requestMobileMarkdownFromRenderer', () => {
|
||||
beforeEach(() => {
|
||||
ipcEmitter.removeAllListeners()
|
||||
ipcMainMock.on.mockClear()
|
||||
ipcMainMock.removeListener.mockClear()
|
||||
})
|
||||
|
||||
it('ignores markdown responses from other renderer processes', async () => {
|
||||
const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay')
|
||||
const mainWebContents = {
|
||||
send: vi.fn()
|
||||
}
|
||||
const otherWebContents = {}
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: mainWebContents
|
||||
}
|
||||
|
||||
const pending = requestMobileMarkdownFromRenderer(mainWindow as never, {
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
const sentRequest = mainWebContents.send.mock.calls[0]?.[1] as { id: string }
|
||||
|
||||
ipcEmitter.emit(
|
||||
'ui:mobileMarkdownResponse',
|
||||
{ sender: otherWebContents },
|
||||
{ id: sentRequest.id, ok: false, error: 'wrong_renderer' }
|
||||
)
|
||||
ipcEmitter.emit(
|
||||
'ui:mobileMarkdownResponse',
|
||||
{ sender: mainWebContents },
|
||||
{
|
||||
id: sentRequest.id,
|
||||
ok: true,
|
||||
result: {
|
||||
tabId: 'tab-md',
|
||||
filePath: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
content: '# ok',
|
||||
isDirty: false,
|
||||
version: 'v1',
|
||||
source: 'file',
|
||||
editable: true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ content: '# ok' })
|
||||
})
|
||||
|
||||
it('rejects and cleans up when the BrowserWindow closes and webContents becomes unavailable', async () => {
|
||||
const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay')
|
||||
const webContents = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn()
|
||||
})
|
||||
let windowClosed = false
|
||||
const mainWindow = Object.assign(new EventEmitter(), {
|
||||
isDestroyed: () => false
|
||||
})
|
||||
Object.defineProperty(mainWindow, 'webContents', {
|
||||
get: () => {
|
||||
if (windowClosed) {
|
||||
throw new Error('webContents unavailable after close')
|
||||
}
|
||||
return webContents
|
||||
}
|
||||
})
|
||||
|
||||
const pending = requestMobileMarkdownFromRenderer(mainWindow as never, {
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(1)
|
||||
|
||||
windowClosed = true
|
||||
mainWindow.emit('closed')
|
||||
|
||||
await expect(pending).rejects.toThrow('renderer_unavailable')
|
||||
expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(0)
|
||||
expect(webContents.listenerCount('destroyed')).toBe(0)
|
||||
expect(webContents.listenerCount('render-process-gone')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type {
|
||||
RuntimeMarkdownReadTabResult,
|
||||
RuntimeMarkdownSaveTabResult,
|
||||
RuntimeMobileMarkdownRequest,
|
||||
RuntimeMobileMarkdownResponse
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
|
||||
const MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS = 20_000
|
||||
|
||||
type RendererMobileMarkdownRequest = RuntimeMobileMarkdownRequest extends infer Request
|
||||
? Request extends { id: string }
|
||||
? Omit<Request, 'id'>
|
||||
: never
|
||||
: never
|
||||
|
||||
export async function requestMobileMarkdownFromRenderer(
|
||||
mainWindow: BrowserWindow,
|
||||
request: RendererMobileMarkdownRequest
|
||||
): Promise<RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult> {
|
||||
if (mainWindow.isDestroyed()) {
|
||||
throw new Error('renderer_unavailable')
|
||||
}
|
||||
const webContents = mainWindow.webContents
|
||||
const id = randomUUID()
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable'))
|
||||
const finish = (
|
||||
error?: Error,
|
||||
result?: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult
|
||||
): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse)
|
||||
if (typeof mainWindow.removeListener === 'function') {
|
||||
mainWindow.removeListener('closed', onRendererUnavailable)
|
||||
}
|
||||
if (typeof webContents.removeListener === 'function') {
|
||||
webContents.removeListener('destroyed', onRendererUnavailable)
|
||||
webContents.removeListener('render-process-gone', onRendererUnavailable)
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else if (result) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error('renderer_unavailable'))
|
||||
}
|
||||
}
|
||||
const timeout = setTimeout(
|
||||
() => finish(new Error('renderer_timeout')),
|
||||
MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS
|
||||
)
|
||||
const onResponse = (
|
||||
event: Electron.IpcMainEvent,
|
||||
response: RuntimeMobileMarkdownResponse
|
||||
): void => {
|
||||
if (event.sender !== webContents) {
|
||||
return
|
||||
}
|
||||
if (response.id !== id) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
finish(undefined, response.result)
|
||||
} else {
|
||||
finish(new Error(response.error))
|
||||
}
|
||||
}
|
||||
ipcMain.on('ui:mobileMarkdownResponse', onResponse)
|
||||
if (typeof mainWindow.once === 'function') {
|
||||
mainWindow.once('closed', onRendererUnavailable)
|
||||
}
|
||||
if (typeof webContents.once === 'function') {
|
||||
webContents.once('destroyed', onRendererUnavailable)
|
||||
webContents.once('render-process-gone', onRendererUnavailable)
|
||||
}
|
||||
try {
|
||||
webContents.send('ui:mobileMarkdownRequest', { id, ...request })
|
||||
} catch {
|
||||
finish(new Error('renderer_unavailable'))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract'
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } }))
|
||||
vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() }))
|
||||
vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() }))
|
||||
vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() }))
|
||||
vi.mock('./session-tab-close-request-relay', () => ({
|
||||
requestSessionTabCloseFromRenderer: vi.fn()
|
||||
|
||||
@@ -4,16 +4,11 @@ import { ipcMain } from 'electron'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type {
|
||||
RuntimeMarkdownReadTabResult,
|
||||
RuntimeMarkdownSaveTabResult
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types'
|
||||
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
|
||||
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
|
||||
import { requestAgentLaunchTabPublishFromRenderer } from './agent-launch-tab-publish-relay'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay'
|
||||
import { registerRendererDocumentNavigation } from './renderer-document-navigation'
|
||||
import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender'
|
||||
import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay'
|
||||
@@ -205,20 +200,6 @@ export function registerRuntimeWindowLifecycle(
|
||||
runtimeEnvironmentId,
|
||||
...(navigation ? { navigation } : {})
|
||||
}),
|
||||
readMobileMarkdownTab: (worktreeId, tabId) =>
|
||||
requestMobileMarkdownFromRenderer(mainWindow, {
|
||||
operation: 'read',
|
||||
worktreeId,
|
||||
tabId
|
||||
}) as Promise<RuntimeMarkdownReadTabResult>,
|
||||
saveMobileMarkdownTab: (worktreeId, tabId, baseVersion, content) =>
|
||||
requestMobileMarkdownFromRenderer(mainWindow, {
|
||||
operation: 'save',
|
||||
worktreeId,
|
||||
tabId,
|
||||
baseVersion,
|
||||
content
|
||||
}) as Promise<RuntimeMarkdownSaveTabResult>,
|
||||
closeTerminal: (tabId) => send('ui:closeTerminal', { kind: 'tab', tabId }),
|
||||
closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }),
|
||||
closeTerminalTab: (tabId, options) =>
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract'
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } }))
|
||||
vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() }))
|
||||
vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() }))
|
||||
vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() }))
|
||||
vi.mock('./session-tab-close-request-relay', () => ({
|
||||
requestSessionTabCloseFromRenderer: vi.fn()
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments'
|
||||
import { ipcRenderer, webFrame } from 'electron'
|
||||
import type {
|
||||
RuntimeMobileMarkdownRequest,
|
||||
RuntimeMobileMarkdownResponse
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
import {
|
||||
richMarkdownContextMenuCommandChannel,
|
||||
richMarkdownContextMenuTargetChannel,
|
||||
@@ -41,17 +37,6 @@ export const uiClipboardAndWindowControlsApi = {
|
||||
ipcRenderer.on('ui:openDiffFromMobile', listener)
|
||||
return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener)
|
||||
},
|
||||
onMobileMarkdownRequest: (
|
||||
callback: (request: RuntimeMobileMarkdownRequest) => void
|
||||
): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) =>
|
||||
callback(request)
|
||||
ipcRenderer.on('ui:mobileMarkdownRequest', listener)
|
||||
return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener)
|
||||
},
|
||||
respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => {
|
||||
ipcRenderer.send('ui:mobileMarkdownResponse', response)
|
||||
},
|
||||
onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, target: TerminalSurfaceCloseTarget) =>
|
||||
callback(target)
|
||||
|
||||
@@ -42,10 +42,6 @@ import type {
|
||||
RuntimeTerminalCreateRequestPayload,
|
||||
RuntimeTerminalPresentation
|
||||
} from '../../shared/runtime-types'
|
||||
import type {
|
||||
RuntimeMobileMarkdownRequest,
|
||||
RuntimeMobileMarkdownResponse
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
|
||||
import type {
|
||||
TerminalTabCloseRequest,
|
||||
@@ -287,8 +283,6 @@ export type UiCommandEventApi = {
|
||||
navigation?: RuntimeNavigationTarget
|
||||
}) => void
|
||||
) => () => void
|
||||
onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void
|
||||
respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse) => void
|
||||
onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void) => () => void
|
||||
onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void
|
||||
respondTerminalTabClose: (response: TerminalTabCloseResponse) => void
|
||||
|
||||
@@ -95,11 +95,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queueSave(
|
||||
file,
|
||||
content,
|
||||
detail.overwrite ? 'overwrite' : detail.origin === 'mobile' ? 'mobile' : 'user'
|
||||
)
|
||||
const result = await queueSave(file, content, detail.overwrite ? 'overwrite' : 'user')
|
||||
if (result !== 'written') {
|
||||
detail.reject(result === 'blocked-stale' ? EDITOR_STALE_SAVE_MESSAGE : NOT_SAVED_MESSAGE)
|
||||
return
|
||||
|
||||
@@ -48,8 +48,6 @@ export type EditorSaveFileTarget = {
|
||||
fallbackContent?: string
|
||||
/** Only the conflict banner's Overwrite sets this. */
|
||||
overwrite?: true
|
||||
/** A save sent from the mobile app; its refusal is reported to mobile, not the desktop banner. */
|
||||
origin?: 'mobile'
|
||||
}
|
||||
|
||||
export type EditorSaveAndCloseDetail = {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createEditorStore } from './editor-autosave-controller-test-fixture'
|
||||
import { reloadTabContentFromDisk } from './ExternalFileChangeBanner'
|
||||
import { mayPushTabTextIntoModel } from './editor-stale-mount-guard'
|
||||
import { stampCleanTabDiskBaseline } from './editor-clean-tab-disk-baseline'
|
||||
import { rollBackFailedMobileSave } from '@/runtime/mobile-markdown-save-rollback'
|
||||
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
|
||||
import { GUARDED_PATH, stubGuardedDisk } from './editor-save-guarded-disk-fixture'
|
||||
|
||||
@@ -91,20 +90,6 @@ it('reload leaves no draft', () => {
|
||||
expectNoCleanDraft(store)
|
||||
})
|
||||
|
||||
it('a refused mobile save restoring a clean tab leaves no draft of the disk text', () => {
|
||||
const { store, id } = openCleanTab('disk')
|
||||
store.getState().setEditorDraft(id, 'phone')
|
||||
store.getState().markFileDirty(id, true)
|
||||
rollBackFailedMobileSave(new Error('save_timeout'), {
|
||||
fileId: id,
|
||||
injectedContent: 'phone',
|
||||
previousDraft: 'disk',
|
||||
previousDirty: false,
|
||||
stampedBaseline: null
|
||||
})
|
||||
expectNoCleanDraft(store)
|
||||
})
|
||||
|
||||
it("never restores a clean tab's leftover draft into the model", () => {
|
||||
const { store, id } = openCleanTab('disk')
|
||||
// A Markdown-whitespace draft may stay on a clean tab; it still never enters the model.
|
||||
|
||||
@@ -10,11 +10,11 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-typ
|
||||
import { writeRuntimeFile } from '@/runtime/runtime-file-mutation-client'
|
||||
|
||||
/**
|
||||
* `user`: an explicit desktop save (shortcut, save-and-close dialog). `mobile`: a save from the
|
||||
* mobile app. `restart`: the save-all before a restart. `overwrite`: the conflict banner's
|
||||
* Overwrite, the only consent to replace a disk change.
|
||||
* `user`: an explicit desktop save (shortcut, save-and-close dialog). `restart`: the save-all
|
||||
* before a restart. `overwrite`: the conflict banner's Overwrite, the only consent to replace a
|
||||
* disk change.
|
||||
*/
|
||||
export type EditorSaveTrigger = 'autosave' | 'user' | 'mobile' | 'restart' | 'overwrite'
|
||||
export type EditorSaveTrigger = 'autosave' | 'user' | 'restart' | 'overwrite'
|
||||
|
||||
/**
|
||||
* `written`: disk holds the text. `blocked-stale`: the host refused because disk moved on since the
|
||||
|
||||
@@ -159,10 +159,7 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue {
|
||||
const connectionId = fileContext.connectionId
|
||||
const precondition = editorSavePrecondition(liveFile, trigger)
|
||||
if (precondition.kind === 'blocked') {
|
||||
// Why: a mobile refusal is reported to mobile; the desktop tab may not even be dirty.
|
||||
if (trigger !== 'mobile') {
|
||||
setModelExternalMutation(store.getState(), file.id, 'changed')
|
||||
}
|
||||
setModelExternalMutation(store.getState(), file.id, 'changed')
|
||||
return 'blocked-stale'
|
||||
}
|
||||
// Why: stamp before writing so useEditorExternalWatch ignores our own fs:changed echo (editor-self-write-registry).
|
||||
@@ -191,13 +188,11 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue {
|
||||
if (!writeResult.ok) {
|
||||
clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId)
|
||||
// Why: the banner (or deleted tombstone) explains the refusal and pauses autosave.
|
||||
if (trigger !== 'mobile') {
|
||||
setModelExternalMutation(
|
||||
store.getState(),
|
||||
file.id,
|
||||
writeResult.diskConflict === 'missing' ? 'deleted' : 'changed'
|
||||
)
|
||||
}
|
||||
setModelExternalMutation(
|
||||
store.getState(),
|
||||
file.id,
|
||||
writeResult.diskConflict === 'missing' ? 'deleted' : 'changed'
|
||||
)
|
||||
return 'blocked-stale'
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,6 @@ import {
|
||||
overwriteDiskWithTabEdits
|
||||
} from './ExternalFileChangeBanner'
|
||||
import { isStaleCachedEditorText, mayPushTabTextIntoModel } from './editor-stale-mount-guard'
|
||||
import {
|
||||
rollBackFailedMobileSave,
|
||||
stampMobileVerifiedBaseline
|
||||
} from '@/runtime/mobile-markdown-save-rollback'
|
||||
import { EDITOR_STALE_SAVE_MESSAGE } from './editor-save-disk-precondition'
|
||||
import { stampCleanTabDiskBaseline } from './editor-clean-tab-disk-baseline'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn() }))
|
||||
@@ -150,46 +145,6 @@ describe('a clean tab of a dirty shared model', () => {
|
||||
expect(store.getState().editorDrafts[b.id]).toBe('unsaved in B')
|
||||
})
|
||||
|
||||
it('a phone save to a dirty model adopts nothing; after it fails the dirty sibling is still refused', async () => {
|
||||
const { store, a, b, tab } = openSharedTabsWithDirtySibling()
|
||||
const cleanup = attachEditorAutosaveController(store)
|
||||
try {
|
||||
// The agent wrote 'E'; the phone read it and saves through the clean tab A.
|
||||
expect(stampMobileVerifiedBaseline(a, { content: 'E', source: 'file' })).toBeNull()
|
||||
await expect(
|
||||
requestEditorFileSave({ fileId: a.id, fallbackContent: 'phone', origin: 'mobile' })
|
||||
).rejects.toThrow(/changed on disk/)
|
||||
expect(tab(a.id)?.lastKnownDiskSignature).toBe(getDiskBaselineSignature('S0'))
|
||||
|
||||
await expect(requestEditorFileSave({ fileId: b.id })).rejects.toThrow(/changed on disk/)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous shared baseline when a phone save that adopted one fails', () => {
|
||||
const { store, a, b, tab } = openSharedTabsWithDirtySibling()
|
||||
store.getState().clearEditorDraft(b.id)
|
||||
store.getState().markFileDirty(b.id, false)
|
||||
|
||||
const adopted = stampMobileVerifiedBaseline(tab(a.id)!, { content: 'E', source: 'file' })
|
||||
expect(adopted).toEqual({
|
||||
adopted: getDiskBaselineSignature('E'),
|
||||
previous: getDiskBaselineSignature('S0')
|
||||
})
|
||||
rollBackFailedMobileSave(new Error(EDITOR_STALE_SAVE_MESSAGE), {
|
||||
fileId: a.id,
|
||||
injectedContent: 'phone',
|
||||
previousDraft: undefined,
|
||||
previousDirty: false,
|
||||
stampedBaseline: adopted
|
||||
})
|
||||
|
||||
for (const id of [a.id, b.id]) {
|
||||
expect(tab(id)?.lastKnownDiskSignature).toBe(getDiskBaselineSignature('S0'))
|
||||
}
|
||||
})
|
||||
|
||||
it("drops a clean tab's leftover draft when the model moves to a newer load, so it cannot overwrite", async () => {
|
||||
const { store, a, b, tab } = openSharedTabsWithDirtySibling()
|
||||
// B undid back to clean but kept a draft equal to the old text; the tab is hidden.
|
||||
|
||||
@@ -20,9 +20,7 @@ import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signa
|
||||
import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-save-events'
|
||||
import { GUARDED_PATH, stubGuardedDisk } from './editor-save-guarded-disk-fixture'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { rollBackFailedMobileSave } from '@/runtime/mobile-markdown-save-rollback'
|
||||
import { toast } from 'sonner'
|
||||
import { EDITOR_STALE_SAVE_MESSAGE } from './editor-save-disk-precondition'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn() }))
|
||||
const storeRef = vi.hoisted((): { current: ReturnType<typeof createEditorStore> | null } => ({
|
||||
@@ -236,24 +234,6 @@ describe('two tabs sharing one model', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a refused mobile save that carried desktop edits marks both tabs', () => {
|
||||
const { store, a, b, edit, tab } = openSharedTabs('A')
|
||||
edit(a.id, 'mobile text')
|
||||
|
||||
const error = rollBackFailedMobileSave(new Error(EDITOR_STALE_SAVE_MESSAGE), {
|
||||
fileId: a.id,
|
||||
injectedContent: 'mobile text',
|
||||
previousDraft: 'desktop edits',
|
||||
previousDirty: true,
|
||||
stampedBaseline: null
|
||||
})
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(store.getState().editorDrafts[a.id]).toBe('desktop edits')
|
||||
expect(tab(a.id)?.externalMutation).toBe('changed')
|
||||
expect(tab(b.id)?.externalMutation).toBe('changed')
|
||||
})
|
||||
|
||||
it("never touches a rich-markdown sibling's pending typing; at worst its save is refused", async () => {
|
||||
const md = '/repo/notes.md'
|
||||
let released = false
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-
|
||||
import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration'
|
||||
import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event'
|
||||
import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background'
|
||||
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
||||
import { remoteRuntimeTerminalColorPush } from '@/runtime/remote-runtime-terminal-color-push'
|
||||
import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -51,7 +50,6 @@ export function installAppLifetimeIpcEvents(): () => void {
|
||||
const unsubs: (() => void)[] = []
|
||||
const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher()
|
||||
unsubs.push(backgroundWakeDispatcher.dispose)
|
||||
unsubs.push(attachMobileMarkdownBridge())
|
||||
unsubs.push(
|
||||
window.api.automations.onChanged((payload) => emitAutomationsChangedWindowEvent(payload))
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../store'
|
||||
import type { AppState } from '../store/types'
|
||||
import { buildEditorSessionData } from '../lib/workspace-session'
|
||||
@@ -6,14 +6,7 @@ import { sameOpenFiles } from './web-session-tabs-sync/state-equality-files'
|
||||
import { buildRuntimeMobileOpenFilesProjection } from './sync-runtime-graph/sync-projections'
|
||||
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots'
|
||||
import { makeState } from './sync-runtime-graph-test-harness'
|
||||
import { attachMobileMarkdownBridge } from './mobile-markdown-bridge'
|
||||
import {
|
||||
cleanupMobileMarkdownBridgeHarness,
|
||||
openMarkdownFile,
|
||||
resetEditorState,
|
||||
sendRequest,
|
||||
setupWindow
|
||||
} from './mobile-markdown-bridge-test-harness'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: () => null
|
||||
@@ -21,6 +14,33 @@ vi.mock('@/lib/connection-context', () => ({
|
||||
|
||||
const FILE_ID = '/repo/README.md'
|
||||
|
||||
function resetEditorState(): void {
|
||||
useAppStore.setState({ openFiles: [], editorDrafts: {} })
|
||||
const tab: Tab = {
|
||||
id: 'tab-md',
|
||||
entityId: FILE_ID,
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'editor',
|
||||
label: FILE_ID,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { 'wt-1': [tab] } })
|
||||
}
|
||||
|
||||
function openMarkdownFile(): void {
|
||||
useAppStore.getState().openFile({
|
||||
filePath: FILE_ID,
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
|
||||
/** Dirties the tab through its buffer record and returns the store on either side. */
|
||||
function dirtyThroughRecord(): { before: AppState; after: AppState } {
|
||||
const before = useAppStore.getState()
|
||||
@@ -36,10 +56,6 @@ describe('a dirty change on the buffer record reaches every openFiles reader', (
|
||||
openMarkdownFile()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMobileMarkdownBridgeHarness()
|
||||
})
|
||||
|
||||
it('web session tab sync sees the open files change', () => {
|
||||
const { before, after } = dirtyThroughRecord()
|
||||
|
||||
@@ -81,28 +97,4 @@ describe('a dirty change on the buffer record reaches every openFiles reader', (
|
||||
expect(markdownTab(before)).toMatchObject({ isDirty: false })
|
||||
expect(markdownTab(after)).toMatchObject({ isDirty: true })
|
||||
})
|
||||
|
||||
it('the mobile markdown bridge reports the tab dirty', async () => {
|
||||
setupWindow({
|
||||
readFile: vi.fn().mockResolvedValue({ content: 'disk', isBinary: false })
|
||||
})
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
dirtyThroughRecord()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-dirty',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { source: 'file', isDirty: true }
|
||||
})
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
hashMarkdownContent,
|
||||
MOBILE_MARKDOWN_EDIT_MAX_BYTES
|
||||
} from '../../../shared/mobile-markdown-document'
|
||||
import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller'
|
||||
import { useAppStore } from '../store'
|
||||
import { attachMobileMarkdownBridge } from './mobile-markdown-bridge'
|
||||
import {
|
||||
cleanupMobileMarkdownBridgeHarness,
|
||||
createDeferred,
|
||||
openMarkdownFile,
|
||||
resetEditorState,
|
||||
sendRequest,
|
||||
setupWindow,
|
||||
guardedWriteRefusal
|
||||
} from './mobile-markdown-bridge-test-harness'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: () => null
|
||||
}))
|
||||
|
||||
describe('mobile markdown bridge save guards', () => {
|
||||
beforeEach(() => {
|
||||
resetEditorState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMobileMarkdownBridgeHarness()
|
||||
})
|
||||
|
||||
it('restores the previous desktop draft when a mobile save write fails', async () => {
|
||||
openMarkdownFile()
|
||||
const state = useAppStore.getState()
|
||||
state.setEditorDraft('/repo/README.md', 'desktop draft')
|
||||
state.markFileDirty('/repo/README.md', true)
|
||||
setupWindow({
|
||||
readFile: vi.fn().mockResolvedValue({ content: 'desktop draft', isBinary: false }),
|
||||
writeFile: vi.fn().mockRejectedValue(new Error('disk full'))
|
||||
})
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-fail',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('desktop draft'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ id: 'save-fail', ok: false })
|
||||
expect(useAppStore.getState().editorDrafts['/repo/README.md']).toBe('desktop draft')
|
||||
expect(useAppStore.getState().openFiles[0]?.isDirty).toBe(true)
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous desktop draft when save verification fails after write', async () => {
|
||||
openMarkdownFile()
|
||||
const state = useAppStore.getState()
|
||||
state.setEditorDraft('/repo/README.md', 'desktop draft')
|
||||
state.markFileDirty('/repo/README.md', true)
|
||||
state.setLastKnownDiskSignature('/repo/README.md', getDiskBaselineSignature('desktop draft'))
|
||||
let diskContent = 'desktop draft'
|
||||
const readFile = vi.fn().mockImplementation(async () => ({
|
||||
content: 'verified mismatch',
|
||||
isBinary: false
|
||||
}))
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-verify-fail',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('desktop draft'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ id: 'save-verify-fail', ok: false })
|
||||
expect(diskContent).toBe('mobile edit')
|
||||
expect(useAppStore.getState().editorDrafts['/repo/README.md']).toBe('desktop draft')
|
||||
expect(useAppStore.getState().openFiles[0]?.isDirty).toBe(true)
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes saves so duplicate base versions do not both write', async () => {
|
||||
openMarkdownFile()
|
||||
let diskContent = 'original'
|
||||
const firstWrite = createDeferred()
|
||||
const readFile = vi.fn().mockImplementation(async () => ({
|
||||
content: diskContent,
|
||||
isBinary: false
|
||||
}))
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
if (content === 'first edit') {
|
||||
await firstWrite.promise
|
||||
}
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const first = sendRequest({
|
||||
id: 'save-a',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'first edit'
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const second = sendRequest({
|
||||
id: 'save-b',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'second edit'
|
||||
})
|
||||
firstWrite.resolve()
|
||||
|
||||
await expect(first).resolves.toMatchObject({ id: 'save-a', ok: true })
|
||||
await expect(second).resolves.toMatchObject({ id: 'save-b', ok: false, error: 'conflict' })
|
||||
expect(diskContent).toBe('first edit')
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a duplicate same-content save as idempotent success', async () => {
|
||||
openMarkdownFile()
|
||||
let diskContent = 'original'
|
||||
const firstWrite = createDeferred()
|
||||
const readFile = vi.fn().mockImplementation(async () => ({
|
||||
content: diskContent,
|
||||
isBinary: false
|
||||
}))
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
if (content === 'mobile edit' && diskContent === 'original') {
|
||||
await firstWrite.promise
|
||||
}
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const first = sendRequest({
|
||||
id: 'save-a',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const second = sendRequest({
|
||||
id: 'save-b',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
firstWrite.resolve()
|
||||
|
||||
await expect(first).resolves.toMatchObject({ id: 'save-a', ok: true })
|
||||
await expect(second).resolves.toMatchObject({ id: 'save-b', ok: true })
|
||||
expect(diskContent).toBe('mobile edit')
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects oversized multibyte mobile saves before writing', async () => {
|
||||
openMarkdownFile()
|
||||
const content = '😀'.repeat(Math.floor(MOBILE_MARKDOWN_EDIT_MAX_BYTES / 4) + 1)
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
setupWindow({
|
||||
readFile: vi.fn().mockResolvedValue({ content: 'original', isBinary: false }),
|
||||
writeFile
|
||||
})
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-large-multibyte',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'save-large-multibyte',
|
||||
ok: false,
|
||||
error: 'file_too_large'
|
||||
})
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,151 +0,0 @@
|
||||
import { expect, vi } from 'vitest'
|
||||
import {
|
||||
compareDiskWritePrecondition,
|
||||
type FileWriteDiskPrecondition
|
||||
} from '../../../shared/file-write-disk-precondition'
|
||||
import type { RuntimeMobileMarkdownRequest } from '../../../shared/mobile-markdown-document'
|
||||
import { useAppStore } from '../store'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
|
||||
type WindowStub = {
|
||||
addEventListener: Window['addEventListener']
|
||||
removeEventListener: Window['removeEventListener']
|
||||
dispatchEvent: Window['dispatchEvent']
|
||||
setTimeout: Window['setTimeout']
|
||||
clearTimeout: Window['clearTimeout']
|
||||
api: {
|
||||
ui: {
|
||||
onMobileMarkdownRequest: (
|
||||
callback: (request: RuntimeMobileMarkdownRequest) => void
|
||||
) => () => void
|
||||
respondMobileMarkdownRequest: ReturnType<typeof vi.fn>
|
||||
}
|
||||
fs: {
|
||||
readFile: ReturnType<typeof vi.fn>
|
||||
writeFile: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | null = null
|
||||
|
||||
export function setupWindow({
|
||||
readFile,
|
||||
writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
}: {
|
||||
readFile: ReturnType<typeof vi.fn>
|
||||
writeFile?: ReturnType<typeof vi.fn>
|
||||
}): { responses: unknown[] } {
|
||||
const eventTarget = new EventTarget()
|
||||
const responses: unknown[] = []
|
||||
mobileMarkdownHandler = null
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
api: {
|
||||
ui: {
|
||||
onMobileMarkdownRequest: (callback) => {
|
||||
mobileMarkdownHandler = callback
|
||||
return () => {
|
||||
mobileMarkdownHandler = null
|
||||
}
|
||||
},
|
||||
respondMobileMarkdownRequest: vi.fn((response) => responses.push(response))
|
||||
},
|
||||
fs: { readFile, writeFile }
|
||||
}
|
||||
} satisfies WindowStub)
|
||||
return { responses }
|
||||
}
|
||||
|
||||
export function resetEditorState(): void {
|
||||
useAppStore.setState({
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
worktreesByRepo: {
|
||||
repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo', branch: 'main', hostId: 'local' }]
|
||||
},
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: '/repo',
|
||||
displayName: 'repo',
|
||||
kind: 'git',
|
||||
executionHostId: 'local'
|
||||
}
|
||||
]
|
||||
} as never)
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { 'wt-1': [markdownUnifiedTab('tab-md', '/repo/README.md', 'group-1')] }
|
||||
})
|
||||
}
|
||||
|
||||
/** The desktop tab strip entry a phone names a markdown tab by. */
|
||||
export function markdownUnifiedTab(id: string, fileId: string, groupId: string): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: fileId,
|
||||
groupId,
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'editor',
|
||||
label: fileId,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function openMarkdownFile(): void {
|
||||
useAppStore.getState().openFile({
|
||||
filePath: '/repo/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendRequest(request: RuntimeMobileMarkdownRequest): Promise<unknown> {
|
||||
expect(mobileMarkdownHandler).not.toBeNull()
|
||||
mobileMarkdownHandler?.(request)
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const response = (
|
||||
window.api.ui.respondMobileMarkdownRequest as ReturnType<typeof vi.fn>
|
||||
).mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((candidate) => candidate?.id === request.id)
|
||||
if (response) {
|
||||
return response
|
||||
}
|
||||
}
|
||||
throw new Error(`No response for ${request.id}`)
|
||||
}
|
||||
|
||||
export function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve: () => void = () => {}
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
export function cleanupMobileMarkdownBridgeHarness(): void {
|
||||
vi.unstubAllGlobals()
|
||||
mobileMarkdownHandler = null
|
||||
}
|
||||
|
||||
/** What the real writer answers for `diskContent`: a refusal when the precondition no longer holds. */
|
||||
export function guardedWriteRefusal(
|
||||
diskPrecondition: FileWriteDiskPrecondition | undefined,
|
||||
diskContent: string | null
|
||||
): { ok: false; diskConflict: 'changed' | 'missing' } | null {
|
||||
const diskConflict = diskPrecondition
|
||||
? compareDiskWritePrecondition(diskPrecondition, diskContent)
|
||||
: null
|
||||
return diskConflict ? { ok: false, diskConflict } : null
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
hashMarkdownContent,
|
||||
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
|
||||
MOBILE_MARKDOWN_READ_MAX_BYTES
|
||||
} from '../../../shared/mobile-markdown-document'
|
||||
import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller'
|
||||
import { ORCA_EDITOR_FILE_CACHE_STALE_EVENT } from '../components/editor/editor-autosave'
|
||||
import { registerPendingEditorFlush } from '../components/editor/editor-pending-flush'
|
||||
import { useAppStore } from '../store'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { attachMobileMarkdownBridge } from './mobile-markdown-bridge'
|
||||
import { MOBILE_STALE_SAVE_MESSAGE } from './mobile-markdown-save-rollback'
|
||||
import {
|
||||
cleanupMobileMarkdownBridgeHarness,
|
||||
markdownUnifiedTab,
|
||||
openMarkdownFile,
|
||||
resetEditorState,
|
||||
sendRequest,
|
||||
setupWindow,
|
||||
guardedWriteRefusal
|
||||
} from './mobile-markdown-bridge-test-harness'
|
||||
|
||||
vi.mock('@/lib/connection-context', () => ({
|
||||
getConnectionIdForFile: () => null
|
||||
}))
|
||||
|
||||
describe('mobile markdown bridge', () => {
|
||||
beforeEach(() => {
|
||||
resetEditorState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMobileMarkdownBridgeHarness()
|
||||
})
|
||||
|
||||
it('flushes pending rich markdown changes before read', async () => {
|
||||
openMarkdownFile()
|
||||
setupWindow({
|
||||
readFile: vi.fn().mockResolvedValue({ content: 'disk', isBinary: false })
|
||||
})
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
const unregisterFlush = registerPendingEditorFlush('/repo/README.md', () => {
|
||||
useAppStore.getState().setEditorDraft('/repo/README.md', '# pending\n')
|
||||
useAppStore.getState().markFileDirty('/repo/README.md', true)
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-1',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'read-1',
|
||||
ok: true,
|
||||
result: { content: '# pending\n', source: 'draft', editable: true }
|
||||
})
|
||||
} finally {
|
||||
unregisterFlush()
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a restored floating-workspace tab for a paired client as user-named', async () => {
|
||||
useAppStore.getState().openFile({
|
||||
filePath: '/Users/me/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
const tabId = useAppStore.getState().openFiles[0]?.id ?? ''
|
||||
const readFile = vi.fn().mockResolvedValue({ content: '# notes', isBinary: false })
|
||||
setupWindow({ readFile })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-floating',
|
||||
operation: 'read',
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
tabId
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { content: '# notes', source: 'file' } })
|
||||
expect(readFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/Users/me/notes.md', access: { kind: 'user-file' } })
|
||||
)
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a project tab for a paired client inside its root', async () => {
|
||||
openMarkdownFile()
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'disk', isBinary: false })
|
||||
setupWindow({ readFile })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
await sendRequest({
|
||||
id: 'read-project',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(readFile.mock.calls[0]?.[0]).not.toHaveProperty('access')
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a markdown tab that sits in a split group the desktop is not focused on', async () => {
|
||||
openMarkdownFile()
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [markdownUnifiedTab('tab-md', '/repo/README.md', 'group-split')]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'group-main', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] },
|
||||
{
|
||||
id: 'group-split',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'tab-md',
|
||||
tabOrder: ['tab-md']
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-main' }
|
||||
})
|
||||
setupWindow({ readFile: vi.fn().mockResolvedValue({ content: '# split', isBinary: false }) })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-split',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { content: '# split' } })
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the requested tab when the same file is open in two groups', async () => {
|
||||
openMarkdownFile()
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
markdownUnifiedTab('tab-main', '/repo/README.md', 'group-main'),
|
||||
markdownUnifiedTab('tab-split', '/repo/README.md', 'group-split')
|
||||
]
|
||||
}
|
||||
})
|
||||
setupWindow({ readFile: vi.fn().mockResolvedValue({ content: '# both', isBinary: false }) })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-duplicate',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-split'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { tabId: 'tab-split', filePath: '/repo/README.md', content: '# both' }
|
||||
})
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects save when a clean file changed after mobile read', async () => {
|
||||
openMarkdownFile()
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
setupWindow({
|
||||
readFile: vi.fn().mockResolvedValue({ content: 'changed on disk', isBinary: false }),
|
||||
writeFile
|
||||
})
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-1',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ id: 'save-1', ok: false, error: 'conflict' })
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('saves through the editor save controller and verifies written content', async () => {
|
||||
openMarkdownFile()
|
||||
let diskContent = 'original'
|
||||
const readFile = vi.fn().mockImplementation(async () => ({
|
||||
content: diskContent,
|
||||
isBinary: false
|
||||
}))
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-2',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: '/repo/README.md',
|
||||
content: 'mobile edit',
|
||||
connectionId: undefined,
|
||||
expectedExecutionHostId: 'local',
|
||||
diskPrecondition: expect.objectContaining({ allowMissing: true })
|
||||
})
|
||||
)
|
||||
expect(response).toMatchObject({
|
||||
id: 'save-2',
|
||||
ok: true,
|
||||
result: { content: 'mobile edit', isDirty: false }
|
||||
})
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a refused phone save to the phone only, with its own message', async () => {
|
||||
openMarkdownFile()
|
||||
const tabId = useAppStore.getState().openFiles[0].id
|
||||
// The tab never loaded on the desktop; the phone read 'original', then an agent wrote.
|
||||
let diskContent = 'original'
|
||||
const readFile = vi.fn().mockImplementation(async () => {
|
||||
const content = diskContent
|
||||
diskContent = 'agent rewrite'
|
||||
return { content, isBinary: false }
|
||||
})
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-stale',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('original'),
|
||||
content: 'mobile edit'
|
||||
})
|
||||
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
diskPrecondition: expect.objectContaining({
|
||||
expectedSignature: getDiskBaselineSignature('original')
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(diskContent).toBe('agent rewrite')
|
||||
expect(response).toMatchObject({
|
||||
id: 'save-stale',
|
||||
ok: false,
|
||||
error: MOBILE_STALE_SAVE_MESSAGE
|
||||
})
|
||||
const tab = useAppStore.getState().openFiles.find((file) => file.id === tabId)
|
||||
expect(tab?.externalMutation).toBeUndefined()
|
||||
expect(tab?.isDirty).toBe(false)
|
||||
// The baseline stamped from the phone's read is withdrawn with the failed save.
|
||||
expect(tab?.lastKnownDiskSignature).toBeUndefined()
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['saves', 'D1'],
|
||||
['types', null]
|
||||
] as const)(
|
||||
'refuses a phone save when the desktop %s during the phone-verifying read',
|
||||
async (_label, desktopSave) => {
|
||||
openMarkdownFile()
|
||||
const tabId = useAppStore.getState().openFiles[0].id
|
||||
useAppStore.getState().setLastKnownDiskSignature(tabId, getDiskBaselineSignature('D0'))
|
||||
let diskContent = 'D0'
|
||||
const readFile = vi.fn().mockImplementation(async () => {
|
||||
const content = diskContent
|
||||
if (desktopSave) {
|
||||
diskContent = desktopSave
|
||||
useAppStore
|
||||
.getState()
|
||||
.setLastKnownDiskSignature(tabId, getDiskBaselineSignature(desktopSave))
|
||||
} else {
|
||||
useAppStore.getState().setEditorDraft(tabId, 'desktop typing')
|
||||
useAppStore.getState().markFileDirty(tabId, true)
|
||||
}
|
||||
return { content, isBinary: false }
|
||||
})
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-race',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('D0'),
|
||||
content: 'phone edit'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ id: 'save-race', ok: false })
|
||||
expect(diskContent).toBe(desktopSave ?? 'D0')
|
||||
if (!desktopSave) {
|
||||
expect(useAppStore.getState().editorDrafts[tabId]).toBe('desktop typing')
|
||||
}
|
||||
} finally {
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('lets the phone save after an external change to a clean desktop tab', async () => {
|
||||
openMarkdownFile()
|
||||
const tabId = useAppStore.getState().openFiles[0].id
|
||||
// The desktop loaded 'A' earlier; an agent then wrote 'B' and the desktop tab is still clean.
|
||||
useAppStore.getState().setLastKnownDiskSignature(tabId, getDiskBaselineSignature('A'))
|
||||
let diskContent = 'B'
|
||||
const readFile = vi.fn().mockImplementation(async () => ({
|
||||
content: diskContent,
|
||||
isBinary: false
|
||||
}))
|
||||
const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => {
|
||||
const refusal = guardedWriteRefusal(diskPrecondition, diskContent)
|
||||
if (refusal) {
|
||||
return refusal
|
||||
}
|
||||
diskContent = content
|
||||
return { ok: true }
|
||||
})
|
||||
setupWindow({ readFile, writeFile })
|
||||
const dropped: string[] = []
|
||||
const onStale = (event: Event): void => {
|
||||
if (event instanceof CustomEvent) {
|
||||
dropped.push(event.detail.fileId)
|
||||
}
|
||||
}
|
||||
window.addEventListener(ORCA_EDITOR_FILE_CACHE_STALE_EVENT, onStale)
|
||||
const detachBridge = attachMobileMarkdownBridge()
|
||||
const detachAutosave = attachEditorAutosaveController(useAppStore as never)
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'save-after-change',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent('B'),
|
||||
content: 'phone edit'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ id: 'save-after-change', ok: true })
|
||||
expect(diskContent).toBe('phone edit')
|
||||
// The tab's baseline and text both moved to the write, so nothing needed dropping.
|
||||
expect(useAppStore.getState().openFiles[0]?.lastKnownDiskSignature).toBe(
|
||||
getDiskBaselineSignature('phone edit')
|
||||
)
|
||||
expect(dropped).toEqual([])
|
||||
} finally {
|
||||
window.removeEventListener(ORCA_EDITOR_FILE_CACHE_STALE_EVENT, onStale)
|
||||
detachAutosave()
|
||||
detachBridge()
|
||||
}
|
||||
})
|
||||
|
||||
it('marks oversized multibyte desktop drafts as read-only for mobile editing', async () => {
|
||||
openMarkdownFile()
|
||||
const content = '😀'.repeat(Math.floor(MOBILE_MARKDOWN_EDIT_MAX_BYTES / 4) + 1)
|
||||
const readFile = vi.fn().mockResolvedValue({ content: 'disk', isBinary: false })
|
||||
const state = useAppStore.getState()
|
||||
state.setEditorDraft('/repo/README.md', content)
|
||||
state.markFileDirty('/repo/README.md', true)
|
||||
setupWindow({ readFile })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-large-multibyte',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
id: 'read-large-multibyte',
|
||||
ok: true,
|
||||
result: { editable: false, readOnlyReason: 'file_too_large' }
|
||||
})
|
||||
expect(readFile).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads a markdown file between the edit and read budgets whole, as read-only', async () => {
|
||||
openMarkdownFile()
|
||||
const content = 'a'.repeat(632 * 1024)
|
||||
setupWindow({ readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-632k',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { content, editable: false, readOnlyReason: 'file_too_large' }
|
||||
})
|
||||
expect(response).not.toHaveProperty('result.truncated')
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
|
||||
it('truncates a markdown file over the read budget on a UTF-8 boundary', async () => {
|
||||
openMarkdownFile()
|
||||
// The 3-byte euro sign straddles the budget, so the cut must drop it whole.
|
||||
const prefix = 'a'.repeat(MOBILE_MARKDOWN_READ_MAX_BYTES - 1)
|
||||
const content = `${prefix}\u20actail`
|
||||
setupWindow({ readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) })
|
||||
const detach = attachMobileMarkdownBridge()
|
||||
|
||||
try {
|
||||
const response = await sendRequest({
|
||||
id: 'read-over-budget',
|
||||
operation: 'read',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
editable: false,
|
||||
readOnlyReason: 'file_too_large',
|
||||
truncated: true,
|
||||
byteLength: MOBILE_MARKDOWN_READ_MAX_BYTES - 1 + 3 + 4,
|
||||
version: hashMarkdownContent(prefix)
|
||||
}
|
||||
})
|
||||
expect(response).toHaveProperty('result.content', prefix)
|
||||
const save = await sendRequest({
|
||||
id: 'save-truncated',
|
||||
operation: 'save',
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-md',
|
||||
baseVersion: hashMarkdownContent(prefix),
|
||||
content: prefix
|
||||
})
|
||||
expect(save).toMatchObject({ ok: false, error: 'file_too_large' })
|
||||
} finally {
|
||||
detach()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,310 +0,0 @@
|
||||
import {
|
||||
ORCA_EDITOR_FILE_SAVED_EVENT,
|
||||
requestEditorFileSave,
|
||||
requestEditorSaveQuiesce,
|
||||
type EditorFileSavedDetail
|
||||
} from '@/components/editor/editor-autosave'
|
||||
import { flushPendingEditorBuffer } from '@/components/editor/editor-pending-flush'
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { editorTabFileAccess } from '@/lib/local-file-access'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { readRuntimeFileContent } from './runtime-file-read-client'
|
||||
import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
|
||||
import {
|
||||
hashMarkdownContent,
|
||||
isMarkdownContentByteLengthOverLimit,
|
||||
MOBILE_MARKDOWN_EDIT_MAX_BYTES,
|
||||
truncateMobileMarkdownRead,
|
||||
type RuntimeMarkdownReadTabResult,
|
||||
type RuntimeMarkdownSaveTabResult,
|
||||
type RuntimeMobileMarkdownRequest,
|
||||
type RuntimeMobileMarkdownResponse
|
||||
} from '../../../shared/mobile-markdown-document'
|
||||
import {
|
||||
rollBackFailedMobileSave,
|
||||
stampMobileVerifiedBaseline
|
||||
} from './mobile-markdown-save-rollback'
|
||||
|
||||
const saveQueues = new Map<string, Promise<void>>()
|
||||
|
||||
type FileContent = {
|
||||
content: string
|
||||
isBinary: boolean
|
||||
}
|
||||
|
||||
export function attachMobileMarkdownBridge(): () => void {
|
||||
if (typeof window.api.ui.onMobileMarkdownRequest !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
return window.api.ui.onMobileMarkdownRequest((request) => {
|
||||
void handleMobileMarkdownRequest(request)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMobileMarkdownRequest(request: RuntimeMobileMarkdownRequest): Promise<void> {
|
||||
try {
|
||||
const result =
|
||||
request.operation === 'read'
|
||||
? await readMobileMarkdownTab(request.worktreeId, request.tabId)
|
||||
: await saveMobileMarkdownTab(
|
||||
request.worktreeId,
|
||||
request.tabId,
|
||||
request.baseVersion,
|
||||
request.content
|
||||
)
|
||||
respond({ id: request.id, ok: true, result })
|
||||
} catch (error) {
|
||||
respond({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function readMobileMarkdownTab(
|
||||
worktreeId: string,
|
||||
tabId: string
|
||||
): Promise<RuntimeMarkdownReadTabResult> {
|
||||
const target = resolveMarkdownTarget(worktreeId, tabId)
|
||||
flushEditorState(target.sourceFile.id)
|
||||
const { content, source } = await readCurrentContent(target.sourceFile)
|
||||
const readOnlyReason = getReadOnlyReason(target.tab, target.sourceFile, content)
|
||||
const preview = truncateMobileMarkdownRead(content)
|
||||
return {
|
||||
tabId,
|
||||
filePath: target.sourceFile.filePath,
|
||||
relativePath: target.sourceFile.relativePath,
|
||||
content: preview.content,
|
||||
isDirty: target.sourceFile.isDirty || source === 'draft',
|
||||
version: hashMarkdownContent(preview.content),
|
||||
source,
|
||||
editable: readOnlyReason === undefined,
|
||||
...(readOnlyReason ? { readOnlyReason } : {}),
|
||||
...(preview.truncated ? { truncated: true, byteLength: preview.byteLength } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMobileMarkdownTab(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
baseVersion: string,
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult> {
|
||||
if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) {
|
||||
throw new Error('file_too_large')
|
||||
}
|
||||
const target = resolveMarkdownTarget(worktreeId, tabId)
|
||||
return await enqueueMarkdownSave(target.sourceFile.id, async () => {
|
||||
const freshTarget = resolveMarkdownTarget(worktreeId, tabId)
|
||||
const readOnlyReason = getReadOnlyReason(freshTarget.tab, freshTarget.sourceFile, content)
|
||||
if (readOnlyReason) {
|
||||
throw new Error(readOnlyReason)
|
||||
}
|
||||
|
||||
flushEditorState(freshTarget.sourceFile.id)
|
||||
const current = await readCurrentContent(freshTarget.sourceFile)
|
||||
const currentVersion = hashMarkdownContent(current.content)
|
||||
if (currentVersion !== baseVersion) {
|
||||
if (current.content === content) {
|
||||
// Why: duplicate mobile save taps can race behind the first successful
|
||||
// write; treat an already-saved identical file as success, not conflict.
|
||||
return {
|
||||
tabId,
|
||||
version: currentVersion,
|
||||
isDirty: false,
|
||||
content: current.content
|
||||
}
|
||||
}
|
||||
throw new Error('conflict')
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
// Why live: a desktop save during the read moved the baseline past the version the phone saw.
|
||||
const sourceFile = state.openFiles.find((file) => file.id === freshTarget.sourceFile.id)
|
||||
if (!sourceFile) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
// Why: desktop typing during the disk read is newer than the version the phone edited.
|
||||
if (current.source === 'file' && state.editorDrafts[sourceFile.id] !== undefined) {
|
||||
throw new Error('conflict')
|
||||
}
|
||||
const stampedBaseline = stampMobileVerifiedBaseline(sourceFile, current)
|
||||
const previousDraft = state.editorDrafts[sourceFile.id]
|
||||
const previousDirty = sourceFile.isDirty
|
||||
state.setEditorDraft(sourceFile.id, content)
|
||||
state.markFileDirty(sourceFile.id, true)
|
||||
let verified: string
|
||||
try {
|
||||
await waitForPositiveSave(freshTarget.sourceFile, content)
|
||||
verified = await readFileContent(freshTarget.sourceFile)
|
||||
if (verified !== content) {
|
||||
throw new Error('save_verification_failed')
|
||||
}
|
||||
} catch (error) {
|
||||
throw rollBackFailedMobileSave(error, {
|
||||
fileId: freshTarget.sourceFile.id,
|
||||
injectedContent: content,
|
||||
previousDraft,
|
||||
previousDirty,
|
||||
stampedBaseline
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
tabId,
|
||||
version: hashMarkdownContent(verified),
|
||||
isDirty: false,
|
||||
content: verified
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function enqueueMarkdownSave<T>(fileId: string, save: () => Promise<T>): Promise<T> {
|
||||
const previous = saveQueues.get(fileId) ?? Promise.resolve()
|
||||
let releaseQueue: () => void = () => {}
|
||||
const current = new Promise<void>((resolve) => {
|
||||
releaseQueue = resolve
|
||||
})
|
||||
const queued = previous.catch(() => undefined).then(() => current)
|
||||
saveQueues.set(fileId, queued)
|
||||
await previous.catch(() => undefined)
|
||||
try {
|
||||
return await save()
|
||||
} finally {
|
||||
releaseQueue()
|
||||
if (saveQueues.get(fileId) === queued) {
|
||||
saveQueues.delete(fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMarkdownTarget(
|
||||
worktreeId: string,
|
||||
tabId: string
|
||||
): { tab: OpenFile; sourceFile: OpenFile } {
|
||||
const state = useAppStore.getState()
|
||||
// Why every group: the phone lists tabs from all split groups, not just the desktop's focused one.
|
||||
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === tabId && candidate.contentType === 'editor'
|
||||
)
|
||||
// Hosts publish the file id when a file has no unified tab.
|
||||
const fileId = unifiedTab?.entityId ?? tabId
|
||||
const tab = state.openFiles.find((file) => file.worktreeId === worktreeId && file.id === fileId)
|
||||
if (!tab || !isMarkdownTab(tab)) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
const sourceFile =
|
||||
tab.mode === 'markdown-preview' && tab.markdownPreviewSourceFileId
|
||||
? (state.openFiles.find(
|
||||
(file) => file.worktreeId === worktreeId && file.id === tab.markdownPreviewSourceFileId
|
||||
) ?? tab)
|
||||
: tab
|
||||
return { tab, sourceFile }
|
||||
}
|
||||
|
||||
function isMarkdownTab(file: OpenFile): boolean {
|
||||
if (file.mode !== 'edit' && file.mode !== 'markdown-preview') {
|
||||
return false
|
||||
}
|
||||
return file.language === 'markdown' || file.mode === 'markdown-preview'
|
||||
}
|
||||
|
||||
function getReadOnlyReason(
|
||||
tab: OpenFile,
|
||||
sourceFile: OpenFile,
|
||||
content: string
|
||||
): RuntimeMarkdownReadTabResult['readOnlyReason'] {
|
||||
if (tab.mode === 'markdown-preview') {
|
||||
return 'unsupported_preview'
|
||||
}
|
||||
if (sourceFile.isUntitled) {
|
||||
return 'unsupported_untitled'
|
||||
}
|
||||
if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) {
|
||||
return 'file_too_large'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function readCurrentContent(
|
||||
file: OpenFile
|
||||
): Promise<{ content: string; source: 'draft' | 'file' }> {
|
||||
const draft = useAppStore.getState().editorDrafts[file.id]
|
||||
if (draft !== undefined) {
|
||||
return { content: draft, source: 'draft' }
|
||||
}
|
||||
return { content: await readFileContent(file), source: 'file' }
|
||||
}
|
||||
|
||||
async function readFileContent(file: OpenFile): Promise<string> {
|
||||
const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined
|
||||
const state = useAppStore.getState()
|
||||
const result: FileContent = await readRuntimeFileContent({
|
||||
target: requireRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId),
|
||||
filePath: file.filePath,
|
||||
relativePath: file.relativePath,
|
||||
worktreeId: file.worktreeId,
|
||||
connectionId,
|
||||
expectedExternalSshTargetId: file.externalSshTargetId,
|
||||
access: editorTabFileAccess(state, file)
|
||||
})
|
||||
if (result.isBinary) {
|
||||
throw new Error('binary_file')
|
||||
}
|
||||
return result.content
|
||||
}
|
||||
|
||||
function flushEditorState(fileId: string): void {
|
||||
// Why: rich markdown serializes through a debounce. Mobile reads/saves must
|
||||
// observe desktop-visible text, not a stale draft from before that debounce.
|
||||
flushPendingEditorBuffer(useAppStore.getState(), fileId)
|
||||
}
|
||||
|
||||
async function waitForPositiveSave(file: OpenFile, content: string): Promise<void> {
|
||||
let timeout: number | null = null
|
||||
let onSaved: ((event: Event) => void) | null = null
|
||||
const cleanup = (): void => {
|
||||
if (timeout !== null) {
|
||||
window.clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (onSaved) {
|
||||
window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved as EventListener)
|
||||
onSaved = null
|
||||
}
|
||||
}
|
||||
const saved = new Promise<void>((resolve, reject) => {
|
||||
timeout = window.setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error('save_timeout'))
|
||||
}, 20_000)
|
||||
onSaved = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<EditorFileSavedDetail>).detail
|
||||
if (detail?.fileId !== file.id || detail.content !== content) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved as EventListener)
|
||||
})
|
||||
|
||||
try {
|
||||
await requestEditorSaveQuiesce({ fileId: file.id })
|
||||
const liveFile = useAppStore.getState().openFiles.find((openFile) => openFile.id === file.id)
|
||||
if (!liveFile) {
|
||||
throw new Error('tab_not_found')
|
||||
}
|
||||
await requestEditorFileSave({ fileId: file.id, fallbackContent: content, origin: 'mobile' })
|
||||
await saved
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function respond(response: RuntimeMobileMarkdownResponse): void {
|
||||
window.api.ui.respondMobileMarkdownRequest(response)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
EDITOR_STALE_SAVE_MESSAGE,
|
||||
isEditorModelDirty,
|
||||
setEditorDiskBaseline
|
||||
} from '@/components/editor/editor-save-disk-precondition'
|
||||
import { setModelExternalMutation } from '@/components/editor/editor-save-rebaseline'
|
||||
import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature'
|
||||
|
||||
export const MOBILE_STALE_SAVE_MESSAGE =
|
||||
'This file changed on the computer. Reload to get the latest version, then reapply your edit.'
|
||||
|
||||
/** The shared baseline a phone save adopted, and the one it replaced. */
|
||||
export type MobileAdoptedBaseline = { adopted: string; previous: string | undefined }
|
||||
|
||||
/**
|
||||
* When no tab of the model has edits, the version mobile just read from disk becomes the model's
|
||||
* baseline for this save. A dirty model keeps its baseline, so the save is refused and the phone told.
|
||||
*/
|
||||
export function stampMobileVerifiedBaseline(
|
||||
file: OpenFile,
|
||||
current: { content: string; source: 'draft' | 'file' }
|
||||
): MobileAdoptedBaseline | null {
|
||||
const state = useAppStore.getState()
|
||||
if (current.source !== 'file' || isEditorModelDirty(state, file)) {
|
||||
return null
|
||||
}
|
||||
const adopted = getDiskBaselineSignature(current.content)
|
||||
const previous = file.lastKnownDiskSignature
|
||||
if (previous === adopted) {
|
||||
return null
|
||||
}
|
||||
setEditorDiskBaseline(state, file.id, adopted)
|
||||
return { adopted, previous }
|
||||
}
|
||||
|
||||
/** Puts the desktop tab back as it was and returns the error to send to mobile. */
|
||||
export function rollBackFailedMobileSave(
|
||||
error: unknown,
|
||||
save: {
|
||||
fileId: string
|
||||
injectedContent: string
|
||||
previousDraft: string | undefined
|
||||
previousDirty: boolean
|
||||
stampedBaseline: MobileAdoptedBaseline | null
|
||||
}
|
||||
): unknown {
|
||||
restoreFailedMobileSaveDraft(
|
||||
save.fileId,
|
||||
save.injectedContent,
|
||||
save.previousDraft,
|
||||
save.previousDirty
|
||||
)
|
||||
const state = useAppStore.getState()
|
||||
const liveFile = state.openFiles.find((openFile) => openFile.id === save.fileId)
|
||||
// Why: nothing was written, so the adopted baseline must not outlive the save.
|
||||
if (
|
||||
save.stampedBaseline !== null &&
|
||||
liveFile?.lastKnownDiskSignature === save.stampedBaseline.adopted
|
||||
) {
|
||||
setEditorDiskBaseline(state, save.fileId, save.stampedBaseline.previous)
|
||||
}
|
||||
if (!(error instanceof Error) || error.message !== EDITOR_STALE_SAVE_MESSAGE) {
|
||||
return error
|
||||
}
|
||||
// Why: desktop edits rode along in this save, so the desktop must learn of the conflict too.
|
||||
if (save.previousDirty) {
|
||||
setModelExternalMutation(state, save.fileId, 'changed')
|
||||
}
|
||||
return new Error(MOBILE_STALE_SAVE_MESSAGE)
|
||||
}
|
||||
|
||||
function restoreFailedMobileSaveDraft(
|
||||
fileId: string,
|
||||
injectedContent: string,
|
||||
previousDraft: string | undefined,
|
||||
previousDirty: boolean
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
const currentDraft = state.editorDrafts[fileId]
|
||||
if (currentDraft !== undefined && currentDraft !== injectedContent) {
|
||||
return
|
||||
}
|
||||
// Why: mobile save injects a desktop draft only to reuse the editor save path.
|
||||
// If save or verification fails, put the desktop editor back exactly as it was before.
|
||||
if (previousDraft === undefined) {
|
||||
state.clearEditorDraft(fileId)
|
||||
} else {
|
||||
state.setEditorDraft(fileId, previousDraft)
|
||||
}
|
||||
state.markFileDirty(fileId, previousDirty)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots'
|
||||
import { makeState } from '../sync-runtime-graph-test-harness'
|
||||
|
||||
function listedMarkdownTab(isUntitled: boolean | undefined) {
|
||||
const state = makeState({
|
||||
browserTabsByWorktree: {},
|
||||
tabBarOrderByWorktree: { 'wt-1': ['/repo/untitled.md'] },
|
||||
openFiles: [
|
||||
{
|
||||
id: '/repo/untitled.md',
|
||||
filePath: '/repo/untitled.md',
|
||||
relativePath: 'untitled.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit',
|
||||
isDirty: true,
|
||||
...(isUntitled === undefined ? {} : { isUntitled })
|
||||
}
|
||||
]
|
||||
})
|
||||
return buildMobileSessionTabSnapshots(state)[0]?.tabs.find((tab) => tab.type === 'markdown')
|
||||
}
|
||||
|
||||
describe('a listed Markdown tab', () => {
|
||||
it('says when its document is untitled, so a phone save cannot make the file', () => {
|
||||
expect(listedMarkdownTab(true)).toMatchObject({ isUntitled: true })
|
||||
})
|
||||
|
||||
it('omits the flag for a named document', () => {
|
||||
expect(listedMarkdownTab(undefined)).not.toHaveProperty('isUntitled')
|
||||
expect(listedMarkdownTab(false)).not.toHaveProperty('isUntitled')
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,7 @@ export function buildMobileMarkdownTab(
|
||||
sourceFileId: sourceFile.id,
|
||||
sourceFilePath: sourceFile.filePath,
|
||||
sourceRelativePath: sourceFile.relativePath,
|
||||
...(sourceFile.isUntitled ? { isUntitled: true as const } : {}),
|
||||
documentVersion: draftVersion ?? `file:${sourceFile.id}`,
|
||||
color: unifiedTab?.color ?? null,
|
||||
isPinned: unifiedTab?.isPinned === true
|
||||
|
||||
@@ -280,8 +280,6 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
||||
onOpenFileFromMobile: () => noopUnsubscribe,
|
||||
onRevealEditorTabFromRuntime: () => noopUnsubscribe,
|
||||
onOpenDiffFromMobile: () => noopUnsubscribe,
|
||||
onMobileMarkdownRequest: () => noopUnsubscribe,
|
||||
respondMobileMarkdownRequest: () => {},
|
||||
onCloseTerminal: () => noopUnsubscribe,
|
||||
onTerminalTabCloseRequest: () => noopUnsubscribe,
|
||||
respondTerminalTabClose: () => {},
|
||||
|
||||
@@ -13,34 +13,6 @@ export type RuntimeMarkdownReadOnlyReason =
|
||||
| 'unsupported_untitled'
|
||||
| 'file_too_large'
|
||||
|
||||
export type RuntimeMobileMarkdownRequest =
|
||||
| {
|
||||
id: string
|
||||
operation: 'read'
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
operation: 'save'
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
baseVersion: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type RuntimeMobileMarkdownResponse =
|
||||
| {
|
||||
id: string
|
||||
ok: true
|
||||
result: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult
|
||||
}
|
||||
| {
|
||||
id: string
|
||||
ok: false
|
||||
error: string
|
||||
}
|
||||
|
||||
export type RuntimeMarkdownReadTabResult = {
|
||||
tabId: string
|
||||
filePath: string
|
||||
|
||||
@@ -55,6 +55,8 @@ export type RuntimeMobileSessionMarkdownTab = {
|
||||
sourceFileId: string
|
||||
sourceFilePath: string
|
||||
sourceRelativePath: string
|
||||
/** Set by a desktop for a "New Markdown" document not yet named; hosts and phones before it omit it. */
|
||||
isUntitled?: true
|
||||
documentVersion: string
|
||||
color?: string | null
|
||||
isPinned?: boolean
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* A phone reads and saves a Markdown tab on a desktop whose editor holds unsaved text for it.
|
||||
*
|
||||
* The phone always works on the file on disk, window or not: it sees the disk with the tab marked
|
||||
* dirty, and its save lands on disk. The desktop keeps its unsaved text and reports the newer disk
|
||||
* content with its usual changed-on-disk banner.
|
||||
*/
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts'
|
||||
import type {
|
||||
RuntimeMarkdownReadTabResult,
|
||||
RuntimeMarkdownSaveTabResult
|
||||
} from '../../src/shared/mobile-markdown-document'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
import { createDesktopMobilePairingOffer } from './helpers/paired-electron-client'
|
||||
import { pairMobileClient } from './helpers/paired-mobile-client'
|
||||
|
||||
const DISK = '# Shared notes\n\nOn disk.\n'
|
||||
const DRAFT_LINE = 'unsaved desktop line'
|
||||
const PHONE = '# Shared notes\n\nSaved from the phone.\n'
|
||||
const BANNER = 'This file changed on disk while you have unsaved edits.'
|
||||
|
||||
test('a phone saves a Markdown tab the desktop has unsaved edits in, without touching them', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-phone-markdown',
|
||||
'desktop-draft',
|
||||
testInfo.workerIndex,
|
||||
DISK
|
||||
)
|
||||
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
|
||||
try {
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await expect(editor).toContainText('On disk.')
|
||||
await editor.click()
|
||||
await orcaPage.keyboard.press('ControlOrMeta+End')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await orcaPage.keyboard.insertText(DRAFT_LINE)
|
||||
const desktopDraft = () =>
|
||||
orcaPage.evaluate((path) => {
|
||||
const state = window.__store!.getState()
|
||||
const file = state.openFiles.find((entry) => entry.filePath === path)
|
||||
return {
|
||||
draft: file ? state.editorDrafts[file.id] : undefined,
|
||||
isDirty: file?.isDirty === true,
|
||||
externalMutation: file?.externalMutation ?? null
|
||||
}
|
||||
}, filePath)
|
||||
await expect.poll(async () => (await desktopDraft()).draft ?? '').toContain(DRAFT_LINE)
|
||||
const before = await desktopDraft()
|
||||
expect(before.isDirty).toBe(true)
|
||||
|
||||
const worktree = `id:${context.worktreeId}`
|
||||
let tabId = ''
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await phone.call<RuntimeMobileSessionTabsResult>('session.tabs.list', {
|
||||
worktree
|
||||
})
|
||||
const tab = listed.tabs.find(
|
||||
(entry) => entry.type === 'markdown' && entry.sourceFilePath === filePath
|
||||
)
|
||||
tabId = tab?.id ?? ''
|
||||
return tab?.type === 'markdown' && tab.isDirty
|
||||
},
|
||||
{ timeout: 30_000, message: 'the desktop never listed its dirty Markdown tab' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Why poll: the desktop persists its session (which names the file) on a debounce.
|
||||
let read: RuntimeMarkdownReadTabResult | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await phone.request<RuntimeMarkdownReadTabResult>('markdown.readTab', {
|
||||
worktree,
|
||||
tabId
|
||||
})
|
||||
read = response.ok ? response.result : null
|
||||
return response.ok ? 'read' : `${response.error.code}: ${response.error.message}`
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe('read')
|
||||
expect(read).toMatchObject({
|
||||
tabId,
|
||||
filePath,
|
||||
content: DISK,
|
||||
source: 'file',
|
||||
isDirty: true,
|
||||
editable: true
|
||||
})
|
||||
|
||||
const saved = await phone.call<RuntimeMarkdownSaveTabResult>('markdown.saveTab', {
|
||||
worktree,
|
||||
tabId,
|
||||
baseVersion: read!.version,
|
||||
content: PHONE
|
||||
})
|
||||
expect(saved).toMatchObject({ tabId, isDirty: false, content: PHONE })
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(PHONE)
|
||||
|
||||
await expect(orcaPage.getByText(BANNER)).toBeVisible({ timeout: 30_000 })
|
||||
await testInfo.attach('desktop-after-phone-save', {
|
||||
body: await orcaPage.screenshot(),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
const after = await desktopDraft()
|
||||
expect(after).toEqual({ ...before, externalMutation: 'changed' })
|
||||
await expect(editor).toContainText(DRAFT_LINE)
|
||||
expect(readFileSync(filePath, 'utf8')).toBe(PHONE)
|
||||
} finally {
|
||||
phone.dispose()
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
test("a phone reads the desktop's unsaved untitled Markdown tab read-only and never saves it", async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage))
|
||||
let untitledPath = ''
|
||||
try {
|
||||
untitledPath = await orcaPage.evaluate(async (worktreeId) => {
|
||||
const store = window.__store!
|
||||
const before = new Set(store.getState().openFiles.map((file) => file.id))
|
||||
const groupId = store.getState().activeGroupIdByWorktree[worktreeId] ?? ''
|
||||
await store.getState().openNewMarkdownInActiveWorkspace(groupId)
|
||||
const created = store
|
||||
.getState()
|
||||
.openFiles.find((file) => !before.has(file.id) && file.isUntitled === true)
|
||||
if (!created) {
|
||||
throw new Error('No untitled Markdown tab was created')
|
||||
}
|
||||
return created.filePath
|
||||
}, context.worktreeId)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await editor.click()
|
||||
await orcaPage.keyboard.insertText(DRAFT_LINE)
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate((path) => {
|
||||
const state = window.__store!.getState()
|
||||
const file = state.openFiles.find((entry) => entry.filePath === path)
|
||||
return file?.isDirty === true && file.isUntitled === true
|
||||
}, untitledPath)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const worktree = `id:${context.worktreeId}`
|
||||
let tabId = ''
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const listed = await phone.call<RuntimeMobileSessionTabsResult>('session.tabs.list', {
|
||||
worktree
|
||||
})
|
||||
tabId =
|
||||
listed.tabs.find(
|
||||
(entry) => entry.type === 'markdown' && entry.sourceFilePath === untitledPath
|
||||
)?.id ?? ''
|
||||
return tabId
|
||||
})
|
||||
.not.toBe('')
|
||||
|
||||
// Why poll: the desktop persists its session (which names the file) on a debounce.
|
||||
let read: RuntimeMarkdownReadTabResult | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await phone.request<RuntimeMarkdownReadTabResult>('markdown.readTab', {
|
||||
worktree,
|
||||
tabId
|
||||
})
|
||||
read = response.ok ? response.result : null
|
||||
return response.ok ? 'read' : `${response.error.code}: ${response.error.message}`
|
||||
},
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe('read')
|
||||
const disk = readFileSync(untitledPath, 'utf8')
|
||||
expect(read).toMatchObject({
|
||||
tabId,
|
||||
filePath: untitledPath,
|
||||
content: disk,
|
||||
source: 'file',
|
||||
isDirty: true,
|
||||
editable: false,
|
||||
readOnlyReason: 'unsupported_untitled'
|
||||
})
|
||||
expect(JSON.stringify(read)).not.toContain(DRAFT_LINE)
|
||||
|
||||
const save = await phone.request('markdown.saveTab', {
|
||||
worktree,
|
||||
tabId,
|
||||
baseVersion: read!.version,
|
||||
content: PHONE
|
||||
})
|
||||
expect(save).toMatchObject({ ok: false, error: { message: 'unsupported_untitled' } })
|
||||
expect(readFileSync(untitledPath, 'utf8')).toBe(disk)
|
||||
|
||||
// With no file on disk the read fails, and a save still makes none.
|
||||
rmSync(untitledPath)
|
||||
const missingRead = await phone.request('markdown.readTab', { worktree, tabId })
|
||||
testInfo.annotations.push({
|
||||
type: 'untitled-missing-read',
|
||||
description: JSON.stringify(missingRead.ok ? missingRead.result : missingRead.error)
|
||||
})
|
||||
expect(missingRead.ok).toBe(false)
|
||||
const missingSave = await phone.request('markdown.saveTab', {
|
||||
worktree,
|
||||
tabId,
|
||||
baseVersion: read!.version,
|
||||
content: PHONE
|
||||
})
|
||||
expect(missingSave).toMatchObject({ ok: false, error: { message: 'unsupported_untitled' } })
|
||||
expect(existsSync(untitledPath)).toBe(false)
|
||||
} finally {
|
||||
phone.dispose()
|
||||
if (untitledPath) {
|
||||
await cleanupMarkdownFixture(untitledPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* A phone reads and saves a Markdown tab on a headless server, from the server's own disk.
|
||||
*
|
||||
* A headless host lists the editor tabs its saved session holds (a converted SSH host's move
|
||||
* carries them in). With no window there is no editor to relay through, so the server answers
|
||||
* from the file itself and saves with a compare against the version the phone last read.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { RuntimeClient } from '../../src/cli/runtime/client'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts'
|
||||
import type {
|
||||
RuntimeMarkdownReadTabResult,
|
||||
RuntimeMarkdownSaveTabResult
|
||||
} from '../../src/shared/mobile-markdown-document'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
addRepoWorktree,
|
||||
cliClient,
|
||||
orcadNodeExecutable,
|
||||
pairedClient,
|
||||
startHeadlessOracleHost,
|
||||
type HeadlessHostKind
|
||||
} from './helpers/headless-layout-oracle-hosts'
|
||||
import { mutateStoppedProfileState } from './helpers/persisted-profile-state'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Saves an open Markdown editor for the worktree, as a desktop or a move leaves it. */
|
||||
function saveMarkdownEditor(userDataDir: string, worktreeId: string, filePath: string): void {
|
||||
mutateStoppedProfileState(userDataDir, (state) => {
|
||||
const session = state.workspaceSession
|
||||
if (!isRecord(session)) {
|
||||
throw new Error('The profile has no workspace session')
|
||||
}
|
||||
session.openFilesByWorktree = {
|
||||
...(isRecord(session.openFilesByWorktree) ? session.openFilesByWorktree : {}),
|
||||
[worktreeId]: [
|
||||
{
|
||||
filePath,
|
||||
relativePath: path.basename(filePath),
|
||||
worktreeId,
|
||||
language: 'markdown',
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function markdownTabId(phone: RuntimeClient, worktree: string): Promise<string> {
|
||||
let tabId = ''
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { result } = await phone.call<RuntimeMobileSessionTabsResult>('session.tabs.list', {
|
||||
worktree
|
||||
})
|
||||
tabId = result.tabs.find((tab) => tab.type === 'markdown')?.id ?? ''
|
||||
return tabId
|
||||
},
|
||||
{ timeout: 30_000, message: 'the server never listed the saved Markdown tab' }
|
||||
)
|
||||
.not.toBe('')
|
||||
return tabId
|
||||
}
|
||||
|
||||
async function readTab(phone: RuntimeClient, worktree: string, tabId: string) {
|
||||
return (await phone.call<RuntimeMarkdownReadTabResult>('markdown.readTab', { worktree, tabId }))
|
||||
.result
|
||||
}
|
||||
|
||||
const HOSTS: HeadlessHostKind[] = ['electron', 'orcad']
|
||||
|
||||
for (const kind of HOSTS) {
|
||||
test(`a phone reads and saves a Markdown tab on a headless ${kind} server`, async () => {
|
||||
// Why skip in CI too: only the orcad mode-switch job builds out/orcad.
|
||||
test.skip(kind === 'orcad' && !orcadNodeExecutable(), 'out/orcad is not built')
|
||||
test.setTimeout(240_000)
|
||||
const host = await startHeadlessOracleHost(kind)
|
||||
try {
|
||||
const cli = cliClient(host)
|
||||
const phone = pairedClient(host, 'phone')
|
||||
const worktreeId = await addRepoWorktree(host, cli)
|
||||
const worktree = `id:${worktreeId}`
|
||||
const listed = await cli.call<{ worktrees: { id: string; path: string }[] }>(
|
||||
'worktree.list',
|
||||
{ repo: `id:${worktreeId.split('::')[0]}` }
|
||||
)
|
||||
const repoPath = listed.result.worktrees.find((entry) => entry.id === worktreeId)!.path
|
||||
const filePath = path.join(repoPath, 'README.md')
|
||||
await host.restart({
|
||||
whileStopped: () => saveMarkdownEditor(host.userDataDir, worktreeId, filePath)
|
||||
})
|
||||
|
||||
const tabId = await markdownTabId(phone, worktree)
|
||||
const read = await readTab(phone, worktree, tabId)
|
||||
expect(read).toMatchObject({
|
||||
tabId,
|
||||
filePath,
|
||||
content: 'layout oracle\n',
|
||||
source: 'file',
|
||||
isDirty: false,
|
||||
editable: true
|
||||
})
|
||||
|
||||
const saved = (
|
||||
await phone.call<RuntimeMarkdownSaveTabResult>('markdown.saveTab', {
|
||||
worktree,
|
||||
tabId,
|
||||
baseVersion: read.version,
|
||||
content: '# from the phone\n'
|
||||
})
|
||||
).result
|
||||
expect(saved).toMatchObject({ tabId, isDirty: false, content: '# from the phone\n' })
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('# from the phone\n')
|
||||
|
||||
// A save over a version the disk no longer holds is refused, and the disk keeps its bytes.
|
||||
writeFileSync(filePath, '# edited on the server\n')
|
||||
await expect(
|
||||
phone.call('markdown.saveTab', {
|
||||
worktree,
|
||||
tabId,
|
||||
baseVersion: saved.version,
|
||||
content: '# stale phone edit\n'
|
||||
})
|
||||
).rejects.toThrow('conflict')
|
||||
expect(readFileSync(filePath, 'utf8')).toBe('# edited on the server\n')
|
||||
expect((await readTab(phone, worktree, tabId)).content).toBe('# edited on the server\n')
|
||||
} finally {
|
||||
await host.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { cliServeProfile, spawnUntilReady, type ReadyProcess } from './orca-serv
|
||||
|
||||
export type HeadlessHostKind = 'orcad' | 'electron'
|
||||
|
||||
type RestartOptions = { cold?: boolean; whileStopped?: () => void | Promise<void> }
|
||||
|
||||
export type HeadlessOracleHost = {
|
||||
kind: HeadlessHostKind
|
||||
userDataDir: string
|
||||
@@ -26,8 +28,11 @@ export type HeadlessOracleHost = {
|
||||
pairingUrl: string
|
||||
/** Run by `dispose`, after the host stops. */
|
||||
cleanups: (() => void)[]
|
||||
/** Stops serve and starts it again on the same profile and port; `cold` also kills the PTY daemon. */
|
||||
restart: (options?: { cold?: boolean }) => Promise<void>
|
||||
/**
|
||||
* Stops serve and starts it again on the same profile and port; `cold` also kills the PTY daemon.
|
||||
* `whileStopped` runs with no serve process alive, the only safe time to edit the profile.
|
||||
*/
|
||||
restart: (options?: RestartOptions) => Promise<void>
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -112,13 +117,14 @@ async function startOrcadHost(scratch: string, node: string): Promise<StartedHos
|
||||
userDataDir: profile.userDataDir,
|
||||
scratch,
|
||||
pairingUrl: serve.ready.pairingUrl,
|
||||
restart: async ({ cold } = {}) => {
|
||||
restart: async ({ cold, whileStopped } = {}) => {
|
||||
// SIGTERM is orcad's graceful stop and leaves the PTY daemon running.
|
||||
await serve?.stop()
|
||||
serve = null
|
||||
if (cold) {
|
||||
await cleanupE2EDaemons(profile.userDataDir)
|
||||
}
|
||||
await whileStopped?.()
|
||||
serve = await launch()
|
||||
},
|
||||
dispose: async () => {
|
||||
@@ -140,10 +146,15 @@ async function startElectronHost(scratch: string): Promise<StartedHost> {
|
||||
userDataDir: host.userDataDir,
|
||||
scratch,
|
||||
pairingUrl: host.offer.pairingUrl,
|
||||
restart: ({ cold } = {}) =>
|
||||
host.restartServeProcess(
|
||||
cold ? { betweenProcesses: () => cleanupE2EDaemons(host.userDataDir) } : {}
|
||||
),
|
||||
restart: ({ cold, whileStopped } = {}) =>
|
||||
host.restartServeProcess({
|
||||
betweenProcesses: async () => {
|
||||
if (cold) {
|
||||
await cleanupE2EDaemons(host.userDataDir)
|
||||
}
|
||||
await whileStopped?.()
|
||||
}
|
||||
}),
|
||||
dispose: async () => {
|
||||
await host.dispose()
|
||||
rmSync(scratch, PROFILE_REMOVAL)
|
||||
|
||||
Reference in New Issue
Block a user