mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
fix(mobile): truncate oversize markdown reads instead of failing them (#23676)
* fix(mobile): truncate oversize markdown reads instead of failing them The desktop bridge refused markdown over a private 512 KiB cap with file_too_large, which reached the phone as a generic runtime_error that the reader discarded, so a 632 KB file showed "Couldn't load markdown". Reads now return a UTF-8-boundary prefix under one shared 2 MiB budget, marked truncated with the full byteLength and read-only. The phone shows the truncation like file tabs do and maps refusal codes to real copy, so an older desktop's refusal reads "File too large for mobile preview". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a truncated markdown read is never editable The read budget sits above the edit budget, so every truncated document is already read-only as file_too_large. Pin that ordering so a future budget change cannot make a prefix editable, and name the constant as the markdown preview budget, separate from the file preview's own. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refuse saves from a truncated markdown read A phone holding a truncated prefix must not write it back; the 256 KiB save guard refuses it as file_too_large before any version check. The shared budget test shrinks to the ordering it pins and names the bridge test as the behavioural pin. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): tighten markdown truncation types and measure once The read truncation measures the document once. The disk fallback drops its truncated-only read-only text, which the status line never showed, and both truncation fields are optional there. A markdown doc's flag is only ever true, and the schema comment names the hook, not line numbers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): drop a stale disk-fallback comment Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: retrigger CI after #23675 landed on main Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<MarkdownDocState, { status: 'ready' }> {
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<MarkdownDocState, { status: 'ready' }>
|
||||
): 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -91,6 +91,8 @@ export type MarkdownDocState =
|
||||
saving?: boolean
|
||||
saveError?: string
|
||||
readOnlyReason?: string
|
||||
truncated?: true
|
||||
byteLength?: number
|
||||
}
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<MarkdownDocState | undefined> {
|
||||
let docs = new Map<string, MarkdownDocState>()
|
||||
let readers: ReturnType<typeof useMobileSessionDocumentReaders> | undefined
|
||||
const hook = hookMount(() => {
|
||||
readers = useMobileSessionDocumentReaders(
|
||||
mountFixture<Parameters<typeof useMobileSessionDocumentReaders>[0]>({
|
||||
worktreeId: 'wt-1',
|
||||
client: { sendRequest: vi.fn(async (): Promise<RpcResponse> => reply) },
|
||||
setMarkdownDocs: (update) => {
|
||||
docs = typeof update === 'function' ? update(docs) : update
|
||||
},
|
||||
setFileDocs: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
hook.mount()
|
||||
await performHookAction(() =>
|
||||
readers?.readMarkdownTab(
|
||||
mountFixture<Parameters<typeof readers.readMarkdownTab>[0]>({
|
||||
type: 'markdown',
|
||||
id: 'tab-md',
|
||||
relativePath: 'README.md',
|
||||
isDirty: false
|
||||
})
|
||||
)
|
||||
)
|
||||
hook.unmount()
|
||||
return docs.get('tab-md')
|
||||
}
|
||||
|
||||
function readTabResult(extra: Record<string, unknown> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<typeof useMobileSessionDocumentReaders>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, Promise<void>>()
|
||||
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user