diff --git a/mobile/src/session/MobileSessionMarkdownReader.tsx b/mobile/src/session/MobileSessionMarkdownReader.tsx index 4e1d380c9a3..134dc34b87a 100644 --- a/mobile/src/session/MobileSessionMarkdownReader.tsx +++ b/mobile/src/session/MobileSessionMarkdownReader.tsx @@ -3,6 +3,7 @@ import { View, Text, Pressable, ActivityIndicator } from 'react-native' import { RefreshCw } from 'lucide-react-native' import { MobileRichMarkdownEditor } from '../components/MobileRichMarkdownEditor' import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout' +import { markdownReaderStatusText } from './mobile-markdown-reader-status' import { colors, spacing } from '../theme/mobile-theme' import { styles } from './mobile-session-styles' import type { MarkdownDocState } from './mobile-session-route-types' @@ -48,13 +49,7 @@ export function MarkdownReader({ ) } - const statusText = doc.saveError - ? doc.saveError - : doc.readOnlyReason - ? 'Read only' - : doc.stale - ? 'Changed on desktop' - : null + const statusText = markdownReaderStatusText(doc) const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable const showCopy = doc.saveError || !doc.editable const showSave = doc.isDirty || doc.saving diff --git a/mobile/src/session/mobile-markdown-disk-fallback.test.ts b/mobile/src/session/mobile-markdown-disk-fallback.test.ts index 04de6c7f14f..fb172e1a425 100644 --- a/mobile/src/session/mobile-markdown-disk-fallback.test.ts +++ b/mobile/src/session/mobile-markdown-disk-fallback.test.ts @@ -75,17 +75,20 @@ describe('buildMarkdownDiskFallbackDoc', () => { }) }) - it('warns when the disk read is truncated', () => { + it('marks a truncated disk read so the status line names it', () => { expect( buildMarkdownDiskFallbackDoc({ content: '# Partial', truncated: true, + byteLength: 700_000, tabIsDirty: true }) ).toMatchObject({ editable: false, stale: true, - readOnlyReason: 'File too large for mobile preview' + readOnlyReason: 'Desktop has unsaved changes. Showing disk content.', + truncated: true, + byteLength: 700_000 }) }) }) diff --git a/mobile/src/session/mobile-markdown-disk-fallback.ts b/mobile/src/session/mobile-markdown-disk-fallback.ts index 910fb072564..81fa55e14cd 100644 --- a/mobile/src/session/mobile-markdown-disk-fallback.ts +++ b/mobile/src/session/mobile-markdown-disk-fallback.ts @@ -1,4 +1,5 @@ import type { RpcFailure } from '../transport/types' +import type { MarkdownDocState } from './mobile-session-route-types' const RENDERER_UNAVAILABLE = 'renderer_unavailable' @@ -9,26 +10,24 @@ export function shouldReadMarkdownFromDiskAfterReadTabFailure(response: RpcFailu ) } -// `truncated` is optional because the preview reader salvages it: an absent flag reads as not -// truncated here, which is the branch main took for a reply that omitted it. export function buildMarkdownDiskFallbackDoc(args: { content: string - truncated: boolean | undefined + truncated?: boolean + byteLength?: number tabIsDirty: boolean -}) { - const readOnlyReason = args.truncated - ? 'File too large for mobile preview' - : args.tabIsDirty - ? 'Desktop has unsaved changes. Showing disk content.' - : 'Editing needs Orca desktop running.' +}): Extract { + const readOnlyReason = args.tabIsDirty + ? 'Desktop has unsaved changes. Showing disk content.' + : 'Editing needs Orca desktop running.' return { - status: 'ready' as const, + status: 'ready', content: args.content, localContent: args.content, baseVersion: '', isDirty: false, editable: false, stale: args.tabIsDirty, - readOnlyReason + readOnlyReason, + ...(args.truncated ? { truncated: true, byteLength: args.byteLength } : {}) } } diff --git a/mobile/src/session/mobile-markdown-reader-status.test.ts b/mobile/src/session/mobile-markdown-reader-status.test.ts new file mode 100644 index 00000000000..8bee01b44d6 --- /dev/null +++ b/mobile/src/session/mobile-markdown-reader-status.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { markdownReaderStatusText } from './mobile-markdown-reader-status' + +const readOnlyDoc = { + status: 'ready' as const, + content: '# head', + localContent: '# head', + baseVersion: 'v1', + isDirty: false, + editable: false, + readOnlyReason: 'file_too_large' +} + +describe('markdownReaderStatusText', () => { + it('names a truncated preview and the full file size the way file tabs do', () => { + expect( + markdownReaderStatusText({ ...readOnlyDoc, truncated: true, byteLength: 3_000_000 }) + ).toBe('Preview truncated. File size: 2.9 MB.') + }) + + it('keeps read-only, stale, and save-error states as before', () => { + expect(markdownReaderStatusText(readOnlyDoc)).toBe('Read only') + expect( + markdownReaderStatusText({ ...readOnlyDoc, readOnlyReason: undefined, stale: true }) + ).toBe('Changed on desktop') + expect(markdownReaderStatusText({ ...readOnlyDoc, saveError: 'Save failed' })).toBe( + 'Save failed' + ) + }) +}) diff --git a/mobile/src/session/mobile-markdown-reader-status.ts b/mobile/src/session/mobile-markdown-reader-status.ts new file mode 100644 index 00000000000..060b73beffb --- /dev/null +++ b/mobile/src/session/mobile-markdown-reader-status.ts @@ -0,0 +1,20 @@ +import { formatPreviewByteLength } from '../files/mobile-file-preview-response' +import type { MarkdownDocState } from './mobile-session-route-types' + +/** The markdown floating bar's status line; a truncated preview reads like a file tab's note. */ +export function markdownReaderStatusText( + doc: Extract +): string | null { + if (doc.saveError) { + return doc.saveError + } + if (doc.truncated) { + return doc.byteLength === undefined + ? 'Preview truncated.' + : `Preview truncated. File size: ${formatPreviewByteLength(doc.byteLength)}.` + } + if (doc.readOnlyReason) { + return 'Read only' + } + return doc.stale ? 'Changed on desktop' : null +} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 6083ecbcd68..7c0ac36e453 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -137,7 +137,8 @@ const HEAD_CALLBACK_IDENTITY_SHA256 = // Again when an init took one options object. // Again when the frame's layout became one `notifyTerminalFrame`. // Again when the init option took the message's name, `initialData`. -const HEAD_CALLBACK_BODY_SHA256 = '4848e925f478f1656f26031c1bdebbb9f2da60811c7e91cfd732fc9da9764079' +// Again when the document readers mapped refusal codes through one function and kept truncation. +const HEAD_CALLBACK_BODY_SHA256 = 'ff818790399c8532ead38d047d072caf715070b21311538932da2dc811312b06' // Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and // the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed // again when the reporter took the reply and interpreted it itself, retiring the hand-built @@ -203,8 +204,9 @@ const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f342345 // 530 -> 532, and the host-JSX hash: the tab bar and the accessory bar take a ref that gives the // page `keyboardShouldPersistTaps` ('handled', 'always'), which react-native-web ignores. Natively // the ref is undefined. 532 -> 531: the live input's reopen flag reads the host OS, not an 'android' literal. +// 531 -> 529: the markdown status line moved to `markdownReaderStatusText`. const HEAD_RUNTIME_STRING_SHA256 = - 'ab8cc43940d3a3e0fdb8df3bdc178b9b61e51c7ccd55a944933862a3c290e8e3' + '4ab2f316f60c234480615136c02273675543f24d653eb76a62b76f6bc986d985' // Moved by both of the dock's fields: their refs, and the live one's submit handler, are the seam's now; // their keyboard type and remount key read the host OS. // Moved again when the terminal frame kept its laid-out width unrounded, for every fit. @@ -654,7 +656,7 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(531) + expect(strings).toHaveLength(529) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) expect(jsx.host).toHaveLength(125) diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts index a584049360a..b28583bd037 100644 --- a/mobile/src/session/mobile-session-route-types.ts +++ b/mobile/src/session/mobile-session-route-types.ts @@ -91,6 +91,8 @@ export type MarkdownDocState = saving?: boolean saveError?: string readOnlyReason?: string + truncated?: true + byteLength?: number } | { status: 'error'; message: string } diff --git a/mobile/src/session/session-read-reply-schema.ts b/mobile/src/session/session-read-reply-schema.ts index 2a2417c6673..14d163360a0 100644 --- a/mobile/src/session/session-read-reply-schema.ts +++ b/mobile/src/session/session-read-reply-schema.ts @@ -106,17 +106,20 @@ export const sessionWorktreeRecordSchema = z /** * A markdown tab's document, read the same way on load and on save. * - * `content`, `version` and `isDirty` are required: use-mobile-session-document-readers.ts:38-45 - * publishes all three into the tab's ready state with no guard, so a reply missing one rendered - * `undefined` in the editor and saved against an undefined base version. - * `editable` and `readOnlyReason` are guarded on the same lines and stay optional. + * `content`, `version` and `isDirty` are required: `readMarkdownTab` in + * `useMobileSessionDocumentReaders` publishes all three into the tab's ready state with no guard, + * so a reply missing one rendered `undefined` in the editor and saved against an undefined base + * version. `editable` and `readOnlyReason` are guarded there and stay optional, as are + * `truncated` and `byteLength`, which only a host that truncates oversize documents sends. */ export const markdownTabDocumentSchema = z.looseObject({ content: z.string(), version: z.string(), isDirty: z.boolean(), editable: salvagedOptional('editable', z.boolean()), - readOnlyReason: salvagedOptional('readOnlyReason', z.string()) + readOnlyReason: salvagedOptional('readOnlyReason', z.string()), + truncated: salvagedOptional('truncated', z.boolean()), + byteLength: salvagedOptional('byteLength', z.number()) }) /** diff --git a/mobile/src/session/use-mobile-session-document-readers.test.ts b/mobile/src/session/use-mobile-session-document-readers.test.ts new file mode 100644 index 00000000000..70de6de02a3 --- /dev/null +++ b/mobile/src/session/use-mobile-session-document-readers.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import { hookMount, performHookAction } from '../test-support/rpc-recording/hook-mount' +import { mountFixture } from '../test-support/rpc-recording/recorder-fixture-shape' +import type { RpcResponse } from '../transport/types' +import type { MarkdownDocState } from './mobile-session-route-types' +import { useMobileSessionDocumentReaders } from './use-mobile-session-document-readers' + +const META = { runtimeId: 'runtime-1' } + +async function readMarkdown(reply: RpcResponse): Promise { + let docs = new Map() + let readers: ReturnType | undefined + const hook = hookMount(() => { + readers = useMobileSessionDocumentReaders( + mountFixture[0]>({ + worktreeId: 'wt-1', + client: { sendRequest: vi.fn(async (): Promise => reply) }, + setMarkdownDocs: (update) => { + docs = typeof update === 'function' ? update(docs) : update + }, + setFileDocs: () => {} + }) + ) + }) + hook.mount() + await performHookAction(() => + readers?.readMarkdownTab( + mountFixture[0]>({ + type: 'markdown', + id: 'tab-md', + relativePath: 'README.md', + isDirty: false + }) + ) + ) + hook.unmount() + return docs.get('tab-md') +} + +function readTabResult(extra: Record = {}): RpcResponse { + return { + id: 'frame-1', + ok: true, + result: { + tabId: 'tab-md', + content: '# head', + version: 'content:6:0', + isDirty: false, + editable: false, + readOnlyReason: 'file_too_large', + ...extra + }, + _meta: META + } +} + +describe('useMobileSessionDocumentReaders markdown reads', () => { + it('shows an older desktop refusing an oversize file as too large', async () => { + const doc = await readMarkdown({ + id: 'frame-1', + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' }, + _meta: META + }) + expect(doc).toEqual({ status: 'error', message: 'File too large for mobile preview' }) + }) + + it('keeps the generic message for an unknown refusal', async () => { + const doc = await readMarkdown({ + id: 'frame-1', + ok: false, + error: { code: 'tab_not_found', message: 'No such tab' }, + _meta: META + }) + expect(doc).toEqual({ status: 'error', message: "Couldn't load markdown" }) + }) + + it('carries a truncated prefix and the full size into the document', async () => { + const doc = await readMarkdown(readTabResult({ truncated: true, byteLength: 3_000_000 })) + expect(doc).toMatchObject({ + status: 'ready', + content: '# head', + editable: false, + truncated: true, + byteLength: 3_000_000 + }) + }) + + it('adds no truncation fields for a whole document', async () => { + const doc = await readMarkdown(readTabResult()) + expect(doc).toMatchObject({ status: 'ready', content: '# head' }) + expect(doc).not.toHaveProperty('truncated') + expect(doc).not.toHaveProperty('byteLength') + }) +}) diff --git a/mobile/src/session/use-mobile-session-document-readers.ts b/mobile/src/session/use-mobile-session-document-readers.ts index 857d1981570..bc2e3d69e8e 100644 --- a/mobile/src/session/use-mobile-session-document-readers.ts +++ b/mobile/src/session/use-mobile-session-document-readers.ts @@ -34,7 +34,10 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati isDirty: false, editable: result.editable === true, stale: result.isDirty, - readOnlyReason: result.readOnlyReason + readOnlyReason: result.readOnlyReason, + ...(result.truncated === true + ? { truncated: true, byteLength: result.byteLength } + : {}) }) ) return @@ -59,15 +62,16 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati buildMarkdownDiskFallbackDoc({ content: fileResult.content, truncated: fileResult.truncated, + byteLength: fileResult.byteLength, tabIsDirty: tab.isDirty }) ) ) - } catch { + } catch (err) { setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'error', - message: "Couldn't load markdown" + message: documentReadErrorMessage(err, "Couldn't load markdown") }) ) } @@ -89,15 +93,12 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati }) setFileDocs((prev) => new Map(prev).set(tab.id, doc)) } catch (err) { - const message = err instanceof Error ? err.message : '' - const previewMessage = - message === 'binary_file' - ? 'Binary preview unavailable' - : message === 'file_too_large' - ? 'File too large for mobile preview' - : tab.diffSource === 'staged' || tab.diffSource === 'unstaged' - ? "Couldn't load diff preview" - : "Couldn't load file preview" + const previewMessage = documentReadErrorMessage( + err, + tab.diffSource === 'staged' || tab.diffSource === 'unstaged' + ? "Couldn't load diff preview" + : "Couldn't load file preview" + ) setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'error', @@ -114,5 +115,17 @@ export function useMobileSessionDocumentReaders(scope: MobileSessionTabApplicati } } +// Why: older desktops refuse oversize markdown as a bare runtime_error whose message is the code. +function documentReadErrorMessage(err: unknown, fallback: string): string { + const message = err instanceof Error ? err.message : '' + if (message === 'binary_file') { + return 'Binary preview unavailable' + } + if (message === 'file_too_large') { + return 'File too large for mobile preview' + } + return fallback +} + export type MobileSessionDocumentReadersModel = MobileSessionTabApplicationModel & ReturnType diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts index 2e5abd834e4..e039cc515eb 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { hashMarkdownContent, - MOBILE_MARKDOWN_EDIT_MAX_BYTES + MOBILE_MARKDOWN_EDIT_MAX_BYTES, + MOBILE_MARKDOWN_READ_MAX_BYTES } from '../../../shared/mobile-markdown-document' import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller' import { registerPendingEditorFlush } from '../components/editor/editor-pending-flush' @@ -157,4 +158,69 @@ describe('mobile markdown bridge', () => { 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() + } + }) }) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index b605c014801..c51a7656843 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -15,13 +15,13 @@ import { hashMarkdownContent, isMarkdownContentByteLengthOverLimit, MOBILE_MARKDOWN_EDIT_MAX_BYTES, + truncateMobileMarkdownRead, type RuntimeMarkdownReadTabResult, type RuntimeMarkdownSaveTabResult, type RuntimeMobileMarkdownRequest, type RuntimeMobileMarkdownResponse } from '../../../shared/mobile-markdown-document' -const MOBILE_MARKDOWN_READ_MAX_BYTES = 512 * 1024 const saveQueues = new Map>() type FileContent = { @@ -67,16 +67,18 @@ async function readMobileMarkdownTab( 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, + content: preview.content, isDirty: target.sourceFile.isDirty || source === 'draft', - version: hashMarkdownContent(content), + version: hashMarkdownContent(preview.content), source, editable: readOnlyReason === undefined, - ...(readOnlyReason ? { readOnlyReason } : {}) + ...(readOnlyReason ? { readOnlyReason } : {}), + ...(preview.truncated ? { truncated: true, byteLength: preview.byteLength } : {}) } } @@ -252,9 +254,6 @@ async function readFileContent(file: OpenFile): Promise { if (result.isBinary) { throw new Error('binary_file') } - if (isMarkdownContentByteLengthOverLimit(result.content, MOBILE_MARKDOWN_READ_MAX_BYTES)) { - throw new Error('file_too_large') - } return result.content } diff --git a/src/shared/mobile-markdown-document.test.ts b/src/shared/mobile-markdown-document.test.ts index f4d2a003261..a44191edf91 100644 --- a/src/shared/mobile-markdown-document.test.ts +++ b/src/shared/mobile-markdown-document.test.ts @@ -3,6 +3,7 @@ import { hashMarkdownContent, isMarkdownContentByteLengthOverLimit, MOBILE_MARKDOWN_EDIT_MAX_BYTES, + MOBILE_MARKDOWN_READ_MAX_BYTES, utf8ByteLength } from './mobile-markdown-document' @@ -45,4 +46,9 @@ describe('mobile markdown document byte accounting', () => { it('keeps content hashes prefixed with exact byte length', () => { expect(hashMarkdownContent('😀')).toMatch(/^content:4:/) }) + + it('keeps the read budget above the edit budget', () => { + // Behavioural pin for truncated => not editable: the bridge's over-budget read test. + expect(MOBILE_MARKDOWN_READ_MAX_BYTES).toBeGreaterThan(MOBILE_MARKDOWN_EDIT_MAX_BYTES) + }) }) diff --git a/src/shared/mobile-markdown-document.ts b/src/shared/mobile-markdown-document.ts index dced4bb8c85..14a6474cf5d 100644 --- a/src/shared/mobile-markdown-document.ts +++ b/src/shared/mobile-markdown-document.ts @@ -1,6 +1,11 @@ import { getClipboardTextByteLength, isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { clampUtf8TextPrefix } from './utf8-byte-limits' export const MOBILE_MARKDOWN_EDIT_MAX_BYTES = 256 * 1024 +/** Markdown preview budget; the file preview keeps its own. Above it a mobile read returns a + * UTF-8-boundary prefix marked `truncated`, never a refusal. Sized like the 2 MiB terminal + * snapshot; the relay splice frame cap is 8 MiB. */ +export const MOBILE_MARKDOWN_READ_MAX_BYTES = 2 * 1024 * 1024 export type RuntimeMarkdownReadOnlyReason = | 'unsupported_preview' @@ -46,6 +51,10 @@ export type RuntimeMarkdownReadTabResult = { source: 'draft' | 'file' editable: boolean readOnlyReason?: RuntimeMarkdownReadOnlyReason + /** Present only when `content` is a prefix; older phones ignore both fields. */ + truncated?: boolean + /** The full document's UTF-8 size, sent with `truncated`. */ + byteLength?: number } export type RuntimeMarkdownSaveTabResult = { @@ -71,3 +80,19 @@ export function isMarkdownContentByteLengthOverLimit(content: string, maxBytes: export function utf8ByteLength(content: string): number { return getClipboardTextByteLength(content) } + +export function truncateMobileMarkdownRead( + content: string +): + | { content: string; truncated: false } + | { content: string; truncated: true; byteLength: number } { + const byteLength = utf8ByteLength(content) + if (byteLength <= MOBILE_MARKDOWN_READ_MAX_BYTES) { + return { content, truncated: false } + } + return { + content: clampUtf8TextPrefix(content, MOBILE_MARKDOWN_READ_MAX_BYTES), + truncated: true, + byteLength + } +}