mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
* 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>
212 lines
7.2 KiB
TypeScript
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 }
|
|
}
|