Files
orca/src/main/runtime/markdown-tab-disk-documents.ts
T
Jinwoo HongandE2E Test b99552cabc 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>
2026-10-20 23:08:47 -04:00

212 lines
7.2 KiB
TypeScript

/**
* 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 }
}