mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -117,7 +117,6 @@ jobs:
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm verify:cli-runtime
|
||||
# Why: boot the BUILT daemon-entry under plain Node the way production
|
||||
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
|
||||
# (leaked electron require) while every other check passed; this fails
|
||||
@@ -159,7 +158,6 @@ jobs:
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm verify:cli-runtime
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
|
||||
|
||||
linux:
|
||||
@@ -187,7 +185,6 @@ jobs:
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm verify:cli-runtime
|
||||
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
|
||||
windows:
|
||||
@@ -207,5 +204,4 @@ jobs:
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
- run: pnpm verify:cli-runtime
|
||||
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('computer-use e2e workflow', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('builds Electron main output and verifies the CLI before every computer-use e2e run', () => {
|
||||
it('builds Electron main output before every computer-use e2e run', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
@@ -187,7 +187,6 @@ describe('computer-use e2e workflow', () => {
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const buildIndex = runs.indexOf('pnpm build:electron-vite')
|
||||
const cliVerifyIndex = runs.indexOf('pnpm verify:cli-runtime')
|
||||
const e2eIndexes = runs
|
||||
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
@@ -196,17 +195,10 @@ describe('computer-use e2e workflow', () => {
|
||||
buildIndex,
|
||||
`${jobName} should build out/main before computer e2e`
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
cliVerifyIndex,
|
||||
`${jobName} should verify the CLI after the main build`
|
||||
).toBeGreaterThan(buildIndex)
|
||||
for (const e2eIndex of e2eIndexes) {
|
||||
expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan(
|
||||
e2eIndex
|
||||
)
|
||||
expect(cliVerifyIndex, `${jobName} should verify the CLI before computer e2e`).toBeLessThan(
|
||||
e2eIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,14 +47,6 @@ describe('verifyPackageCliBin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a compiled CLI with a missing runtime dependency', () => {
|
||||
const { projectDir } = makeProjectWithCli(
|
||||
'#!/usr/bin/env node\nrequire("./missing-runtime-dependency")\n'
|
||||
)
|
||||
|
||||
expect(() => verifyPackageCliBin({ projectDir, runHelp: true })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects an empty package bin target', () => {
|
||||
const { projectDir } = makeProjectWithCli('')
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
"../src/cli/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/main/agent-state-file-reader.ts",
|
||||
"../src/main/agent-hooks/agent-hook-file-comparison.ts",
|
||||
"../src/main/agent-hooks/agent-hook-file-limits.ts",
|
||||
"../src/main/agent-hooks/agent-hook-sftp-text-reader.ts",
|
||||
"../src/main/agent-hooks/hook-stdin-contract.ts",
|
||||
"../src/main/agent-hooks/hook-config-write-path.ts",
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
@@ -23,7 +20,6 @@
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
"../src/main/codex/codex-app-server-grant-bridge.ts",
|
||||
"../src/main/codex/codex-app-server-grant-envelope.ts",
|
||||
"../src/main/codex/codex-app-server-grant-json.ts",
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
@@ -31,7 +27,6 @@
|
||||
"../src/main/codex/codex-config-settings-removal.ts",
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-host-retry-deadlines.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/codex-hook-trust-grant.ts",
|
||||
"../src/main/codex/codex-managed-trust-reconciliation.ts",
|
||||
@@ -43,7 +38,6 @@
|
||||
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
|
||||
"../src/main/codex/codex-user-hook-trust-rebase.ts",
|
||||
"../src/main/codex/codex-wsl-hook-install-plan.ts",
|
||||
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
|
||||
"../src/main/codex/config-settings-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
@@ -55,14 +49,12 @@
|
||||
"../src/main/codex-accounts/fs-utils.ts",
|
||||
"../src/main/codex-accounts/wsl-codex-command.ts",
|
||||
"../src/main/codex-cli/command.ts",
|
||||
"../src/main/codex-cli/nvm-version-directory-discovery.ts",
|
||||
"../src/main/command-code/command-code-managed-script.ts",
|
||||
"../src/main/command-code/hook-service.ts",
|
||||
"../src/main/copilot/hook-service.ts",
|
||||
"../src/main/cursor/hook-service.ts",
|
||||
"../src/main/droid/hook-service.ts",
|
||||
"../src/main/gemini/hook-service.ts",
|
||||
"../src/main/generated-node-bounded-file-reader.ts",
|
||||
"../src/main/grok/hook-service.ts",
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
"../src/main/devin/hook-service.ts",
|
||||
|
||||
@@ -86,10 +86,6 @@ import {
|
||||
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
|
||||
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
|
||||
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
|
||||
import {
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
serializeLastVisitedWorktreeRecord
|
||||
} from '../../../../src/worktree/last-visited-worktree-repo'
|
||||
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
|
||||
import {
|
||||
triggerMediumImpact,
|
||||
@@ -231,8 +227,6 @@ import {
|
||||
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
|
||||
updateTerminalCwdFromStreamEvent
|
||||
} from '../../../../src/session/mobile-session-route-helpers'
|
||||
import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle'
|
||||
import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle'
|
||||
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
|
||||
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
|
||||
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
|
||||
@@ -903,9 +897,7 @@ export default function SessionScreen() {
|
||||
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
|
||||
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
|
||||
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
|
||||
const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
|
||||
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
|
||||
const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
|
||||
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
|
||||
const diffCommentsRef = useRef<DiffComment[]>([])
|
||||
const [diffCommentBusy, setDiffCommentBusy] = useState(false)
|
||||
@@ -1729,8 +1721,6 @@ export default function SessionScreen() {
|
||||
if (orphanedDraftTabs.length > 0) {
|
||||
nextTabs = [...orphanedDraftTabs, ...nextTabs]
|
||||
}
|
||||
markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs)
|
||||
fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs)
|
||||
sessionTabsRef.current = nextTabs
|
||||
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
|
||||
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
|
||||
@@ -1848,7 +1838,8 @@ export default function SessionScreen() {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => {
|
||||
setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
|
||||
try {
|
||||
const response = await client.sendRequest('markdown.readTab', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id
|
||||
@@ -1861,16 +1852,19 @@ export default function SessionScreen() {
|
||||
editable?: boolean
|
||||
readOnlyReason?: string
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
content: result.content,
|
||||
localContent: result.content,
|
||||
baseVersion: result.version,
|
||||
isDirty: false,
|
||||
editable: result.editable === true,
|
||||
stale: result.isDirty,
|
||||
readOnlyReason: result.readOnlyReason
|
||||
}
|
||||
setMarkdownDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'ready',
|
||||
content: result.content,
|
||||
localContent: result.content,
|
||||
baseVersion: result.version,
|
||||
isDirty: false,
|
||||
editable: result.editable === true,
|
||||
stale: result.isDirty,
|
||||
readOnlyReason: result.readOnlyReason
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
|
||||
throw new Error((response as RpcFailure).error.message)
|
||||
@@ -1888,12 +1882,24 @@ export default function SessionScreen() {
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
return buildMarkdownDiskFallbackDoc({
|
||||
content: fileResult.content,
|
||||
truncated: fileResult.truncated,
|
||||
tabIsDirty: tab.isDirty
|
||||
})
|
||||
})
|
||||
setMarkdownDocs((prev) =>
|
||||
new Map(prev).set(
|
||||
tab.id,
|
||||
buildMarkdownDiskFallbackDoc({
|
||||
content: fileResult.content,
|
||||
truncated: fileResult.truncated,
|
||||
tabIsDirty: tab.isDirty
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
setMarkdownDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'error',
|
||||
message: "Couldn't load markdown"
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
[client, worktreeId]
|
||||
)
|
||||
@@ -1903,13 +1909,31 @@ export default function SessionScreen() {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
await fileDocLifecycleRef.current.load(tab, setFileDocs, () =>
|
||||
resolveMobileFileTabDoc(client, {
|
||||
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
|
||||
try {
|
||||
const doc = await resolveMobileFileTabDoc(client, {
|
||||
worktreeId,
|
||||
relativePath: tab.relativePath,
|
||||
diffSource: tab.diffSource
|
||||
})
|
||||
)
|
||||
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"
|
||||
setFileDocs((prev) =>
|
||||
new Map(prev).set(tab.id, {
|
||||
status: 'error',
|
||||
message: previewMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
[client, worktreeId]
|
||||
)
|
||||
@@ -2224,9 +2248,6 @@ export default function SessionScreen() {
|
||||
})
|
||||
} finally {
|
||||
markdownSaveInFlightRef.current.delete(tab.id)
|
||||
if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) {
|
||||
markdownSaveSeqRef.current.delete(tab.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, markdownDocs, showToast, worktreeId]
|
||||
@@ -2499,10 +2520,10 @@ export default function SessionScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
if (hostId && worktreeId) {
|
||||
const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId })
|
||||
if (serialized) {
|
||||
void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized)
|
||||
}
|
||||
void AsyncStorage.setItem(
|
||||
'orca:last-visited-worktree',
|
||||
JSON.stringify({ hostId, worktreeId })
|
||||
)
|
||||
}
|
||||
}, [hostId, worktreeId])
|
||||
|
||||
@@ -2536,10 +2557,6 @@ export default function SessionScreen() {
|
||||
terminalDiagnosticsRef.current.resetRoute()
|
||||
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
|
||||
closedTabTombstonesRef.current.clear()
|
||||
markdownDocLifecycleRef.current.reset()
|
||||
fileDocLifecycleRef.current.reset()
|
||||
markdownSaveSeqRef.current.clear()
|
||||
markdownSaveInFlightRef.current.clear()
|
||||
for (const queued of terminalGestureInputQueuesRef.current.values()) {
|
||||
if (queued.timer) {
|
||||
clearTimeout(queued.timer)
|
||||
@@ -2559,10 +2576,6 @@ export default function SessionScreen() {
|
||||
return () => {
|
||||
sessionTabActionSheetRequestSeqRef.current += 1
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
markdownDocLifecycleRef.current.reset()
|
||||
fileDocLifecycleRef.current.reset()
|
||||
markdownSaveSeqRef.current.clear()
|
||||
markdownSaveInFlightRef.current.clear()
|
||||
clearPendingLiveInputCommit()
|
||||
clearDelayedActionTimers()
|
||||
}
|
||||
@@ -4096,23 +4109,7 @@ export default function SessionScreen() {
|
||||
initializedHandlesRef.current.delete(terminalHandle)
|
||||
clearTerminalLiveInputDefault(terminalHandle)
|
||||
}
|
||||
if (tab.type === 'file') {
|
||||
fileDocLifecycleRef.current.close(tab.id, setFileDocs)
|
||||
}
|
||||
if (tab.type === 'markdown') {
|
||||
markdownDocLifecycleRef.current.close(tab.id, (update) => {
|
||||
setMarkdownDocs((current) => {
|
||||
const next = update(current)
|
||||
markdownDocsRef.current = next
|
||||
return next
|
||||
})
|
||||
})
|
||||
markdownSaveSeqRef.current.delete(tab.id)
|
||||
markdownSaveInFlightRef.current.delete(tab.id)
|
||||
}
|
||||
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
|
||||
sessionTabsRef.current = remainingTabs
|
||||
setSessionTabs(remainingTabs)
|
||||
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
|
||||
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
|
||||
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
|
||||
if (activeSessionTabId === tab.id) {
|
||||
|
||||
@@ -137,15 +137,9 @@ import {
|
||||
clearMobileTaskCopyFeedbackTimer,
|
||||
scheduleMobileTaskCopyFeedbackReset
|
||||
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
|
||||
import {
|
||||
createMobileItemPrFileContentScope,
|
||||
createMobileProjectPrFileContentScope,
|
||||
useMobilePrFileContentCache
|
||||
} from '../../../src/tasks/use-mobile-pr-file-content-cache'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
GitHubOwnerRepo,
|
||||
GitHubPRFileContents,
|
||||
PersistedTrustedOrcaHooks,
|
||||
SparsePreset,
|
||||
TuiAgent
|
||||
@@ -364,6 +358,13 @@ type GitHubDetailCheck = {
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
type GitHubPRFileContents = {
|
||||
original: string
|
||||
modified: string
|
||||
originalIsBinary: boolean
|
||||
modifiedIsBinary: boolean
|
||||
}
|
||||
|
||||
type DetailPayload =
|
||||
| {
|
||||
provider: 'github'
|
||||
@@ -2262,6 +2263,8 @@ export default function MobileTasksScreen() {
|
||||
const [itemReviewersDraft, setItemReviewersDraft] = useState('')
|
||||
const [itemReplyDrafts, setItemReplyDrafts] = useState<Record<string, string>>({})
|
||||
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
|
||||
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
|
||||
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
|
||||
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
|
||||
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
|
||||
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -2599,23 +2602,6 @@ export default function MobileTasksScreen() {
|
||||
() => (projectRowItem ? findProjectRowRepo(projectRowItem) : null),
|
||||
[findProjectRowRepo, projectRowItem]
|
||||
)
|
||||
const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload)
|
||||
const projectPrFileContentScope = createMobileProjectPrFileContentScope(
|
||||
projectRowItem,
|
||||
projectRowHostedRepo,
|
||||
projectRowDetail,
|
||||
projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null
|
||||
)
|
||||
const activePrFileContentScope = projectRowItem
|
||||
? projectPrFileContentScope
|
||||
: itemPrFileContentScope
|
||||
const {
|
||||
clear: clearPrFileContents,
|
||||
contents: prFileContents,
|
||||
load: loadPrFileContent,
|
||||
loadingPath: prFileLoadingPath
|
||||
} = useMobilePrFileContentCache(activePrFileContentScope)
|
||||
|
||||
const itemReviewerCandidates = useMemo(() => {
|
||||
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
|
||||
return []
|
||||
@@ -4155,7 +4141,8 @@ export default function MobileTasksScreen() {
|
||||
setItemReviewersDraft('')
|
||||
setItemReplyDrafts({})
|
||||
setExpandedPrFilePath(null)
|
||||
clearPrFileContents()
|
||||
setPrFileContents({})
|
||||
setPrFileLoadingPath(null)
|
||||
setPrFileCommentDrafts({})
|
||||
setExpandedResolvedCommentGroups(new Set())
|
||||
return
|
||||
@@ -4170,10 +4157,11 @@ export default function MobileTasksScreen() {
|
||||
setItemReviewersDraft('')
|
||||
setItemReplyDrafts({})
|
||||
setExpandedPrFilePath(null)
|
||||
clearPrFileContents()
|
||||
setPrFileContents({})
|
||||
setPrFileLoadingPath(null)
|
||||
setPrFileCommentDrafts({})
|
||||
setExpandedResolvedCommentGroups(new Set())
|
||||
}, [actionItem, clearPrFileContents])
|
||||
}, [actionItem])
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailPayload) {
|
||||
@@ -4470,7 +4458,8 @@ export default function MobileTasksScreen() {
|
||||
setProjectEditingCommentDraft('')
|
||||
setProjectReviewersDraft('')
|
||||
setExpandedPrFilePath(null)
|
||||
clearPrFileContents()
|
||||
setPrFileContents({})
|
||||
setPrFileLoadingPath(null)
|
||||
setPrFileCommentDrafts({})
|
||||
setProjectFieldDrafts({})
|
||||
return
|
||||
@@ -4485,7 +4474,8 @@ export default function MobileTasksScreen() {
|
||||
setProjectEditingCommentDraft('')
|
||||
setProjectReviewersDraft('')
|
||||
setExpandedPrFilePath(null)
|
||||
clearPrFileContents()
|
||||
setPrFileContents({})
|
||||
setPrFileLoadingPath(null)
|
||||
setPrFileCommentDrafts({})
|
||||
setProjectFieldDrafts(
|
||||
Object.fromEntries(
|
||||
@@ -4589,7 +4579,6 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
}, [
|
||||
activeGitHubProjectHost,
|
||||
clearPrFileContents,
|
||||
client,
|
||||
githubProjectTable,
|
||||
projectRowDetailRefreshSeq,
|
||||
@@ -6560,6 +6549,9 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
setExpandedPrFilePath(file.path)
|
||||
if (prFileContents[file.path]) {
|
||||
return
|
||||
}
|
||||
const repo = findProjectRowRepo(row)
|
||||
if (
|
||||
!client ||
|
||||
@@ -6568,45 +6560,49 @@ export default function MobileTasksScreen() {
|
||||
!row.content.number ||
|
||||
projectRowDetail?.provider !== 'github' ||
|
||||
!projectRowDetail.headSha ||
|
||||
!projectRowDetail.baseSha ||
|
||||
!projectPrFileContentScope
|
||||
!projectRowDetail.baseSha
|
||||
) {
|
||||
setProjectRowDetailError('Unable to load file contents for this pull request.')
|
||||
return
|
||||
}
|
||||
await loadPrFileContent(
|
||||
projectPrFileContentScope,
|
||||
file,
|
||||
async () => {
|
||||
const response = await client.sendRequest(
|
||||
'github.prFileContents',
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status ?? 'modified',
|
||||
headSha: projectRowDetail.headSha,
|
||||
baseSha: projectRowDetail.baseSha
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
setProjectRowDetailError
|
||||
)
|
||||
setPrFileLoadingPath(file.path)
|
||||
setProjectRowDetailError('')
|
||||
try {
|
||||
const response = await client.sendRequest(
|
||||
'github.prFileContents',
|
||||
{
|
||||
repo: `id:${repo.id}`,
|
||||
prNumber: row.content.number,
|
||||
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status ?? 'modified',
|
||||
headSha: projectRowDetail.headSha,
|
||||
baseSha: projectRowDetail.baseSha
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
setPrFileContents((current) => ({
|
||||
...current,
|
||||
[file.path]: response.result as GitHubPRFileContents
|
||||
}))
|
||||
} catch (err) {
|
||||
setProjectRowDetailError(
|
||||
err instanceof Error ? err.message : 'Failed to load file contents'
|
||||
)
|
||||
} finally {
|
||||
setPrFileLoadingPath(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeGitHubProjectHost,
|
||||
client,
|
||||
expandedPrFilePath,
|
||||
findProjectRowRepo,
|
||||
loadPrFileContent,
|
||||
projectPrFileContentScope,
|
||||
prFileContents,
|
||||
projectRowDetail
|
||||
]
|
||||
)
|
||||
@@ -7530,43 +7526,49 @@ export default function MobileTasksScreen() {
|
||||
return
|
||||
}
|
||||
setExpandedPrFilePath(file.path)
|
||||
if (prFileContents[file.path]) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!client ||
|
||||
item.source.type !== 'pr' ||
|
||||
detailPayload?.provider !== 'github' ||
|
||||
!detailPayload.headSha ||
|
||||
!detailPayload.baseSha ||
|
||||
!itemPrFileContentScope
|
||||
!detailPayload.baseSha
|
||||
) {
|
||||
setError('Unable to load file contents for this pull request.')
|
||||
return
|
||||
}
|
||||
await loadPrFileContent(
|
||||
itemPrFileContentScope,
|
||||
file,
|
||||
async () => {
|
||||
const response = await client.sendRequest(
|
||||
'github.prFileContents',
|
||||
{
|
||||
repo: `id:${item.source.repoId}`,
|
||||
prNumber: item.source.number,
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status ?? 'modified',
|
||||
headSha: detailPayload.headSha,
|
||||
baseSha: detailPayload.baseSha
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
setError
|
||||
)
|
||||
setPrFileLoadingPath(file.path)
|
||||
setError('')
|
||||
try {
|
||||
const response = await client.sendRequest(
|
||||
'github.prFileContents',
|
||||
{
|
||||
repo: `id:${item.source.repoId}`,
|
||||
prNumber: item.source.number,
|
||||
path: file.path,
|
||||
oldPath: file.oldPath,
|
||||
status: file.status ?? 'modified',
|
||||
headSha: detailPayload.headSha,
|
||||
baseSha: detailPayload.baseSha
|
||||
},
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
if (!isSuccess(response)) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
setPrFileContents((current) => ({
|
||||
...current,
|
||||
[file.path]: response.result as GitHubPRFileContents
|
||||
}))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load file contents')
|
||||
} finally {
|
||||
setPrFileLoadingPath(null)
|
||||
}
|
||||
},
|
||||
[client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent]
|
||||
[client, detailPayload, expandedPrFilePath, prFileContents]
|
||||
)
|
||||
|
||||
const addGitHubFileReviewComment = useCallback(
|
||||
@@ -12320,7 +12322,7 @@ export default function MobileTasksScreen() {
|
||||
) : prFileContents[file.path] ? (
|
||||
<GitHubPrFileDiff
|
||||
filePath={file.path}
|
||||
contents={prFileContents[file.path]!}
|
||||
contents={prFileContents[file.path]}
|
||||
commentDrafts={prFileCommentDrafts}
|
||||
disabled={projectMutating}
|
||||
onCommentDraftChange={(draftKey, next) =>
|
||||
@@ -13266,7 +13268,7 @@ export default function MobileTasksScreen() {
|
||||
) : prFileContents[file.path] ? (
|
||||
<GitHubPrFileDiff
|
||||
filePath={file.path}
|
||||
contents={prFileContents[file.path]!}
|
||||
contents={prFileContents[file.path]}
|
||||
commentDrafts={prFileCommentDrafts}
|
||||
disabled={mutatingStatus}
|
||||
onCommentDraftChange={(draftKey, next) =>
|
||||
|
||||
@@ -27,10 +27,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import {
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
readLastVisitedWorktreeRecord
|
||||
} from '../src/worktree/last-visited-worktree-repo'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import {
|
||||
@@ -398,14 +394,13 @@ export default function HomeScreen() {
|
||||
router.replace(mobileOnboardingDestination(onboardingSteps))
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
if (stale || !raw) {
|
||||
return
|
||||
}
|
||||
const record = readLastVisitedWorktreeRecord(raw)
|
||||
if (record) {
|
||||
setLastVisited(record)
|
||||
}
|
||||
try {
|
||||
setLastVisited(JSON.parse(raw))
|
||||
} catch {}
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
export const PROCESS_LINE_MAX_BYTES = 64 * 1024
|
||||
export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024
|
||||
const TRUNCATED_LINE_SUFFIX = '… [line truncated]'
|
||||
|
||||
export function appendProcessOutputTail(
|
||||
current,
|
||||
line,
|
||||
maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS
|
||||
) {
|
||||
if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) {
|
||||
throw new RangeError('Process output tail limit must be a non-negative safe integer')
|
||||
}
|
||||
if (maxCodeUnits === 0) {
|
||||
return ''
|
||||
}
|
||||
const appended = `${current}${line}\n`
|
||||
return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits)
|
||||
}
|
||||
|
||||
export function attachBoundedProcessLineReader(
|
||||
stream,
|
||||
onLine,
|
||||
maxLineBytes = PROCESS_LINE_MAX_BYTES
|
||||
) {
|
||||
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
|
||||
throw new RangeError('Process line limit must be a non-negative safe integer')
|
||||
}
|
||||
|
||||
const retained = Buffer.allocUnsafe(maxLineBytes)
|
||||
let retainedBytes = 0
|
||||
let truncated = false
|
||||
let swallowLineFeed = false
|
||||
let closed = false
|
||||
|
||||
const append = (bytes, start, end) => {
|
||||
if (start >= end) {
|
||||
return
|
||||
}
|
||||
const available = maxLineBytes - retainedBytes
|
||||
const copied = Math.min(available, end - start)
|
||||
if (copied > 0) {
|
||||
bytes.copy(retained, retainedBytes, start, start + copied)
|
||||
retainedBytes += copied
|
||||
}
|
||||
truncated ||= copied < end - start
|
||||
}
|
||||
|
||||
const emit = () => {
|
||||
const line = retained.subarray(0, retainedBytes).toString('utf8')
|
||||
onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line)
|
||||
retainedBytes = 0
|
||||
truncated = false
|
||||
}
|
||||
|
||||
const onData = (chunk) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
let start = 0
|
||||
if (swallowLineFeed) {
|
||||
swallowLineFeed = false
|
||||
if (bytes[0] === 0x0a) {
|
||||
start = 1
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = start; index < bytes.length; index += 1) {
|
||||
const value = bytes[index]
|
||||
if (value !== 0x0a && value !== 0x0d) {
|
||||
continue
|
||||
}
|
||||
append(bytes, start, index)
|
||||
emit()
|
||||
if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) {
|
||||
index += 1
|
||||
} else if (value === 0x0d && index + 1 === bytes.length) {
|
||||
swallowLineFeed = true
|
||||
}
|
||||
start = index + 1
|
||||
}
|
||||
append(bytes, start, bytes.length)
|
||||
}
|
||||
|
||||
const detach = () => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
stream.off('data', onData)
|
||||
stream.off('end', onEnd)
|
||||
}
|
||||
|
||||
const onEnd = () => {
|
||||
if (retainedBytes > 0 || truncated) {
|
||||
emit()
|
||||
}
|
||||
detach()
|
||||
}
|
||||
|
||||
stream.on('data', onData)
|
||||
stream.on('end', onEnd)
|
||||
return detach
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) {
|
||||
const declaredLength = response.headers.get('content-length')
|
||||
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined)
|
||||
return false
|
||||
}
|
||||
if (!response.body) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let observedBytes = 0
|
||||
let suffix = ''
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
return `${suffix}${decoder.decode()}`.includes(needle)
|
||||
}
|
||||
observedBytes += value.byteLength
|
||||
if (observedBytes > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return false
|
||||
}
|
||||
const candidate = suffix + decoder.decode(value, { stream: true })
|
||||
if (candidate.includes(needle)) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return true
|
||||
}
|
||||
suffix = candidate.slice(-Math.max(0, needle.length - 1))
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import { mkdirSync, mkdtempSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
appendProcessOutputTail,
|
||||
attachBoundedProcessLineReader
|
||||
} from './bounded-process-line-reader.mjs'
|
||||
import readline from 'node:readline'
|
||||
|
||||
function primaryLanIp(lanIpCandidates) {
|
||||
return lanIpCandidates()[0] || '127.0.0.1'
|
||||
@@ -69,15 +66,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
|
||||
let stderr = ''
|
||||
let resolved = false
|
||||
let exited = false
|
||||
let closeStdout = () => {}
|
||||
let closeStderr = () => {}
|
||||
let rl = null
|
||||
let rlErr = null
|
||||
|
||||
const stop = () => {
|
||||
if (!exited) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
closeStdout()
|
||||
closeStderr()
|
||||
rl?.close()
|
||||
rlErr?.close()
|
||||
child.stdout?.destroy()
|
||||
child.stderr?.destroy()
|
||||
}
|
||||
@@ -122,17 +119,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
|
||||
reject(error)
|
||||
}
|
||||
|
||||
closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => {
|
||||
if (!resolved) {
|
||||
output = appendProcessOutputTail(output, line)
|
||||
}
|
||||
rl = readline.createInterface({ input: child.stdout })
|
||||
rl.on('line', (line) => {
|
||||
output += line + '\n'
|
||||
handleRuntimeLine(line, finishResolve)
|
||||
})
|
||||
|
||||
closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => {
|
||||
if (!resolved) {
|
||||
stderr = appendProcessOutputTail(stderr, line)
|
||||
}
|
||||
rlErr = readline.createInterface({ input: child.stderr })
|
||||
rlErr.on('line', (line) => {
|
||||
stderr += line + '\n'
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
|
||||
@@ -22,21 +22,16 @@ import os from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import readline from 'node:readline'
|
||||
import {
|
||||
registerWorktreeForPairingRuntime,
|
||||
startHeadlessPairingRuntime
|
||||
} from './start-emulator-pairing-runtime.mjs'
|
||||
import {
|
||||
appendProcessOutputTail,
|
||||
attachBoundedProcessLineReader
|
||||
} from './bounded-process-line-reader.mjs'
|
||||
import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs'
|
||||
import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_METRO_PORT = 8081
|
||||
const METRO_PORT_SEARCH_LIMIT = 100
|
||||
const METRO_STATUS_MAX_BYTES = 64 * 1024
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = process.argv.slice(2)
|
||||
@@ -118,22 +113,6 @@ function logInfo(message) {
|
||||
log(`[info] ${message}`, 'yellow')
|
||||
}
|
||||
|
||||
function createBackpressuredLineWriter(source, target, color) {
|
||||
let waitingForDrain = false
|
||||
return (line) => {
|
||||
const accepted = target.write(color + line + colors.reset + '\n')
|
||||
if (accepted || waitingForDrain) {
|
||||
return
|
||||
}
|
||||
waitingForDrain = true
|
||||
source.pause()
|
||||
target.once('drain', () => {
|
||||
waitingForDrain = false
|
||||
source.resume()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function assertIosSimulatorPlatform() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('iOS Simulator automation requires macOS and Xcode.')
|
||||
@@ -403,10 +382,8 @@ async function startMetro(worktree) {
|
||||
let url = null
|
||||
let resolved = false
|
||||
let exited = false
|
||||
let closeStdout = () => {}
|
||||
let closeStderr = () => {}
|
||||
const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim)
|
||||
const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red)
|
||||
let rl = null
|
||||
let rlErr = null
|
||||
|
||||
const metroResult = () => ({
|
||||
process: metro,
|
||||
@@ -414,8 +391,8 @@ async function startMetro(worktree) {
|
||||
output,
|
||||
isExited: () => exited,
|
||||
closeOutput: () => {
|
||||
closeStdout()
|
||||
closeStderr()
|
||||
rl?.close()
|
||||
rlErr?.close()
|
||||
metro.stdin?.destroy()
|
||||
metro.stdout?.destroy()
|
||||
metro.stderr?.destroy()
|
||||
@@ -423,11 +400,10 @@ async function startMetro(worktree) {
|
||||
})
|
||||
|
||||
// Parse Metro output for the development URL
|
||||
closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => {
|
||||
if (!resolved) {
|
||||
output = appendProcessOutputTail(output, line)
|
||||
}
|
||||
writeStdoutLine(line)
|
||||
rl = readline.createInterface({ input: metro.stdout })
|
||||
rl.on('line', (line) => {
|
||||
output += line + '\n'
|
||||
process.stdout.write(colors.dim + line + colors.reset + '\n')
|
||||
|
||||
// Look for "Waiting on" message from Metro
|
||||
// When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL
|
||||
@@ -470,11 +446,10 @@ async function startMetro(worktree) {
|
||||
})
|
||||
|
||||
// Also check stderr
|
||||
closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => {
|
||||
if (!resolved) {
|
||||
output = appendProcessOutputTail(output, line)
|
||||
}
|
||||
writeStderrLine(line)
|
||||
rlErr = readline.createInterface({ input: metro.stderr })
|
||||
rlErr.on('line', (line) => {
|
||||
output += line + '\n'
|
||||
process.stderr.write(colors.red + line + colors.reset + '\n')
|
||||
})
|
||||
|
||||
metro.on('error', (error) => {
|
||||
@@ -572,11 +547,7 @@ async function verifyMetro(url) {
|
||||
|
||||
try {
|
||||
const response = await fetch(statusUrl, { signal: controller.signal })
|
||||
return await responseBodyIncludesWithinLimit(
|
||||
response,
|
||||
'packager-status:running',
|
||||
METRO_STATUS_MAX_BYTES
|
||||
)
|
||||
return (await response.text()).includes('packager-status:running')
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -39,10 +42,6 @@ import {
|
||||
getInitialMobileBrowserViewMode,
|
||||
saveMobileBrowserViewMode
|
||||
} from './mobile-browser-view-mode-state'
|
||||
import {
|
||||
createMobileBrowserFrameDataUri,
|
||||
MobileBrowserFrameCache
|
||||
} from './mobile-browser-frame-cache'
|
||||
import {
|
||||
clampBrowserZoomState,
|
||||
computeBrowserFrameGeometry,
|
||||
@@ -109,7 +108,14 @@ const TOUCH_CLICK_RADIUS_DIP = 14
|
||||
const MIN_ZOOM = 1
|
||||
const MAX_ZOOM = 3.5
|
||||
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
|
||||
const browserFrameCache = new MobileBrowserFrameCache()
|
||||
const BROWSER_FRAME_CACHE_LIMIT = 4
|
||||
|
||||
type BrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
|
||||
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
|
||||
|
||||
type BrowserPageParams = {
|
||||
worktree: string
|
||||
@@ -137,7 +143,7 @@ export function MobileBrowserPane({
|
||||
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
|
||||
)
|
||||
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
|
||||
const cachedInitialFrame = browserFrameCache.peek(cacheKey)
|
||||
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
|
||||
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
|
||||
const [addressFocused, setAddressFocused] = useState(false)
|
||||
const [addressSyncState, setAddressSyncState] = useState({
|
||||
@@ -223,7 +229,7 @@ export function MobileBrowserPane({
|
||||
const subscription = AppState.addEventListener('change', (nextState) => {
|
||||
const active = nextState === 'active'
|
||||
if (!active) {
|
||||
browserFrameCache.clearWorktree(worktreeId)
|
||||
clearCachedBrowserFramesForWorktree(worktreeId)
|
||||
}
|
||||
setAppActive(active)
|
||||
})
|
||||
@@ -274,14 +280,8 @@ export function MobileBrowserPane({
|
||||
frameMetadataRef.current = frame.metadata
|
||||
setFrameMetadata(frame.metadata)
|
||||
}
|
||||
const nextFrameUri = createMobileBrowserFrameDataUri(frame)
|
||||
if (!nextFrameUri) {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setError('Browser frame is too large to display safely.')
|
||||
return
|
||||
}
|
||||
browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
|
||||
const nextFrameUri = createBrowserFrameDataUri(frame)
|
||||
cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
|
||||
if (!frameMountedRef.current) {
|
||||
frameUriRef.current = nextFrameUri
|
||||
frameMountedRef.current = true
|
||||
@@ -389,7 +389,7 @@ export function MobileBrowserPane({
|
||||
const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey
|
||||
lastStreamCacheKeyRef.current = cacheKey
|
||||
if (!sameStream || !frameUriRef.current) {
|
||||
const cachedFrame = browserFrameCache.get(cacheKey)
|
||||
const cachedFrame = getCachedBrowserFrame(cacheKey)
|
||||
if (cachedFrame) {
|
||||
frameUriRef.current = cachedFrame.uri
|
||||
frameMountedRef.current = true
|
||||
@@ -1303,6 +1303,10 @@ function buttonColor(enabled: boolean): string {
|
||||
return enabled ? colors.textSecondary : colors.textMuted
|
||||
}
|
||||
|
||||
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
|
||||
function makeBrowserFrameCacheKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null,
|
||||
@@ -1311,6 +1315,47 @@ function makeBrowserFrameCacheKey(
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
|
||||
}
|
||||
|
||||
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of browserFrameCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
browserFrameCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
if (!cacheKey) {
|
||||
return null
|
||||
}
|
||||
const cached = browserFrameCache.get(cacheKey)
|
||||
if (!cached) {
|
||||
return null
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
|
||||
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
|
||||
}
|
||||
|
||||
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
browserFrameCache.delete(cacheKey)
|
||||
browserFrameCache.set(cacheKey, entry)
|
||||
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
|
||||
const oldestKey = browserFrameCache.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
break
|
||||
}
|
||||
browserFrameCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function updateBrowserLayerVisibility(
|
||||
layers: [View | null, View | null],
|
||||
visible: FrameLayer
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BrowserScreencastOpcode,
|
||||
type BrowserScreencastFrame
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
import {
|
||||
createMobileBrowserFrameDataUri,
|
||||
MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES,
|
||||
MobileBrowserFrameCache
|
||||
} from './mobile-browser-frame-cache'
|
||||
|
||||
function entry(uri: string) {
|
||||
return { uri, metadata: {} }
|
||||
}
|
||||
|
||||
function frame(imageBytes: number): BrowserScreencastFrame {
|
||||
return {
|
||||
opcode: BrowserScreencastOpcode.Frame,
|
||||
seq: 1,
|
||||
format: 'jpeg',
|
||||
metadata: {},
|
||||
image: new Uint8Array(imageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MobileBrowserFrameCache', () => {
|
||||
it('preserves LRU behavior below the count and retained-character limits', () => {
|
||||
const cache = new MobileBrowserFrameCache(2, 100)
|
||||
cache.set('first', entry('frame-1'))
|
||||
cache.set('second', entry('frame-2'))
|
||||
|
||||
expect(cache.get('first')).toEqual(entry('frame-1'))
|
||||
cache.set('third', entry('frame-3'))
|
||||
|
||||
expect(cache.peek('second')).toBeNull()
|
||||
expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third'])
|
||||
})
|
||||
|
||||
it('accepts the exact aggregate budget and evicts oldest at one character over', () => {
|
||||
const cache = new MobileBrowserFrameCache(4, 20)
|
||||
expect(cache.set('a', entry('x'.repeat(9)))).toBe(true)
|
||||
expect(cache.set('b', entry('x'.repeat(9)))).toBe(true)
|
||||
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 })
|
||||
|
||||
expect(cache.set('c', entry(''))).toBe(true)
|
||||
expect(cache.peek('a')).toBeNull()
|
||||
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 })
|
||||
})
|
||||
|
||||
it('does not retain one entry larger than the full budget', () => {
|
||||
const cache = new MobileBrowserFrameCache(4, 10)
|
||||
|
||||
expect(cache.set('key', entry('x'.repeat(8)))).toBe(false)
|
||||
expect(cache.evidence()).toEqual({
|
||||
entryCount: 0,
|
||||
retainedCharacters: 0,
|
||||
keysOldestFirst: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMobileBrowserFrameDataUri', () => {
|
||||
it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => {
|
||||
expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024)
|
||||
expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==')
|
||||
expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
import { Buffer } from 'buffer'
|
||||
import type {
|
||||
BrowserScreencastFrame,
|
||||
BrowserScreencastFrameMetadata
|
||||
} from '../transport/browser-screencast-protocol'
|
||||
|
||||
export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4
|
||||
export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024
|
||||
|
||||
export type MobileBrowserFrameCacheEntry = {
|
||||
uri: string
|
||||
metadata: BrowserScreencastFrameMetadata
|
||||
}
|
||||
|
||||
export type MobileBrowserFrameCacheEvidence = {
|
||||
entryCount: number
|
||||
retainedCharacters: number
|
||||
keysOldestFirst: string[]
|
||||
}
|
||||
|
||||
type RetainedFrame = {
|
||||
entry: MobileBrowserFrameCacheEntry
|
||||
retainedCharacters: number
|
||||
}
|
||||
|
||||
export class MobileBrowserFrameCache {
|
||||
private readonly entries = new Map<string, RetainedFrame>()
|
||||
private retainedCharacters = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES,
|
||||
private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS
|
||||
) {
|
||||
if (
|
||||
!Number.isInteger(maxEntries) ||
|
||||
maxEntries < 1 ||
|
||||
!Number.isSafeInteger(maxRetainedCharacters) ||
|
||||
maxRetainedCharacters < 1
|
||||
) {
|
||||
throw new Error('Mobile browser frame cache limits must be positive integers')
|
||||
}
|
||||
}
|
||||
|
||||
get(key: string | null): MobileBrowserFrameCacheEntry | null {
|
||||
if (!key) {
|
||||
return null
|
||||
}
|
||||
const retained = this.entries.get(key)
|
||||
if (!retained) {
|
||||
return null
|
||||
}
|
||||
this.entries.delete(key)
|
||||
this.entries.set(key, retained)
|
||||
return retained.entry
|
||||
}
|
||||
|
||||
peek(key: string | null): MobileBrowserFrameCacheEntry | null {
|
||||
return key ? (this.entries.get(key)?.entry ?? null) : null
|
||||
}
|
||||
|
||||
set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean {
|
||||
if (!key) {
|
||||
return false
|
||||
}
|
||||
const retainedCharacters = key.length + entry.uri.length
|
||||
const previous = this.entries.get(key)
|
||||
if (previous) {
|
||||
this.retainedCharacters -= previous.retainedCharacters
|
||||
this.entries.delete(key)
|
||||
}
|
||||
if (retainedCharacters > this.maxRetainedCharacters) {
|
||||
return false
|
||||
}
|
||||
this.entries.set(key, { entry, retainedCharacters })
|
||||
this.retainedCharacters += retainedCharacters
|
||||
this.evictOverflow()
|
||||
return this.entries.has(key)
|
||||
}
|
||||
|
||||
clearWorktree(worktreeId: string): void {
|
||||
const prefix = `${worktreeId}:`
|
||||
for (const key of this.entries.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear()
|
||||
this.retainedCharacters = 0
|
||||
}
|
||||
|
||||
evidence(): MobileBrowserFrameCacheEvidence {
|
||||
return {
|
||||
entryCount: this.entries.size,
|
||||
retainedCharacters: this.retainedCharacters,
|
||||
keysOldestFirst: [...this.entries.keys()]
|
||||
}
|
||||
}
|
||||
|
||||
private delete(key: string): void {
|
||||
const retained = this.entries.get(key)
|
||||
if (!retained) {
|
||||
return
|
||||
}
|
||||
this.retainedCharacters -= retained.retainedCharacters
|
||||
this.entries.delete(key)
|
||||
}
|
||||
|
||||
private evictOverflow(): void {
|
||||
while (
|
||||
this.entries.size > this.maxEntries ||
|
||||
this.retainedCharacters > this.maxRetainedCharacters
|
||||
) {
|
||||
const oldestKey = this.entries.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
return
|
||||
}
|
||||
this.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobileBrowserFrameDataUri(
|
||||
frame: BrowserScreencastFrame,
|
||||
maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES
|
||||
): string | null {
|
||||
if (frame.image.byteLength > maxImageBytes) {
|
||||
return null
|
||||
}
|
||||
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS,
|
||||
BROWSER_VIEW_MODE_STATE_LIMIT,
|
||||
clearMobileBrowserViewModeState,
|
||||
getInitialMobileBrowserViewMode,
|
||||
saveMobileBrowserViewMode
|
||||
@@ -24,28 +22,4 @@ describe('mobile browser view mode state', () => {
|
||||
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
|
||||
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
|
||||
})
|
||||
|
||||
it('retains the exact LRU count and evicts the oldest page at one over', () => {
|
||||
for (let index = 0; index < BROWSER_VIEW_MODE_STATE_LIMIT; index += 1) {
|
||||
saveMobileBrowserViewMode('worktree', `page-${index}`, 'mobile')
|
||||
}
|
||||
expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('mobile')
|
||||
|
||||
saveMobileBrowserViewMode('worktree', 'one-over', 'mobile')
|
||||
|
||||
expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('web')
|
||||
expect(getInitialMobileBrowserViewMode('worktree', 'one-over')).toBe('mobile')
|
||||
})
|
||||
|
||||
it('accepts the exact page-key character limit and rejects one over', () => {
|
||||
const worktreeId = 'w'
|
||||
const exactPageId = 'p'.repeat(BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS - 2)
|
||||
const oversizedPageId = `${exactPageId}p`
|
||||
|
||||
saveMobileBrowserViewMode(worktreeId, exactPageId, 'mobile')
|
||||
saveMobileBrowserViewMode(worktreeId, oversizedPageId, 'mobile')
|
||||
|
||||
expect(getInitialMobileBrowserViewMode(worktreeId, exactPageId)).toBe('mobile')
|
||||
expect(getInitialMobileBrowserViewMode(worktreeId, oversizedPageId)).toBe('web')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { MobileBrowserViewMode } from './browser-screencast-request'
|
||||
|
||||
export const BROWSER_VIEW_MODE_STATE_LIMIT = 40
|
||||
export const BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS = 4_096
|
||||
const BROWSER_VIEW_MODE_STATE_LIMIT = 40
|
||||
const browserViewModeByPageKey = new Map<string, MobileBrowserViewMode>()
|
||||
|
||||
export function getInitialMobileBrowserViewMode(
|
||||
@@ -43,9 +42,5 @@ function makeBrowserViewModePageKey(
|
||||
worktreeId: string,
|
||||
browserPageId: string | null
|
||||
): string | null {
|
||||
if (!browserPageId) {
|
||||
return null
|
||||
}
|
||||
const key = `${worktreeId}:${browserPageId}`
|
||||
return key.length <= BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS ? key : null
|
||||
return browserPageId ? `${worktreeId}:${browserPageId}` : null
|
||||
}
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HOME_SNAPSHOT_MAX_SERIALIZED_BYTES,
|
||||
loadHomeSnapshot,
|
||||
resetHomeSnapshotCacheForTests,
|
||||
saveHomeSnapshot,
|
||||
type HomeSnapshot
|
||||
} from './home-snapshot-cache'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function snapshotWithSerializedBytes(serializedBytes: number): HomeSnapshot {
|
||||
const base = {
|
||||
worktreeInfo: {},
|
||||
accountsByHost: {},
|
||||
savedAt: 1,
|
||||
padding: ''
|
||||
}
|
||||
const baseBytes = JSON.stringify(base).length
|
||||
return {
|
||||
...base,
|
||||
padding: 'x'.repeat(serializedBytes - baseBytes)
|
||||
} as unknown as HomeSnapshot
|
||||
}
|
||||
|
||||
describe('home snapshot cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
resetHomeSnapshotCacheForTests()
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetHomeSnapshotCacheForTests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('persists and reloads normal snapshots unchanged', async () => {
|
||||
const snapshot = snapshotWithSerializedBytes(128)
|
||||
saveHomeSnapshot(snapshot)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
const raw = vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]
|
||||
expect(raw).toBe(JSON.stringify(snapshot))
|
||||
|
||||
resetHomeSnapshotCacheForTests()
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(raw ?? null)
|
||||
await expect(loadHomeSnapshot()).resolves.toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('accepts the exact serialized-byte cap and rejects one byte over', async () => {
|
||||
const exact = snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
|
||||
saveHomeSnapshot(exact)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]).toHaveLength(
|
||||
HOME_SNAPSHOT_MAX_SERIALIZED_BYTES
|
||||
)
|
||||
|
||||
vi.mocked(AsyncStorage.setItem).mockClear()
|
||||
saveHomeSnapshot(snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES + 1))
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an oversized durable payload before JSON parsing', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
|
||||
`"${'x'.repeat(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)}"`
|
||||
)
|
||||
|
||||
await expect(loadHomeSnapshot()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
+1
-50
@@ -5,10 +5,8 @@
|
||||
// WebSocket reconnects and the first responses come back.
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
|
||||
|
||||
const STORAGE_KEY = 'orca:home-snapshot:v1'
|
||||
export const HOME_SNAPSHOT_MAX_SERIALIZED_BYTES = 2 * 1024 * 1024
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
@@ -44,12 +42,6 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
raw.length > HOME_SNAPSHOT_MAX_SERIALIZED_BYTES ||
|
||||
utf8ByteLengthExceeds(raw, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(raw) as HomeSnapshot
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
@@ -69,53 +61,12 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
|
||||
// Why: throttle writes so a flurry of streamed account-snapshot updates
|
||||
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
|
||||
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = stringifyMobileOutboundJson(snapshot, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
memoryCache = snapshot
|
||||
if (writeTimer) {
|
||||
clearTimeout(writeTimer)
|
||||
}
|
||||
writeTimer = setTimeout(() => {
|
||||
writeTimer = null
|
||||
void AsyncStorage.setItem(STORAGE_KEY, serialized).catch(() => {})
|
||||
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function utf8ByteLengthExceeds(value: string, limit: number): boolean {
|
||||
let bytes = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code <= 0x7f) {
|
||||
bytes += 1
|
||||
} else if (code <= 0x7ff) {
|
||||
bytes += 2
|
||||
} else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
if (bytes > limit) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Test-only: clear the process-lifetime snapshot and delayed write. */
|
||||
export function resetHomeSnapshotCacheForTests(): void {
|
||||
memoryCache = null
|
||||
if (writeTimer) {
|
||||
clearTimeout(writeTimer)
|
||||
writeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MobileRpcListCache } from './mobile-rpc-list-cache'
|
||||
|
||||
describe('MobileRpcListCache', () => {
|
||||
it('preserves values below the limits and expires them at the existing age boundary', () => {
|
||||
const cache = new MobileRpcListCache(100, 2, 3, 100)
|
||||
const values = [{ id: 'repo' }]
|
||||
|
||||
expect(cache.set('host', values, 1_000)).toBe(true)
|
||||
expect(cache.get('host', 1_100)).toBe(values)
|
||||
expect(cache.get('host', 1_101)).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts the exact item cap and rejects one over', () => {
|
||||
const cache = new MobileRpcListCache(100, 2, 3, 1_000)
|
||||
const exact = [1, 2, 3]
|
||||
|
||||
expect(cache.set('host', exact)).toBe(true)
|
||||
expect(cache.get('host')).toBe(exact)
|
||||
expect(cache.set('host', [...exact, 4])).toBe(false)
|
||||
expect(cache.get('host')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts the exact aggregate byte budget and evicts oldest at one over', () => {
|
||||
const probe = new MobileRpcListCache(100, 3, 3, 1_000)
|
||||
probe.set('first', ['a'])
|
||||
const firstBytes = probe.evidence().retainedBytes
|
||||
probe.clear()
|
||||
|
||||
const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2)
|
||||
expect(cache.set('first', ['a'])).toBe(true)
|
||||
expect(cache.set('other', ['a'])).toBe(true)
|
||||
expect(cache.evidence().retainedBytes).toBe(firstBytes * 2)
|
||||
|
||||
expect(cache.set('x', [])).toBe(true)
|
||||
expect(cache.get('first')).toBeNull()
|
||||
expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2)
|
||||
})
|
||||
|
||||
it('rejects a single payload larger than the full byte budget', () => {
|
||||
const cache = new MobileRpcListCache(100, 2, 3, 20)
|
||||
|
||||
expect(cache.set('host', ['a'.repeat(100)])).toBe(false)
|
||||
expect(cache.evidence()).toEqual({
|
||||
entryCount: 0,
|
||||
retainedBytes: 0,
|
||||
keysOldestFirst: []
|
||||
})
|
||||
})
|
||||
})
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
|
||||
|
||||
export type MobileRpcListCacheEvidence = {
|
||||
entryCount: number
|
||||
retainedBytes: number
|
||||
keysOldestFirst: string[]
|
||||
}
|
||||
|
||||
type CacheEntry = {
|
||||
values: unknown[]
|
||||
at: number
|
||||
retainedBytes: number
|
||||
}
|
||||
|
||||
export class MobileRpcListCache {
|
||||
private readonly entries = new Map<string, CacheEntry>()
|
||||
private retainedBytes = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxAgeMs: number,
|
||||
private readonly maxEntries: number,
|
||||
private readonly maxItemsPerEntry: number,
|
||||
private readonly maxRetainedBytes: number
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(maxAgeMs) ||
|
||||
maxAgeMs < 0 ||
|
||||
!Number.isInteger(maxEntries) ||
|
||||
maxEntries < 1 ||
|
||||
!Number.isInteger(maxItemsPerEntry) ||
|
||||
maxItemsPerEntry < 1 ||
|
||||
!Number.isSafeInteger(maxRetainedBytes) ||
|
||||
maxRetainedBytes < 1
|
||||
) {
|
||||
throw new Error('Mobile RPC list cache limits must be positive')
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, values: unknown[], now = Date.now()): boolean {
|
||||
this.delete(key)
|
||||
if (values.length > this.maxItemsPerEntry) {
|
||||
return false
|
||||
}
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const retainedBytes = utf8ByteLength(serialized)
|
||||
if (retainedBytes > this.maxRetainedBytes) {
|
||||
return false
|
||||
}
|
||||
this.entries.set(key, { values, at: now, retainedBytes })
|
||||
this.retainedBytes += retainedBytes
|
||||
this.evictOverflow()
|
||||
return this.entries.has(key)
|
||||
}
|
||||
|
||||
get(key: string, now = Date.now()): unknown[] | null {
|
||||
const entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (now - entry.at > this.maxAgeMs) {
|
||||
this.delete(key)
|
||||
return null
|
||||
}
|
||||
return entry.values
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear()
|
||||
this.retainedBytes = 0
|
||||
}
|
||||
|
||||
evidence(): MobileRpcListCacheEvidence {
|
||||
return {
|
||||
entryCount: this.entries.size,
|
||||
retainedBytes: this.retainedBytes,
|
||||
keysOldestFirst: [...this.entries.keys()]
|
||||
}
|
||||
}
|
||||
|
||||
private delete(key: string): void {
|
||||
const entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
this.retainedBytes -= entry.retainedBytes
|
||||
this.entries.delete(key)
|
||||
}
|
||||
|
||||
private evictOverflow(): void {
|
||||
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) {
|
||||
const oldestKey = this.entries.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
return
|
||||
}
|
||||
this.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function utf8ByteLength(value: string): number {
|
||||
let bytes = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code <= 0x7f) {
|
||||
bytes += 1
|
||||
} else if (code <= 0x7ff) {
|
||||
bytes += 2
|
||||
} else if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4
|
||||
index += 1
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
} else {
|
||||
bytes += 3
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
Vendored
+2
-20
@@ -1,17 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getCachedRepos,
|
||||
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
|
||||
resetRepoCacheForTests,
|
||||
setCachedRepos
|
||||
} from './repo-cache'
|
||||
import { getCachedRepos, setCachedRepos } from './repo-cache'
|
||||
|
||||
describe('repo cache', () => {
|
||||
beforeEach(() => {
|
||||
resetRepoCacheForTests()
|
||||
})
|
||||
|
||||
it('returns recent host-scoped repos', () => {
|
||||
const repos = [{ id: 'repo-1' }]
|
||||
|
||||
@@ -32,13 +23,4 @@ describe('repo cache', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains the exact per-host item cap and rejects one over', () => {
|
||||
const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null)
|
||||
setCachedRepos('host', exact)
|
||||
expect(getCachedRepos('host')).toBe(exact)
|
||||
|
||||
setCachedRepos('host', [...exact, null])
|
||||
expect(getCachedRepos('host')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+24
-18
@@ -2,29 +2,35 @@
|
||||
// host-scoped cache lets workspace creation open from the last known list while
|
||||
// a fresh repo.list refresh happens in the background.
|
||||
|
||||
import { MobileRpcListCache } from './mobile-rpc-list-cache'
|
||||
type CachedRepos = {
|
||||
repos: unknown[]
|
||||
at: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedRepos>()
|
||||
|
||||
const MAX_AGE_MS = 60_000
|
||||
export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20
|
||||
export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000
|
||||
export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
|
||||
|
||||
const cache = new MobileRpcListCache(
|
||||
MAX_AGE_MS,
|
||||
MOBILE_REPO_CACHE_MAX_ENTRIES,
|
||||
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
|
||||
MOBILE_REPO_CACHE_MAX_RETAINED_BYTES
|
||||
)
|
||||
const MAX_ENTRIES = 20
|
||||
|
||||
export function setCachedRepos(hostId: string, repos: unknown[]): void {
|
||||
cache.set(hostId, repos)
|
||||
cache.delete(hostId)
|
||||
cache.set(hostId, { repos, at: Date.now() })
|
||||
if (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest) {
|
||||
cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedRepos(hostId: string): unknown[] | null {
|
||||
return cache.get(hostId)
|
||||
}
|
||||
|
||||
/** Test-only: clear process-lifetime cache state between cases. */
|
||||
export function resetRepoCacheForTests(): void {
|
||||
cache.clear()
|
||||
const entry = cache.get(hostId)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (Date.now() - entry.at > MAX_AGE_MS) {
|
||||
cache.delete(hostId)
|
||||
return null
|
||||
}
|
||||
return entry.repos
|
||||
}
|
||||
|
||||
+2
-20
@@ -1,19 +1,10 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getCachedWorktrees,
|
||||
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
|
||||
resetWorktreeCacheForTests,
|
||||
setCachedWorktrees
|
||||
} from './worktree-cache'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
|
||||
|
||||
// Why: AC #8498 guarantees a reconnect refetch writes through the
|
||||
// same cache path the host detail screen seeds from, so a reconnect can't
|
||||
// serve a stale snapshot. This unit pins the write-through contract.
|
||||
describe('worktree-cache write-through', () => {
|
||||
beforeEach(() => {
|
||||
resetWorktreeCacheForTests()
|
||||
})
|
||||
|
||||
it('returns the most-recently written snapshot, not a stale one', () => {
|
||||
const hostId = 'host-write-through'
|
||||
const stale = [{ worktreeId: 'a', name: 'stale' }]
|
||||
@@ -49,13 +40,4 @@ describe('worktree-cache write-through', () => {
|
||||
// A fresh screen mount reads the cache — must see the connected set.
|
||||
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
|
||||
})
|
||||
|
||||
it('retains the exact per-host item cap and rejects one over', () => {
|
||||
const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null)
|
||||
setCachedWorktrees('host', exact)
|
||||
expect(getCachedWorktrees('host')).toBe(exact)
|
||||
|
||||
setCachedWorktrees('host', [...exact, null])
|
||||
expect(getCachedWorktrees('host')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+27
-18
@@ -2,29 +2,38 @@
|
||||
// so the host detail page can render instantly on navigation instead of
|
||||
// waiting for a fresh RPC connection + fetch cycle.
|
||||
|
||||
import { MobileRpcListCache } from './mobile-rpc-list-cache'
|
||||
type CachedWorktrees = {
|
||||
worktrees: unknown[]
|
||||
at: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedWorktrees>()
|
||||
|
||||
const MAX_AGE_MS = 30_000
|
||||
export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20
|
||||
export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000
|
||||
export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
|
||||
|
||||
const cache = new MobileRpcListCache(
|
||||
MAX_AGE_MS,
|
||||
MOBILE_WORKTREE_CACHE_MAX_ENTRIES,
|
||||
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
|
||||
MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES
|
||||
)
|
||||
const MAX_ENTRIES = 20
|
||||
|
||||
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
|
||||
cache.set(hostId, worktrees)
|
||||
// Why: Map.set on an existing key does not move it to the end of iteration
|
||||
// order. Delete first so the re-inserted key becomes the newest entry,
|
||||
// giving us true LRU eviction when the cap is hit.
|
||||
cache.delete(hostId)
|
||||
cache.set(hostId, { worktrees, at: Date.now() })
|
||||
if (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest) {
|
||||
cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWorktrees(hostId: string): unknown[] | null {
|
||||
return cache.get(hostId)
|
||||
}
|
||||
|
||||
/** Test-only: clear process-lifetime cache state between cases. */
|
||||
export function resetWorktreeCacheForTests(): void {
|
||||
cache.clear()
|
||||
const entry = cache.get(hostId)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (Date.now() - entry.at > MAX_AGE_MS) {
|
||||
cache.delete(hostId)
|
||||
return null
|
||||
}
|
||||
return entry.worktrees
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import {
|
||||
@@ -10,19 +11,15 @@ import {
|
||||
type TerminalShortcutModifier,
|
||||
type TerminalShortcutSpecialKey
|
||||
} from '../terminal/terminal-accessory-keys'
|
||||
import {
|
||||
CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
|
||||
CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS,
|
||||
loadCustomKeys,
|
||||
saveCustomKeys,
|
||||
type CustomKey
|
||||
} from '../terminal/custom-accessory-key-store'
|
||||
|
||||
export {
|
||||
loadCustomKeys,
|
||||
saveCustomKeys,
|
||||
type CustomKey
|
||||
} from '../terminal/custom-accessory-key-store'
|
||||
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
|
||||
export type CustomKey = {
|
||||
id: string
|
||||
label: string
|
||||
bytes: string
|
||||
enter: boolean
|
||||
}
|
||||
|
||||
type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro'
|
||||
|
||||
@@ -67,6 +64,19 @@ type Props = {
|
||||
onManageShortcuts?: () => void
|
||||
}
|
||||
|
||||
export async function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as CustomKey[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
|
||||
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
|
||||
}
|
||||
|
||||
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
|
||||
const [step, setStep] = useState<Step>('choose-type')
|
||||
const [shortcutKey, setShortcutKey] = useState('c')
|
||||
@@ -94,7 +104,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
async (key: Omit<CustomKey, 'id'>) => {
|
||||
const existing = await loadCustomKeys()
|
||||
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
|
||||
const updated = await saveCustomKeys([...existing, newKey])
|
||||
const updated = [...existing, newKey]
|
||||
await saveCustomKeys(updated)
|
||||
onKeysChanged(updated)
|
||||
onClose()
|
||||
},
|
||||
@@ -361,7 +372,6 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Command</Text>
|
||||
<TextInput
|
||||
@@ -372,7 +382,6 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS - 1}
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Press Enter</Text>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function TerminalShortcutSettings({
|
||||
pendingCustomKeysWritesRef.current += 1
|
||||
customKeysWriteChainRef.current = customKeysWriteChainRef.current
|
||||
.catch(() => {})
|
||||
.then(() => saveCustomKeys(next).then(() => undefined))
|
||||
.then(() => saveCustomKeys(next))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pendingCustomKeysWritesRef.current -= 1
|
||||
|
||||
@@ -167,10 +167,7 @@ function TableBlock({
|
||||
block: Extract<MarkdownBlock, { kind: 'table' }>
|
||||
base: number
|
||||
}) {
|
||||
let columnCount = Math.max(block.headers.length, 1)
|
||||
for (const row of block.rows) {
|
||||
columnCount = Math.max(columnCount, row.length)
|
||||
}
|
||||
const columnCount = Math.max(block.headers.length, ...block.rows.map((r) => r.length), 1)
|
||||
const columns = Array.from({ length: columnCount }, (_, c) => c)
|
||||
return (
|
||||
<ScrollView
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
|
||||
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
|
||||
|
||||
describe('parseMarkdownBlocks', () => {
|
||||
it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => {
|
||||
@@ -100,42 +100,6 @@ describe('parseMarkdownBlocks tables', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts the exact table column limit and falls back one column later', () => {
|
||||
const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) =>
|
||||
String(index)
|
||||
).join('|')
|
||||
const delimiter = Array.from(
|
||||
{ length: COMMENT_MARKDOWN_LIMITS.tableColumns },
|
||||
() => '---'
|
||||
).join('|')
|
||||
|
||||
expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({
|
||||
kind: 'table',
|
||||
headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns }
|
||||
})
|
||||
expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({
|
||||
kind: 'paragraph'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a bounded preview before splitting oversized input', () => {
|
||||
const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits)
|
||||
const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1)
|
||||
const [block] = parseMarkdownBlocks(content)
|
||||
|
||||
expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }])
|
||||
expect(block).toMatchObject({ kind: 'paragraph' })
|
||||
expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan(
|
||||
COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100
|
||||
)
|
||||
})
|
||||
|
||||
it('checks normalized break tags before allocating line arrays', () => {
|
||||
const content = '<br>'.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1)
|
||||
|
||||
expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' })
|
||||
})
|
||||
|
||||
it('reads per-column alignment from the delimiter row', () => {
|
||||
const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n')
|
||||
const block = parseMarkdownBlocks(md)[0]
|
||||
|
||||
@@ -40,20 +40,6 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
|
||||
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
|
||||
const SUMMARY = /<summary\b[^>]*>([\s\S]*?)<\/summary>/i
|
||||
|
||||
export const COMMENT_MARKDOWN_LIMITS = {
|
||||
sourceCodeUnits: 1024 * 1024,
|
||||
lines: 10_000,
|
||||
detailsDepth: 64,
|
||||
tableColumns: 256,
|
||||
tableRows: 2000,
|
||||
tableCells: 50_000,
|
||||
fallbackCodeUnits: 64 * 1024
|
||||
} as const
|
||||
|
||||
const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]'
|
||||
|
||||
class CommentMarkdownLimitError extends Error {}
|
||||
|
||||
// Removes residual HTML tags from rendered text so stray <b>/<kbd>/<sub> etc. don't
|
||||
// show literally. Conservative: only matches `<tag ...>` / `</tag>` shapes, so a bare
|
||||
// "a < b" in prose is left alone.
|
||||
@@ -62,56 +48,9 @@ export function stripHtmlTags(text: string): string {
|
||||
}
|
||||
|
||||
export function parseMarkdownBlocks(content: string): MarkdownBlock[] {
|
||||
if (!isCommentMarkdownWithinPreparseLimits(content)) {
|
||||
return commentMarkdownLimitFallback(content)
|
||||
}
|
||||
// Drop HTML comments and normalize <br> before block parsing.
|
||||
const cleaned = content.replace(/<!--[\s\S]*?-->/g, '').replace(/<br\s*\/?>/gi, '\n')
|
||||
if (!isCommentMarkdownWithinPreparseLimits(cleaned)) {
|
||||
return commentMarkdownLimitFallback(content)
|
||||
}
|
||||
try {
|
||||
return parseSegment(cleaned)
|
||||
} catch (error) {
|
||||
if (error instanceof CommentMarkdownLimitError) {
|
||||
return commentMarkdownLimitFallback(content)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isCommentMarkdownWithinPreparseLimits(content: string): boolean {
|
||||
if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) {
|
||||
return false
|
||||
}
|
||||
let lines = 1
|
||||
let detailsDepth = 0
|
||||
const detailsPattern = /<\/?details\b/gi
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
if (content.charCodeAt(index) !== 10) {
|
||||
continue
|
||||
}
|
||||
lines += 1
|
||||
if (lines > COMMENT_MARKDOWN_LIMITS.lines) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const match of content.matchAll(detailsPattern)) {
|
||||
if (match[0][1] === '/') {
|
||||
detailsDepth = Math.max(0, detailsDepth - 1)
|
||||
continue
|
||||
}
|
||||
detailsDepth += 1
|
||||
if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function commentMarkdownLimitFallback(content: string): MarkdownBlock[] {
|
||||
const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits)
|
||||
return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }]
|
||||
return parseSegment(cleaned)
|
||||
}
|
||||
|
||||
// Splits a segment at top-level <details>/<blockquote> regions (preserving order),
|
||||
@@ -181,17 +120,8 @@ function parseLines(content: string): MarkdownBlock[] {
|
||||
const align = parseAlignRow(lines[i + 1])
|
||||
i += 2
|
||||
const rows: string[][] = []
|
||||
let tableCells = headers.length
|
||||
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
|
||||
if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) {
|
||||
throw new CommentMarkdownLimitError('Table row limit exceeded')
|
||||
}
|
||||
const row = splitTableRow(lines[i])
|
||||
tableCells += row.length
|
||||
if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) {
|
||||
throw new CommentMarkdownLimitError('Table cell limit exceeded')
|
||||
}
|
||||
rows.push(row)
|
||||
rows.push(splitTableRow(lines[i]))
|
||||
i += 1
|
||||
}
|
||||
blocks.push({ kind: 'table', headers, rows, align })
|
||||
@@ -277,18 +207,12 @@ function splitTableRow(line: string): string[] {
|
||||
continue
|
||||
}
|
||||
if (ch === '|') {
|
||||
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
|
||||
throw new CommentMarkdownLimitError('Table column limit exceeded')
|
||||
}
|
||||
cells.push(cell.trim())
|
||||
cell = ''
|
||||
continue
|
||||
}
|
||||
cell += ch
|
||||
}
|
||||
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
|
||||
throw new CommentMarkdownLimitError('Table column limit exceeded')
|
||||
}
|
||||
cells.push(cell.trim())
|
||||
return cells
|
||||
}
|
||||
|
||||
@@ -132,27 +132,6 @@ describe('DictationSetupPollController', () => {
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('shares one completion while repeated manual refreshes are pending', async () => {
|
||||
const requests = [deferred<boolean>(), deferred<boolean>()]
|
||||
const refresh = vi.fn(() => requests[refresh.mock.calls.length - 1].promise)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
|
||||
const completions = Array.from({ length: 1_000 }, () => poller.refreshNow())
|
||||
expect(new Set(completions).size).toBe(1)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
requests[0].resolve(true)
|
||||
await flushPromises()
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
requests[1].resolve(false)
|
||||
await expect(completions[0]).resolves.toBeUndefined()
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(true)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
|
||||
@@ -11,7 +11,7 @@ export class DictationSetupPollController {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private inFlight = false
|
||||
private immediateRefreshPending = false
|
||||
private refreshCompletion: { promise: Promise<void>; resolve: () => void } | null = null
|
||||
private refreshWaiters: Array<() => void> = []
|
||||
private disposed = false
|
||||
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
|
||||
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
|
||||
@@ -40,22 +40,17 @@ export class DictationSetupPollController {
|
||||
if (this.disposed || !this.isEligible()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (!this.refreshCompletion) {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((nextResolve) => {
|
||||
resolve = nextResolve
|
||||
})
|
||||
this.refreshCompletion = { promise, resolve }
|
||||
}
|
||||
this.requestRefresh(true)
|
||||
return this.refreshCompletion.promise
|
||||
return new Promise((resolve) => {
|
||||
this.refreshWaiters.push(resolve)
|
||||
this.requestRefresh(true)
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.immediateRefreshPending = false
|
||||
this.clearTimer()
|
||||
this.resolveRefreshCompletion()
|
||||
this.resolveRefreshWaiters()
|
||||
}
|
||||
|
||||
private update(next: Partial<PollState>): void {
|
||||
@@ -118,7 +113,7 @@ export class DictationSetupPollController {
|
||||
this.state.polling = shouldContinue
|
||||
}
|
||||
if (this.disposed || !this.isEligible()) {
|
||||
this.resolveRefreshCompletion()
|
||||
this.resolveRefreshWaiters()
|
||||
return
|
||||
}
|
||||
if (this.immediateRefreshPending) {
|
||||
@@ -126,7 +121,7 @@ export class DictationSetupPollController {
|
||||
this.requestRefresh(true)
|
||||
return
|
||||
}
|
||||
this.resolveRefreshCompletion()
|
||||
this.resolveRefreshWaiters()
|
||||
if (this.state.polling) {
|
||||
this.scheduleRefresh()
|
||||
}
|
||||
@@ -149,9 +144,10 @@ export class DictationSetupPollController {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRefreshCompletion(): void {
|
||||
const completion = this.refreshCompletion
|
||||
this.refreshCompletion = null
|
||||
completion?.resolve()
|
||||
private resolveRefreshWaiters(): void {
|
||||
const waiters = this.refreshWaiters.splice(0)
|
||||
for (const resolve of waiters) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
|
||||
import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback'
|
||||
import type { MobileDirEntry } from './file-tree'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
@@ -207,18 +206,6 @@ describe('MobileFileExplorerPanel', () => {
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects an extreme directory listing instead of silently truncating it', async () => {
|
||||
const client = createMockClient({
|
||||
'': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`))
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile')
|
||||
expect(renderedText(renderer)).toContain('10,000 items')
|
||||
})
|
||||
|
||||
it('keeps the loaded tree visible during a transient disconnect', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
@@ -379,43 +366,6 @@ describe('MobileFileExplorerPanel', () => {
|
||||
expect(renderedText(renderer)).toContain('Showing first 5000')
|
||||
})
|
||||
|
||||
it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: true,
|
||||
result: {
|
||||
files: [
|
||||
{
|
||||
relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1),
|
||||
basename: 'oversized',
|
||||
kind: 'text'
|
||||
}
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(renderedText(renderer)).toContain('legacy file list is too large')
|
||||
expect(renderedText(renderer)).toContain('Update Orca Desktop')
|
||||
})
|
||||
|
||||
it('reports the files.list failure when the fallback itself fails', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
|
||||
@@ -16,15 +16,14 @@ import {
|
||||
flattenDirectoryCache,
|
||||
getDirectoryCacheState,
|
||||
type DirectoryCache,
|
||||
type DirectoryState,
|
||||
type FileExplorerRow
|
||||
type FileExplorerRow,
|
||||
type MobileDirEntry
|
||||
} from './file-tree'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
beginDirectoryLoad,
|
||||
createDirectoryLoadRevisions,
|
||||
forgetDirectoryLoadBranches,
|
||||
isCurrentDirectoryLoad,
|
||||
resetDirectoryLoadRevisions,
|
||||
type DirectoryLoadRevisions
|
||||
@@ -37,12 +36,6 @@ import {
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
|
||||
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
|
||||
import {
|
||||
MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE,
|
||||
parseBoundedMobileDirectoryEntries,
|
||||
removeEvictedExpandedPaths,
|
||||
retainMobileDirectoryState
|
||||
} from './mobile-directory-cache-retention'
|
||||
|
||||
export function MobileFileExplorerPanel(props: {
|
||||
hostId: string
|
||||
@@ -61,48 +54,13 @@ export function MobileFileExplorerPanel(props: {
|
||||
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
|
||||
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
|
||||
const directoryCacheRef = useRef<DirectoryCache>({})
|
||||
const directoryCacheAccessRef = useRef(0)
|
||||
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||||
const expandedRef = useRef(expanded)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
|
||||
const worktreeLabel = getWorktreeLabel(name, worktreeId)
|
||||
|
||||
const commitDirectoryState = useCallback(
|
||||
(relativePath: string, state: DirectoryState): boolean => {
|
||||
const retained = retainMobileDirectoryState(
|
||||
directoryCacheRef.current,
|
||||
relativePath,
|
||||
{ ...state, lastAccess: ++directoryCacheAccessRef.current },
|
||||
expandedRef.current
|
||||
)
|
||||
if (!retained.admitted) {
|
||||
return false
|
||||
}
|
||||
directoryCacheRef.current = retained.cache
|
||||
setDirectoryCache(retained.cache)
|
||||
if (retained.evictedPaths.length > 0) {
|
||||
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths)
|
||||
for (const pendingPath of pendingDirectoryRetriesRef.current) {
|
||||
if (
|
||||
retained.evictedPaths.some(
|
||||
(evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`)
|
||||
)
|
||||
) {
|
||||
pendingDirectoryRetriesRef.current.delete(pendingPath)
|
||||
}
|
||||
}
|
||||
const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths)
|
||||
expandedRef.current = nextExpanded
|
||||
setExpanded(nextExpanded)
|
||||
}
|
||||
return true
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const loadDirectory = useCallback(
|
||||
async (relativePath: string) => {
|
||||
const scope = scopeRef.current
|
||||
@@ -119,10 +77,13 @@ export function MobileFileExplorerPanel(props: {
|
||||
// Why: transient reconnects should not blank an already browsable tree.
|
||||
setError(hasLoadedRoot ? null : message)
|
||||
} else {
|
||||
commitDirectoryState(relativePath, {
|
||||
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
})
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
}
|
||||
}))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -137,16 +98,13 @@ export function MobileFileExplorerPanel(props: {
|
||||
}
|
||||
setError(null)
|
||||
}
|
||||
const admittedLoading = commitDirectoryState(relativePath, {
|
||||
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
|
||||
loading: true
|
||||
})
|
||||
if (!admittedLoading) {
|
||||
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath])
|
||||
setLoading(false)
|
||||
setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
|
||||
return
|
||||
}
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
loading: true
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('files.readDir', {
|
||||
@@ -175,9 +133,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
return
|
||||
}
|
||||
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
|
||||
const legacyCache = directoryCacheFromFileList(legacyResult.files)
|
||||
directoryCacheRef.current = legacyCache
|
||||
setDirectoryCache(legacyCache)
|
||||
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
|
||||
// Why: the capped list silently omits files past the cap — keep
|
||||
// the legacy explorer's "Showing first 5000" note.
|
||||
setLegacyListTruncated(legacyResult.truncated)
|
||||
@@ -194,13 +150,14 @@ export function MobileFileExplorerPanel(props: {
|
||||
) {
|
||||
return
|
||||
}
|
||||
const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result)
|
||||
const entries = (response as RpcSuccess).result as MobileDirEntry[]
|
||||
if (rootLoad) {
|
||||
setLegacyListTruncated(false)
|
||||
}
|
||||
if (!commitDirectoryState(relativePath, { entries })) {
|
||||
throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
|
||||
}
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: { entries }
|
||||
}))
|
||||
} catch (err) {
|
||||
if (
|
||||
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
@@ -213,10 +170,13 @@ export function MobileFileExplorerPanel(props: {
|
||||
// only a cold load surfaces the full-screen error.
|
||||
setError(hadLoadedRoot ? null : message)
|
||||
} else {
|
||||
commitDirectoryState(relativePath, {
|
||||
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
})
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
}
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
@@ -227,7 +187,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, commitDirectoryState, connState, worktreeId]
|
||||
[client, connState, worktreeId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -235,15 +195,17 @@ export function MobileFileExplorerPanel(props: {
|
||||
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
|
||||
pendingDirectoryRetriesRef.current.clear()
|
||||
directoryCacheRef.current = {}
|
||||
directoryCacheAccessRef.current = 0
|
||||
setDirectoryCache({})
|
||||
expandedRef.current = new Set()
|
||||
setExpanded(expandedRef.current)
|
||||
setExpanded(new Set())
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLegacyListTruncated(false)
|
||||
}, [scope])
|
||||
|
||||
useEffect(() => {
|
||||
directoryCacheRef.current = directoryCache
|
||||
}, [directoryCache])
|
||||
|
||||
useEffect(() => {
|
||||
void loadDirectory('')
|
||||
}, [hostId, loadDirectory])
|
||||
@@ -266,21 +228,21 @@ export function MobileFileExplorerPanel(props: {
|
||||
|
||||
const toggleDirectory = useCallback(
|
||||
(relativePath: string) => {
|
||||
const wasExpanded = expandedRef.current.has(relativePath)
|
||||
const nextExpanded = new Set(expandedRef.current)
|
||||
if (wasExpanded) {
|
||||
nextExpanded.delete(relativePath)
|
||||
} else {
|
||||
nextExpanded.add(relativePath)
|
||||
}
|
||||
expandedRef.current = nextExpanded
|
||||
setExpanded(nextExpanded)
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relativePath)) {
|
||||
next.delete(relativePath)
|
||||
} else {
|
||||
next.add(relativePath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
const state = getDirectoryCacheState(directoryCache, relativePath)
|
||||
if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) {
|
||||
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
|
||||
void loadDirectory(relativePath)
|
||||
}
|
||||
},
|
||||
[directoryCache, loadDirectory]
|
||||
[directoryCache, expanded, loadDirectory]
|
||||
)
|
||||
|
||||
const retryDirectory = useCallback(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginDirectoryLoad,
|
||||
createDirectoryLoadRevisions,
|
||||
forgetDirectoryLoadBranches,
|
||||
isCurrentDirectoryLoad,
|
||||
resetDirectoryLoadRevisions,
|
||||
type DirectoryLoadRevisions
|
||||
@@ -42,17 +41,4 @@ describe('directory-load-revisions', () => {
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates evicted branches without staling unrelated loads', () => {
|
||||
const revisions = createDirectoryLoadRevisions()
|
||||
const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
|
||||
const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib')
|
||||
const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs')
|
||||
|
||||
forgetDirectoryLoadBranches(revisions, ['src'])
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false)
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false)
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,21 +19,6 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions):
|
||||
revisions.revisionsByPath.clear()
|
||||
}
|
||||
|
||||
export function forgetDirectoryLoadBranches(
|
||||
revisions: DirectoryLoadRevisions,
|
||||
relativePaths: readonly string[]
|
||||
): void {
|
||||
for (const loadedPath of revisions.revisionsByPath.keys()) {
|
||||
if (
|
||||
relativePaths.some(
|
||||
(relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`)
|
||||
)
|
||||
) {
|
||||
revisions.revisionsByPath.delete(loadedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function beginDirectoryLoad(
|
||||
revisions: DirectoryLoadRevisions,
|
||||
scope: string,
|
||||
|
||||
@@ -1,34 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
directoryCacheFromFileList,
|
||||
isMobileMethodUnavailableError,
|
||||
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES,
|
||||
LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES,
|
||||
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES,
|
||||
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE,
|
||||
LEGACY_MOBILE_FILE_LIST_MAX_FILES,
|
||||
LEGACY_MOBILE_FILE_PATH_MAX_BYTES,
|
||||
LEGACY_MOBILE_FILE_PATH_MAX_DEPTH,
|
||||
type LegacyMobileFileEntry
|
||||
} from './file-list-fallback'
|
||||
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
|
||||
import { getDirectoryCacheState } from './file-tree'
|
||||
|
||||
const RETAINED_NODE_ESTIMATE_BYTES = 64
|
||||
|
||||
function file(relativePath: string): LegacyMobileFileEntry {
|
||||
return { relativePath, basename: relativePath, kind: 'text' }
|
||||
}
|
||||
|
||||
function directoryChainPath(prefix: string, directories: number): string {
|
||||
const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd'))
|
||||
return `${names.join('/')}/file`
|
||||
}
|
||||
|
||||
function fixedLengthUniqueName(index: number, length: number): string {
|
||||
const suffix = `-${index.toString(36)}`
|
||||
return `${'x'.repeat(length - suffix.length)}${suffix}`
|
||||
}
|
||||
|
||||
describe('isMobileMethodUnavailableError', () => {
|
||||
it('detects old-desktop allowlist and missing-method failures', () => {
|
||||
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
|
||||
@@ -47,9 +20,9 @@ describe('isMobileMethodUnavailableError', () => {
|
||||
describe('directoryCacheFromFileList', () => {
|
||||
it('synthesizes every ancestor directory from flat paths', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
file('src/lib/util.ts'),
|
||||
file('src/app.ts'),
|
||||
file('README.md')
|
||||
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
|
||||
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
|
||||
])
|
||||
expect(cache['']?.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -66,27 +39,11 @@ describe('directoryCacheFromFileList', () => {
|
||||
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
|
||||
})
|
||||
|
||||
it('preserves empty-segment filtering and first-seen entry order', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
file('//src///lib//util.ts//'),
|
||||
file('/README.md'),
|
||||
file('src/app.ts'),
|
||||
file('///')
|
||||
])
|
||||
|
||||
expect(cache['']?.entries).toEqual([
|
||||
{ name: 'src', isDirectory: true },
|
||||
{ name: 'README.md', isDirectory: false }
|
||||
])
|
||||
expect(cache['src']?.entries).toEqual([
|
||||
{ name: 'lib', isDirectory: true },
|
||||
{ name: 'app.ts', isDirectory: false }
|
||||
])
|
||||
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
|
||||
})
|
||||
|
||||
it('keeps a name a directory when it appears as both file and dir prefix', () => {
|
||||
const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')])
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: 'src', basename: 'src', kind: 'text' },
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
|
||||
])
|
||||
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
|
||||
})
|
||||
|
||||
@@ -96,112 +53,13 @@ describe('directoryCacheFromFileList', () => {
|
||||
})
|
||||
|
||||
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
|
||||
const cache = directoryCacheFromFileList([file('__proto__/pollute.js')])
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
|
||||
])
|
||||
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
|
||||
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
|
||||
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
|
||||
{ name: 'pollute.js', isDirectory: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => {
|
||||
const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))])
|
||||
expect(cache['']?.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => {
|
||||
const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2)
|
||||
expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([
|
||||
{ name: exactPath, isDirectory: false }
|
||||
])
|
||||
expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow(
|
||||
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the exact path depth and rejects the next segment', () => {
|
||||
const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/')
|
||||
const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/'))
|
||||
expect(
|
||||
getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath)
|
||||
).toEqual({
|
||||
entries: [{ name: 'd', isDirectory: false }]
|
||||
})
|
||||
expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow(
|
||||
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a response above the desktop files.list record cap', () => {
|
||||
const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file(''))
|
||||
expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
|
||||
})
|
||||
|
||||
it('accepts the exact directory cap and rejects one additional directory', () => {
|
||||
const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255)
|
||||
const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255
|
||||
const files = Array.from({ length: fullChains }, (_, index) =>
|
||||
file(directoryChainPath(`root-${index}`, 255))
|
||||
)
|
||||
if (remainingDirectories > 0) {
|
||||
files.push(file(directoryChainPath('tail', remainingDirectories)))
|
||||
}
|
||||
|
||||
expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength(
|
||||
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES
|
||||
)
|
||||
expect(() =>
|
||||
directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))])
|
||||
).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
|
||||
})
|
||||
|
||||
it('accepts the exact aggregate entry cap and rejects one additional entry', () => {
|
||||
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) =>
|
||||
file(`root-${index}/a/b/file`)
|
||||
)
|
||||
const overflowFiles = exactFiles.map((entry, index) =>
|
||||
index === 0 ? file('root-0/a/b/c/file') : entry
|
||||
)
|
||||
|
||||
const cache = directoryCacheFromFileList(exactFiles)
|
||||
const entryCount = Object.values(cache).reduce(
|
||||
(total, state) => total + (state?.entries.length ?? 0),
|
||||
0
|
||||
)
|
||||
expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES)
|
||||
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
|
||||
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the exact retained-byte budget and rejects two bytes over', () => {
|
||||
const namesBytes =
|
||||
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES -
|
||||
RETAINED_NODE_ESTIMATE_BYTES -
|
||||
LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES
|
||||
const namesCharacters = namesBytes / 2
|
||||
const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES)
|
||||
const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES
|
||||
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => {
|
||||
const length = baseLength + (index < longerNames ? 1 : 0)
|
||||
return file(fixedLengthUniqueName(index, length))
|
||||
})
|
||||
const overflowFiles = exactFiles.map((entry, index) =>
|
||||
index === 0 ? file(`${entry.relativePath}x`) : entry
|
||||
)
|
||||
|
||||
expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength(
|
||||
LEGACY_MOBILE_FILE_LIST_MAX_FILES
|
||||
)
|
||||
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
|
||||
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed legacy response shapes with a clear error', () => {
|
||||
expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list')
|
||||
expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow(
|
||||
'invalid legacy file list'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
// Fallback for desktops that predate files.readDir in the mobile RPC
|
||||
// allowlist: synthesize the lazy directory cache from the flat, capped
|
||||
// files.list result so the Files tab stays browsable against old desktops.
|
||||
import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/utf8-byte-limits'
|
||||
import type { DirectoryCache, MobileDirEntry } from './file-tree'
|
||||
|
||||
// Why: each flat legacy path can amplify into many retained ancestor records.
|
||||
export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000
|
||||
export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024
|
||||
export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256
|
||||
export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384
|
||||
export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000
|
||||
export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
|
||||
export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE =
|
||||
'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.'
|
||||
|
||||
const DIRECTORY_RETAINED_BYTES = 64
|
||||
const ENTRY_RETAINED_BYTES = 64
|
||||
|
||||
export type LegacyMobileFileEntry = {
|
||||
relativePath: string
|
||||
basename: string
|
||||
@@ -29,13 +15,6 @@ export type LegacyFilesListResult = {
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
type LegacyFileCacheBuildState = {
|
||||
childrenByDir: Map<string, Map<string, MobileDirEntry>>
|
||||
directories: number
|
||||
entries: number
|
||||
retainedBytes: number
|
||||
}
|
||||
|
||||
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
|
||||
// 'forbidden' = method exists but is not mobile-allowlisted on the old
|
||||
// desktop; 'method_not_found' = desktop predates the method entirely.
|
||||
@@ -50,156 +29,45 @@ export function isMobileMethodUnavailableError(
|
||||
)
|
||||
}
|
||||
|
||||
export function directoryCacheFromFileList(files: unknown): DirectoryCache {
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error('Desktop returned an invalid legacy file list.')
|
||||
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
|
||||
const childrenByDir = new Map<string, Map<string, boolean>>()
|
||||
const ensureDir = (path: string): Map<string, boolean> => {
|
||||
let children = childrenByDir.get(path)
|
||||
if (!children) {
|
||||
children = new Map()
|
||||
childrenByDir.set(path, children)
|
||||
}
|
||||
return children
|
||||
}
|
||||
if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) {
|
||||
throwLegacyFileListLimitError()
|
||||
}
|
||||
|
||||
const state: LegacyFileCacheBuildState = {
|
||||
childrenByDir: new Map(),
|
||||
directories: 0,
|
||||
entries: 0,
|
||||
retainedBytes: 0
|
||||
}
|
||||
ensureDirectory(state, '')
|
||||
ensureDir('')
|
||||
for (const file of files) {
|
||||
const relativePath = getLegacyRelativePath(file)
|
||||
if (relativePath === null) {
|
||||
throw new Error('Desktop returned an invalid legacy file list.')
|
||||
}
|
||||
addFilePath(state, relativePath, measureBoundedPathDepth(relativePath))
|
||||
}
|
||||
return createDirectoryCache(state.childrenByDir)
|
||||
}
|
||||
|
||||
function getLegacyRelativePath(value: unknown): string | null {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const relativePath = (value as { relativePath?: unknown }).relativePath
|
||||
return typeof relativePath === 'string' ? relativePath : null
|
||||
}
|
||||
|
||||
function measureBoundedPathDepth(relativePath: string): number {
|
||||
let bytes = 0
|
||||
let depth = 0
|
||||
let insideSegment = false
|
||||
for (let index = 0; index < relativePath.length; index += 1) {
|
||||
const codePoint = relativePath.codePointAt(index) ?? 0
|
||||
bytes += getUtf8ByteLengthForCodePoint(codePoint)
|
||||
if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) {
|
||||
throwLegacyFileListLimitError()
|
||||
}
|
||||
if (codePoint === 47) {
|
||||
if (insideSegment) {
|
||||
depth += 1
|
||||
assertPathDepth(depth)
|
||||
}
|
||||
insideSegment = false
|
||||
} else {
|
||||
insideSegment = true
|
||||
}
|
||||
if (codePoint > 0xffff) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if (insideSegment) {
|
||||
depth += 1
|
||||
assertPathDepth(depth)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
function assertPathDepth(depth: number): void {
|
||||
if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) {
|
||||
throwLegacyFileListLimitError()
|
||||
}
|
||||
}
|
||||
|
||||
function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void {
|
||||
let parentPath = ''
|
||||
let segmentStart = 0
|
||||
let segmentIndex = 0
|
||||
for (let cursor = 0; cursor <= relativePath.length; cursor += 1) {
|
||||
if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) {
|
||||
continue
|
||||
}
|
||||
if (cursor > segmentStart) {
|
||||
segmentIndex += 1
|
||||
const name = relativePath.slice(segmentStart, cursor)
|
||||
const isDirectory = segmentIndex < depth
|
||||
addDirectoryEntry(state, parentPath, name, isDirectory)
|
||||
const parts = file.relativePath.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
parts.forEach((name, index) => {
|
||||
const isDirectory = index < parts.length - 1
|
||||
const children = ensureDir(parentPath)
|
||||
children.set(name, children.get(name) === true || isDirectory)
|
||||
parentPath = parentPath ? `${parentPath}/${name}` : name
|
||||
if (isDirectory) {
|
||||
parentPath = parentPath ? `${parentPath}/${name}` : name
|
||||
ensureDirectory(state, parentPath)
|
||||
ensureDir(parentPath)
|
||||
}
|
||||
}
|
||||
segmentStart = cursor + 1
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void {
|
||||
if (state.childrenByDir.has(path)) {
|
||||
return
|
||||
}
|
||||
const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES
|
||||
if (
|
||||
state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES ||
|
||||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
|
||||
) {
|
||||
throwLegacyFileListLimitError()
|
||||
}
|
||||
state.childrenByDir.set(path, new Map())
|
||||
state.directories += 1
|
||||
state.retainedBytes += retainedBytes
|
||||
}
|
||||
|
||||
function addDirectoryEntry(
|
||||
state: LegacyFileCacheBuildState,
|
||||
parentPath: string,
|
||||
name: string,
|
||||
isDirectory: boolean
|
||||
): void {
|
||||
const children = state.childrenByDir.get(parentPath)
|
||||
if (!children) {
|
||||
throw new Error('Legacy file cache builder lost its parent directory.')
|
||||
}
|
||||
const existing = children.get(name)
|
||||
if (existing) {
|
||||
existing.isDirectory ||= isDirectory
|
||||
return
|
||||
}
|
||||
const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES
|
||||
if (
|
||||
state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES ||
|
||||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
|
||||
) {
|
||||
throwLegacyFileListLimitError()
|
||||
}
|
||||
children.set(name, { name, isDirectory })
|
||||
state.entries += 1
|
||||
state.retainedBytes += retainedBytes
|
||||
}
|
||||
|
||||
function createDirectoryCache(
|
||||
childrenByDir: ReadonlyMap<string, ReadonlyMap<string, MobileDirEntry>>
|
||||
): DirectoryCache {
|
||||
const cache: DirectoryCache = {}
|
||||
for (const [path, children] of childrenByDir) {
|
||||
// Why: assignment to a '__proto__' path would invoke its legacy setter.
|
||||
Object.defineProperty(cache, path, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: { entries: Array.from(children.values()) },
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
function throwLegacyFileListLimitError(): never {
|
||||
throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
|
||||
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
|
||||
// object's prototype instead of storing the directory; fromEntries always
|
||||
// creates own keys.
|
||||
return Object.fromEntries(
|
||||
Array.from(childrenByDir, ([path, children]) => [
|
||||
path,
|
||||
{
|
||||
entries: Array.from(
|
||||
children,
|
||||
([name, isDirectory]): MobileDirEntry => ({
|
||||
name,
|
||||
isDirectory
|
||||
})
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export type DirectoryState = {
|
||||
entries: MobileDirEntry[]
|
||||
loading?: boolean
|
||||
error?: string
|
||||
lastAccess?: number
|
||||
}
|
||||
|
||||
export type DirectoryCache = Record<string, DirectoryState | undefined>
|
||||
|
||||
@@ -1,57 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
|
||||
|
||||
function pngBase64(width = 1): string {
|
||||
const bytes = Buffer.alloc(24)
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
|
||||
bytes.writeUInt32BE(13, 8)
|
||||
bytes.write('IHDR', 12, 'ascii')
|
||||
bytes.writeUInt32BE(width, 16)
|
||||
bytes.writeUInt32BE(1, 20)
|
||||
return bytes.toString('base64')
|
||||
}
|
||||
|
||||
describe('mobileDiffImageDataUri', () => {
|
||||
it('renders a modified image diff from the post-change bytes', () => {
|
||||
const modifiedContent = pngBase64(2)
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
modifiedContent,
|
||||
modifiedContent: 'bmV3',
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBe(`data:image/png;base64,${modifiedContent}`)
|
||||
).toBe('data:image/png;base64,bmV3')
|
||||
})
|
||||
|
||||
it('renders an added image diff (no original) from the modified bytes', () => {
|
||||
const modifiedContent = pngBase64()
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent: '',
|
||||
modifiedContent,
|
||||
modifiedContent: 'bmV3',
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
).toBe(`data:image/png;base64,${modifiedContent}`)
|
||||
).toBe('data:image/png;base64,bmV3')
|
||||
})
|
||||
|
||||
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
|
||||
const originalContent = pngBase64()
|
||||
expect(
|
||||
mobileDiffImageDataUri({
|
||||
kind: 'binary',
|
||||
originalContent,
|
||||
originalContent: 'b2xk',
|
||||
originalIsBinary: true,
|
||||
modifiedContent: '',
|
||||
modifiedIsBinary: false,
|
||||
modifiedDeleted: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
mimeType: 'image/jpeg'
|
||||
})
|
||||
).toBe(`data:image/png;base64,${originalContent}`)
|
||||
).toBe('data:image/jpeg;base64,b2xk')
|
||||
})
|
||||
|
||||
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DirectoryCache, DirectoryState } from './file-tree'
|
||||
import {
|
||||
MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
|
||||
parseBoundedMobileDirectoryEntries,
|
||||
removeEvictedExpandedPaths,
|
||||
retainMobileDirectoryState
|
||||
} from './mobile-directory-cache-retention'
|
||||
|
||||
function state(name: string, lastAccess: number): DirectoryState {
|
||||
return { entries: [{ name, isDirectory: false }], lastAccess }
|
||||
}
|
||||
|
||||
describe('mobile directory cache retention', () => {
|
||||
it('keeps the cache bounded across an unlimited sequence of visited directories', () => {
|
||||
let cache: DirectoryCache = { '': state('root', 0) }
|
||||
|
||||
for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) {
|
||||
const result = retainMobileDirectoryState(
|
||||
cache,
|
||||
`dir-${index}`,
|
||||
state(`file-${index}`, index),
|
||||
new Set()
|
||||
)
|
||||
expect(result.admitted).toBe(true)
|
||||
cache = result.cache
|
||||
}
|
||||
|
||||
expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES)
|
||||
expect(cache['']).toBeDefined()
|
||||
expect(cache['dir-1']).toBeUndefined()
|
||||
expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined()
|
||||
})
|
||||
|
||||
it('evicts a collapsed branch before an older expanded branch', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': state('root', 0),
|
||||
expanded: state('expanded', 1),
|
||||
collapsed: state('collapsed', 2)
|
||||
}
|
||||
|
||||
const result = retainMobileDirectoryState(
|
||||
cache,
|
||||
'new',
|
||||
state('new', 3),
|
||||
new Set(['expanded']),
|
||||
{ directories: 3, entries: 100, retainedBytes: 10_000 }
|
||||
)
|
||||
|
||||
expect(result.cache.expanded).toBeDefined()
|
||||
expect(result.cache.collapsed).toBeUndefined()
|
||||
expect(result.evictedPaths).toEqual(['collapsed'])
|
||||
})
|
||||
|
||||
it('evicts old content when aggregate entry retention reaches its cap', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': state('root', 0),
|
||||
old: state('old', 1)
|
||||
}
|
||||
|
||||
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
|
||||
directories: 10,
|
||||
entries: 2,
|
||||
retainedBytes: 10_000
|
||||
})
|
||||
|
||||
expect(result.cache.old).toBeUndefined()
|
||||
expect(result.cache.new).toBeDefined()
|
||||
})
|
||||
|
||||
it('evicts old content when aggregate retained bytes reach their cap', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': state('root', 0),
|
||||
old: state('x'.repeat(100), 1)
|
||||
}
|
||||
|
||||
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
|
||||
directories: 10,
|
||||
entries: 100,
|
||||
retainedBytes: 300
|
||||
})
|
||||
|
||||
expect(result.cache.old).toBeUndefined()
|
||||
expect(result.cache.new).toBeDefined()
|
||||
})
|
||||
|
||||
it('collapses expanded descendants whose cached branch was evicted', () => {
|
||||
expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([
|
||||
'docs'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects malformed listings instead of retaining untrusted response shapes', () => {
|
||||
expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow(
|
||||
'invalid folder listing'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
import {
|
||||
assertMobileFileDirectoryWithinLimit,
|
||||
estimateMobileDirectoryEntryBytes,
|
||||
MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE,
|
||||
MOBILE_FILE_DIRECTORY_MAX_ENTRIES
|
||||
} from '../../../src/shared/mobile-file-directory-limit'
|
||||
import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree'
|
||||
|
||||
// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling.
|
||||
export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128
|
||||
export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000
|
||||
export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
|
||||
export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE =
|
||||
'Too many folders are open to load this folder safely. Close another folder and retry.'
|
||||
|
||||
type CacheLimits = {
|
||||
directories: number
|
||||
entries: number
|
||||
retainedBytes: number
|
||||
}
|
||||
|
||||
type RetentionResult = {
|
||||
cache: DirectoryCache
|
||||
evictedPaths: string[]
|
||||
admitted: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: CacheLimits = {
|
||||
directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
|
||||
entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES,
|
||||
retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES
|
||||
}
|
||||
|
||||
export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('Desktop returned an invalid folder listing.')
|
||||
}
|
||||
if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) {
|
||||
throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE)
|
||||
}
|
||||
for (const entry of value) {
|
||||
if (!isMobileDirectoryEntry(entry)) {
|
||||
throw new Error('Desktop returned an invalid folder listing.')
|
||||
}
|
||||
}
|
||||
const entries = value as MobileDirEntry[]
|
||||
assertMobileFileDirectoryWithinLimit(entries)
|
||||
return entries
|
||||
}
|
||||
|
||||
export function retainMobileDirectoryState(
|
||||
cache: DirectoryCache,
|
||||
relativePath: string,
|
||||
state: DirectoryState,
|
||||
expandedPaths: ReadonlySet<string>,
|
||||
limits: CacheLimits = DEFAULT_LIMITS
|
||||
): RetentionResult {
|
||||
const next: DirectoryCache = { ...cache, [relativePath]: state }
|
||||
const evictedPaths: string[] = []
|
||||
const essentialPaths = directoryAncestors(relativePath)
|
||||
|
||||
while (cacheExceedsLimits(next, limits)) {
|
||||
const victim = selectEvictionPath(next, expandedPaths, essentialPaths)
|
||||
if (victim === null) {
|
||||
return { cache, evictedPaths: [], admitted: false }
|
||||
}
|
||||
for (const path of Object.keys(next)) {
|
||||
if (path === victim || path.startsWith(`${victim}/`)) {
|
||||
delete next[path]
|
||||
evictedPaths.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cache: next, evictedPaths, admitted: true }
|
||||
}
|
||||
|
||||
export function removeEvictedExpandedPaths(
|
||||
expandedPaths: ReadonlySet<string>,
|
||||
evictedPaths: readonly string[]
|
||||
): Set<string> {
|
||||
if (evictedPaths.length === 0) {
|
||||
return new Set(expandedPaths)
|
||||
}
|
||||
return new Set(
|
||||
[...expandedPaths].filter(
|
||||
(expanded) =>
|
||||
!evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const entry = value as Record<string, unknown>
|
||||
return (
|
||||
typeof entry.name === 'string' &&
|
||||
typeof entry.isDirectory === 'boolean' &&
|
||||
(entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
|
||||
)
|
||||
}
|
||||
|
||||
function directoryAncestors(relativePath: string): Set<string> {
|
||||
const ancestors = new Set(['', relativePath])
|
||||
let cursor = relativePath
|
||||
while (cursor.includes('/')) {
|
||||
cursor = cursor.slice(0, cursor.lastIndexOf('/'))
|
||||
ancestors.add(cursor)
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
function selectEvictionPath(
|
||||
cache: DirectoryCache,
|
||||
expandedPaths: ReadonlySet<string>,
|
||||
essentialPaths: ReadonlySet<string>
|
||||
): string | null {
|
||||
const candidates = Object.keys(cache)
|
||||
.filter((path) => !essentialPaths.has(path))
|
||||
.sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right))
|
||||
return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null
|
||||
}
|
||||
|
||||
function accessOrder(cache: DirectoryCache, path: string): number {
|
||||
return cache[path]?.lastAccess ?? 0
|
||||
}
|
||||
|
||||
function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean {
|
||||
const paths = Object.keys(cache)
|
||||
if (paths.length > limits.directories) {
|
||||
return true
|
||||
}
|
||||
let entries = 0
|
||||
let retainedBytes = 0
|
||||
for (const path of paths) {
|
||||
const state = cache[path]
|
||||
if (!state) {
|
||||
continue
|
||||
}
|
||||
entries += state.entries.length
|
||||
retainedBytes += estimateDirectoryStateBytes(path, state)
|
||||
if (entries > limits.entries || retainedBytes > limits.retainedBytes) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function estimateDirectoryStateBytes(path: string, state: DirectoryState): number {
|
||||
let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64
|
||||
for (const entry of state.entries) {
|
||||
bytes += estimateMobileDirectoryEntryBytes(entry)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -28,16 +28,6 @@ function clientWithResponses(responses: RpcResponse[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function pngBase64(width = 1, height = 1): string {
|
||||
const bytes = Buffer.alloc(24)
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
|
||||
bytes.writeUInt32BE(13, 8)
|
||||
bytes.write('IHDR', 12, 'ascii')
|
||||
bytes.writeUInt32BE(width, 16)
|
||||
bytes.writeUInt32BE(height, 20)
|
||||
return bytes.toString('base64')
|
||||
}
|
||||
|
||||
describe('mobile-file-preview-request', () => {
|
||||
it('selects readPreview for raster images and read for text-like files', () => {
|
||||
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
|
||||
@@ -55,13 +45,14 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
|
||||
it('loads images through readPreview and never calls files.open', async () => {
|
||||
const content = pngBase64()
|
||||
const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' }))
|
||||
const client = clientWith(
|
||||
ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' })
|
||||
)
|
||||
|
||||
await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:image/png;base64,${content}`
|
||||
dataUri: 'data:image/png;base64,aW1hZ2U='
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', {
|
||||
worktree: 'id:wt-1',
|
||||
@@ -536,9 +527,9 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }],
|
||||
['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }],
|
||||
['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }],
|
||||
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
|
||||
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
|
||||
['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }],
|
||||
['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }]
|
||||
])('rejects invalid image preview results: %s', (_label, result) => {
|
||||
expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({
|
||||
@@ -548,24 +539,6 @@ describe('mobile-file-preview-request', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an oversized raster response before React Native receives a data URI', () => {
|
||||
expect(
|
||||
normalizeMobileFilePreviewResponse(
|
||||
'assets/logo.png',
|
||||
ok({
|
||||
content: pngBase64(32_769, 1),
|
||||
isBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
status: 'error',
|
||||
message: 'Binary preview unavailable',
|
||||
reconnect: false
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes markdown, html, text, empty, and truncated reads', () => {
|
||||
expect(
|
||||
normalizeMobileFilePreviewResponse(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { isMarkdownPath } from './file-tree'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
import { buildImageDataUri } from '../../../src/shared/image-data-uri'
|
||||
|
||||
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
|
||||
|
||||
@@ -118,14 +117,10 @@ function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
|
||||
) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
const dataUri = buildImageDataUri(preview.mimeType, preview.content)
|
||||
if (!dataUri) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri
|
||||
dataUri: `data:${preview.mimeType};base64,${preview.content}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,16 +31,6 @@ function clientOf(byMethod: Record<string, RpcResponse>): {
|
||||
|
||||
const WT = { worktreeId: 'wt1' }
|
||||
|
||||
function pngBase64(width = 1): string {
|
||||
const bytes = Buffer.alloc(24)
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
|
||||
bytes.writeUInt32BE(13, 8)
|
||||
bytes.write('IHDR', 12, 'ascii')
|
||||
bytes.writeUInt32BE(width, 16)
|
||||
bytes.writeUInt32BE(1, 20)
|
||||
return bytes.toString('base64')
|
||||
}
|
||||
|
||||
describe('resolveMobileFileTabDoc', () => {
|
||||
it('renders a staged text diff', async () => {
|
||||
const client = clientOf({
|
||||
@@ -56,12 +46,11 @@ describe('resolveMobileFileTabDoc', () => {
|
||||
})
|
||||
|
||||
it('renders an unstaged image diff from the modified bytes', async () => {
|
||||
const modifiedContent = pngBase64()
|
||||
const client = clientOf({
|
||||
'git.diff': ok({
|
||||
kind: 'binary',
|
||||
originalContent: 'b2xk',
|
||||
modifiedContent,
|
||||
modifiedContent: 'bmV3',
|
||||
modifiedIsBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
@@ -72,11 +61,7 @@ describe('resolveMobileFileTabDoc', () => {
|
||||
relativePath: 'm1.png',
|
||||
diffSource: 'unstaged'
|
||||
})
|
||||
expect(doc).toEqual({
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:image/png;base64,${modifiedContent}`
|
||||
})
|
||||
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
|
||||
})
|
||||
|
||||
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
|
||||
@@ -103,16 +88,11 @@ describe('resolveMobileFileTabDoc', () => {
|
||||
})
|
||||
|
||||
it('renders a live image preview via files.readPreview', async () => {
|
||||
const content = pngBase64()
|
||||
const client = clientOf({
|
||||
'files.readPreview': ok({ content, isImage: true, mimeType: 'image/png' })
|
||||
'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
|
||||
})
|
||||
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
|
||||
expect(doc).toEqual({
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:image/png;base64,${content}`
|
||||
})
|
||||
expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
|
||||
expect(client.calls).toEqual(['files.readPreview'])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
enqueueMobileDictationAudioChunk,
|
||||
MOBILE_DICTATION_MAX_PENDING_CHUNKS
|
||||
} from './mobile-dictation-audio-chunk'
|
||||
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
|
||||
|
||||
describe('enqueueMobileDictationAudioChunk', () => {
|
||||
it('accepts the exact pending-promise cap and rejects one over even for empty chunks', () => {
|
||||
const sendRequest = vi.fn(() => new Promise<never>(() => undefined))
|
||||
const failActiveDictation = vi.fn()
|
||||
const pendingChunks = new Set<Promise<void>>()
|
||||
const queue = {
|
||||
pendingChunks,
|
||||
pendingAudioBudget: new MobileDictationPendingAudioBudget(),
|
||||
shouldReleaseBudget: () => true,
|
||||
failActiveDictation
|
||||
}
|
||||
const event = { data: new Uint8Array() }
|
||||
|
||||
for (let index = 0; index < MOBILE_DICTATION_MAX_PENDING_CHUNKS; index += 1) {
|
||||
enqueueMobileDictationAudioChunk(
|
||||
{ sendRequest } as unknown as RpcClient,
|
||||
'dictation',
|
||||
event,
|
||||
queue
|
||||
)
|
||||
}
|
||||
expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
|
||||
expect(failActiveDictation).not.toHaveBeenCalled()
|
||||
|
||||
enqueueMobileDictationAudioChunk(
|
||||
{ sendRequest } as unknown as RpcClient,
|
||||
'dictation',
|
||||
event,
|
||||
queue
|
||||
)
|
||||
expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
|
||||
expect(failActiveDictation).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -14,21 +14,12 @@ type MobileDictationAudioChunkQueue = {
|
||||
failActiveDictation: (dictationId: string, err: unknown) => void
|
||||
}
|
||||
|
||||
export const MOBILE_DICTATION_MAX_PENDING_CHUNKS = 256
|
||||
|
||||
export function enqueueMobileDictationAudioChunk(
|
||||
client: RpcClient,
|
||||
dictationId: string,
|
||||
event: MicrophoneDataEvent,
|
||||
queue: MobileDictationAudioChunkQueue
|
||||
): void {
|
||||
if (queue.pendingChunks.size >= MOBILE_DICTATION_MAX_PENDING_CHUNKS) {
|
||||
queue.failActiveDictation(
|
||||
dictationId,
|
||||
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
|
||||
)
|
||||
return
|
||||
}
|
||||
const raw = event.data
|
||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
const byteLength = bytes.byteLength
|
||||
|
||||
@@ -11,11 +11,9 @@ vi.mock('expo-keep-awake', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS,
|
||||
MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS,
|
||||
MobileDictationKeepAwakeOwner,
|
||||
drainMobileDictationKeepAwakeCleanup,
|
||||
resetMobileDictationKeepAwakeForTests
|
||||
drainMobileDictationKeepAwakeCleanup
|
||||
} from './mobile-dictation-keep-awake'
|
||||
|
||||
function deferred(): {
|
||||
@@ -38,33 +36,10 @@ function deferred(): {
|
||||
|
||||
describe('MobileDictationKeepAwakeOwner', () => {
|
||||
beforeEach(() => {
|
||||
resetMobileDictationKeepAwakeForTests()
|
||||
keepAwake.activate.mockReset().mockResolvedValue(undefined)
|
||||
keepAwake.deactivate.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('accepts the exact tracked-tag cap, rejects one over, and recovers after release', async () => {
|
||||
const owners = Array.from(
|
||||
{ length: MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS },
|
||||
() => new MobileDictationKeepAwakeOwner()
|
||||
)
|
||||
await Promise.all(owners.map((owner, index) => owner.acquire(`dictation-${index}`)))
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS)
|
||||
|
||||
const oneOver = new MobileDictationKeepAwakeOwner()
|
||||
await expect(oneOver.acquire('one-over')).rejects.toThrow(
|
||||
'Too many dictation keep-awake operations are pending'
|
||||
)
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS)
|
||||
|
||||
await owners[0]!.release('dictation-0')
|
||||
await expect(oneOver.reacquire('one-over')).resolves.toBeUndefined()
|
||||
await Promise.all([
|
||||
...owners.slice(1).map((owner, index) => owner.release(`dictation-${index + 1}`)),
|
||||
oneOver.release('one-over')
|
||||
])
|
||||
})
|
||||
|
||||
it('retries a failed native deactivation after the hook owner is replaced', async () => {
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation'
|
||||
// Native keep-awake promises can be lost during Activity teardown; a bounded
|
||||
// wait keeps the serialized queue below from wedging dictation until restart.
|
||||
export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000
|
||||
export const MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS = 128
|
||||
|
||||
let nextOwnerId = 0
|
||||
let keepAwakeOperation: Promise<void> = Promise.resolve()
|
||||
@@ -52,34 +51,7 @@ function withNativeCallTimeout(nativeCall: Promise<void>): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function trackedTagCount(): number {
|
||||
let count = activeTags.size
|
||||
for (const tag of pendingCleanupTags) {
|
||||
if (!activeTags.has(tag)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
for (const tag of pendingActivations.keys()) {
|
||||
if (!activeTags.has(tag) && !pendingCleanupTags.has(tag)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function assertTrackedTagCapacity(tag: string): void {
|
||||
if (
|
||||
!activeTags.has(tag) &&
|
||||
!pendingCleanupTags.has(tag) &&
|
||||
!pendingActivations.has(tag) &&
|
||||
trackedTagCount() >= MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS
|
||||
) {
|
||||
throw new Error('Too many dictation keep-awake operations are pending')
|
||||
}
|
||||
}
|
||||
|
||||
async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise<void> {
|
||||
assertTrackedTagCapacity(tag)
|
||||
const nativeActivation = activateKeepAwakeAsync(tag)
|
||||
try {
|
||||
await withNativeCallTimeout(nativeActivation)
|
||||
@@ -262,12 +234,3 @@ export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeO
|
||||
export function drainMobileDictationKeepAwakeCleanup(): Promise<void> {
|
||||
return enqueueKeepAwakeOperation(cleanupPendingTags)
|
||||
}
|
||||
|
||||
/** Test-only: drop retained native-operation bookkeeping between cases. */
|
||||
export function resetMobileDictationKeepAwakeForTests(): void {
|
||||
nextOwnerId = 0
|
||||
keepAwakeOperation = Promise.resolve()
|
||||
activeTags.clear()
|
||||
pendingCleanupTags.clear()
|
||||
pendingActivations.clear()
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
import { MobileNotificationDeliveryLedger } from './mobile-notification-retention'
|
||||
import {
|
||||
MobileScheduledNotificationRegistry,
|
||||
type MobileScheduledNotificationState
|
||||
} from './mobile-scheduled-notification-registry'
|
||||
|
||||
export type NotificationEvent = {
|
||||
type: 'notification'
|
||||
source: DesktopNotificationSource
|
||||
title: string
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
export type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
const scheduledNotifications = new MobileScheduledNotificationRegistry()
|
||||
const notificationDeliveryLedger = new MobileNotificationDeliveryLedger()
|
||||
|
||||
function getStoredNotificationKey(hostId: string, notificationId: string): string {
|
||||
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
|
||||
}
|
||||
|
||||
/** Test-only: override the cap (pass no arg to restore the default). */
|
||||
export function setScheduledNotificationsMaxForTests(max?: number): void {
|
||||
scheduledNotifications.resetForTests(max)
|
||||
notificationDeliveryLedger.resetForTests(max)
|
||||
}
|
||||
|
||||
export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
canAskAgain: boolean
|
||||
authorizationReflectsUserChoice: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
|
||||
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
|
||||
return {
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain,
|
||||
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
const existing = await getNotificationPermissionState()
|
||||
if (existing.granted) {
|
||||
return true
|
||||
}
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
return status === 'granted'
|
||||
}
|
||||
|
||||
export function configureNotificationChannel(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Notifications.setNotificationChannelAsync('orca-desktop', {
|
||||
name: 'Desktop Notifications',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250],
|
||||
lightColor: '#6366f1'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
|
||||
const storedKey = event.notificationId
|
||||
? getStoredNotificationKey(hostId, event.notificationId)
|
||||
: null
|
||||
if (!storedKey) {
|
||||
if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) {
|
||||
return
|
||||
}
|
||||
await scheduleLocalNotification(event, hostId)
|
||||
return
|
||||
}
|
||||
|
||||
let state = scheduledNotifications.get(storedKey)
|
||||
let evictedIdentifiers: string[] = []
|
||||
if (state?.pending) {
|
||||
return
|
||||
}
|
||||
if (!state) {
|
||||
const reservation = scheduledNotifications.reserve(storedKey)
|
||||
if (!reservation) {
|
||||
return
|
||||
}
|
||||
state = reservation.state
|
||||
evictedIdentifiers = reservation.evictedIdentifiers
|
||||
}
|
||||
const notificationState = state
|
||||
const pending = scheduleTrackedNotification(event, hostId, notificationState, evictedIdentifiers)
|
||||
notificationState.pending = pending
|
||||
|
||||
try {
|
||||
const scheduledIdentifier = await pending
|
||||
if (!scheduledIdentifier) {
|
||||
if (!notificationState.identifier) {
|
||||
scheduledNotifications.delete(storedKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (notificationState.dismissAfterSchedule) {
|
||||
notificationState.dismissAfterSchedule = false
|
||||
scheduledNotifications.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (!scheduledNotifications.retainIdentifier(notificationState, scheduledIdentifier)) {
|
||||
scheduledNotifications.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
|
||||
}
|
||||
} finally {
|
||||
if (notificationState.pending === pending) {
|
||||
notificationState.pending = undefined
|
||||
notificationState.dismissAfterSchedule = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleTrackedNotification(
|
||||
event: NotificationEvent,
|
||||
hostId: string,
|
||||
state: MobileScheduledNotificationState,
|
||||
evictedIdentifiers: string[]
|
||||
): Promise<string | null> {
|
||||
// Why: retaining the native id makes a later desktop dismiss work; eviction must close it before slot reuse.
|
||||
for (const identifier of evictedIdentifiers) {
|
||||
await Notifications.dismissNotificationAsync(identifier).catch(() => {})
|
||||
}
|
||||
if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) {
|
||||
return null
|
||||
}
|
||||
if (state.identifier) {
|
||||
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
|
||||
scheduledNotifications.clearIdentifier(state)
|
||||
}
|
||||
return scheduleLocalNotification(event, hostId)
|
||||
}
|
||||
|
||||
function scheduleLocalNotification(event: NotificationEvent, hostId: string): Promise<string> {
|
||||
return Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: buildLocalNotificationData(event, hostId),
|
||||
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
|
||||
},
|
||||
trigger: null
|
||||
})
|
||||
}
|
||||
|
||||
async function dismissLocalNotification(
|
||||
event: DismissNotificationEvent,
|
||||
hostId: string
|
||||
): Promise<void> {
|
||||
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
|
||||
const state = scheduledNotifications.get(storedKey)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (state.pending) {
|
||||
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
|
||||
state.dismissAfterSchedule = true
|
||||
return
|
||||
}
|
||||
if (!state.identifier) {
|
||||
return
|
||||
}
|
||||
scheduledNotifications.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
|
||||
}
|
||||
|
||||
export function startLocalNotificationDelivery(
|
||||
event: NotificationEvent | DismissNotificationEvent,
|
||||
hostId: string
|
||||
): Promise<void> | null {
|
||||
const releaseDelivery = notificationDeliveryLedger.claim(event, hostId)
|
||||
if (!releaseDelivery) {
|
||||
return null
|
||||
}
|
||||
const delivery =
|
||||
event.type === 'notification'
|
||||
? showLocalNotification(event, hostId)
|
||||
: dismissLocalNotification(event, hostId)
|
||||
return delivery.finally(() => {
|
||||
releaseDelivery()
|
||||
})
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES,
|
||||
MOBILE_NOTIFICATION_EVENT_MAX_BYTES,
|
||||
MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES,
|
||||
MOBILE_NOTIFICATION_ID_MAX_BYTES,
|
||||
MobileNotificationDeliveryLedger,
|
||||
measureMobileNotificationDeliveryBytes
|
||||
} from './mobile-notification-retention'
|
||||
import {
|
||||
MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES,
|
||||
MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES,
|
||||
MobileScheduledNotificationRegistry
|
||||
} from './mobile-scheduled-notification-registry'
|
||||
|
||||
describe('mobile notification retention', () => {
|
||||
it('accepts an exact-limit event and rejects one more byte', () => {
|
||||
const baseEvent = {
|
||||
type: 'notification',
|
||||
source: 'test',
|
||||
title: '',
|
||||
body: '',
|
||||
notificationId: 'notification-1'
|
||||
}
|
||||
const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')!
|
||||
const exactEvent = {
|
||||
...baseEvent,
|
||||
body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes)
|
||||
}
|
||||
|
||||
expect(measureMobileNotificationDeliveryBytes(exactEvent, 'host-1')).toBe(
|
||||
MOBILE_NOTIFICATION_EVENT_MAX_BYTES
|
||||
)
|
||||
expect(
|
||||
measureMobileNotificationDeliveryBytes(
|
||||
{ ...exactEvent, body: `${exactEvent.body}x` },
|
||||
'host-1'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds host and notification identifiers before URI key expansion', () => {
|
||||
const event = {
|
||||
type: 'dismiss',
|
||||
notificationId: 'n'.repeat(MOBILE_NOTIFICATION_ID_MAX_BYTES)
|
||||
}
|
||||
|
||||
expect(
|
||||
measureMobileNotificationDeliveryBytes(
|
||||
event,
|
||||
'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES)
|
||||
)
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
measureMobileNotificationDeliveryBytes(
|
||||
{ ...event, notificationId: `${event.notificationId}n` },
|
||||
'host-1'
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
measureMobileNotificationDeliveryBytes(
|
||||
event,
|
||||
'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES + 1)
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('caps aggregate active bytes and restores capacity on release', () => {
|
||||
const ledger = new MobileNotificationDeliveryLedger()
|
||||
const baseEvent = { type: 'notification', title: '', body: '' }
|
||||
const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')!
|
||||
const exactEvent = {
|
||||
...baseEvent,
|
||||
body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes)
|
||||
}
|
||||
const exactClaims = MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES / MOBILE_NOTIFICATION_EVENT_MAX_BYTES
|
||||
const releases = Array.from({ length: exactClaims }, () => ledger.claim(exactEvent, 'host-1'))
|
||||
|
||||
expect(releases.every(Boolean)).toBe(true)
|
||||
expect(ledger.claim(exactEvent, 'host-1')).toBeNull()
|
||||
releases[0]?.()
|
||||
expect(ledger.claim(exactEvent, 'host-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a scheduled key that alone exceeds the aggregate budget', () => {
|
||||
const registry = new MobileScheduledNotificationRegistry()
|
||||
const maximumKeyCharacters = (MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - 64) / 2
|
||||
|
||||
expect(registry.reserve('x'.repeat(maximumKeyCharacters))).not.toBeNull()
|
||||
registry.resetForTests()
|
||||
expect(registry.reserve('x'.repeat(maximumKeyCharacters + 1))).toBeNull()
|
||||
})
|
||||
|
||||
it('retains exact-limit native ids and rejects oversized values', () => {
|
||||
const registry = new MobileScheduledNotificationRegistry()
|
||||
const exactState = registry.reserve('exact')!.state
|
||||
const oversizedState = registry.reserve('oversized')!.state
|
||||
|
||||
expect(
|
||||
registry.retainIdentifier(exactState, 'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES))
|
||||
).toBe(true)
|
||||
expect(
|
||||
registry.retainIdentifier(
|
||||
oversizedState,
|
||||
'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES + 1)
|
||||
)
|
||||
).toBe(false)
|
||||
expect(registry.getRetainedBytesForTests()).toBeLessThanOrEqual(
|
||||
MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
|
||||
|
||||
export const MOBILE_NOTIFICATION_EVENT_MAX_BYTES = 256 * 1024
|
||||
export const MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES = 4 * 1024 * 1024
|
||||
export const MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES = 256
|
||||
export const MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES = 8 * 1024
|
||||
export const MOBILE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024
|
||||
export const MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES = 16 * 1024
|
||||
|
||||
type MobileNotificationRetentionEvent = {
|
||||
type: string
|
||||
source?: string
|
||||
title?: string
|
||||
body?: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
}
|
||||
|
||||
function boundedStringBytes(value: unknown, maxBytes: number): number | null {
|
||||
if (value === undefined) {
|
||||
return 0
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const measured = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes })
|
||||
return measured.exceededLimit ? null : measured.byteLength
|
||||
}
|
||||
|
||||
export function isMobileNotificationHostIdRetainable(hostId: string): boolean {
|
||||
return boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES) !== null
|
||||
}
|
||||
|
||||
export function measureMobileNotificationDeliveryBytes(
|
||||
event: MobileNotificationRetentionEvent,
|
||||
hostId: string
|
||||
): number | null {
|
||||
const hostBytes = boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES)
|
||||
const notificationIdBytes = boundedStringBytes(
|
||||
event.notificationId,
|
||||
MOBILE_NOTIFICATION_ID_MAX_BYTES
|
||||
)
|
||||
const worktreeIdBytes = boundedStringBytes(
|
||||
event.worktreeId,
|
||||
MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES
|
||||
)
|
||||
if (hostBytes === null || notificationIdBytes === null || worktreeIdBytes === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
let retainedBytes = 256 + hostBytes + notificationIdBytes + worktreeIdBytes
|
||||
for (const value of [event.type, event.source, event.title, event.body]) {
|
||||
const remaining = MOBILE_NOTIFICATION_EVENT_MAX_BYTES - retainedBytes
|
||||
const valueBytes = boundedStringBytes(value, remaining)
|
||||
if (valueBytes === null) {
|
||||
return null
|
||||
}
|
||||
retainedBytes += valueBytes
|
||||
}
|
||||
return retainedBytes <= MOBILE_NOTIFICATION_EVENT_MAX_BYTES ? retainedBytes : null
|
||||
}
|
||||
|
||||
export class MobileNotificationDeliveryLedger {
|
||||
private activeEntries = 0
|
||||
private activeBytes = 0
|
||||
private maxEntries = MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES
|
||||
|
||||
claim(event: MobileNotificationRetentionEvent, hostId: string): (() => void) | null {
|
||||
const retainedBytes = measureMobileNotificationDeliveryBytes(event, hostId)
|
||||
if (
|
||||
retainedBytes === null ||
|
||||
this.activeEntries >= this.maxEntries ||
|
||||
this.activeBytes + retainedBytes > MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES
|
||||
) {
|
||||
return null
|
||||
}
|
||||
this.activeEntries += 1
|
||||
this.activeBytes += retainedBytes
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
this.activeEntries = Math.max(0, this.activeEntries - 1)
|
||||
this.activeBytes = Math.max(0, this.activeBytes - retainedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
resetForTests(maxEntries?: number): void {
|
||||
this.activeEntries = 0
|
||||
this.activeBytes = 0
|
||||
this.maxEntries = maxEntries ?? MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES
|
||||
}
|
||||
}
|
||||
@@ -313,11 +313,9 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
|
||||
// Why: notificationId is unique per completion, so the map grew unbounded when
|
||||
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
|
||||
it('dismisses the oldest scheduled entry when reusing its bounded slot', async () => {
|
||||
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
|
||||
setScheduledNotificationsMaxForTests(1)
|
||||
try {
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockReset()
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockReset()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
@@ -343,12 +341,10 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
|
||||
await flushAsync()
|
||||
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-old')
|
||||
|
||||
// The older entry was already dismissed during eviction, so a later desktop dismiss is a no-op...
|
||||
// The older entry was evicted by the cap: dismissing it is a no-op...
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
|
||||
|
||||
// ...while the most-recent entry is retained and still dismissable.
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
|
||||
@@ -358,105 +354,6 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
setScheduledNotificationsMaxForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps overload gaps replayable and advances after reconnect catches up', async () => {
|
||||
setScheduledNotificationsMaxForTests(1)
|
||||
const firstSchedule = makeDeferred<string>()
|
||||
try {
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockReset()
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockReset()
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mockReturnValueOnce(firstSchedule.promise)
|
||||
.mockResolvedValueOnce('scheduled-after-drain')
|
||||
.mockResolvedValueOnce('scheduled-replayed')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'two',
|
||||
body: 'two',
|
||||
notificationId: 'two',
|
||||
notificationSeq: 2
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'three',
|
||||
body: 'three',
|
||||
notificationId: 'three',
|
||||
notificationSeq: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-hung')
|
||||
onEvent?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
title: 'one',
|
||||
body: 'one',
|
||||
notificationId: 'one',
|
||||
notificationSeq: 1
|
||||
})
|
||||
await flushAsync()
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
title: 'two',
|
||||
body: 'two',
|
||||
notificationId: 'two',
|
||||
notificationSeq: 2
|
||||
})
|
||||
await flushAsync()
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstSchedule.resolve('scheduled-first')
|
||||
await flushAsync()
|
||||
onEvent?.({
|
||||
type: 'notification',
|
||||
title: 'three',
|
||||
body: 'three',
|
||||
notificationId: 'three',
|
||||
notificationSeq: 3
|
||||
})
|
||||
await flushAsync()
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
|
||||
|
||||
onEvent?.({ type: 'ready', subscriptionId: 'sub-2' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(3)
|
||||
expect(AsyncStorage.setItem).toHaveBeenLastCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-hung',
|
||||
'3'
|
||||
)
|
||||
} finally {
|
||||
firstSchedule.resolve('scheduled-first')
|
||||
setScheduledNotificationsMaxForTests()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
configureNotificationChannel,
|
||||
startLocalNotificationDelivery,
|
||||
type DismissNotificationEvent,
|
||||
type NotificationEvent
|
||||
} from './mobile-notification-delivery'
|
||||
import {
|
||||
isMobileNotificationHostIdRetainable,
|
||||
measureMobileNotificationDeliveryBytes
|
||||
} from './mobile-notification-retention'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
import {
|
||||
createSeenNotificationGuard,
|
||||
loadLastSeenSeq,
|
||||
@@ -16,81 +10,249 @@ import {
|
||||
seenKeyForEvent
|
||||
} from './notification-reconnect-catchup'
|
||||
|
||||
export {
|
||||
ensureNotificationPermissions,
|
||||
getNotificationPermissionState,
|
||||
setScheduledNotificationsMaxForTests
|
||||
} from './mobile-notification-delivery'
|
||||
export type { NotificationPermissionState } from './mobile-notification-delivery'
|
||||
type NotificationEvent = {
|
||||
type: 'notification'
|
||||
source: DesktopNotificationSource
|
||||
title: string
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
// Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type SubscribeResult = {
|
||||
type: 'ready'
|
||||
subscriptionId: string
|
||||
}
|
||||
|
||||
type ScheduledNotificationState = {
|
||||
identifier?: string
|
||||
pending?: Promise<string | null>
|
||||
dismissAfterSchedule?: boolean
|
||||
}
|
||||
|
||||
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
|
||||
|
||||
// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
|
||||
const MAX_SCHEDULED_NOTIFICATIONS = 256
|
||||
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
|
||||
|
||||
function getStoredNotificationKey(hostId: string, notificationId: string): string {
|
||||
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
|
||||
}
|
||||
|
||||
// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
|
||||
function boundScheduledNotifications(): void {
|
||||
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
|
||||
let evicted = false
|
||||
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
|
||||
if (!state.pending) {
|
||||
scheduledNotificationsByHostAndNotificationId.delete(key)
|
||||
evicted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!evicted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: override the cap (pass no arg to restore the default). */
|
||||
export function setScheduledNotificationsMaxForTests(max?: number): void {
|
||||
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
|
||||
}
|
||||
|
||||
export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
canAskAgain: boolean
|
||||
authorizationReflectsUserChoice: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
|
||||
const { status, canAskAgain } = await Notifications.getPermissionsAsync()
|
||||
return {
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain,
|
||||
// Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
|
||||
export async function ensureNotificationPermissions(): Promise<boolean> {
|
||||
const existing = await getNotificationPermissionState()
|
||||
if (existing.granted) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
return status === 'granted'
|
||||
}
|
||||
|
||||
function configureNotificationChannel(): void {
|
||||
if (Platform.OS === 'android') {
|
||||
void Notifications.setNotificationChannelAsync('orca-desktop', {
|
||||
name: 'Desktop Notifications',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [0, 250],
|
||||
lightColor: '#6366f1'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
|
||||
const storedKey = event.notificationId
|
||||
? getStoredNotificationKey(hostId, event.notificationId)
|
||||
: null
|
||||
|
||||
if (!storedKey) {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) {
|
||||
return
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: buildLocalNotificationData(event, hostId),
|
||||
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
|
||||
},
|
||||
trigger: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
|
||||
if (state?.pending) {
|
||||
return
|
||||
}
|
||||
if (!state) {
|
||||
state = {}
|
||||
scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
|
||||
}
|
||||
const notificationState = state
|
||||
|
||||
const pending = (async () => {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const granted = await ensureNotificationPermissions()
|
||||
if (!granted) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (notificationState.identifier) {
|
||||
await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
|
||||
notificationState.identifier = undefined
|
||||
}
|
||||
|
||||
return Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: buildLocalNotificationData(event, hostId),
|
||||
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
|
||||
},
|
||||
trigger: null
|
||||
})
|
||||
})()
|
||||
notificationState.pending = pending
|
||||
|
||||
try {
|
||||
const scheduledIdentifier = await pending
|
||||
if (!scheduledIdentifier) {
|
||||
if (!notificationState.identifier) {
|
||||
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (notificationState.dismissAfterSchedule) {
|
||||
notificationState.dismissAfterSchedule = false
|
||||
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
|
||||
return
|
||||
}
|
||||
notificationState.identifier = scheduledIdentifier
|
||||
boundScheduledNotifications()
|
||||
} finally {
|
||||
if (notificationState.pending === pending) {
|
||||
notificationState.pending = undefined
|
||||
notificationState.dismissAfterSchedule = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissLocalNotification(
|
||||
event: DismissNotificationEvent,
|
||||
hostId: string
|
||||
): Promise<void> {
|
||||
if (!event.notificationId) {
|
||||
return
|
||||
}
|
||||
const storedKey = getStoredNotificationKey(hostId, event.notificationId)
|
||||
const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
if (state.pending) {
|
||||
// Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
|
||||
state.dismissAfterSchedule = true
|
||||
return
|
||||
}
|
||||
if (!state.identifier) {
|
||||
return
|
||||
}
|
||||
scheduledNotificationsByHostAndNotificationId.delete(storedKey)
|
||||
await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
|
||||
}
|
||||
|
||||
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
|
||||
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
|
||||
if (!isMobileNotificationHostIdRetainable(hostId)) {
|
||||
return () => {}
|
||||
}
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
|
||||
let lastDeliveredSeq = 0
|
||||
let watermarkBlockedThroughSeq: number | null = null
|
||||
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
|
||||
const seenReplay = createSeenNotificationGuard()
|
||||
|
||||
function eventSequence(event: NotificationEvent | DismissNotificationEvent): number | null {
|
||||
return typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq)
|
||||
? event.notificationSeq
|
||||
: null
|
||||
}
|
||||
|
||||
function advanceDeliveredWatermark(
|
||||
event: NotificationEvent | DismissNotificationEvent,
|
||||
replay: boolean
|
||||
): void {
|
||||
const seq = eventSequence(event)
|
||||
if (seq == null || seq <= lastDeliveredSeq) {
|
||||
return
|
||||
}
|
||||
if (watermarkBlockedThroughSeq !== null) {
|
||||
if (!replay) {
|
||||
watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq, seq)
|
||||
return
|
||||
}
|
||||
if (seq < watermarkBlockedThroughSeq) {
|
||||
return
|
||||
}
|
||||
watermarkBlockedThroughSeq = null
|
||||
}
|
||||
lastDeliveredSeq = seq
|
||||
void saveLastSeenSeq(hostId, lastDeliveredSeq)
|
||||
}
|
||||
|
||||
function deliverLive(
|
||||
event: NotificationEvent | DismissNotificationEvent,
|
||||
replay = false
|
||||
type: 'notification' | 'dismiss',
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
const delivery = startLocalNotificationDelivery(event, hostId)
|
||||
if (!delivery) {
|
||||
const seq = eventSequence(event)
|
||||
if (seq !== null && seq > lastDeliveredSeq) {
|
||||
// Why: advancing past a dropped event would make reconnect catch-up permanently skip it.
|
||||
watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq ?? seq, seq)
|
||||
}
|
||||
return Promise.resolve()
|
||||
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
|
||||
lastDeliveredSeq = event.notificationSeq
|
||||
void saveLastSeenSeq(hostId, lastDeliveredSeq)
|
||||
}
|
||||
// Why (#8129): only accepted work is seen; overload drops remain eligible for reconnect catch-up.
|
||||
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
advanceDeliveredWatermark(event, replay)
|
||||
return delivery
|
||||
if (type === 'notification') {
|
||||
return showLocalNotification(event as NotificationEvent, hostId)
|
||||
}
|
||||
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
|
||||
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
|
||||
@@ -110,19 +272,17 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
.catch(() => [])
|
||||
for (const raw of missed) {
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
if (measureMobileNotificationDeliveryBytes(event, hostId) === null) {
|
||||
await deliverLive(event, true)
|
||||
continue
|
||||
}
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && seenReplay.has(key)) {
|
||||
advanceDeliveredWatermark(event, true)
|
||||
continue
|
||||
}
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
await deliverLive(event, true)
|
||||
await deliverLive('notification', event)
|
||||
} else if (event.type === 'dismiss') {
|
||||
await deliverLive(event, true)
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,9 +331,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
return
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
void deliverLive(event as NotificationEvent)
|
||||
void deliverLive('notification', event as NotificationEvent)
|
||||
} else if (event.type === 'dismiss') {
|
||||
void deliverLive(event as DismissNotificationEvent)
|
||||
void deliverLive('dismiss', event as DismissNotificationEvent)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
|
||||
|
||||
export const MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES = 256
|
||||
export const MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES = 2 * 1024 * 1024
|
||||
export const MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024
|
||||
|
||||
export type MobileScheduledNotificationState = {
|
||||
identifier?: string
|
||||
pending?: Promise<string | null>
|
||||
dismissAfterSchedule?: boolean
|
||||
retainedKeyBytes: number
|
||||
retainedIdentifierBytes: number
|
||||
retained: boolean
|
||||
}
|
||||
|
||||
export type MobileScheduledNotificationReservation = {
|
||||
state: MobileScheduledNotificationState
|
||||
evictedIdentifiers: string[]
|
||||
}
|
||||
|
||||
function retainedStringBytes(value: string): number {
|
||||
return 64 + value.length * 2
|
||||
}
|
||||
|
||||
export class MobileScheduledNotificationRegistry {
|
||||
private readonly entries = new Map<string, MobileScheduledNotificationState>()
|
||||
private retainedBytes = 0
|
||||
private maxEntries = MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES
|
||||
|
||||
get(key: string): MobileScheduledNotificationState | undefined {
|
||||
return this.entries.get(key)
|
||||
}
|
||||
|
||||
reserve(key: string): MobileScheduledNotificationReservation | null {
|
||||
const retainedKeyBytes = retainedStringBytes(key)
|
||||
if (retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES) {
|
||||
return null
|
||||
}
|
||||
const evictedIdentifiers: string[] = []
|
||||
while (
|
||||
this.entries.size >= this.maxEntries ||
|
||||
this.retainedBytes + retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
|
||||
) {
|
||||
const settled = this.findOldestSettled()
|
||||
if (!settled) {
|
||||
return null
|
||||
}
|
||||
this.delete(settled[0])
|
||||
if (settled[1].identifier) {
|
||||
evictedIdentifiers.push(settled[1].identifier)
|
||||
}
|
||||
}
|
||||
const state: MobileScheduledNotificationState = {
|
||||
retainedKeyBytes,
|
||||
retainedIdentifierBytes: 0,
|
||||
retained: true
|
||||
}
|
||||
this.entries.set(key, state)
|
||||
this.retainedBytes += retainedKeyBytes
|
||||
return { state, evictedIdentifiers }
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
const state = this.entries.get(key)
|
||||
if (!state) {
|
||||
return false
|
||||
}
|
||||
this.entries.delete(key)
|
||||
this.retainedBytes -= state.retainedKeyBytes + state.retainedIdentifierBytes
|
||||
state.retained = false
|
||||
return true
|
||||
}
|
||||
|
||||
clearIdentifier(state: MobileScheduledNotificationState): void {
|
||||
if (!state.identifier) {
|
||||
return
|
||||
}
|
||||
if (state.retained) {
|
||||
this.retainedBytes -= state.retainedIdentifierBytes
|
||||
}
|
||||
state.identifier = undefined
|
||||
state.retainedIdentifierBytes = 0
|
||||
}
|
||||
|
||||
retainIdentifier(state: MobileScheduledNotificationState, identifier: string): boolean {
|
||||
const measurement = measureUtf8ByteLength(identifier, {
|
||||
stopAfterBytes: MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES
|
||||
})
|
||||
if (measurement.exceededLimit || !state.retained) {
|
||||
return false
|
||||
}
|
||||
const identifierBytes = retainedStringBytes(identifier)
|
||||
if (
|
||||
this.retainedBytes - state.retainedIdentifierBytes + identifierBytes >
|
||||
MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.clearIdentifier(state)
|
||||
state.identifier = identifier
|
||||
state.retainedIdentifierBytes = identifierBytes
|
||||
this.retainedBytes += identifierBytes
|
||||
return true
|
||||
}
|
||||
|
||||
resetForTests(maxEntries?: number): void {
|
||||
for (const state of this.entries.values()) {
|
||||
state.retained = false
|
||||
}
|
||||
this.entries.clear()
|
||||
this.retainedBytes = 0
|
||||
this.maxEntries = maxEntries ?? MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES
|
||||
}
|
||||
|
||||
getRetainedBytesForTests(): number {
|
||||
return this.retainedBytes
|
||||
}
|
||||
|
||||
private findOldestSettled(): [string, MobileScheduledNotificationState] | null {
|
||||
for (const entry of this.entries) {
|
||||
if (!entry[1].pending) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
|
||||
import { MOBILE_NOTIFICATION_ID_MAX_BYTES } from './mobile-notification-retention'
|
||||
|
||||
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
|
||||
// from mobile-notifications.ts so that file stays under its max-lines budget.
|
||||
@@ -86,23 +84,14 @@ export function seenKeyForEvent(event: {
|
||||
notificationSeq?: number
|
||||
}): string | null {
|
||||
const id = event.notificationId
|
||||
const seq =
|
||||
typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq)
|
||||
? event.notificationSeq
|
||||
: null
|
||||
const retainedId =
|
||||
typeof id === 'string' &&
|
||||
!measureUtf8ByteLength(id, { stopAfterBytes: MOBILE_NOTIFICATION_ID_MAX_BYTES }).exceededLimit
|
||||
? id
|
||||
: null
|
||||
if (retainedId !== null && seq !== null) {
|
||||
return `id:${retainedId}#${seq}`
|
||||
if (id != null && event.notificationSeq != null) {
|
||||
return `id:${id}#${event.notificationSeq}`
|
||||
}
|
||||
if (retainedId !== null) {
|
||||
return `id:${retainedId}`
|
||||
if (id != null) {
|
||||
return `id:${id}`
|
||||
}
|
||||
if (seq !== null) {
|
||||
return `seq:${seq}`
|
||||
if (event.notificationSeq != null) {
|
||||
return `seq:${event.notificationSeq}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
|
||||
|
||||
describe('MobileImageBase64Accumulator', () => {
|
||||
it('preserves bytes delivered as 100,000 one-byte fragments', () => {
|
||||
const accumulator = new MobileImageBase64Accumulator()
|
||||
const expected = Buffer.alloc(100_000)
|
||||
|
||||
for (let index = 0; index < expected.byteLength; index += 1) {
|
||||
const value = index % 251
|
||||
expected[index] = value
|
||||
accumulator.append(Uint8Array.of(value))
|
||||
}
|
||||
|
||||
expect(accumulator.finish()).toBe(expected.toString('base64'))
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1
|
||||
|
||||
export class MobileImageBase64Accumulator {
|
||||
private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES)
|
||||
private readonly encodedChunks: string[] = []
|
||||
private stagingLength = 0
|
||||
|
||||
append(bytes: Uint8Array): void {
|
||||
let offset = 0
|
||||
while (offset < bytes.byteLength) {
|
||||
const copied = Math.min(
|
||||
this.staging.byteLength - this.stagingLength,
|
||||
bytes.byteLength - offset
|
||||
)
|
||||
this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength)
|
||||
this.stagingLength += copied
|
||||
offset += copied
|
||||
if (this.stagingLength === this.staging.byteLength) {
|
||||
this.flushStaging()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finish(): string {
|
||||
this.flushStaging()
|
||||
return this.encodedChunks.join('')
|
||||
}
|
||||
|
||||
private flushStaging(): void {
|
||||
if (this.stagingLength === 0) {
|
||||
return
|
||||
}
|
||||
this.encodedChunks.push(
|
||||
Buffer.from(this.staging.subarray(0, this.stagingLength)).toString('base64')
|
||||
)
|
||||
this.stagingLength = 0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
|
||||
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
requestMediaLibraryPermissionsAsync: vi.fn(),
|
||||
@@ -8,9 +7,6 @@ vi.mock('expo-image-picker', () => ({
|
||||
vi.mock('expo-document-picker', () => ({
|
||||
getDocumentAsync: vi.fn()
|
||||
}))
|
||||
vi.mock('expo-file-system', () => ({
|
||||
File: vi.fn()
|
||||
}))
|
||||
|
||||
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
|
||||
|
||||
@@ -19,48 +15,17 @@ const granted = { granted: true } as Awaited<
|
||||
>
|
||||
const denied = { granted: false } as typeof granted
|
||||
|
||||
function fileFactory(
|
||||
chunks: Uint8Array[],
|
||||
options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
|
||||
) {
|
||||
const close = vi.fn()
|
||||
const readBytes = vi.fn(() => {
|
||||
if (options?.readError) {
|
||||
throw options.readError
|
||||
}
|
||||
return chunks.shift() ?? new Uint8Array()
|
||||
})
|
||||
const open = vi.fn(() => ({
|
||||
size: options?.handleSize ?? options?.fileSize ?? 0,
|
||||
readBytes,
|
||||
close
|
||||
}))
|
||||
const createFile = vi.fn(() => ({ size: options?.fileSize ?? 0, open }))
|
||||
return { close, createFile, open, readBytes }
|
||||
}
|
||||
|
||||
describe('pickMobileImage', () => {
|
||||
it('returns base64 from the photo library', async () => {
|
||||
const bytes = new Uint8Array([0, 1, 2, 3])
|
||||
const file = fileFactory([bytes])
|
||||
const launchLibrary = vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }]
|
||||
})
|
||||
const result = await pickMobileImage('library', {
|
||||
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
|
||||
launchLibrary,
|
||||
createFile: file.createFile
|
||||
launchLibrary: vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
base64: Buffer.from(bytes).toString('base64'),
|
||||
uri: 'file:///x.jpg'
|
||||
})
|
||||
expect(launchLibrary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ base64: false, allowsMultipleSelection: false })
|
||||
)
|
||||
expect(file.close).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' })
|
||||
})
|
||||
|
||||
it('throws when photo library permission is denied', async () => {
|
||||
@@ -83,25 +48,22 @@ describe('pickMobileImage', () => {
|
||||
|
||||
it('reads a picked file URI into base64 for the files source', async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 4])
|
||||
const file = fileFactory([bytes])
|
||||
const launchFiles = vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///doc.png', size: bytes.length }]
|
||||
})
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } }))
|
||||
|
||||
const result = await pickMobileImage('files', {
|
||||
launchFiles,
|
||||
createFile: file.createFile
|
||||
launchFiles: vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///doc.png' }]
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
base64: Buffer.from(bytes).toString('base64'),
|
||||
uri: 'file:///doc.png'
|
||||
})
|
||||
expect(launchFiles).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ copyToCacheDirectory: true })
|
||||
)
|
||||
expect(file.close).toHaveBeenCalledTimes(1)
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null when the files picker is cancelled', async () => {
|
||||
@@ -111,72 +73,4 @@ describe('pickMobileImage', () => {
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a declared oversized asset before opening it', async () => {
|
||||
const file = fileFactory([], { fileSize: 1 })
|
||||
await expect(
|
||||
pickMobileImage('files', {
|
||||
launchFiles: vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }]
|
||||
}),
|
||||
createFile: file.createFile
|
||||
})
|
||||
).rejects.toThrow('Clipboard image is too large')
|
||||
expect(file.createFile).not.toHaveBeenCalled()
|
||||
expect(file.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let stale size metadata bypass the bounded read', async () => {
|
||||
const close = vi.fn()
|
||||
const readBytes = vi.fn((length: number) => new Uint8Array(length))
|
||||
const createFile = vi.fn(() => ({
|
||||
size: 1,
|
||||
open: () => ({ size: 1, readBytes, close })
|
||||
}))
|
||||
|
||||
await expect(
|
||||
pickMobileImage('library', {
|
||||
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
|
||||
launchLibrary: vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///grew.png', fileSize: 1 }]
|
||||
}),
|
||||
createFile
|
||||
})
|
||||
).rejects.toThrow('Clipboard image is too large')
|
||||
expect(readBytes).toHaveBeenLastCalledWith(1)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes the file handle when a read fails', async () => {
|
||||
const file = fileFactory([], { fileSize: 4, readError: new Error('read failed') })
|
||||
await expect(
|
||||
pickMobileImage('files', {
|
||||
launchFiles: vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///broken.png', size: 4 }]
|
||||
}),
|
||||
createFile: file.createFile
|
||||
})
|
||||
).rejects.toThrow('read failed')
|
||||
expect(file.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves bytes across chunk boundaries that are not base64 aligned', async () => {
|
||||
const chunks = [new Uint8Array([1]), new Uint8Array([2, 3]), new Uint8Array([4, 5])]
|
||||
const file = fileFactory([...chunks], { fileSize: 5, handleSize: 5 })
|
||||
const result = await pickMobileImage('files', {
|
||||
launchFiles: vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [{ uri: 'file:///chunked.png', size: 5 }]
|
||||
}),
|
||||
createFile: file.createFile
|
||||
})
|
||||
expect(result).toEqual({
|
||||
base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'),
|
||||
uri: 'file:///chunked.png'
|
||||
})
|
||||
expect(file.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import * as DocumentPicker from 'expo-document-picker'
|
||||
import { File as FsFile } from 'expo-file-system'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
import {
|
||||
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
|
||||
assertClipboardImageBase64LengthWithinLimit,
|
||||
assertClipboardImageByteLengthWithinLimit
|
||||
} from '../../../src/shared/clipboard-image'
|
||||
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
|
||||
|
||||
export type MobileImageSource = 'library' | 'files'
|
||||
|
||||
@@ -25,68 +21,18 @@ export class ImageLibraryPermissionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024
|
||||
|
||||
type MobileImageFileHandle = {
|
||||
readonly size: number | null
|
||||
readBytes(length: number): Uint8Array
|
||||
close(): void
|
||||
}
|
||||
|
||||
type MobileImageFile = {
|
||||
readonly size: number
|
||||
open(): MobileImageFileHandle
|
||||
}
|
||||
|
||||
export type MobileImageFileFactory = (uri: string) => MobileImageFile
|
||||
|
||||
function defaultMobileImageFileFactory(uri: string): MobileImageFile {
|
||||
return new FsFile(uri)
|
||||
}
|
||||
|
||||
async function readUriAsBase64(
|
||||
uri: string,
|
||||
declaredSize: number | undefined,
|
||||
createFile: MobileImageFileFactory
|
||||
): Promise<string> {
|
||||
if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) {
|
||||
assertClipboardImageByteLengthWithinLimit(declaredSize)
|
||||
}
|
||||
|
||||
const file = createFile(uri)
|
||||
assertClipboardImageByteLengthWithinLimit(file.size)
|
||||
const handle = file.open()
|
||||
try {
|
||||
if (handle.size !== null) {
|
||||
assertClipboardImageByteLengthWithinLimit(handle.size)
|
||||
}
|
||||
const accumulator = new MobileImageBase64Accumulator()
|
||||
let bytesRead = 0
|
||||
while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) {
|
||||
const requested = Math.min(
|
||||
MOBILE_IMAGE_READ_CHUNK_BYTES,
|
||||
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1
|
||||
)
|
||||
const bytes = handle.readBytes(requested)
|
||||
if (bytes.byteLength === 0) {
|
||||
break
|
||||
}
|
||||
bytesRead += bytes.byteLength
|
||||
assertClipboardImageByteLengthWithinLimit(bytesRead)
|
||||
accumulator.append(bytes)
|
||||
}
|
||||
const base64 = accumulator.finish()
|
||||
assertClipboardImageBase64LengthWithinLimit(base64.length)
|
||||
return base64
|
||||
} finally {
|
||||
handle.close()
|
||||
}
|
||||
// Why: expo-document-picker returns a file URI, not base64. Read it through
|
||||
// fetch + Buffer so we match the base64 contract the upload pipeline expects
|
||||
// without pulling in expo-file-system.
|
||||
async function readUriAsBase64(uri: string): Promise<string> {
|
||||
const response = await fetch(uri)
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
async function pickFromLibrary(
|
||||
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
|
||||
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
|
||||
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
|
||||
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
|
||||
): Promise<PickedMobileImage | null> {
|
||||
const permission = await requestPermission()
|
||||
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
|
||||
@@ -95,7 +41,7 @@ async function pickFromLibrary(
|
||||
}
|
||||
const result = await launch({
|
||||
mediaTypes: ['images'],
|
||||
base64: false,
|
||||
base64: true,
|
||||
allowsMultipleSelection: false,
|
||||
quality: 1
|
||||
})
|
||||
@@ -103,7 +49,7 @@ async function pickFromLibrary(
|
||||
return null
|
||||
}
|
||||
const asset = result.assets[0]
|
||||
const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
|
||||
const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
|
||||
if (!base64) {
|
||||
return null
|
||||
}
|
||||
@@ -111,8 +57,7 @@ async function pickFromLibrary(
|
||||
}
|
||||
|
||||
async function pickFromFiles(
|
||||
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
|
||||
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
|
||||
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
|
||||
): Promise<PickedMobileImage | null> {
|
||||
const result = await launch({
|
||||
type: 'image/*',
|
||||
@@ -126,8 +71,7 @@ async function pickFromFiles(
|
||||
if (!asset?.uri) {
|
||||
return null
|
||||
}
|
||||
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
|
||||
return base64 ? { base64, uri: asset.uri } : null
|
||||
return { base64: await readUriAsBase64(asset.uri), uri: asset.uri }
|
||||
}
|
||||
|
||||
export async function pickMobileImage(
|
||||
@@ -136,11 +80,10 @@ export async function pickMobileImage(
|
||||
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
|
||||
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
|
||||
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
|
||||
readonly createFile?: MobileImageFileFactory
|
||||
}
|
||||
): Promise<PickedMobileImage | null> {
|
||||
if (source === 'library') {
|
||||
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
|
||||
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
|
||||
}
|
||||
return pickFromFiles(deps?.launchFiles, deps?.createFile)
|
||||
return pickFromFiles(deps?.launchFiles)
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MobileSessionFileDocLifecycle,
|
||||
beginMobileFileDocLoad,
|
||||
createMobileFileDocLifecycle,
|
||||
finishMobileFileDocLoad,
|
||||
forgetMobileFileDocTab,
|
||||
reconcileMobileFileDocTabs,
|
||||
removeMobileFileDoc,
|
||||
resetMobileFileDocLifecycle,
|
||||
retainLiveMobileFileDocs,
|
||||
type MobileFileTabIdentity
|
||||
} from './mobile-session-file-doc-lifecycle'
|
||||
import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
|
||||
|
||||
type TestFileDoc = MobileFileTabDoc | { status: 'loading' } | { status: 'error'; message: string }
|
||||
|
||||
function fileTab(id: string, relativePath = `${id}.ts`): MobileFileTabIdentity {
|
||||
return {
|
||||
id,
|
||||
filePath: `/repo/${relativePath}`,
|
||||
relativePath,
|
||||
mode: 'edit'
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile session file document lifecycle', () => {
|
||||
it('retains payloads only for file tabs in the accepted live snapshot', () => {
|
||||
const lifecycle = createMobileFileDocLifecycle()
|
||||
reconcileMobileFileDocTabs(lifecycle, [fileTab('live-a'), fileTab('live-b')])
|
||||
const docs = new Map([
|
||||
['closed', { payload: 'x'.repeat(1_000_000) }],
|
||||
['live-a', { payload: 'a' }],
|
||||
['live-b', { payload: 'b' }]
|
||||
])
|
||||
|
||||
const retained = retainLiveMobileFileDocs(docs, lifecycle)
|
||||
|
||||
expect([...retained.keys()]).toEqual(['live-a', 'live-b'])
|
||||
expect(retained.get('live-a')).toBe(docs.get('live-a'))
|
||||
})
|
||||
|
||||
it('deletes a successfully closed tab immediately', () => {
|
||||
const lifecycle = createMobileFileDocLifecycle()
|
||||
reconcileMobileFileDocTabs(lifecycle, [fileTab('closed')])
|
||||
forgetMobileFileDocTab(lifecycle, 'closed')
|
||||
|
||||
expect(removeMobileFileDoc(new Map([['closed', { payload: 'large' }]]), 'closed').size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a late read after its tab closes', () => {
|
||||
const lifecycle = createMobileFileDocLifecycle()
|
||||
const tab = fileTab('file')
|
||||
reconcileMobileFileDocTabs(lifecycle, [tab])
|
||||
const token = beginMobileFileDocLoad(lifecycle, tab)
|
||||
expect(token).not.toBeNull()
|
||||
|
||||
forgetMobileFileDocTab(lifecycle, tab.id)
|
||||
|
||||
expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps route state empty when a read resolves after close', async () => {
|
||||
const lifecycle = new MobileSessionFileDocLifecycle()
|
||||
const tab = fileTab('file')
|
||||
let docs = new Map<string, TestFileDoc>()
|
||||
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
|
||||
docs = update(docs)
|
||||
}
|
||||
lifecycle.reconcile([{ ...tab, type: 'file' }], updateDocs)
|
||||
let resolveRead: (doc: MobileFileTabDoc) => void = () => undefined
|
||||
const read = new Promise<MobileFileTabDoc>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
|
||||
const pending = lifecycle.load(tab, updateDocs, () => read)
|
||||
expect(docs.get(tab.id)).toEqual({ status: 'loading' })
|
||||
lifecycle.close(tab.id, updateDocs)
|
||||
resolveRead({ status: 'ready', kind: 'file', content: 'late', truncated: false, byteLength: 4 })
|
||||
await pending
|
||||
|
||||
expect(docs.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a late read when the same tab id is replaced with another file', () => {
|
||||
const lifecycle = createMobileFileDocLifecycle()
|
||||
const oldTab = fileTab('file', 'old.ts')
|
||||
reconcileMobileFileDocTabs(lifecycle, [oldTab])
|
||||
const token = beginMobileFileDocLoad(lifecycle, oldTab)
|
||||
expect(token).not.toBeNull()
|
||||
|
||||
const replaced = reconcileMobileFileDocTabs(lifecycle, [fileTab('file', 'replacement.ts')])
|
||||
|
||||
expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
|
||||
expect(
|
||||
retainLiveMobileFileDocs(
|
||||
new Map([['file', { payload: 'old contents' }]]),
|
||||
lifecycle,
|
||||
replaced
|
||||
)
|
||||
).toEqual(new Map())
|
||||
})
|
||||
|
||||
it('lets only the newest read for a live tab commit', () => {
|
||||
const lifecycle = createMobileFileDocLifecycle()
|
||||
const tab = fileTab('file')
|
||||
reconcileMobileFileDocTabs(lifecycle, [tab])
|
||||
const first = beginMobileFileDocLoad(lifecycle, tab)
|
||||
const second = beginMobileFileDocLoad(lifecycle, tab)
|
||||
|
||||
expect(finishMobileFileDocLoad(lifecycle, first!)).toBe(false)
|
||||
expect(finishMobileFileDocLoad(lifecycle, second!)).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates requests when the route scope resets even if tab ids are reused', () => {
|
||||
const lifecycle = createMobileFileDocLifecycle()
|
||||
const tab = fileTab('reused')
|
||||
reconcileMobileFileDocTabs(lifecycle, [tab])
|
||||
const oldScope = beginMobileFileDocLoad(lifecycle, tab)
|
||||
|
||||
resetMobileFileDocLifecycle(lifecycle)
|
||||
reconcileMobileFileDocTabs(lifecycle, [tab])
|
||||
const newScope = beginMobileFileDocLoad(lifecycle, tab)
|
||||
|
||||
expect(finishMobileFileDocLoad(lifecycle, oldScope!)).toBe(false)
|
||||
expect(finishMobileFileDocLoad(lifecycle, newScope!)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,242 +0,0 @@
|
||||
import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
|
||||
|
||||
export type MobileFileTabIdentity = {
|
||||
id: string
|
||||
filePath: string
|
||||
relativePath: string
|
||||
mode?: 'edit' | 'diff'
|
||||
diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
|
||||
}
|
||||
|
||||
export type MobileFileDocLoadToken = {
|
||||
generation: number
|
||||
requestId: number
|
||||
tabId: string
|
||||
tabIdentity: string
|
||||
}
|
||||
|
||||
export type MobileFileDocLifecycle = {
|
||||
generation: number
|
||||
nextRequestId: number
|
||||
liveTabIdentityById: Map<string, string>
|
||||
activeRequestIdByTabId: Map<string, number>
|
||||
}
|
||||
|
||||
type MobileFileTabCandidate = {
|
||||
type: string
|
||||
id: string
|
||||
filePath?: unknown
|
||||
relativePath?: unknown
|
||||
mode?: unknown
|
||||
diffSource?: unknown
|
||||
}
|
||||
|
||||
type MobileSessionFileDoc =
|
||||
| MobileFileTabDoc
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
type MobileSessionFileDocUpdater = (
|
||||
update: (current: Map<string, MobileSessionFileDoc>) => Map<string, MobileSessionFileDoc>
|
||||
) => void
|
||||
|
||||
export class MobileSessionFileDocLifecycle {
|
||||
private readonly lifecycle = createMobileFileDocLifecycle()
|
||||
|
||||
reconcile(
|
||||
tabs: readonly MobileFileTabCandidate[],
|
||||
updateDocs: MobileSessionFileDocUpdater
|
||||
): void {
|
||||
const replacedTabIds = reconcileMobileFileDocTabs(
|
||||
this.lifecycle,
|
||||
tabs.filter(isMobileFileTabIdentity)
|
||||
)
|
||||
updateDocs((current) => retainLiveMobileFileDocs(current, this.lifecycle, replacedTabIds))
|
||||
}
|
||||
|
||||
async load(
|
||||
tab: MobileFileTabIdentity,
|
||||
updateDocs: MobileSessionFileDocUpdater,
|
||||
read: () => Promise<MobileFileTabDoc>
|
||||
): Promise<void> {
|
||||
const token = beginMobileFileDocLoad(this.lifecycle, tab)
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
|
||||
try {
|
||||
const doc = await read()
|
||||
if (finishMobileFileDocLoad(this.lifecycle, token)) {
|
||||
updateDocs((current) => new Map(current).set(tab.id, doc))
|
||||
}
|
||||
} catch (error) {
|
||||
if (finishMobileFileDocLoad(this.lifecycle, token)) {
|
||||
updateDocs((current) =>
|
||||
new Map(current).set(tab.id, {
|
||||
status: 'error',
|
||||
message: getMobileFileDocLoadErrorMessage(tab, error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(tabId: string, updateDocs: MobileSessionFileDocUpdater): void {
|
||||
forgetMobileFileDocTab(this.lifecycle, tabId)
|
||||
updateDocs((current) => removeMobileFileDoc(current, tabId))
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
resetMobileFileDocLifecycle(this.lifecycle)
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobileFileDocLifecycle(): MobileFileDocLifecycle {
|
||||
return {
|
||||
generation: 0,
|
||||
nextRequestId: 0,
|
||||
liveTabIdentityById: new Map(),
|
||||
activeRequestIdByTabId: new Map()
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileMobileFileDocTabs(
|
||||
lifecycle: MobileFileDocLifecycle,
|
||||
tabs: readonly MobileFileTabIdentity[]
|
||||
): ReadonlySet<string> {
|
||||
const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)]))
|
||||
const replacedTabIds = new Set<string>()
|
||||
for (const tabId of lifecycle.activeRequestIdByTabId.keys()) {
|
||||
const nextIdentity = nextIdentityById.get(tabId)
|
||||
const currentIdentity = lifecycle.liveTabIdentityById.get(tabId)
|
||||
if (!nextIdentity || nextIdentity !== currentIdentity) {
|
||||
lifecycle.activeRequestIdByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
for (const [tabId, currentIdentity] of lifecycle.liveTabIdentityById) {
|
||||
const nextIdentity = nextIdentityById.get(tabId)
|
||||
if (nextIdentity && nextIdentity !== currentIdentity) {
|
||||
replacedTabIds.add(tabId)
|
||||
}
|
||||
}
|
||||
lifecycle.liveTabIdentityById.clear()
|
||||
for (const [tabId, identity] of nextIdentityById) {
|
||||
lifecycle.liveTabIdentityById.set(tabId, identity)
|
||||
}
|
||||
return replacedTabIds
|
||||
}
|
||||
|
||||
export function beginMobileFileDocLoad(
|
||||
lifecycle: MobileFileDocLifecycle,
|
||||
tab: MobileFileTabIdentity
|
||||
): MobileFileDocLoadToken | null {
|
||||
const tabIdentity = getMobileFileTabIdentity(tab)
|
||||
if (lifecycle.liveTabIdentityById.get(tab.id) !== tabIdentity) {
|
||||
return null
|
||||
}
|
||||
const requestId = ++lifecycle.nextRequestId
|
||||
lifecycle.activeRequestIdByTabId.set(tab.id, requestId)
|
||||
return {
|
||||
generation: lifecycle.generation,
|
||||
requestId,
|
||||
tabId: tab.id,
|
||||
tabIdentity
|
||||
}
|
||||
}
|
||||
|
||||
export function finishMobileFileDocLoad(
|
||||
lifecycle: MobileFileDocLifecycle,
|
||||
token: MobileFileDocLoadToken
|
||||
): boolean {
|
||||
if (!isCurrentMobileFileDocLoad(lifecycle, token)) {
|
||||
return false
|
||||
}
|
||||
lifecycle.activeRequestIdByTabId.delete(token.tabId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function forgetMobileFileDocTab(lifecycle: MobileFileDocLifecycle, tabId: string): void {
|
||||
lifecycle.liveTabIdentityById.delete(tabId)
|
||||
lifecycle.activeRequestIdByTabId.delete(tabId)
|
||||
}
|
||||
|
||||
export function resetMobileFileDocLifecycle(lifecycle: MobileFileDocLifecycle): void {
|
||||
lifecycle.generation += 1
|
||||
lifecycle.liveTabIdentityById.clear()
|
||||
lifecycle.activeRequestIdByTabId.clear()
|
||||
}
|
||||
|
||||
export function retainLiveMobileFileDocs<T>(
|
||||
docs: Map<string, T>,
|
||||
lifecycle: MobileFileDocLifecycle,
|
||||
replacedTabIds?: ReadonlySet<string>
|
||||
): Map<string, T> {
|
||||
let next: Map<string, T> | null = null
|
||||
for (const tabId of docs.keys()) {
|
||||
if (lifecycle.liveTabIdentityById.has(tabId) && !replacedTabIds?.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
next ??= new Map(docs)
|
||||
next.delete(tabId)
|
||||
}
|
||||
return next ?? docs
|
||||
}
|
||||
|
||||
export function removeMobileFileDoc<T>(docs: Map<string, T>, tabId: string): Map<string, T> {
|
||||
if (!docs.has(tabId)) {
|
||||
return docs
|
||||
}
|
||||
const next = new Map(docs)
|
||||
next.delete(tabId)
|
||||
return next
|
||||
}
|
||||
|
||||
function isCurrentMobileFileDocLoad(
|
||||
lifecycle: MobileFileDocLifecycle,
|
||||
token: MobileFileDocLoadToken
|
||||
): boolean {
|
||||
return (
|
||||
lifecycle.generation === token.generation &&
|
||||
lifecycle.liveTabIdentityById.get(token.tabId) === token.tabIdentity &&
|
||||
lifecycle.activeRequestIdByTabId.get(token.tabId) === token.requestId
|
||||
)
|
||||
}
|
||||
|
||||
function getMobileFileTabIdentity(tab: MobileFileTabIdentity): string {
|
||||
return JSON.stringify([
|
||||
tab.id,
|
||||
tab.filePath,
|
||||
tab.relativePath,
|
||||
tab.mode ?? '',
|
||||
tab.diffSource ?? ''
|
||||
])
|
||||
}
|
||||
|
||||
function isMobileFileTabIdentity(
|
||||
tab: MobileFileTabCandidate
|
||||
): tab is MobileFileTabCandidate & MobileFileTabIdentity {
|
||||
return (
|
||||
tab.type === 'file' &&
|
||||
typeof tab.filePath === 'string' &&
|
||||
typeof tab.relativePath === 'string' &&
|
||||
(tab.mode === undefined || tab.mode === 'edit' || tab.mode === 'diff') &&
|
||||
(tab.diffSource === undefined ||
|
||||
tab.diffSource === 'staged' ||
|
||||
tab.diffSource === 'unstaged' ||
|
||||
tab.diffSource === 'branch' ||
|
||||
tab.diffSource === 'commit')
|
||||
)
|
||||
}
|
||||
|
||||
function getMobileFileDocLoadErrorMessage(tab: MobileFileTabIdentity, error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === 'binary_file') {
|
||||
return 'Binary preview unavailable'
|
||||
}
|
||||
if (message === 'file_too_large') {
|
||||
return 'File too large for mobile preview'
|
||||
}
|
||||
return tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
|
||||
? "Couldn't load diff preview"
|
||||
: "Couldn't load file preview"
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
MarkdownDocState,
|
||||
MobileSessionTab
|
||||
} from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
import { MobileSessionMarkdownDocLifecycle } from './mobile-session-markdown-doc-lifecycle'
|
||||
|
||||
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
|
||||
type ReadyMarkdownDoc = Extract<MarkdownDocState, { status: 'ready' }>
|
||||
|
||||
function markdownTab(id: string, relativePath = `${id}.md`): MarkdownTab {
|
||||
return {
|
||||
type: 'markdown',
|
||||
id,
|
||||
title: relativePath,
|
||||
filePath: `/repo/${relativePath}`,
|
||||
relativePath,
|
||||
isDirty: false,
|
||||
isActive: false,
|
||||
documentVersion: 'v1'
|
||||
}
|
||||
}
|
||||
|
||||
function readyDoc(content: string, isDirty = false): ReadyMarkdownDoc {
|
||||
return {
|
||||
status: 'ready',
|
||||
content,
|
||||
localContent: content,
|
||||
baseVersion: 'v1',
|
||||
isDirty,
|
||||
editable: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile session markdown document lifecycle', () => {
|
||||
it('retains payloads only for markdown tabs in the accepted snapshot', () => {
|
||||
const lifecycle = new MobileSessionMarkdownDocLifecycle()
|
||||
let docs = new Map<string, MarkdownDocState>([
|
||||
['closed', readyDoc('x'.repeat(1_000_000))],
|
||||
['live', readyDoc('live')]
|
||||
])
|
||||
|
||||
lifecycle.reconcile([markdownTab('live')], (update) => {
|
||||
docs = update(docs)
|
||||
})
|
||||
|
||||
expect([...docs.keys()]).toEqual(['live'])
|
||||
})
|
||||
|
||||
it('retains a dirty orphan when reconciliation keeps its draft tab', () => {
|
||||
const lifecycle = new MobileSessionMarkdownDocLifecycle()
|
||||
let docs = new Map<string, MarkdownDocState>([['draft', readyDoc('unsaved', true)]])
|
||||
|
||||
lifecycle.reconcile([markdownTab('draft')], (update) => {
|
||||
docs = update(docs)
|
||||
})
|
||||
|
||||
expect(docs.get('draft')).toEqual(readyDoc('unsaved', true))
|
||||
})
|
||||
|
||||
it('does not let a late read resurrect a closed tab', async () => {
|
||||
const lifecycle = new MobileSessionMarkdownDocLifecycle()
|
||||
const tab = markdownTab('late')
|
||||
let docs = new Map<string, MarkdownDocState>()
|
||||
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
|
||||
docs = update(docs)
|
||||
}
|
||||
lifecycle.reconcile([tab], updateDocs)
|
||||
let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
|
||||
const read = new Promise<ReadyMarkdownDoc>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
|
||||
const pending = lifecycle.load(tab, updateDocs, () => read)
|
||||
lifecycle.close(tab.id, updateDocs)
|
||||
resolveRead(readyDoc('late payload'))
|
||||
await pending
|
||||
|
||||
expect(docs.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not let an old path overwrite a replacement using the same tab id', async () => {
|
||||
const lifecycle = new MobileSessionMarkdownDocLifecycle()
|
||||
const oldTab = markdownTab('same', 'old.md')
|
||||
let docs = new Map<string, MarkdownDocState>()
|
||||
const updateDocs = (update: (current: typeof docs) => typeof docs) => {
|
||||
docs = update(docs)
|
||||
}
|
||||
lifecycle.reconcile([oldTab], updateDocs)
|
||||
let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
|
||||
const pending = lifecycle.load(
|
||||
oldTab,
|
||||
updateDocs,
|
||||
() =>
|
||||
new Promise<ReadyMarkdownDoc>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
|
||||
lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs)
|
||||
resolveRead(readyDoc('old payload'))
|
||||
await pending
|
||||
|
||||
expect(docs.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,147 +0,0 @@
|
||||
import type {
|
||||
MarkdownDocState,
|
||||
MobileSessionTab
|
||||
} from '../../app/h/[hostId]/session/mobile-session-route-types'
|
||||
|
||||
type MobileMarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
|
||||
|
||||
type MobileMarkdownDocUpdater = (
|
||||
update: (current: Map<string, MarkdownDocState>) => Map<string, MarkdownDocState>
|
||||
) => void
|
||||
|
||||
type MobileMarkdownDocLoadToken = {
|
||||
generation: number
|
||||
requestId: number
|
||||
tabId: string
|
||||
tabIdentity: string
|
||||
}
|
||||
|
||||
export class MobileSessionMarkdownDocLifecycle {
|
||||
private generation = 0
|
||||
private nextRequestId = 0
|
||||
private readonly liveTabIdentityById = new Map<string, string>()
|
||||
private readonly activeRequestIdByTabId = new Map<string, number>()
|
||||
|
||||
reconcile(tabs: readonly MobileSessionTab[], updateDocs: MobileMarkdownDocUpdater): void {
|
||||
const nextIdentityById = new Map(
|
||||
tabs
|
||||
.filter((tab): tab is MobileMarkdownTab => tab.type === 'markdown')
|
||||
.map((tab) => [tab.id, markdownTabIdentity(tab)])
|
||||
)
|
||||
const replacedTabIds = new Set<string>()
|
||||
for (const [tabId, currentIdentity] of this.liveTabIdentityById) {
|
||||
const nextIdentity = nextIdentityById.get(tabId)
|
||||
if (nextIdentity && nextIdentity !== currentIdentity) {
|
||||
replacedTabIds.add(tabId)
|
||||
}
|
||||
}
|
||||
for (const tabId of this.activeRequestIdByTabId.keys()) {
|
||||
if (nextIdentityById.get(tabId) !== this.liveTabIdentityById.get(tabId)) {
|
||||
this.activeRequestIdByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
this.liveTabIdentityById.clear()
|
||||
for (const [tabId, identity] of nextIdentityById) {
|
||||
this.liveTabIdentityById.set(tabId, identity)
|
||||
}
|
||||
updateDocs((current) => retainLiveMarkdownDocs(current, nextIdentityById, replacedTabIds))
|
||||
}
|
||||
|
||||
async load(
|
||||
tab: MobileMarkdownTab,
|
||||
updateDocs: MobileMarkdownDocUpdater,
|
||||
read: () => Promise<Extract<MarkdownDocState, { status: 'ready' }>>
|
||||
): Promise<void> {
|
||||
const token = this.beginLoad(tab)
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
|
||||
try {
|
||||
const doc = await read()
|
||||
if (this.finishLoad(token)) {
|
||||
updateDocs((current) => new Map(current).set(tab.id, doc))
|
||||
}
|
||||
} catch {
|
||||
if (this.finishLoad(token)) {
|
||||
updateDocs((current) =>
|
||||
new Map(current).set(tab.id, {
|
||||
status: 'error',
|
||||
message: "Couldn't load markdown"
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(tabId: string, updateDocs: MobileMarkdownDocUpdater): void {
|
||||
this.liveTabIdentityById.delete(tabId)
|
||||
this.activeRequestIdByTabId.delete(tabId)
|
||||
updateDocs((current) => removeMarkdownDoc(current, tabId))
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.generation += 1
|
||||
this.liveTabIdentityById.clear()
|
||||
this.activeRequestIdByTabId.clear()
|
||||
}
|
||||
|
||||
private beginLoad(tab: MobileMarkdownTab): MobileMarkdownDocLoadToken | null {
|
||||
const tabIdentity = markdownTabIdentity(tab)
|
||||
if (this.liveTabIdentityById.get(tab.id) !== tabIdentity) {
|
||||
return null
|
||||
}
|
||||
const requestId = ++this.nextRequestId
|
||||
this.activeRequestIdByTabId.set(tab.id, requestId)
|
||||
return {
|
||||
generation: this.generation,
|
||||
requestId,
|
||||
tabId: tab.id,
|
||||
tabIdentity
|
||||
}
|
||||
}
|
||||
|
||||
private finishLoad(token: MobileMarkdownDocLoadToken): boolean {
|
||||
if (
|
||||
token.generation !== this.generation ||
|
||||
this.liveTabIdentityById.get(token.tabId) !== token.tabIdentity ||
|
||||
this.activeRequestIdByTabId.get(token.tabId) !== token.requestId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.activeRequestIdByTabId.delete(token.tabId)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function retainLiveMarkdownDocs(
|
||||
docs: Map<string, MarkdownDocState>,
|
||||
liveIdentityById: ReadonlyMap<string, string>,
|
||||
replacedTabIds: ReadonlySet<string>
|
||||
): Map<string, MarkdownDocState> {
|
||||
let next: Map<string, MarkdownDocState> | null = null
|
||||
for (const tabId of docs.keys()) {
|
||||
if (liveIdentityById.has(tabId) && !replacedTabIds.has(tabId)) {
|
||||
continue
|
||||
}
|
||||
next ??= new Map(docs)
|
||||
next.delete(tabId)
|
||||
}
|
||||
return next ?? docs
|
||||
}
|
||||
|
||||
function removeMarkdownDoc(
|
||||
docs: Map<string, MarkdownDocState>,
|
||||
tabId: string
|
||||
): Map<string, MarkdownDocState> {
|
||||
if (!docs.has(tabId)) {
|
||||
return docs
|
||||
}
|
||||
const next = new Map(docs)
|
||||
next.delete(tabId)
|
||||
return next
|
||||
}
|
||||
|
||||
function markdownTabIdentity(tab: MobileMarkdownTab): string {
|
||||
return JSON.stringify([tab.id, tab.filePath, tab.relativePath])
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getMobileTerminalDiagnosticErrorName,
|
||||
logMobileTerminalDiagnostic,
|
||||
MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
|
||||
MobileTerminalDiagnostics,
|
||||
shortenMobileTerminalDiagnosticId
|
||||
} from './mobile-terminal-diagnostics'
|
||||
@@ -44,28 +43,4 @@ describe('mobile terminal diagnostics', () => {
|
||||
expect(log).toHaveBeenCalledTimes(2)
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('accepts the exact diagnostic handle cap and evicts oldest at one over', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const diagnostics = new MobileTerminalDiagnostics()
|
||||
for (let index = 0; index < MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES; index += 1) {
|
||||
diagnostics.streamSkipped(`terminal-${index}`, 'inactive', false)
|
||||
diagnostics.firstStreamEvent(`terminal-${index}`, 1, 'subscribed')
|
||||
}
|
||||
expect(diagnostics.retainedHandleCountsForTests()).toEqual({
|
||||
streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
|
||||
firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES
|
||||
})
|
||||
|
||||
diagnostics.streamSkipped('one-over', 'inactive', false)
|
||||
diagnostics.firstStreamEvent('one-over', 1, 'subscribed')
|
||||
expect(diagnostics.retainedHandleCountsForTests()).toEqual({
|
||||
streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
|
||||
firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES
|
||||
})
|
||||
diagnostics.streamSkipped('terminal-0', 'inactive', false)
|
||||
diagnostics.firstStreamEvent('terminal-0', 1, 'subscribed')
|
||||
expect(log).toHaveBeenCalledTimes(MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES * 2 + 4)
|
||||
log.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
|
||||
export const MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES = 1_024
|
||||
|
||||
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
|
||||
|
||||
@@ -91,7 +90,7 @@ export class MobileTerminalDiagnostics {
|
||||
if (this.streamGateByHandle.get(handle) === reason) {
|
||||
return
|
||||
}
|
||||
setBoundedHandleState(this.streamGateByHandle, handle, reason)
|
||||
this.streamGateByHandle.set(handle, reason)
|
||||
logMobileTerminalDiagnostic('stream-skipped', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
reason,
|
||||
@@ -114,7 +113,7 @@ export class MobileTerminalDiagnostics {
|
||||
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
|
||||
return
|
||||
}
|
||||
setBoundedHandleState(this.firstStreamEventSeqByHandle, handle, seq)
|
||||
this.firstStreamEventSeqByHandle.set(handle, seq)
|
||||
logMobileTerminalDiagnostic('stream-first-event', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
@@ -268,14 +267,6 @@ export class MobileTerminalDiagnostics {
|
||||
})
|
||||
}
|
||||
|
||||
/** Test-only evidence for process-retained diagnostic dedupe state. */
|
||||
retainedHandleCountsForTests(): { streamGates: number; firstEvents: number } {
|
||||
return {
|
||||
streamGates: this.streamGateByHandle.size,
|
||||
firstEvents: this.firstStreamEventSeqByHandle.size
|
||||
}
|
||||
}
|
||||
|
||||
private logTabs(
|
||||
event: 'tabs-applied' | 'tabs-fetch-success',
|
||||
snapshot: DiagnosticTabsSnapshot,
|
||||
@@ -298,15 +289,3 @@ export class MobileTerminalDiagnostics {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setBoundedHandleState<T>(map: Map<string, T>, handle: string, value: T): void {
|
||||
map.delete(handle)
|
||||
map.set(handle, value)
|
||||
while (map.size > MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES) {
|
||||
const oldestHandle = map.keys().next().value
|
||||
if (typeof oldestHandle !== 'string') {
|
||||
return
|
||||
}
|
||||
map.delete(oldestHandle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS,
|
||||
PrActionsEngine,
|
||||
type PrActionMutations
|
||||
} from './pr-actions-engine'
|
||||
import { PrActionsEngine, type PrActionMutations } from './pr-actions-engine'
|
||||
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -242,32 +238,3 @@ describe('PrActionsEngine — PR identity changes', () => {
|
||||
expect(refetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrActionsEngine — reviewer retention', () => {
|
||||
it('releases settled reviewer fields so sequential reviewer actions do not accumulate', async () => {
|
||||
const engine = makeEngine({})
|
||||
|
||||
for (let index = 0; index < MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS + 1; index += 1) {
|
||||
await engine.requestReviewer(`reviewer-${index}`)
|
||||
}
|
||||
|
||||
expect(engine.retainedReviewerFieldCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the exact concurrent reviewer cap and rejects one over', async () => {
|
||||
const pending = deferred<GitHubPrMutationOutcome>()
|
||||
const engine = makeEngine({ requestReviewers: () => pending.promise })
|
||||
const actions = Array.from({ length: MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS }, (_, index) =>
|
||||
engine.requestReviewer(`reviewer-${index}`)
|
||||
)
|
||||
expect(engine.retainedReviewerFieldCountForTests()).toBe(MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS)
|
||||
|
||||
await expect(engine.requestReviewer('one-over')).rejects.toThrow(
|
||||
'Too many reviewer actions are pending'
|
||||
)
|
||||
|
||||
pending.resolve({ ok: true })
|
||||
await Promise.all(actions)
|
||||
expect(engine.retainedReviewerFieldCountForTests()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,6 @@ import type { PrActionMutations } from './pr-action-mutation-contract'
|
||||
|
||||
export type { PrActionMutations } from './pr-action-mutation-contract'
|
||||
|
||||
export const MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS = 64
|
||||
const MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS = 256
|
||||
|
||||
// Pure (React-free) engine for the PR mutation actions: owns optimistic fields,
|
||||
// busy/error/blocked state, and the success/transient/permanent routing. The hook
|
||||
// is a thin adapter that subscribes to `onChange` and exposes these methods. Kept
|
||||
@@ -99,25 +96,12 @@ export class PrActionsEngine {
|
||||
private reviewerField(login: string): OptimisticField<boolean> {
|
||||
let f = this.reviewerFields.get(login)
|
||||
if (!f) {
|
||||
if (
|
||||
login.length === 0 ||
|
||||
login.length > MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS ||
|
||||
this.reviewerFields.size >= MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS
|
||||
) {
|
||||
throw new Error('Too many reviewer actions are pending')
|
||||
}
|
||||
f = createOptimisticField<boolean>(this.cfg.onChange)
|
||||
this.reviewerFields.set(login, f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
private releaseReviewerFieldIfIdle(login: string, field: OptimisticField<boolean>): void {
|
||||
if (field.peek() === undefined && this.reviewerFields.get(login) === field) {
|
||||
this.reviewerFields.delete(login)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
|
||||
// so we don't force a full PR panel re-render for free.
|
||||
private setBusy(key: PrActionBusyKey | null): void {
|
||||
@@ -280,7 +264,6 @@ export class PrActionsEngine {
|
||||
})
|
||||
} finally {
|
||||
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
|
||||
this.releaseReviewerFieldIfIdle(login, field)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +286,6 @@ export class PrActionsEngine {
|
||||
})
|
||||
} finally {
|
||||
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
|
||||
this.releaseReviewerFieldIfIdle(login, field)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,9 +319,4 @@ export class PrActionsEngine {
|
||||
const f = this.reviewerFields.get(login)
|
||||
return f ? f.resolve(authoritative) : authoritative
|
||||
}
|
||||
|
||||
/** Test-only evidence for retained optimistic reviewer state. */
|
||||
retainedReviewerFieldCountForTests(): number {
|
||||
return this.reviewerFields.size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
retainMobileNativeChatFilePaths,
|
||||
useMobileNativeChatFileSearch
|
||||
} from './use-mobile-native-chat-file-search'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
|
||||
type SearchState = ReturnType<typeof useMobileNativeChatFileSearch>
|
||||
|
||||
@@ -166,15 +163,3 @@ describe('useMobileNativeChatFileSearch', () => {
|
||||
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('retainMobileNativeChatFilePaths', () => {
|
||||
it('accepts exact count and retained-byte limits and truncates at one over', () => {
|
||||
const result = {
|
||||
files: [{ relativePath: 'a' }, { relativePath: 'bb' }, { relativePath: 'ignored' }]
|
||||
}
|
||||
const exactBytes = 'a'.length * 2 + 64 + ('bb'.length * 2 + 64)
|
||||
|
||||
expect(retainMobileNativeChatFilePaths(result, 2, exactBytes)).toEqual(['a', 'bb'])
|
||||
expect(retainMobileNativeChatFilePaths(result, 3, exactBytes - 1)).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,31 +5,12 @@ import { rankSuggestions } from './mobile-native-chat-autocomplete'
|
||||
const FILE_SEARCH_DEBOUNCE_MS = 120
|
||||
const FILE_SEARCH_RESULT_LIMIT = 16
|
||||
const FILE_SEARCH_QUERY_CACHE_LIMIT = 20
|
||||
export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS = 50_000
|
||||
export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
|
||||
const FILE_SEARCH_RESULT_MAX_RETAINED_BYTES = 256 * 1024
|
||||
|
||||
export function retainMobileNativeChatFilePaths(
|
||||
result: unknown,
|
||||
maxPaths: number,
|
||||
maxRetainedBytes: number
|
||||
): string[] {
|
||||
function extractPaths(result: unknown): string[] {
|
||||
const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? []
|
||||
const paths: string[] = []
|
||||
let retainedBytes = 0
|
||||
for (const file of files) {
|
||||
const path = file.relativePath
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
continue
|
||||
}
|
||||
const nextBytes = retainedBytes + path.length * 2 + 64
|
||||
if (paths.length >= maxPaths || nextBytes > maxRetainedBytes) {
|
||||
break
|
||||
}
|
||||
paths.push(path)
|
||||
retainedBytes = nextBytes
|
||||
}
|
||||
return paths
|
||||
return files
|
||||
.map((file) => file.relativePath ?? '')
|
||||
.filter((path): path is string => path.length > 0)
|
||||
}
|
||||
|
||||
/** Debounces current-host path searches, bounds the mobile result/cache, and
|
||||
@@ -113,11 +94,7 @@ export function useMobileNativeChatFileSearch(args: {
|
||||
if (!response.ok || generationRef.current !== generation) {
|
||||
return null
|
||||
}
|
||||
const paths = retainMobileNativeChatFilePaths(
|
||||
response.result,
|
||||
MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS,
|
||||
MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES
|
||||
)
|
||||
const paths = extractPaths(response.result)
|
||||
legacyPathsRef.current = paths
|
||||
return paths
|
||||
})
|
||||
@@ -152,13 +129,7 @@ export function useMobileNativeChatFileSearch(args: {
|
||||
})
|
||||
if (response.ok) {
|
||||
searchSupportedRef.current = true
|
||||
applyPaths(
|
||||
retainMobileNativeChatFilePaths(
|
||||
response.result,
|
||||
FILE_SEARCH_RESULT_LIMIT,
|
||||
FILE_SEARCH_RESULT_MAX_RETAINED_BYTES
|
||||
)
|
||||
)
|
||||
applyPaths(extractPaths(response.result))
|
||||
return
|
||||
}
|
||||
if (response.error.code === 'method_not_found') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HOST_DOCK_MAX_WIDTH,
|
||||
HOST_DOCK_MIN_WIDTH,
|
||||
@@ -10,29 +10,22 @@ import {
|
||||
clampHostSidebarWidth,
|
||||
loadDisabledTerminalLiveInputHandles,
|
||||
loadHostSidebarWidth,
|
||||
loadPinnedIds,
|
||||
loadPushNotificationsEnabled,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
readPushNotificationsPreference,
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
MOBILE_STORED_ID_SET_MAX_ENTRIES,
|
||||
MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
saveHostSidebarWidth,
|
||||
savePinnedIds,
|
||||
savePushNotificationsEnabled,
|
||||
saveTerminalAutocompleteEnabled,
|
||||
saveTerminalLinkOpenMode
|
||||
} from './preferences'
|
||||
import {
|
||||
SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS,
|
||||
SESSION_VIEW_OVERRIDE_MAX_ENTRIES,
|
||||
loadDefaultSessionView,
|
||||
loadSessionViewOverrides,
|
||||
readDefaultSessionViewPreference,
|
||||
readSessionViewOverridesPreference,
|
||||
resetSessionViewPreferencesForTests,
|
||||
saveDefaultSessionView,
|
||||
updateSessionViewOverride
|
||||
} from './session-view-preferences'
|
||||
@@ -54,15 +47,10 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
|
||||
describe('session view preference', () => {
|
||||
beforeEach(() => {
|
||||
resetSessionViewPreferencesForTests()
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSessionViewPreferencesForTests()
|
||||
})
|
||||
|
||||
it('defaults to terminal and persists the chat default', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
|
||||
@@ -282,72 +270,6 @@ describe('session view preference', () => {
|
||||
JSON.stringify({ tab: 'chat' })
|
||||
)
|
||||
})
|
||||
|
||||
it('loads the exact override count and rejects one over without parsing it into state', async () => {
|
||||
const exact = Object.fromEntries(
|
||||
Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [
|
||||
`tab-${index}`,
|
||||
'chat'
|
||||
])
|
||||
)
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact))
|
||||
|
||||
await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
|
||||
overrides: new Map(Object.entries(exact)) as Map<string, 'chat'>,
|
||||
loaded: true
|
||||
})
|
||||
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
|
||||
JSON.stringify({ ...exact, 'one-over': 'terminal' })
|
||||
)
|
||||
await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
|
||||
overrides: new Map(),
|
||||
loaded: false
|
||||
})
|
||||
})
|
||||
|
||||
it('evicts the oldest override when a new user choice exceeds the count cap', async () => {
|
||||
const exact = Object.fromEntries(
|
||||
Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [
|
||||
`tab-${index}`,
|
||||
'chat'
|
||||
])
|
||||
)
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact))
|
||||
|
||||
await updateSessionViewOverride('host', 'worktree', 'new-tab', 'terminal')
|
||||
|
||||
const stored = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1]) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
expect(Object.keys(stored)).toHaveLength(SESSION_VIEW_OVERRIDE_MAX_ENTRIES)
|
||||
expect(stored['tab-0']).toBeUndefined()
|
||||
expect(stored['new-tab']).toBe('terminal')
|
||||
})
|
||||
|
||||
it('caps unresolved scoped write barriers and accepts another after one settles', async () => {
|
||||
const blocked = deferred<void>()
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
vi.mocked(AsyncStorage.setItem).mockImplementation(() => blocked.promise)
|
||||
const writes = Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS }, (_, index) =>
|
||||
updateSessionViewOverride(`host-${index}`, 'worktree', 'tab', 'chat')
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledTimes(SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS)
|
||||
)
|
||||
|
||||
await expect(updateSessionViewOverride('one-over', 'worktree', 'tab', 'chat')).rejects.toThrow(
|
||||
'Too many session view override writes are pending'
|
||||
)
|
||||
|
||||
blocked.resolve()
|
||||
await Promise.all(writes)
|
||||
vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
|
||||
await expect(
|
||||
updateSessionViewOverride('recovered', 'worktree', 'tab', 'chat')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('push notification preference', () => {
|
||||
@@ -484,57 +406,6 @@ describe('terminal live input disabled handles preference', () => {
|
||||
JSON.stringify(['pty-2', 'pty-1'])
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the exact handle cap and persists the newest handles at one over', async () => {
|
||||
const exact = new Set(
|
||||
Array.from({ length: MOBILE_STORED_ID_SET_MAX_ENTRIES }, (_, index) => `pty-${index}`)
|
||||
)
|
||||
await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact)
|
||||
expect(JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1])).toHaveLength(
|
||||
MOBILE_STORED_ID_SET_MAX_ENTRIES
|
||||
)
|
||||
|
||||
exact.add('one-over')
|
||||
await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact)
|
||||
const retained = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[1]![1]) as string[]
|
||||
expect(retained).toHaveLength(MOBILE_STORED_ID_SET_MAX_ENTRIES)
|
||||
expect(retained).not.toContain('pty-0')
|
||||
expect(retained).toContain('one-over')
|
||||
})
|
||||
|
||||
it('rejects an oversized handle payload before parsing', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
|
||||
'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1)
|
||||
)
|
||||
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host', 'worktree')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pinned worktree ids preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
it('round-trips normal pins unchanged', async () => {
|
||||
const pins = new Set(['worktree-2', 'worktree-1'])
|
||||
await savePinnedIds('host', pins)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pins:host', JSON.stringify([...pins]))
|
||||
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([...pins]))
|
||||
await expect(loadPinnedIds('host')).resolves.toEqual(pins)
|
||||
})
|
||||
|
||||
it('bounds oversized durable pin payloads', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
|
||||
'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1)
|
||||
)
|
||||
|
||||
await expect(loadPinnedIds('host')).resolves.toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('host sidebar width preference', () => {
|
||||
|
||||
@@ -2,9 +2,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const PINS_PREFIX = 'orca:pins:'
|
||||
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
|
||||
export const MOBILE_STORED_ID_SET_MAX_ENTRIES = 10_000
|
||||
export const MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS = 1024 * 1024
|
||||
export const MOBILE_STORED_ID_MAX_CHARACTERS = 4_096
|
||||
|
||||
export type PushNotificationsPreference = {
|
||||
readonly value: boolean | null
|
||||
@@ -104,10 +101,7 @@ export async function readDisabledTerminalLiveInputHandlesPreference(
|
||||
if (!raw) {
|
||||
return { handles: new Set(), loaded: true }
|
||||
}
|
||||
if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) {
|
||||
return { handles: new Set(), loaded: false }
|
||||
}
|
||||
return { handles: new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids), loaded: true }
|
||||
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
|
||||
} catch {
|
||||
return { handles: new Set(), loaded: false }
|
||||
}
|
||||
@@ -128,7 +122,7 @@ export async function saveDisabledTerminalLiveInputHandles(
|
||||
): Promise<void> {
|
||||
await AsyncStorage.setItem(
|
||||
terminalLiveInputDisabledKey(hostId, worktreeId),
|
||||
retainStoredPreferenceIds(handles).serialized
|
||||
JSON.stringify([...handles])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,59 +210,24 @@ export async function saveTerminalLinkOpenMode(mode: MobileTerminalLinkOpenMode)
|
||||
await AsyncStorage.setItem(TERMINAL_LINK_OPEN_MODE_KEY, mode)
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
export async function loadPinnedIds(hostId: string): Promise<Set<string>> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
|
||||
if (!raw) {
|
||||
return new Set()
|
||||
}
|
||||
if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids)
|
||||
return new Set(stringArray(JSON.parse(raw)))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePinnedIds(hostId: string, ids: Set<string>): Promise<void> {
|
||||
await AsyncStorage.setItem(PINS_PREFIX + hostId, retainStoredPreferenceIds(ids).serialized)
|
||||
}
|
||||
|
||||
function retainStoredPreferenceIds(value: unknown): { ids: string[]; serialized: string } {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
: value && typeof value === 'object' && Symbol.iterator in value
|
||||
? (value as Iterable<unknown>)
|
||||
: []
|
||||
const retained = new Map<string, string>()
|
||||
let entryCharacters = 0
|
||||
for (const candidate of values) {
|
||||
if (
|
||||
typeof candidate !== 'string' ||
|
||||
candidate.length > MOBILE_STORED_ID_MAX_CHARACTERS ||
|
||||
retained.has(candidate)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const serialized = JSON.stringify(candidate)
|
||||
retained.set(candidate, serialized)
|
||||
entryCharacters += serialized.length
|
||||
while (
|
||||
retained.size > MOBILE_STORED_ID_SET_MAX_ENTRIES ||
|
||||
2 + entryCharacters + Math.max(0, retained.size - 1) >
|
||||
MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS
|
||||
) {
|
||||
const oldestId = retained.keys().next().value
|
||||
if (typeof oldestId !== 'string') {
|
||||
break
|
||||
}
|
||||
entryCharacters -= retained.get(oldestId)?.length ?? 0
|
||||
retained.delete(oldestId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
ids: [...retained.keys()],
|
||||
serialized: `[${[...retained.values()].join(',')}]`
|
||||
}
|
||||
await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids]))
|
||||
}
|
||||
|
||||
@@ -5,11 +5,6 @@ export type MobileSessionView = 'terminal' | 'chat'
|
||||
|
||||
const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView'
|
||||
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
|
||||
export const SESSION_VIEW_OVERRIDE_MAX_ENTRIES = 4_096
|
||||
export const SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS = 512 * 1024
|
||||
export const SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS = 1_024
|
||||
export const SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS = 64
|
||||
const SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS = 4_096
|
||||
|
||||
// Why: default stays terminal so native chat remains strictly opt-in.
|
||||
export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
|
||||
@@ -17,11 +12,8 @@ export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
|
||||
let defaultViewWriteBarrier: Promise<void> | null = null
|
||||
const overrideUpdateBarriers = new Map<string, Promise<void>>()
|
||||
|
||||
function sessionViewOverridesKey(hostId: string, worktreeId: string): string | null {
|
||||
const key = `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
|
||||
worktreeId
|
||||
)}`
|
||||
return key.length <= SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS ? key : null
|
||||
function sessionViewOverridesKey(hostId: string, worktreeId: string): string {
|
||||
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
|
||||
}
|
||||
|
||||
function clearDefaultViewWriteBarrier(barrier: Promise<void>): void {
|
||||
@@ -85,9 +77,6 @@ async function readSessionViewOverridesStorage(
|
||||
if (!raw) {
|
||||
return { overrides: new Map(), loaded: true }
|
||||
}
|
||||
if (raw.length > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) {
|
||||
return { overrides: new Map(), loaded: false }
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown
|
||||
@@ -97,38 +86,21 @@ async function readSessionViewOverridesStorage(
|
||||
}
|
||||
// Legacy format: an array of tab ids that were showing native chat.
|
||||
if (Array.isArray(parsed)) {
|
||||
const overrides = new Map<string, MobileSessionView>()
|
||||
for (const id of parsed) {
|
||||
if (typeof id !== 'string' || id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) {
|
||||
continue
|
||||
}
|
||||
if (!overrides.has(id) && overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) {
|
||||
return { overrides: new Map(), loaded: false }
|
||||
}
|
||||
overrides.set(id, 'chat')
|
||||
return {
|
||||
overrides: new Map(
|
||||
parsed
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
.map((id) => [id, 'chat' as const])
|
||||
),
|
||||
loaded: true
|
||||
}
|
||||
return { overrides, loaded: true }
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const overrides = new Map<string, MobileSessionView>()
|
||||
const record = parsed as Record<string, unknown>
|
||||
for (const id in record) {
|
||||
if (!Object.prototype.hasOwnProperty.call(record, id)) {
|
||||
continue
|
||||
}
|
||||
const view = record[id]
|
||||
if (
|
||||
(view !== 'terminal' && view !== 'chat') ||
|
||||
id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) {
|
||||
return { overrides: new Map(), loaded: false }
|
||||
}
|
||||
overrides.set(id, view)
|
||||
}
|
||||
return { overrides, loaded: true }
|
||||
const entries = Object.entries(parsed as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, MobileSessionView] =>
|
||||
entry[1] === 'terminal' || entry[1] === 'chat'
|
||||
)
|
||||
return { overrides: new Map(entries), loaded: true }
|
||||
}
|
||||
return { overrides: new Map(), loaded: true }
|
||||
}
|
||||
@@ -149,9 +121,6 @@ export async function readSessionViewOverridesPreference(
|
||||
worktreeId: string
|
||||
): Promise<SessionViewOverridesPreference> {
|
||||
const key = sessionViewOverridesKey(hostId, worktreeId)
|
||||
if (!key) {
|
||||
return { overrides: new Map(), loaded: false }
|
||||
}
|
||||
await overrideUpdateBarriers.get(key)
|
||||
return readSessionViewOverridesStorage(key)
|
||||
}
|
||||
@@ -164,17 +133,7 @@ export async function updateSessionViewOverride(
|
||||
view: MobileSessionView
|
||||
): Promise<void> {
|
||||
const key = sessionViewOverridesKey(hostId, worktreeId)
|
||||
if (!key || tabId.length === 0 || tabId.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) {
|
||||
throw new Error('Session view override identifier is too large')
|
||||
}
|
||||
const existingBarrier = overrideUpdateBarriers.get(key)
|
||||
if (
|
||||
!existingBarrier &&
|
||||
overrideUpdateBarriers.size >= SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS
|
||||
) {
|
||||
throw new Error('Too many session view override writes are pending')
|
||||
}
|
||||
const previous = existingBarrier ?? Promise.resolve()
|
||||
const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve()
|
||||
const update = previous.then(async () => {
|
||||
const current = await readSessionViewOverridesStorage(key)
|
||||
// Why: a transient read failure must not replace valid saved siblings with
|
||||
@@ -182,17 +141,8 @@ export async function updateSessionViewOverride(
|
||||
if (!current.loaded) {
|
||||
throw new Error('Session view overrides could not be read')
|
||||
}
|
||||
if (
|
||||
!current.overrides.has(tabId) &&
|
||||
current.overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES
|
||||
) {
|
||||
const oldestId = current.overrides.keys().next().value
|
||||
if (typeof oldestId === 'string') {
|
||||
current.overrides.delete(oldestId)
|
||||
}
|
||||
}
|
||||
current.overrides.set(tabId, view)
|
||||
await AsyncStorage.setItem(key, serializeSessionViewOverrides(current.overrides, tabId))
|
||||
await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides)))
|
||||
})
|
||||
const barrier = update.catch(() => undefined)
|
||||
overrideUpdateBarriers.set(key, barrier)
|
||||
@@ -204,48 +154,3 @@ export async function updateSessionViewOverride(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSessionViewOverrides(
|
||||
overrides: Map<string, MobileSessionView>,
|
||||
protectedId: string
|
||||
): string {
|
||||
while (true) {
|
||||
const serialized = trySerializeSessionViewOverrides(overrides)
|
||||
if (serialized !== null) {
|
||||
return serialized
|
||||
}
|
||||
let evictionId: string | null = null
|
||||
for (const id of overrides.keys()) {
|
||||
if (id !== protectedId) {
|
||||
evictionId = id
|
||||
break
|
||||
}
|
||||
}
|
||||
if (evictionId === null) {
|
||||
throw new Error('Session view override storage limit exceeded')
|
||||
}
|
||||
overrides.delete(evictionId)
|
||||
}
|
||||
}
|
||||
|
||||
function trySerializeSessionViewOverrides(
|
||||
overrides: ReadonlyMap<string, MobileSessionView>
|
||||
): string | null {
|
||||
const entries: string[] = []
|
||||
let characters = 2
|
||||
for (const [id, view] of overrides) {
|
||||
const entry = `${JSON.stringify(id)}:${JSON.stringify(view)}`
|
||||
characters += entry.length + (entries.length > 0 ? 1 : 0)
|
||||
if (characters > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) {
|
||||
return null
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
return `{${entries.join(',')}}`
|
||||
}
|
||||
|
||||
/** Test-only: drop pending module write barriers between cases. */
|
||||
export function resetSessionViewPreferencesForTests(): void {
|
||||
defaultViewWriteBarrier = null
|
||||
overrideUpdateBarriers.clear()
|
||||
}
|
||||
|
||||
@@ -44,24 +44,6 @@ describe('buildGitHubPrFileDiffLines', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves a terminal lone carriage return like the previous line splitter', () => {
|
||||
expect(buildGitHubPrFileDiffLines('', 'line\r')).toEqual([
|
||||
{ key: '0:added:1', kind: 'added', newLineNumber: 1, text: 'line\r' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats one empty LF line and CRLF line as equal', () => {
|
||||
expect(buildGitHubPrFileDiffLines('\n', '\r\n')).toEqual([
|
||||
{
|
||||
key: '0:context:1:1',
|
||||
kind: 'context',
|
||||
oldLineNumber: 1,
|
||||
newLineNumber: 1,
|
||||
text: ''
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps all lines for large files without exact diff truncation', () => {
|
||||
const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n')
|
||||
const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n')
|
||||
@@ -89,39 +71,6 @@ describe('buildGitHubPrFileDiffLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('counts newline-dense files without materializing discarded line arrays', () => {
|
||||
const original = '\n'.repeat(200_000)
|
||||
|
||||
const preview = buildGitHubPrFileDiffPreview(original, 'changed', 3)
|
||||
|
||||
expect(preview.totalLineCount).toBe(200_001)
|
||||
expect(preview.lines).toEqual([
|
||||
{ key: '0:removed:1', kind: 'removed', oldLineNumber: 1, text: '' },
|
||||
{ key: '1:removed:2', kind: 'removed', oldLineNumber: 2, text: '' },
|
||||
{ key: '2:removed:3', kind: 'removed', oldLineNumber: 3, text: '' }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves common prefix and suffix rows around a streamed middle diff', () => {
|
||||
const originalMiddle = Array.from({ length: 500 }, (_, index) => `old-${index}`)
|
||||
const modifiedMiddle = Array.from({ length: 500 }, (_, index) => `new-${index}`)
|
||||
const original = ['first', ...originalMiddle, 'last'].join('\r\n')
|
||||
const modified = ['first', ...modifiedMiddle, 'last'].join('\r\n')
|
||||
|
||||
const preview = buildGitHubPrFileDiffPreview(original, modified, 1_002)
|
||||
|
||||
expect(preview.totalLineCount).toBe(1_002)
|
||||
expect(preview.lines[0]).toMatchObject({ kind: 'context', text: 'first' })
|
||||
expect(preview.lines[1]).toMatchObject({ kind: 'removed', text: 'old-0' })
|
||||
expect(preview.lines[501]).toMatchObject({ kind: 'added', text: 'new-0' })
|
||||
expect(preview.lines.at(-1)).toMatchObject({
|
||||
kind: 'context',
|
||||
oldLineNumber: 502,
|
||||
newLineNumber: 502,
|
||||
text: 'last'
|
||||
})
|
||||
})
|
||||
|
||||
it('can compute the total without retaining preview rows', () => {
|
||||
const modified = Array.from({ length: 20 }, (_, index) => `new-${index}`).join('\n')
|
||||
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
collectGitHubPrFileLineRange,
|
||||
createGitHubPrFileLineSource,
|
||||
findGitHubPrFileCommonLineEdges,
|
||||
visitGitHubPrFileLineRange,
|
||||
type GitHubPrFileLineSource
|
||||
} from './github-pr-file-line-scan'
|
||||
|
||||
export type GitHubPrFileDiffLine = {
|
||||
key: string
|
||||
kind: 'context' | 'added' | 'removed'
|
||||
@@ -26,6 +18,14 @@ type DiffOperation =
|
||||
|
||||
const EXACT_DIFF_CELL_LIMIT = 160_000
|
||||
|
||||
function splitContentLines(value: string): string[] {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
const lines = value.split(/\r?\n/)
|
||||
return lines.at(-1) === '' ? lines.slice(0, -1) : lines
|
||||
}
|
||||
|
||||
function appendExactLineDiff(
|
||||
original: string[],
|
||||
modified: string[],
|
||||
@@ -79,6 +79,37 @@ function appendExactLineDiff(
|
||||
}
|
||||
}
|
||||
|
||||
function appendMiddleDiff(
|
||||
original: string[],
|
||||
modified: string[],
|
||||
appendOperation: (operation: DiffOperation) => void
|
||||
): void {
|
||||
if (original.length === 0) {
|
||||
for (const newLine of modified) {
|
||||
appendOperation({ kind: 'added', newLine })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (modified.length === 0) {
|
||||
for (const oldLine of original) {
|
||||
appendOperation({ kind: 'removed', oldLine })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) {
|
||||
appendExactLineDiff(original, modified, appendOperation)
|
||||
return
|
||||
}
|
||||
// Why: the Tasks diff UI renders a capped preview. Stream fallback rows so a
|
||||
// generated PR file does not allocate thousands of discarded row objects.
|
||||
for (const oldLine of original) {
|
||||
appendOperation({ kind: 'removed', oldLine })
|
||||
}
|
||||
for (const newLine of modified) {
|
||||
appendOperation({ kind: 'added', newLine })
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGitHubPrFileDiffLines(
|
||||
originalContent: string,
|
||||
modifiedContent: string
|
||||
@@ -91,17 +122,42 @@ export function buildGitHubPrFileDiffPreview(
|
||||
modifiedContent: string,
|
||||
maxLines = Number.POSITIVE_INFINITY
|
||||
): GitHubPrFileDiffPreview {
|
||||
const original = createGitHubPrFileLineSource(originalContent)
|
||||
const modified = createGitHubPrFileLineSource(modifiedContent)
|
||||
const { prefixLineCount, suffixLineCount } = findGitHubPrFileCommonLineEdges(original, modified)
|
||||
const originalMiddleLineCount = original.lineCount - prefixLineCount - suffixLineCount
|
||||
const modifiedMiddleLineCount = modified.lineCount - prefixLineCount - suffixLineCount
|
||||
const originalLines = splitContentLines(originalContent)
|
||||
const modifiedLines = splitContentLines(modifiedContent)
|
||||
let prefixLength = 0
|
||||
while (
|
||||
prefixLength < originalLines.length &&
|
||||
prefixLength < modifiedLines.length &&
|
||||
originalLines[prefixLength] === modifiedLines[prefixLength]
|
||||
) {
|
||||
prefixLength += 1
|
||||
}
|
||||
|
||||
let suffixLength = 0
|
||||
while (
|
||||
suffixLength < originalLines.length - prefixLength &&
|
||||
suffixLength < modifiedLines.length - prefixLength &&
|
||||
originalLines[originalLines.length - suffixLength - 1] ===
|
||||
modifiedLines[modifiedLines.length - suffixLength - 1]
|
||||
) {
|
||||
suffixLength += 1
|
||||
}
|
||||
|
||||
const originalMiddle = originalLines.slice(
|
||||
prefixLength,
|
||||
suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength
|
||||
)
|
||||
const modifiedMiddle = modifiedLines.slice(
|
||||
prefixLength,
|
||||
suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength
|
||||
)
|
||||
|
||||
const result: GitHubPrFileDiffLine[] = []
|
||||
let oldLineNumber = 1
|
||||
let newLineNumber = 1
|
||||
let operationIndex = 0
|
||||
let totalLineCount = 0
|
||||
const normalizedMaxLines = Number.isNaN(maxLines) ? 0 : Math.max(0, Math.floor(maxLines))
|
||||
const normalizedMaxLines = Math.max(0, Math.floor(maxLines))
|
||||
function appendOperation(operation: DiffOperation): void {
|
||||
const index = operationIndex
|
||||
operationIndex += 1
|
||||
@@ -143,55 +199,15 @@ export function buildGitHubPrFileDiffPreview(
|
||||
newLineNumber += 1
|
||||
}
|
||||
|
||||
function skipOperations(kind: DiffOperation['kind'], count: number): void {
|
||||
operationIndex += count
|
||||
totalLineCount += count
|
||||
if (kind === 'context') {
|
||||
oldLineNumber += count
|
||||
newLineNumber += count
|
||||
} else if (kind === 'removed') {
|
||||
oldLineNumber += count
|
||||
} else {
|
||||
newLineNumber += count
|
||||
}
|
||||
for (let i = 0; i < prefixLength; i += 1) {
|
||||
const line = originalLines[i] ?? ''
|
||||
appendOperation({ kind: 'context', oldLine: line, newLine: line })
|
||||
}
|
||||
|
||||
function appendLineRange(
|
||||
kind: DiffOperation['kind'],
|
||||
source: GitHubPrFileLineSource,
|
||||
startLine: number,
|
||||
lineCount: number
|
||||
): void {
|
||||
const retainedLineCount = Math.min(lineCount, Math.max(0, normalizedMaxLines - result.length))
|
||||
visitGitHubPrFileLineRange(source, startLine, retainedLineCount, (line) => {
|
||||
if (kind === 'context') {
|
||||
appendOperation({ kind, oldLine: line, newLine: line })
|
||||
} else if (kind === 'removed') {
|
||||
appendOperation({ kind, oldLine: line })
|
||||
} else {
|
||||
appendOperation({ kind, newLine: line })
|
||||
}
|
||||
})
|
||||
skipOperations(kind, lineCount - retainedLineCount)
|
||||
appendMiddleDiff(originalMiddle, modifiedMiddle, appendOperation)
|
||||
for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) {
|
||||
const line = originalLines[i] ?? ''
|
||||
appendOperation({ kind: 'context', oldLine: line, newLine: line })
|
||||
}
|
||||
|
||||
appendLineRange('context', original, 0, prefixLineCount)
|
||||
if (originalMiddleLineCount === 0) {
|
||||
appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount)
|
||||
} else if (modifiedMiddleLineCount === 0) {
|
||||
appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount)
|
||||
} else if (originalMiddleLineCount * modifiedMiddleLineCount <= EXACT_DIFF_CELL_LIMIT) {
|
||||
appendExactLineDiff(
|
||||
collectGitHubPrFileLineRange(original, prefixLineCount, originalMiddleLineCount),
|
||||
collectGitHubPrFileLineRange(modified, prefixLineCount, modifiedMiddleLineCount),
|
||||
appendOperation
|
||||
)
|
||||
} else {
|
||||
// Why: large generated files need exact counts without retaining discarded preview rows.
|
||||
appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount)
|
||||
appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount)
|
||||
}
|
||||
appendLineRange('context', original, original.lineCount - suffixLineCount, suffixLineCount)
|
||||
|
||||
return { lines: result, totalLineCount }
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
export type GitHubPrFileLineSource = {
|
||||
content: string
|
||||
lineCount: number
|
||||
}
|
||||
|
||||
export type GitHubPrFileCommonLineEdges = {
|
||||
prefixLineCount: number
|
||||
suffixLineCount: number
|
||||
}
|
||||
|
||||
type LineBounds = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type ForwardLineCursor = {
|
||||
offset: number
|
||||
remaining: number
|
||||
source: GitHubPrFileLineSource
|
||||
}
|
||||
|
||||
type ReverseLineCursor = {
|
||||
boundary: number
|
||||
remaining: number
|
||||
source: GitHubPrFileLineSource
|
||||
}
|
||||
|
||||
export function createGitHubPrFileLineSource(content: string): GitHubPrFileLineSource {
|
||||
if (content.length === 0) {
|
||||
return { content, lineCount: 0 }
|
||||
}
|
||||
let lineCount = content.endsWith('\n') ? 0 : 1
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
if (content.charCodeAt(index) === 10) {
|
||||
lineCount += 1
|
||||
}
|
||||
}
|
||||
return { content, lineCount }
|
||||
}
|
||||
|
||||
export function findGitHubPrFileCommonLineEdges(
|
||||
original: GitHubPrFileLineSource,
|
||||
modified: GitHubPrFileLineSource
|
||||
): GitHubPrFileCommonLineEdges {
|
||||
const comparableLineCount = Math.min(original.lineCount, modified.lineCount)
|
||||
const originalForward = createForwardCursor(original)
|
||||
const modifiedForward = createForwardCursor(modified)
|
||||
let prefixLineCount = 0
|
||||
while (prefixLineCount < comparableLineCount) {
|
||||
const originalLine = takeNextLine(originalForward)!
|
||||
const modifiedLine = takeNextLine(modifiedForward)!
|
||||
if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) {
|
||||
break
|
||||
}
|
||||
prefixLineCount += 1
|
||||
}
|
||||
|
||||
const originalReverse = createReverseCursor(original)
|
||||
const modifiedReverse = createReverseCursor(modified)
|
||||
const maxSuffixLineCount = comparableLineCount - prefixLineCount
|
||||
let suffixLineCount = 0
|
||||
while (suffixLineCount < maxSuffixLineCount) {
|
||||
const originalLine = takePreviousLine(originalReverse)!
|
||||
const modifiedLine = takePreviousLine(modifiedReverse)!
|
||||
if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) {
|
||||
break
|
||||
}
|
||||
suffixLineCount += 1
|
||||
}
|
||||
return { prefixLineCount, suffixLineCount }
|
||||
}
|
||||
|
||||
export function visitGitHubPrFileLineRange(
|
||||
source: GitHubPrFileLineSource,
|
||||
startLine: number,
|
||||
lineCount: number,
|
||||
visit: (line: string) => void
|
||||
): void {
|
||||
if (lineCount <= 0) {
|
||||
return
|
||||
}
|
||||
const cursor = createForwardCursor(source)
|
||||
for (let index = 0; index < startLine; index += 1) {
|
||||
takeNextLine(cursor)
|
||||
}
|
||||
for (let index = 0; index < lineCount; index += 1) {
|
||||
const bounds = takeNextLine(cursor)
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
visit(source.content.slice(bounds.start, bounds.end))
|
||||
}
|
||||
}
|
||||
|
||||
export function collectGitHubPrFileLineRange(
|
||||
source: GitHubPrFileLineSource,
|
||||
startLine: number,
|
||||
lineCount: number
|
||||
): string[] {
|
||||
const lines: string[] = []
|
||||
visitGitHubPrFileLineRange(source, startLine, lineCount, (line) => lines.push(line))
|
||||
return lines
|
||||
}
|
||||
|
||||
function createForwardCursor(source: GitHubPrFileLineSource): ForwardLineCursor {
|
||||
return { source, offset: 0, remaining: source.lineCount }
|
||||
}
|
||||
|
||||
function takeNextLine(cursor: ForwardLineCursor): LineBounds | null {
|
||||
if (cursor.remaining === 0) {
|
||||
return null
|
||||
}
|
||||
const { content } = cursor.source
|
||||
const separator = content.indexOf('\n', cursor.offset)
|
||||
const rawEnd = separator === -1 ? content.length : separator
|
||||
const end =
|
||||
separator !== -1 && rawEnd > cursor.offset && content.charCodeAt(rawEnd - 1) === 13
|
||||
? rawEnd - 1
|
||||
: rawEnd
|
||||
const bounds = { start: cursor.offset, end }
|
||||
cursor.offset = separator === -1 ? content.length : separator + 1
|
||||
cursor.remaining -= 1
|
||||
return bounds
|
||||
}
|
||||
|
||||
function createReverseCursor(source: GitHubPrFileLineSource): ReverseLineCursor {
|
||||
return {
|
||||
source,
|
||||
boundary: source.content.endsWith('\n') ? source.content.length - 1 : source.content.length,
|
||||
remaining: source.lineCount
|
||||
}
|
||||
}
|
||||
|
||||
function takePreviousLine(cursor: ReverseLineCursor): LineBounds | null {
|
||||
if (cursor.remaining === 0) {
|
||||
return null
|
||||
}
|
||||
const { content } = cursor.source
|
||||
const separator = cursor.boundary === 0 ? -1 : content.lastIndexOf('\n', cursor.boundary - 1)
|
||||
const start = separator + 1
|
||||
const end =
|
||||
cursor.boundary < content.length &&
|
||||
cursor.boundary > start &&
|
||||
content.charCodeAt(cursor.boundary - 1) === 13
|
||||
? cursor.boundary - 1
|
||||
: cursor.boundary
|
||||
cursor.boundary = separator === -1 ? 0 : separator
|
||||
cursor.remaining -= 1
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function lineBoundsEqual(
|
||||
leftSource: GitHubPrFileLineSource,
|
||||
left: LineBounds,
|
||||
rightSource: GitHubPrFileLineSource,
|
||||
right: LineBounds
|
||||
): boolean {
|
||||
const length = left.end - left.start
|
||||
if (length !== right.end - right.start) {
|
||||
return false
|
||||
}
|
||||
for (let offset = 0; offset < length; offset += 1) {
|
||||
if (
|
||||
leftSource.content.charCodeAt(left.start + offset) !==
|
||||
rightSource.content.charCodeAt(right.start + offset)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitHubPRFileContents } from '../../../src/shared/types'
|
||||
import {
|
||||
MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES,
|
||||
MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
|
||||
MobilePrFileContentCache,
|
||||
createMobilePrFileContentKey,
|
||||
createMobilePrFileContentScope,
|
||||
getMobilePrFileContentByteCount,
|
||||
getMobilePrFileContentsForScope
|
||||
} from './mobile-pr-file-content-cache'
|
||||
import {
|
||||
createMobileItemPrFileContentScope,
|
||||
createMobileProjectPrFileContentScope
|
||||
} from './use-mobile-pr-file-content-cache'
|
||||
|
||||
function contents(original: string, modified = ''): GitHubPRFileContents {
|
||||
return {
|
||||
original,
|
||||
modified,
|
||||
originalIsBinary: false,
|
||||
modifiedIsBinary: false
|
||||
}
|
||||
}
|
||||
|
||||
const scopeA = createMobilePrFileContentScope({
|
||||
source: 'item',
|
||||
repoId: 'repo-1',
|
||||
prNumber: 10,
|
||||
headSha: 'head-a',
|
||||
baseSha: 'base-a'
|
||||
})
|
||||
const scopeB = createMobilePrFileContentScope({
|
||||
source: 'item',
|
||||
repoId: 'repo-1',
|
||||
prNumber: 11,
|
||||
headSha: 'head-b',
|
||||
baseSha: 'base-a'
|
||||
})
|
||||
|
||||
describe('MobilePrFileContentCache', () => {
|
||||
it('derives route scopes only from complete GitHub PR revisions', () => {
|
||||
expect(
|
||||
createMobileItemPrFileContentScope(
|
||||
{ provider: 'github', source: { type: 'pr', repoId: 'repo-1', number: 10 } },
|
||||
{ provider: 'github', headSha: 'head-a', baseSha: 'base-a' }
|
||||
)
|
||||
).toBe(scopeA)
|
||||
expect(
|
||||
createMobileProjectPrFileContentScope(
|
||||
{ itemType: 'PULL_REQUEST', content: { number: 10 } },
|
||||
{ id: 'repo-1' },
|
||||
{ provider: 'github', headSha: 'head-a' }
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
createMobileProjectPrFileContentScope(
|
||||
{ itemType: 'PULL_REQUEST', content: { number: 10 } },
|
||||
{ id: 'repo-1' },
|
||||
{ provider: 'github', headSha: 'head-a', baseSha: 'base-a' },
|
||||
{ host: 'github.example', owner: 'orca', repo: 'app' }
|
||||
)
|
||||
).toBe(
|
||||
createMobilePrFileContentScope({
|
||||
source: 'project',
|
||||
repoId: 'repo-1',
|
||||
prNumber: 10,
|
||||
headSha: 'head-a',
|
||||
baseSha: 'base-a',
|
||||
repository: { host: 'github.example', owner: 'orca', repo: 'app' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the desktop diff budget as a hard byte cap with a smaller mobile entry cap', () => {
|
||||
expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES).toBeLessThan(64)
|
||||
expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES).toBe(24_000_000)
|
||||
})
|
||||
|
||||
it('evicts least-recently-used files at the entry limit', () => {
|
||||
const cache = new MobilePrFileContentCache(2, 100)
|
||||
const a = createMobilePrFileContentKey({ path: 'a.ts' })
|
||||
const b = createMobilePrFileContentKey({ path: 'b.ts' })
|
||||
const c = createMobilePrFileContentKey({ path: 'c.ts' })
|
||||
cache.commitRequest(cache.beginRequest(scopeA, a), contents('a'))
|
||||
cache.commitRequest(cache.beginRequest(scopeA, b), contents('b'))
|
||||
expect(cache.select(scopeA, a).contents).toEqual(contents('a'))
|
||||
|
||||
cache.commitRequest(cache.beginRequest(scopeA, c), contents('c'))
|
||||
|
||||
expect(cache.evidence()).toMatchObject({ entryCount: 2, keysOldestFirst: [a, c] })
|
||||
expect(cache.select(scopeA, b).contents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('evicts older payloads to stay within the byte budget', () => {
|
||||
const cache = new MobilePrFileContentCache(10, 8)
|
||||
const a = createMobilePrFileContentKey({ path: 'a.ts' })
|
||||
const b = createMobilePrFileContentKey({ path: 'b.ts' })
|
||||
cache.commitRequest(cache.beginRequest(scopeA, a), contents('12345'))
|
||||
cache.commitRequest(cache.beginRequest(scopeA, b), contents('67890'))
|
||||
|
||||
expect(cache.evidence()).toMatchObject({
|
||||
entryCount: 1,
|
||||
retainedBytes: 5,
|
||||
keysOldestFirst: [b]
|
||||
})
|
||||
})
|
||||
|
||||
it('measures retained UTF-8 bytes rather than JavaScript code units', () => {
|
||||
expect(getMobilePrFileContentByteCount(contents('a😀', 'é'))).toBe(7)
|
||||
})
|
||||
|
||||
it('counts the retained side when the other side is an oversized sentinel', () => {
|
||||
expect(
|
||||
getMobilePrFileContentByteCount({
|
||||
...contents('', 'retained'),
|
||||
originalTooLarge: true
|
||||
})
|
||||
).toBe(8)
|
||||
})
|
||||
|
||||
it('rejects a response from a prior PR context after scope replacement', () => {
|
||||
const cache = new MobilePrFileContentCache(2, 100)
|
||||
const key = createMobilePrFileContentKey({ path: 'file.ts' })
|
||||
const oldRequest = cache.beginRequest(scopeA, key)
|
||||
cache.activateScope(scopeB)
|
||||
|
||||
expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
|
||||
expect(cache.evidence()).toMatchObject({ scope: scopeB, entryCount: 0, retainedBytes: 0 })
|
||||
})
|
||||
|
||||
it('rejects an older request after the active file selection changes', () => {
|
||||
const cache = new MobilePrFileContentCache(2, 100)
|
||||
const a = createMobilePrFileContentKey({ path: 'a.ts' })
|
||||
const b = createMobilePrFileContentKey({ path: 'b.ts' })
|
||||
const oldRequest = cache.beginRequest(scopeA, a)
|
||||
cache.select(scopeA, b)
|
||||
const currentRequest = cache.beginRequest(scopeA, b)
|
||||
|
||||
expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
|
||||
expect(cache.commitRequest(currentRequest, contents('current'))).toBe('stored')
|
||||
})
|
||||
|
||||
it('publishes only the active scope and refuses a single over-budget payload', () => {
|
||||
const cache = new MobilePrFileContentCache(2, 4)
|
||||
const key = createMobilePrFileContentKey({ path: 'file.ts' })
|
||||
expect(cache.commitRequest(cache.beginRequest(scopeA, key), contents('12345'))).toBe(
|
||||
'too-large'
|
||||
)
|
||||
expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeA)).toEqual({})
|
||||
expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeB)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../src/shared/large-diff-render-limit'
|
||||
import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
|
||||
|
||||
export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES = 8
|
||||
export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4
|
||||
|
||||
export type MobilePrFileContentScopeInput = {
|
||||
source: 'item' | 'project'
|
||||
repoId: string
|
||||
prNumber: number
|
||||
headSha: string
|
||||
baseSha: string
|
||||
repository?: GitHubRepositoryIdentity | null
|
||||
}
|
||||
|
||||
export type MobilePrFileContentKeyInput = {
|
||||
path: string
|
||||
oldPath?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type MobilePrFileContentRequestToken = {
|
||||
scope: string
|
||||
key: string
|
||||
requestId: number
|
||||
}
|
||||
|
||||
export type MobilePrFileContentCacheSnapshot = {
|
||||
scope: string | null
|
||||
contentsByKey: Readonly<Record<string, GitHubPRFileContents | undefined>>
|
||||
}
|
||||
|
||||
export type MobilePrFileContentCacheEvidence = {
|
||||
scope: string | null
|
||||
entryCount: number
|
||||
retainedBytes: number
|
||||
keysOldestFirst: string[]
|
||||
}
|
||||
|
||||
type CacheEntry = {
|
||||
contents: GitHubPRFileContents
|
||||
byteCount: number
|
||||
}
|
||||
|
||||
type CacheSelection = {
|
||||
contents: GitHubPRFileContents | undefined
|
||||
scopeChanged: boolean
|
||||
}
|
||||
|
||||
export class MobilePrFileContentCache {
|
||||
private readonly entries = new Map<string, CacheEntry>()
|
||||
private scope: string | null = null
|
||||
private retainedBytes = 0
|
||||
private requestSequence = 0
|
||||
private activeRequest: MobilePrFileContentRequestToken | null = null
|
||||
|
||||
constructor(
|
||||
private readonly maxEntries = MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
|
||||
private readonly maxBytes = MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES
|
||||
) {
|
||||
if (
|
||||
!Number.isInteger(maxEntries) ||
|
||||
maxEntries < 1 ||
|
||||
!Number.isFinite(maxBytes) ||
|
||||
maxBytes < 1
|
||||
) {
|
||||
throw new Error('Mobile PR file-content cache limits must be positive')
|
||||
}
|
||||
}
|
||||
|
||||
activateScope(scope: string): boolean {
|
||||
if (this.scope === scope) {
|
||||
return false
|
||||
}
|
||||
this.scope = scope
|
||||
this.entries.clear()
|
||||
this.retainedBytes = 0
|
||||
this.activeRequest = null
|
||||
return true
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.scope = null
|
||||
this.entries.clear()
|
||||
this.retainedBytes = 0
|
||||
this.activeRequest = null
|
||||
}
|
||||
|
||||
select(scope: string, key: string): CacheSelection {
|
||||
const scopeChanged = this.activateScope(scope)
|
||||
this.activeRequest = null
|
||||
const entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
return { contents: undefined, scopeChanged }
|
||||
}
|
||||
this.entries.delete(key)
|
||||
this.entries.set(key, entry)
|
||||
return { contents: entry.contents, scopeChanged }
|
||||
}
|
||||
|
||||
beginRequest(scope: string, key: string): MobilePrFileContentRequestToken {
|
||||
this.activateScope(scope)
|
||||
const token = { scope, key, requestId: ++this.requestSequence }
|
||||
this.activeRequest = token
|
||||
return token
|
||||
}
|
||||
|
||||
commitRequest(
|
||||
token: MobilePrFileContentRequestToken,
|
||||
contents: GitHubPRFileContents
|
||||
): 'stored' | 'stale' | 'too-large' {
|
||||
if (!this.isCurrentRequest(token)) {
|
||||
return 'stale'
|
||||
}
|
||||
this.activeRequest = null
|
||||
const byteCount = getMobilePrFileContentByteCount(contents)
|
||||
if (byteCount > this.maxBytes) {
|
||||
return 'too-large'
|
||||
}
|
||||
const previous = this.entries.get(token.key)
|
||||
this.retainedBytes -= previous?.byteCount ?? 0
|
||||
this.entries.delete(token.key)
|
||||
this.entries.set(token.key, { contents, byteCount })
|
||||
this.retainedBytes += byteCount
|
||||
this.evictOverflow()
|
||||
return 'stored'
|
||||
}
|
||||
|
||||
rejectRequest(token: MobilePrFileContentRequestToken): boolean {
|
||||
if (!this.isCurrentRequest(token)) {
|
||||
return false
|
||||
}
|
||||
this.activeRequest = null
|
||||
return true
|
||||
}
|
||||
|
||||
snapshot(): MobilePrFileContentCacheSnapshot {
|
||||
return {
|
||||
scope: this.scope,
|
||||
contentsByKey: Object.fromEntries(
|
||||
[...this.entries].map(([key, entry]) => [key, entry.contents])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
evidence(): MobilePrFileContentCacheEvidence {
|
||||
return {
|
||||
scope: this.scope,
|
||||
entryCount: this.entries.size,
|
||||
retainedBytes: this.retainedBytes,
|
||||
keysOldestFirst: [...this.entries.keys()]
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrentRequest(token: MobilePrFileContentRequestToken): boolean {
|
||||
return (
|
||||
this.scope === token.scope &&
|
||||
this.activeRequest?.requestId === token.requestId &&
|
||||
this.activeRequest.scope === token.scope &&
|
||||
this.activeRequest.key === token.key
|
||||
)
|
||||
}
|
||||
|
||||
private evictOverflow(): void {
|
||||
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxBytes) {
|
||||
const oldestKey = this.entries.keys().next().value
|
||||
if (typeof oldestKey !== 'string') {
|
||||
return
|
||||
}
|
||||
const oldest = this.entries.get(oldestKey)
|
||||
this.retainedBytes -= oldest?.byteCount ?? 0
|
||||
this.entries.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobilePrFileContentScope(input: MobilePrFileContentScopeInput): string {
|
||||
return JSON.stringify([
|
||||
input.source,
|
||||
input.repoId,
|
||||
input.prNumber,
|
||||
input.repository?.host?.toLowerCase() ?? '',
|
||||
input.repository?.owner.toLowerCase() ?? '',
|
||||
input.repository?.repo.toLowerCase() ?? '',
|
||||
input.headSha,
|
||||
input.baseSha
|
||||
])
|
||||
}
|
||||
|
||||
export function createMobilePrFileContentKey(input: MobilePrFileContentKeyInput): string {
|
||||
return input.path
|
||||
}
|
||||
|
||||
export function getMobilePrFileContentsForScope(
|
||||
snapshot: MobilePrFileContentCacheSnapshot,
|
||||
scope: string | null
|
||||
): Readonly<Record<string, GitHubPRFileContents | undefined>> {
|
||||
return scope !== null && snapshot.scope === scope ? snapshot.contentsByKey : {}
|
||||
}
|
||||
|
||||
export function getMobilePrFileContentByteCount(contents: GitHubPRFileContents): number {
|
||||
return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified)
|
||||
}
|
||||
|
||||
function getUtf8ByteCount(value: string): number {
|
||||
let byteCount = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code < 0x80) {
|
||||
byteCount += 1
|
||||
} else if (code < 0x800) {
|
||||
byteCount += 2
|
||||
} else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
|
||||
const next = value.charCodeAt(index + 1)
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
byteCount += 4
|
||||
index += 1
|
||||
} else {
|
||||
byteCount += 3
|
||||
}
|
||||
} else {
|
||||
byteCount += 3
|
||||
}
|
||||
}
|
||||
return byteCount
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
|
||||
import {
|
||||
MobilePrFileContentCache,
|
||||
createMobilePrFileContentKey,
|
||||
createMobilePrFileContentScope,
|
||||
getMobilePrFileContentsForScope,
|
||||
type MobilePrFileContentKeyInput
|
||||
} from './mobile-pr-file-content-cache'
|
||||
|
||||
type MobilePrScopeTaskItem = {
|
||||
provider: string
|
||||
source: unknown
|
||||
} | null
|
||||
|
||||
type MobilePrScopeDetail = {
|
||||
provider: string
|
||||
headSha?: unknown
|
||||
baseSha?: unknown
|
||||
} | null
|
||||
|
||||
type MobileProjectPrScopeItem = {
|
||||
itemType: string
|
||||
content: { number?: unknown }
|
||||
} | null
|
||||
|
||||
type MobileProjectPrScopeRepo = { id?: unknown } | null
|
||||
|
||||
type MobilePrFileContentLoad = () => Promise<unknown>
|
||||
type MobilePrFileContentErrorSetter = (message: string) => void
|
||||
|
||||
export function createMobileItemPrFileContentScope(
|
||||
item: MobilePrScopeTaskItem,
|
||||
detail: MobilePrScopeDetail
|
||||
): string | null {
|
||||
const source =
|
||||
item?.source && typeof item.source === 'object'
|
||||
? (item.source as { type?: unknown; repoId?: unknown; number?: unknown })
|
||||
: null
|
||||
if (
|
||||
item?.provider !== 'github' ||
|
||||
source?.type !== 'pr' ||
|
||||
typeof source.repoId !== 'string' ||
|
||||
typeof source.number !== 'number' ||
|
||||
detail?.provider !== 'github' ||
|
||||
typeof detail.headSha !== 'string' ||
|
||||
!detail.headSha ||
|
||||
typeof detail.baseSha !== 'string' ||
|
||||
!detail.baseSha
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return createMobilePrFileContentScope({
|
||||
source: 'item',
|
||||
repoId: source.repoId,
|
||||
prNumber: source.number,
|
||||
headSha: detail.headSha,
|
||||
baseSha: detail.baseSha
|
||||
})
|
||||
}
|
||||
|
||||
export function createMobileProjectPrFileContentScope(
|
||||
item: MobileProjectPrScopeItem,
|
||||
repo: MobileProjectPrScopeRepo,
|
||||
detail: MobilePrScopeDetail,
|
||||
repository?: GitHubRepositoryIdentity | null
|
||||
): string | null {
|
||||
if (
|
||||
item?.itemType !== 'PULL_REQUEST' ||
|
||||
typeof item.content.number !== 'number' ||
|
||||
typeof repo?.id !== 'string' ||
|
||||
detail?.provider !== 'github' ||
|
||||
typeof detail.headSha !== 'string' ||
|
||||
!detail.headSha ||
|
||||
typeof detail.baseSha !== 'string' ||
|
||||
!detail.baseSha
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return createMobilePrFileContentScope({
|
||||
source: 'project',
|
||||
repoId: repo.id,
|
||||
prNumber: item.content.number,
|
||||
repository,
|
||||
headSha: detail.headSha,
|
||||
baseSha: detail.baseSha
|
||||
})
|
||||
}
|
||||
|
||||
export function useMobilePrFileContentCache(activeScope: string | null): {
|
||||
clear: () => void
|
||||
contents: Readonly<Record<string, GitHubPRFileContents | undefined>>
|
||||
load: (
|
||||
scope: string,
|
||||
file: MobilePrFileContentKeyInput,
|
||||
loadContents: MobilePrFileContentLoad,
|
||||
setError: MobilePrFileContentErrorSetter
|
||||
) => Promise<void>
|
||||
loadingPath: string | null
|
||||
} {
|
||||
const [cache] = useState(() => new MobilePrFileContentCache())
|
||||
const [snapshot, setSnapshot] = useState(() => cache.snapshot())
|
||||
const [loadingPath, setLoadingPath] = useState<string | null>(null)
|
||||
const clear = useCallback(() => {
|
||||
cache.clear()
|
||||
setSnapshot(cache.snapshot())
|
||||
setLoadingPath(null)
|
||||
}, [cache])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeScope === null) {
|
||||
clear()
|
||||
} else if (cache.activateScope(activeScope)) {
|
||||
setSnapshot(cache.snapshot())
|
||||
setLoadingPath(null)
|
||||
}
|
||||
}, [activeScope, cache, clear])
|
||||
|
||||
const load = useCallback(
|
||||
async (
|
||||
scope: string,
|
||||
file: MobilePrFileContentKeyInput,
|
||||
loadContents: MobilePrFileContentLoad,
|
||||
setError: MobilePrFileContentErrorSetter
|
||||
): Promise<void> => {
|
||||
const key = createMobilePrFileContentKey(file)
|
||||
const selection = cache.select(scope, key)
|
||||
if (selection.scopeChanged) {
|
||||
setSnapshot(cache.snapshot())
|
||||
}
|
||||
if (selection.contents) {
|
||||
setLoadingPath(null)
|
||||
return
|
||||
}
|
||||
const token = cache.beginRequest(scope, key)
|
||||
setLoadingPath(file.path)
|
||||
setError('')
|
||||
try {
|
||||
const result = await loadContents()
|
||||
if (!isGitHubPrFileContents(result)) {
|
||||
throw new Error('Invalid file contents response')
|
||||
}
|
||||
const commit = cache.commitRequest(token, result)
|
||||
if (commit === 'stale') {
|
||||
return
|
||||
}
|
||||
if (commit === 'too-large') {
|
||||
setError('File too large for mobile preview.')
|
||||
} else {
|
||||
setSnapshot(cache.snapshot())
|
||||
}
|
||||
setLoadingPath((current) => (current === file.path ? null : current))
|
||||
} catch (error) {
|
||||
if (!cache.rejectRequest(token)) {
|
||||
return
|
||||
}
|
||||
setError(error instanceof Error ? error.message : 'Failed to load file contents')
|
||||
setLoadingPath((current) => (current === file.path ? null : current))
|
||||
}
|
||||
},
|
||||
[cache]
|
||||
)
|
||||
|
||||
return {
|
||||
clear,
|
||||
contents: getMobilePrFileContentsForScope(snapshot, activeScope),
|
||||
load,
|
||||
loadingPath
|
||||
}
|
||||
}
|
||||
|
||||
function isGitHubPrFileContents(value: unknown): value is GitHubPRFileContents {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const contents = value as Partial<GitHubPRFileContents>
|
||||
return (
|
||||
typeof contents.original === 'string' &&
|
||||
typeof contents.modified === 'string' &&
|
||||
typeof contents.originalIsBinary === 'boolean' &&
|
||||
typeof contents.modifiedIsBinary === 'boolean'
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
|
||||
CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES,
|
||||
CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS,
|
||||
loadCustomKeys,
|
||||
saveCustomKeys,
|
||||
type CustomKey
|
||||
} from './custom-accessory-key-store'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function customKey(index: number, bytes = 'echo ok'): CustomKey {
|
||||
return { id: `key-${index}`, label: `Key ${index}`, bytes, enter: false }
|
||||
}
|
||||
|
||||
describe('custom accessory key store', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('round-trips normal custom keys unchanged', async () => {
|
||||
const keys = [customKey(1), customKey(2)]
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(keys))
|
||||
|
||||
await expect(loadCustomKeys()).resolves.toEqual(keys)
|
||||
await expect(saveCustomKeys(keys)).resolves.toEqual(keys)
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:custom-accessory-keys',
|
||||
JSON.stringify(keys)
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the exact key count and evicts oldest at one over', async () => {
|
||||
const exact = Array.from({ length: CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES }, (_, index) =>
|
||||
customKey(index)
|
||||
)
|
||||
await expect(saveCustomKeys(exact)).resolves.toEqual(exact)
|
||||
|
||||
const oneOver = [...exact, customKey(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)]
|
||||
const retained = await saveCustomKeys(oneOver)
|
||||
expect(retained).toHaveLength(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)
|
||||
expect(retained[0]?.id).toBe('key-1')
|
||||
expect(retained.at(-1)?.id).toBe(`key-${CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES}`)
|
||||
})
|
||||
|
||||
it('accepts the exact macro length and drops one character over', async () => {
|
||||
const exact = customKey(1, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS))
|
||||
const oversized = customKey(2, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS + 1))
|
||||
|
||||
await expect(saveCustomKeys([exact, oversized])).resolves.toEqual([exact])
|
||||
})
|
||||
|
||||
it('rejects durable JSON over the storage character budget before parsing', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
|
||||
'x'.repeat(CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS + 1)
|
||||
)
|
||||
|
||||
await expect(loadCustomKeys()).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
|
||||
export const CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES = 128
|
||||
export const CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS = 512 * 1024
|
||||
export const CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS = 256
|
||||
export const CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS = 128
|
||||
export const CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS = 16 * 1024
|
||||
|
||||
export type CustomKey = {
|
||||
id: string
|
||||
label: string
|
||||
bytes: string
|
||||
enter: boolean
|
||||
}
|
||||
|
||||
export async function loadCustomKeys(): Promise<CustomKey[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
|
||||
if (!raw || raw.length > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) {
|
||||
return []
|
||||
}
|
||||
return retainCustomKeys(JSON.parse(raw) as unknown).keys
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCustomKeys(keys: CustomKey[]): Promise<CustomKey[]> {
|
||||
const retained = retainCustomKeys(keys)
|
||||
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, retained.serialized)
|
||||
return retained.keys
|
||||
}
|
||||
|
||||
function retainCustomKeys(value: unknown): { keys: CustomKey[]; serialized: string } {
|
||||
if (!Array.isArray(value)) {
|
||||
return { keys: [], serialized: '[]' }
|
||||
}
|
||||
const retained: Array<{ key: CustomKey; serialized: string }> = []
|
||||
let storageCharacters = 2
|
||||
for (let index = value.length - 1; index >= 0; index -= 1) {
|
||||
if (retained.length >= CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES) {
|
||||
break
|
||||
}
|
||||
const key = normalizeCustomKey(value[index])
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
const serialized = JSON.stringify(key)
|
||||
const nextCharacters = storageCharacters + serialized.length + (retained.length > 0 ? 1 : 0)
|
||||
if (nextCharacters > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) {
|
||||
continue
|
||||
}
|
||||
retained.push({ key, serialized })
|
||||
storageCharacters = nextCharacters
|
||||
}
|
||||
retained.reverse()
|
||||
return {
|
||||
keys: retained.map(({ key }) => key),
|
||||
serialized: `[${retained.map(({ serialized }) => serialized).join(',')}]`
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCustomKey(value: unknown): CustomKey | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const key = value as Partial<CustomKey>
|
||||
if (
|
||||
typeof key.id !== 'string' ||
|
||||
key.id.length > CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS ||
|
||||
typeof key.label !== 'string' ||
|
||||
key.label.length > CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS ||
|
||||
typeof key.bytes !== 'string' ||
|
||||
key.bytes.length > CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS ||
|
||||
typeof key.enter !== 'boolean'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { id: key.id, label: key.label, bytes: key.bytes, enter: key.enter }
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
TERMINAL_ACCESSORY_LAYOUT_MAX_IDS,
|
||||
TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS,
|
||||
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
|
||||
createTerminalAccessoryLayoutPreference,
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
@@ -85,61 +83,6 @@ describe('terminal accessory layout', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the exact storage limit and skips parsing one character more', async () => {
|
||||
const preference = {
|
||||
version: 2,
|
||||
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
|
||||
visibleBuiltInIds: ['escape']
|
||||
}
|
||||
const serialized = JSON.stringify(preference)
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(
|
||||
serialized + ' '.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS - serialized.length)
|
||||
)
|
||||
await expect(loadTerminalAccessoryLayout()).resolves.toMatchObject({
|
||||
visibleBuiltInIds: ['escape']
|
||||
})
|
||||
|
||||
const parse = vi.spyOn(JSON, 'parse')
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(
|
||||
'x'.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS + 1)
|
||||
)
|
||||
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
|
||||
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
|
||||
)
|
||||
expect(parse).not.toHaveBeenCalled()
|
||||
parse.mockRestore()
|
||||
})
|
||||
|
||||
it('accepts the exact stored id count and falls back on one more', () => {
|
||||
const exact = Array.from({ length: TERMINAL_ACCESSORY_LAYOUT_MAX_IDS }, (_, index) =>
|
||||
index === 0 ? 'tab' : 'escape'
|
||||
)
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{ version: 2, orderedBuiltInIds: exact, visibleBuiltInIds: ['escape'] },
|
||||
['escape', 'tab']
|
||||
)
|
||||
).toEqual({
|
||||
version: 2,
|
||||
orderedBuiltInIds: ['tab', 'escape'],
|
||||
visibleBuiltInIds: ['escape']
|
||||
})
|
||||
expect(
|
||||
normalizeTerminalAccessoryLayoutPreference(
|
||||
{
|
||||
version: 2,
|
||||
orderedBuiltInIds: [...exact, 'tab'],
|
||||
visibleBuiltInIds: ['escape']
|
||||
},
|
||||
['escape', 'tab']
|
||||
)
|
||||
).toEqual({
|
||||
version: 2,
|
||||
orderedBuiltInIds: ['escape', 'tab'],
|
||||
visibleBuiltInIds: ['escape', 'tab']
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a custom v2 order and its visible subset', () => {
|
||||
const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].toReversed()
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys'
|
||||
|
||||
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
|
||||
export const TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS = 64 * 1024
|
||||
export const TERMINAL_ACCESSORY_LAYOUT_MAX_IDS = 256
|
||||
export const TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS = 256
|
||||
|
||||
export type TerminalAccessoryLayout = {
|
||||
orderedBuiltInIds: string[]
|
||||
@@ -29,28 +26,17 @@ function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreferenc
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || value.length > TERMINAL_ACCESSORY_LAYOUT_MAX_IDS) {
|
||||
if (!Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value.every(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && item.length <= TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS
|
||||
)
|
||||
? value
|
||||
: null
|
||||
return value.every((item): item is string => typeof item === 'string') ? value : null
|
||||
}
|
||||
|
||||
function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
const count = Math.min(ids.length, TERMINAL_ACCESSORY_LAYOUT_MAX_IDS)
|
||||
for (let index = 0; index < count; index++) {
|
||||
const id = ids[index]!
|
||||
if (
|
||||
id.length > TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS ||
|
||||
!builtInSet.has(id) ||
|
||||
seen.has(id)
|
||||
) {
|
||||
for (const id of ids) {
|
||||
if (!builtInSet.has(id) || seen.has(id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(id)
|
||||
@@ -232,9 +218,6 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
|
||||
if (!raw) {
|
||||
return defaultPreference()
|
||||
}
|
||||
if (raw.length > TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) {
|
||||
return defaultPreference()
|
||||
}
|
||||
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
|
||||
} catch {
|
||||
return defaultPreference()
|
||||
@@ -243,9 +226,5 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
|
||||
|
||||
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
|
||||
const preference = createTerminalAccessoryLayoutPreference(layout)
|
||||
const serialized = JSON.stringify(preference)
|
||||
if (serialized.length > TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) {
|
||||
throw new Error('terminal accessory layout exceeds storage limit')
|
||||
}
|
||||
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, serialized)
|
||||
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export const DEFAULT_TERMINAL_WEBVIEW_THEME: RuntimeMobileTerminalTheme['theme'] = {
|
||||
background: colors.terminalBg,
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: colors.terminalBg,
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5'
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// xterm.js WebView document; extracted from TerminalWebView.tsx for the max-lines budget.
|
||||
// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
|
||||
import { DEFAULT_TERMINAL_WEBVIEW_THEME } from './terminal-webview-default-theme'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
|
||||
@@ -12,9 +12,30 @@ import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
|
||||
export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000
|
||||
export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096
|
||||
export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256
|
||||
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
|
||||
background: colors.terminalBg,
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: colors.terminalBg,
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5'
|
||||
}
|
||||
|
||||
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
|
||||
export const XTERM_HTML = `<!DOCTYPE html>
|
||||
@@ -192,11 +213,10 @@ window.onerror = function(msg) {
|
||||
var scrollIndicator = document.getElementById('scroll-indicator');
|
||||
var scrollThumb = document.getElementById('scroll-thumb');
|
||||
var scrollIndicatorHideTimer = null;
|
||||
var writeQueue = [], writeQueueUnits = 0;
|
||||
var writeQueue = [];
|
||||
var writeQueueHead = 0;
|
||||
var writesDraining = false, writeBacklogFailed = false, afterDrainCallbacks = [];
|
||||
var WRITE_QUEUE_MAX_UNITS = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS}, WRITE_QUEUE_MAX_ENTRIES = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES};
|
||||
var AFTER_DRAIN_MAX_CALLBACKS = ${TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS};
|
||||
var writesDraining = false;
|
||||
var afterDrainCallbacks = [];
|
||||
var termObserverDisposables = [];
|
||||
var ready = false;
|
||||
// Why: init() flips ready false on every re-init (live width reflow included)
|
||||
@@ -267,7 +287,7 @@ window.onerror = function(msg) {
|
||||
var normalScrollFrameId = null;
|
||||
var initRows = 24;
|
||||
var terminalGeneration = 0;
|
||||
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_WEBVIEW_THEME)};
|
||||
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
|
||||
var terminalThemeInput = null;
|
||||
var terminalTheme = defaultTheme;
|
||||
var terminalMinimumContrastRatio = 3;
|
||||
@@ -537,22 +557,9 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
}
|
||||
}
|
||||
|
||||
function resetWriteQueue() { writeQueue = []; writeQueueHead = 0; writeQueueUnits = 0; }
|
||||
|
||||
function failWriteBacklog() {
|
||||
if (writeBacklogFailed) return;
|
||||
writeBacklogFailed = true; terminalGeneration++; ready = false; writesDraining = false;
|
||||
resetWriteQueue(); afterDrainCallbacks = [];
|
||||
reportEngineError('terminal write backlog exceeded safe limit', null, true);
|
||||
}
|
||||
|
||||
function reserveWriteQueueEntry(units) {
|
||||
if (writeBacklogFailed) return false;
|
||||
var pendingEntries = writeQueue.length - writeQueueHead;
|
||||
if (pendingEntries >= WRITE_QUEUE_MAX_ENTRIES || writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS) {
|
||||
failWriteBacklog(); return false;
|
||||
}
|
||||
writeQueueUnits += units; return true;
|
||||
function resetWriteQueue() {
|
||||
writeQueue = [];
|
||||
writeQueueHead = 0;
|
||||
}
|
||||
|
||||
function isStatusDotPresentationSelector(value) {
|
||||
@@ -583,12 +590,13 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function enqueueWrite(data) { var normalized = normalizeStatusDotPresentation(data);
|
||||
if (!reserveWriteQueueEntry(normalized.length)) return false;
|
||||
writeQueue.push(normalized); return true;
|
||||
function enqueueWrite(data) {
|
||||
writeQueue.push(normalizeStatusDotPresentation(data));
|
||||
}
|
||||
|
||||
function enqueueWriteBoundary(callback) { if (!reserveWriteQueueEntry(0)) return false; writeQueue.push(callback); return true; }
|
||||
function enqueueWriteBoundary(callback) {
|
||||
writeQueue.push(callback);
|
||||
}
|
||||
|
||||
function nextQueuedWrite() {
|
||||
if (writeQueueHead >= writeQueue.length) {
|
||||
@@ -597,7 +605,6 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
}
|
||||
var next = writeQueue[writeQueueHead];
|
||||
writeQueueHead++;
|
||||
if (typeof next === 'string') writeQueueUnits = Math.max(0, writeQueueUnits - next.length);
|
||||
// Why: high-throughput terminals can enqueue faster than xterm parses;
|
||||
// compact consumed slots so drain work stays O(1) without retaining old chunks.
|
||||
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
|
||||
@@ -653,8 +660,8 @@ ${TERMINAL_WEBVIEW_THEME_JS}
|
||||
}
|
||||
|
||||
function afterWritesDrained(callback) {
|
||||
if (afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS) { failWriteBacklog(); return; }
|
||||
afterDrainCallbacks.push(callback); pumpWrites(terminalGeneration);
|
||||
afterDrainCallbacks.push(callback);
|
||||
pumpWrites(terminalGeneration);
|
||||
}
|
||||
|
||||
${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
@@ -675,7 +682,6 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
webglAddon = null;
|
||||
ready = false;
|
||||
resetWriteQueue();
|
||||
writeBacklogFailed = false;
|
||||
statusDotPendingSelector = false;
|
||||
writesDraining = false;
|
||||
afterDrainCallbacks = [];
|
||||
@@ -762,7 +768,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
|
||||
function write(data) {
|
||||
updateMouseModeFromData(data);
|
||||
if (!enqueueWrite(data)) return;
|
||||
enqueueWrite(data);
|
||||
pumpWrites(terminalGeneration);
|
||||
// Why: first live data chunk after init may widen the buffer past
|
||||
// what the post-replay applyFitScale measured. Re-fit once after this
|
||||
@@ -926,7 +932,6 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
} else if (msg.type === 'clear') {
|
||||
terminalGeneration++;
|
||||
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
|
||||
writeBacklogFailed = false;
|
||||
statusDotPendingSelector = false;
|
||||
afterDrainCallbacks = [];
|
||||
writesDraining = false;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import {
|
||||
createTerminalWebViewPendingMessages,
|
||||
MAX_PENDING_WEB_MESSAGES
|
||||
} from './terminal-webview-pending-messages'
|
||||
|
||||
function flushPending(queue: ReturnType<typeof createTerminalWebViewPendingMessages>) {
|
||||
const delivered: TerminalWebViewCommand[] = []
|
||||
queue.flush((message) => delivered.push(message))
|
||||
return delivered
|
||||
}
|
||||
|
||||
describe('terminal WebView pending messages', () => {
|
||||
it('retains only the latest snapshot when init is superseded before readiness', () => {
|
||||
const queue = createTerminalWebViewPendingMessages()
|
||||
queue.queue({ type: 'write', data: 'current-document-tail' })
|
||||
queue.queue({ type: 'init', cols: 80, rows: 24, initialData: 'old snapshot' })
|
||||
queue.queue({ type: 'write', data: 'covered by the replacement snapshot' })
|
||||
queue.queue({ type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' })
|
||||
|
||||
expect(flushPending(queue)).toEqual([
|
||||
{ type: 'write', data: 'current-document-tail' },
|
||||
{ type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' }
|
||||
])
|
||||
})
|
||||
|
||||
it('caps tiny control-message floods while preserving the newest state', () => {
|
||||
const queue = createTerminalWebViewPendingMessages()
|
||||
for (let index = 0; index < MAX_PENDING_WEB_MESSAGES + 100; index += 1) {
|
||||
queue.queue({ type: 'resize', cols: index + 1, rows: 24 })
|
||||
}
|
||||
|
||||
const delivered = flushPending(queue)
|
||||
expect(delivered).toHaveLength(MAX_PENDING_WEB_MESSAGES)
|
||||
expect(delivered.at(-1)).toEqual({
|
||||
type: 'resize',
|
||||
cols: MAX_PENDING_WEB_MESSAGES + 100,
|
||||
rows: 24
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
|
||||
const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000
|
||||
const MAX_PENDING_WEB_WRITE_MESSAGES = 4096
|
||||
export const MAX_PENDING_WEB_MESSAGES = 8192
|
||||
|
||||
export function createTerminalWebViewPendingMessages() {
|
||||
let pending: TerminalWebViewCommand[] = []
|
||||
@@ -19,42 +18,9 @@ export function createTerminalWebViewPendingMessages() {
|
||||
resetCounters()
|
||||
}
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
const [removed] = pending.splice(index, 1)
|
||||
if (removed?.type === 'write') {
|
||||
pendingWriteBytes = Math.max(0, pendingWriteBytes - removed.data.length)
|
||||
pendingWriteCount = Math.max(0, pendingWriteCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const supersedePendingInit = (msg: Extract<TerminalWebViewCommand, { type: 'init' }>) => {
|
||||
const existingIndex = pending.findIndex((candidate) => candidate.type === 'init')
|
||||
if (existingIndex === -1) {
|
||||
return false
|
||||
}
|
||||
while (pending.length > existingIndex) {
|
||||
removeAt(pending.length - 1)
|
||||
}
|
||||
pending.push(msg)
|
||||
return true
|
||||
}
|
||||
|
||||
const trimMessageCount = () => {
|
||||
while (pending.length > MAX_PENDING_WEB_MESSAGES) {
|
||||
const controlIndex = pending.findIndex(
|
||||
(candidate) => candidate.type !== 'write' && candidate.type !== 'init'
|
||||
)
|
||||
removeAt(Math.max(controlIndex, 0))
|
||||
}
|
||||
}
|
||||
|
||||
const queue = (msg: TerminalWebViewCommand) => {
|
||||
if (msg.type === 'init' && supersedePendingInit(msg)) {
|
||||
return
|
||||
}
|
||||
pending.push(msg)
|
||||
if (msg.type !== 'write') {
|
||||
trimMessageCount()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,9 +35,12 @@ export function createTerminalWebViewPendingMessages() {
|
||||
resetCounters()
|
||||
return
|
||||
}
|
||||
removeAt(dropIndex)
|
||||
const [dropped] = pending.splice(dropIndex, 1)
|
||||
if (dropped?.type === 'write') {
|
||||
pendingWriteBytes = Math.max(0, pendingWriteBytes - dropped.data.length)
|
||||
pendingWriteCount = Math.max(0, pendingWriteCount - 1)
|
||||
}
|
||||
}
|
||||
trimMessageCount()
|
||||
}
|
||||
|
||||
const flush = (send: (msg: TerminalWebViewCommand) => void) => {
|
||||
|
||||
@@ -117,16 +117,6 @@ describe('TerminalWebView scroll routing', () => {
|
||||
expect(source).not.toContain('writeQueue.shift()')
|
||||
})
|
||||
|
||||
it('fails closed when the in-WebView parser backlog reaches its exact bounds', () => {
|
||||
expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000')
|
||||
expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096')
|
||||
expect(source).toContain('export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256')
|
||||
expect(source).toContain('writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS')
|
||||
expect(source).toContain('pendingEntries >= WRITE_QUEUE_MAX_ENTRIES')
|
||||
expect(source).toContain('afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS')
|
||||
expect(source).toContain("reportEngineError('terminal write backlog exceeded safe limit'")
|
||||
})
|
||||
|
||||
it('bounds native-side pending WebView writes while preserving control messages', () => {
|
||||
expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000')
|
||||
expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096')
|
||||
|
||||
@@ -91,8 +91,7 @@ describe('TerminalWebView text zoom', () => {
|
||||
expect(terminalHtmlSource).toContain(
|
||||
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
|
||||
)
|
||||
expect(terminalHtmlSource).toContain('var normalized = normalizeStatusDotPresentation(data)')
|
||||
expect(terminalHtmlSource).toContain('writeQueue.push(normalized)')
|
||||
expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
|
||||
})
|
||||
|
||||
it('normalizes Claude status dots idempotently across write chunks', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTerminalWriteCoalescer,
|
||||
TERMINAL_WRITE_FLUSH_WINDOW_MS,
|
||||
TERMINAL_WRITE_MAX_PENDING_CHUNKS,
|
||||
TERMINAL_WRITE_MAX_PENDING_UNITS
|
||||
} from './terminal-write-coalescer'
|
||||
|
||||
@@ -129,20 +128,6 @@ describe('terminal write coalescer', () => {
|
||||
expect(sink.delivered).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('flushes tiny chunks before their object count can grow unbounded', () => {
|
||||
vi.useFakeTimers()
|
||||
const sink = createDeliverySink()
|
||||
const coalescer = createTerminalWriteCoalescer(sink.deliver)
|
||||
|
||||
coalescer.write('leading')
|
||||
for (let index = 0; index < TERMINAL_WRITE_MAX_PENDING_CHUNKS; index += 1) {
|
||||
coalescer.write('x')
|
||||
}
|
||||
|
||||
expect(sink.delivered).toEqual(['leading', 'x'.repeat(TERMINAL_WRITE_MAX_PENDING_CHUNKS)])
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('treats write("") as a no-op: no delivery, no buffer append, no timer', () => {
|
||||
vi.useFakeTimers()
|
||||
const sink = createDeliverySink()
|
||||
|
||||
@@ -5,7 +5,6 @@ export const TERMINAL_WRITE_FLUSH_WINDOW_MS = 48
|
||||
// Why: defense-in-depth only — server ack flow control bounds inflow; this cap keeps
|
||||
// an upstream flow-control bug from growing the buffer unboundedly. UTF-16 code units.
|
||||
export const TERMINAL_WRITE_MAX_PENDING_UNITS = 512 * 1024
|
||||
export const TERMINAL_WRITE_MAX_PENDING_CHUNKS = 4_096
|
||||
|
||||
export function createTerminalWriteCoalescer(deliver: (data: string) => void) {
|
||||
let pendingChunks: string[] = []
|
||||
@@ -47,10 +46,7 @@ export function createTerminalWriteCoalescer(deliver: (data: string) => void) {
|
||||
}
|
||||
pendingChunks.push(data)
|
||||
pendingUnits += data.length
|
||||
if (
|
||||
pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS ||
|
||||
pendingChunks.length >= TERMINAL_WRITE_MAX_PENDING_CHUNKS
|
||||
) {
|
||||
if (pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS) {
|
||||
flushNow()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { once } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendProcessOutputTail,
|
||||
attachBoundedProcessLineReader,
|
||||
PROCESS_LINE_MAX_BYTES
|
||||
} from '../../scripts/bounded-process-line-reader.mjs'
|
||||
|
||||
describe('attachBoundedProcessLineReader', () => {
|
||||
it('preserves ordinary LF, CRLF, and split UTF-8 lines', () => {
|
||||
const stream = new PassThrough()
|
||||
const lines: string[] = []
|
||||
attachBoundedProcessLineReader(stream, (line) => lines.push(line))
|
||||
|
||||
const unicode = Buffer.from('café\n')
|
||||
stream.write('first\r\nsecond\r')
|
||||
stream.write('\n')
|
||||
stream.write(unicode.subarray(0, -2))
|
||||
stream.end(unicode.subarray(-2))
|
||||
|
||||
expect(lines).toEqual(['first', 'second', 'café'])
|
||||
})
|
||||
|
||||
it('retains only the configured prefix of an unterminated line', async () => {
|
||||
const stream = new PassThrough()
|
||||
const lines: string[] = []
|
||||
attachBoundedProcessLineReader(stream, (line) => lines.push(line), 4)
|
||||
|
||||
const ended = once(stream, 'end')
|
||||
stream.end('abcdefgh')
|
||||
await ended
|
||||
|
||||
expect(lines).toEqual(['abcd… [line truncated]'])
|
||||
})
|
||||
|
||||
it('uses a bounded default for process output lines', () => {
|
||||
expect(PROCESS_LINE_MAX_BYTES).toBe(64 * 1024)
|
||||
})
|
||||
|
||||
it('keeps exact output below the tail cap and newest output above it', () => {
|
||||
expect(appendProcessOutputTail('first\n', 'second', 32)).toBe('first\nsecond\n')
|
||||
expect(appendProcessOutputTail('first\n', 'second', 8)).toBe('\nsecond\n')
|
||||
expect(appendProcessOutputTail('first\n', 'second', 0)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { responseBodyIncludesWithinLimit } from '../../scripts/bounded-response-body.mjs'
|
||||
|
||||
function responseWithChunks(chunks: string[], contentLength?: number): Response {
|
||||
const encoder = new TextEncoder()
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
}),
|
||||
{ headers: contentLength === undefined ? {} : { 'content-length': String(contentLength) } }
|
||||
)
|
||||
}
|
||||
|
||||
describe('responseBodyIncludesWithinLimit', () => {
|
||||
it('matches a marker split across admitted chunks', async () => {
|
||||
const response = responseWithChunks(['packager-', 'status:running'])
|
||||
await expect(
|
||||
responseBodyIncludesWithinLimit(response, 'packager-status:running', 64)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects streamed and declared bodies above the byte limit', async () => {
|
||||
const streamed = responseWithChunks(['1234', '5'])
|
||||
const declared = responseWithChunks(['ok'], 5)
|
||||
|
||||
await expect(responseBodyIncludesWithinLimit(streamed, 'missing', 4)).resolves.toBe(false)
|
||||
await expect(responseBodyIncludesWithinLimit(declared, 'ok', 4)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
|
||||
|
||||
const BROWSER_SCREENCAST_KIND = 0x62
|
||||
const BROWSER_SCREENCAST_VERSION = 1
|
||||
const HEADER_BYTES = 16
|
||||
@@ -53,7 +51,7 @@ function byteToFormat(value: number): BrowserScreencastFormat | null {
|
||||
|
||||
function decodeJson(bytes: Uint8Array): unknown {
|
||||
try {
|
||||
return parseMobileJsonTextWithinLimits(new TextDecoder().decode(bytes))
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CONNECTION_LOG_HOST_ID_MAX_BYTES, createConnectionLogStore } from './connection-log-buffer'
|
||||
import { createConnectionLogStore } from './connection-log-buffer'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
function entry(id: number): ConnectionLogEntry {
|
||||
@@ -53,81 +53,4 @@ describe('connection log buffer', () => {
|
||||
store.append('host-a', entry(2))
|
||||
expect(onA).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('evicts the least-recently-written inactive host past the host cap', () => {
|
||||
const store = createConnectionLogStore(3, 2)
|
||||
store.append('host-a', entry(1))
|
||||
store.append('host-b', entry(2))
|
||||
store.append('host-a', entry(3))
|
||||
store.append('host-c', entry(4))
|
||||
|
||||
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-3'])
|
||||
expect(store.get('host-b')).toEqual([])
|
||||
expect(store.get('host-c').map((e) => e.id)).toEqual(['log-4'])
|
||||
})
|
||||
|
||||
it('drops removed-host entries and refreshes an active snapshot', () => {
|
||||
const store = createConnectionLogStore()
|
||||
const listener = vi.fn()
|
||||
store.append('host-a', entry(1))
|
||||
store.subscribe('host-a', listener)
|
||||
|
||||
store.delete('host-a')
|
||||
|
||||
expect(store.get('host-a')).toEqual([])
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts the exact host-id limit and rejects one more byte', () => {
|
||||
const store = createConnectionLogStore()
|
||||
const exactHost = 'h'.repeat(CONNECTION_LOG_HOST_ID_MAX_BYTES)
|
||||
|
||||
store.append(exactHost, entry(1))
|
||||
store.append(`${exactHost}h`, entry(2))
|
||||
|
||||
expect(store.get(exactHost).map((value) => value.id)).toEqual(['log-1'])
|
||||
expect(store.get(`${exactHost}h`)).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts an exact entry-byte budget and rejects one more byte', () => {
|
||||
const store = createConnectionLogStore(10, 10, {
|
||||
maxEntryBytes: 260,
|
||||
maxHostBytes: 1024,
|
||||
maxStoreBytes: 2048
|
||||
})
|
||||
const exact: ConnectionLogEntry = { id: '', ts: 1, level: 'info', message: '' }
|
||||
|
||||
store.append('host-a', exact)
|
||||
store.append('host-a', { ...exact, message: 'x' })
|
||||
|
||||
expect(store.get('host-a')).toEqual([exact])
|
||||
})
|
||||
|
||||
it('keeps newest entries within the per-host byte budget', () => {
|
||||
const store = createConnectionLogStore(10, 10, {
|
||||
maxEntryBytes: 1024,
|
||||
maxHostBytes: 700,
|
||||
maxStoreBytes: 4096
|
||||
})
|
||||
|
||||
store.append('host-a', entry(1))
|
||||
store.append('host-a', entry(2))
|
||||
store.append('host-a', entry(3))
|
||||
|
||||
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-2', 'log-3'])
|
||||
})
|
||||
|
||||
it('evicts inactive hosts to stay within the aggregate byte budget', () => {
|
||||
const store = createConnectionLogStore(10, 10, {
|
||||
maxEntryBytes: 1024,
|
||||
maxHostBytes: 1024,
|
||||
maxStoreBytes: 700
|
||||
})
|
||||
|
||||
store.append('host-a', entry(1))
|
||||
store.append('host-b', entry(2))
|
||||
|
||||
expect(store.get('host-a')).toEqual([])
|
||||
expect(store.get('host-b').map((value) => value.id)).toEqual(['log-2'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
|
||||
|
||||
// Why: the rpc-client's onLog entries were only wired during pairing; for
|
||||
// long-lived host connections everything went to console.log, invisible to
|
||||
@@ -9,159 +8,35 @@ import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
|
||||
// swaps (forceReconnect) and provider remounts (hot reload); bounded so an
|
||||
// all-night reconnect loop can't grow memory unbounded.
|
||||
const MAX_ENTRIES_PER_HOST = 200
|
||||
const MAX_RETAINED_HOSTS = 128
|
||||
export const CONNECTION_LOG_HOST_ID_MAX_BYTES = 4 * 1024
|
||||
export const CONNECTION_LOG_ENTRY_MAX_BYTES = 64 * 1024
|
||||
export const CONNECTION_LOG_HOST_MAX_RETAINED_BYTES = 256 * 1024
|
||||
export const CONNECTION_LOG_STORE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
|
||||
const CONNECTION_LOG_MAX_LISTENERS_PER_HOST = 16
|
||||
|
||||
type RetainedConnectionLogEntry = {
|
||||
entry: ConnectionLogEntry
|
||||
bytes: number
|
||||
}
|
||||
|
||||
type RetainedHostLog = {
|
||||
entries: RetainedConnectionLogEntry[]
|
||||
bytes: number
|
||||
}
|
||||
|
||||
type ConnectionLogByteLimits = {
|
||||
maxEntryBytes: number
|
||||
maxHostBytes: number
|
||||
maxStoreBytes: number
|
||||
}
|
||||
|
||||
function measureBoundedString(value: unknown, maxBytes: number): number | null {
|
||||
if (value === undefined) {
|
||||
return 0
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const measurement = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes })
|
||||
return measurement.exceededLimit ? null : measurement.byteLength
|
||||
}
|
||||
|
||||
function measureConnectionLogEntry(entry: ConnectionLogEntry, maxBytes: number): number | null {
|
||||
let bytes = 256
|
||||
for (const value of [entry.id, entry.level, entry.message, entry.detail]) {
|
||||
const valueBytes = measureBoundedString(value, maxBytes - bytes)
|
||||
if (valueBytes === null) {
|
||||
return null
|
||||
}
|
||||
bytes += valueBytes
|
||||
}
|
||||
return bytes <= maxBytes ? bytes : null
|
||||
}
|
||||
|
||||
export type ConnectionLogStore = {
|
||||
append: (hostId: string, entry: ConnectionLogEntry) => void
|
||||
get: (hostId: string) => readonly ConnectionLogEntry[]
|
||||
subscribe: (hostId: string, listener: () => void) => () => void
|
||||
delete: (hostId: string) => void
|
||||
}
|
||||
|
||||
export function createConnectionLogStore(
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
|
||||
maxRetainedHosts: number = MAX_RETAINED_HOSTS,
|
||||
byteLimits: Partial<ConnectionLogByteLimits> = {}
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
|
||||
): ConnectionLogStore {
|
||||
const maxEntryBytes = Math.min(
|
||||
byteLimits.maxEntryBytes ?? CONNECTION_LOG_ENTRY_MAX_BYTES,
|
||||
CONNECTION_LOG_ENTRY_MAX_BYTES
|
||||
)
|
||||
const maxHostBytes = Math.min(
|
||||
byteLimits.maxHostBytes ?? CONNECTION_LOG_HOST_MAX_RETAINED_BYTES,
|
||||
CONNECTION_LOG_HOST_MAX_RETAINED_BYTES
|
||||
)
|
||||
const maxStoreBytes = Math.min(
|
||||
byteLimits.maxStoreBytes ?? CONNECTION_LOG_STORE_MAX_RETAINED_BYTES,
|
||||
CONNECTION_LOG_STORE_MAX_RETAINED_BYTES
|
||||
)
|
||||
const entriesByHost = new Map<string, RetainedHostLog>()
|
||||
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
|
||||
const listenersByHost = new Map<string, Set<() => void>>()
|
||||
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
|
||||
// must return the SAME array until the data actually changes, or React
|
||||
// loops re-rendering. Cache per host; invalidate on append.
|
||||
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
|
||||
const EMPTY: readonly ConnectionLogEntry[] = []
|
||||
let retainedStoreBytes = 0
|
||||
|
||||
const deleteHost = (hostId: string): boolean => {
|
||||
const host = entriesByHost.get(hostId)
|
||||
if (!host) {
|
||||
return false
|
||||
}
|
||||
entriesByHost.delete(hostId)
|
||||
retainedStoreBytes -= host.bytes
|
||||
snapshotByHost.delete(hostId)
|
||||
return true
|
||||
}
|
||||
|
||||
const evictOldestUnobservedHost = (exceptHostId?: string): boolean => {
|
||||
for (const hostId of entriesByHost.keys()) {
|
||||
if (hostId !== exceptHostId && !listenersByHost.has(hostId)) {
|
||||
return deleteHost(hostId)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
append(hostId, entry) {
|
||||
const hostIdBytes = measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES)
|
||||
const entryBytes = measureConnectionLogEntry(entry, maxEntryBytes)
|
||||
if (hostIdBytes === null || entryBytes === null || entryBytes > maxHostBytes) {
|
||||
return
|
||||
let entries = entriesByHost.get(hostId)
|
||||
if (!entries) {
|
||||
entries = []
|
||||
entriesByHost.set(hostId, entries)
|
||||
}
|
||||
let host = entriesByHost.get(hostId)
|
||||
if (!host) {
|
||||
while (entriesByHost.size >= maxRetainedHosts) {
|
||||
if (!evictOldestUnobservedHost()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const retainedHostKeyBytes = hostIdBytes + 128
|
||||
while (retainedStoreBytes + retainedHostKeyBytes + entryBytes > maxStoreBytes) {
|
||||
if (!evictOldestUnobservedHost()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
host = { entries: [], bytes: retainedHostKeyBytes }
|
||||
entriesByHost.set(hostId, host)
|
||||
retainedStoreBytes += retainedHostKeyBytes
|
||||
} else {
|
||||
entriesByHost.delete(hostId)
|
||||
entriesByHost.set(hostId, host)
|
||||
entries.push(entry)
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
while (
|
||||
host.entries.length >= maxEntriesPerHost ||
|
||||
host.bytes + entryBytes > maxHostBytes ||
|
||||
retainedStoreBytes + entryBytes > maxStoreBytes
|
||||
) {
|
||||
const oldest = host.entries.shift()
|
||||
if (oldest) {
|
||||
host.bytes -= oldest.bytes
|
||||
retainedStoreBytes -= oldest.bytes
|
||||
continue
|
||||
}
|
||||
if (!evictOldestUnobservedHost(hostId)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (
|
||||
host.bytes + entryBytes > maxHostBytes ||
|
||||
retainedStoreBytes + entryBytes > maxStoreBytes
|
||||
) {
|
||||
if (host.entries.length === 0) {
|
||||
deleteHost(hostId)
|
||||
}
|
||||
return
|
||||
}
|
||||
host.entries.push({ entry, bytes: entryBytes })
|
||||
host.bytes += entryBytes
|
||||
retainedStoreBytes += entryBytes
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
@@ -176,30 +51,21 @@ export function createConnectionLogStore(
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const host = entriesByHost.get(hostId)
|
||||
if (!host || host.entries.length === 0) {
|
||||
const entries = entriesByHost.get(hostId)
|
||||
if (!entries || entries.length === 0) {
|
||||
return EMPTY
|
||||
}
|
||||
const snapshot = Object.freeze(host.entries.map((retained) => retained.entry))
|
||||
const snapshot = Object.freeze([...entries])
|
||||
snapshotByHost.set(hostId, snapshot)
|
||||
return snapshot
|
||||
},
|
||||
|
||||
subscribe(hostId, listener) {
|
||||
if (measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) === null) {
|
||||
return () => {}
|
||||
}
|
||||
let listeners = listenersByHost.get(hostId)
|
||||
if (!listeners) {
|
||||
if (listenersByHost.size >= maxRetainedHosts) {
|
||||
return () => {}
|
||||
}
|
||||
listeners = new Set()
|
||||
listenersByHost.set(hostId, listeners)
|
||||
}
|
||||
if (listeners.size >= CONNECTION_LOG_MAX_LISTENERS_PER_HOST) {
|
||||
return () => {}
|
||||
}
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
const set = listenersByHost.get(hostId)
|
||||
@@ -211,16 +77,6 @@ export function createConnectionLogStore(
|
||||
listenersByHost.delete(hostId)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
delete(hostId) {
|
||||
deleteHost(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
// stream frames use the raw byte bundle.
|
||||
import nacl from 'tweetnacl'
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
|
||||
|
||||
export const MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS =
|
||||
Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4
|
||||
|
||||
// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues,
|
||||
// which tweetnacl requires. expo-crypto provides a native secure RNG
|
||||
@@ -43,14 +39,8 @@ function uint8ToBase64(bytes: Uint8Array): string {
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array {
|
||||
if (b64.length > Math.ceil(maxDecodedBytes / 3) * 4) {
|
||||
throw new Error('Base64 payload exceeds safe size')
|
||||
}
|
||||
function base64ToUint8(b64: string): Uint8Array {
|
||||
const binary = atob(b64)
|
||||
if (binary.length > maxDecodedBytes) {
|
||||
throw new Error('Base64 payload exceeds safe size')
|
||||
}
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
@@ -59,7 +49,7 @@ function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array {
|
||||
}
|
||||
|
||||
export function publicKeyFromBase64(b64: string): Uint8Array {
|
||||
const key = base64ToUint8(b64, 32)
|
||||
const key = base64ToUint8(b64)
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."`
|
||||
@@ -78,10 +68,7 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
|
||||
}
|
||||
|
||||
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
|
||||
if (encrypted.length > MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS) {
|
||||
throw new Error('Base64 payload exceeds safe size')
|
||||
}
|
||||
const bundle = base64ToUint8(encrypted, MOBILE_INBOUND_MAX_FRAME_BYTES)
|
||||
const bundle = base64ToUint8(encrypted)
|
||||
const plaintext = decryptBytes(bundle, sharedKey)
|
||||
return plaintext ? new TextDecoder().decode(plaintext) : null
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HostClientOpenRegistry } from './host-client-open-registry'
|
||||
|
||||
describe('HostClientOpenRegistry', () => {
|
||||
it('cancels replaced and oldest tickets while bounding pending hosts', () => {
|
||||
const registry = new HostClientOpenRegistry(2)
|
||||
const first = registry.register('host-a', new Promise<void>(() => {}))
|
||||
const replacement = registry.register('host-a', new Promise<void>(() => {}))
|
||||
const second = registry.register('host-b', new Promise<void>(() => {}))
|
||||
const third = registry.register('host-c', new Promise<void>(() => {}))
|
||||
|
||||
expect(first.cancelled).toBe(true)
|
||||
expect(replacement.cancelled).toBe(true)
|
||||
expect(second.cancelled).toBe(false)
|
||||
expect(third.cancelled).toBe(false)
|
||||
expect(registry.getActivePromise('host-a')).toBeNull()
|
||||
expect(registry.getActivePromise('host-b')).toBe(second.promise)
|
||||
expect(registry.getActivePromise('host-c')).toBe(third.promise)
|
||||
})
|
||||
})
|
||||
@@ -3,35 +3,15 @@ export type HostClientOpenTicket = {
|
||||
promise: Promise<void>
|
||||
}
|
||||
|
||||
export const HOST_CLIENT_OPEN_MAX_PENDING = 64
|
||||
|
||||
export class HostClientOpenRegistry {
|
||||
private readonly pending = new Map<string, HostClientOpenTicket>()
|
||||
|
||||
constructor(private readonly maxPending = HOST_CLIENT_OPEN_MAX_PENDING) {}
|
||||
|
||||
getActivePromise(hostId: string): Promise<void> | null {
|
||||
const ticket = this.pending.get(hostId)
|
||||
return ticket && !ticket.cancelled ? ticket.promise : null
|
||||
}
|
||||
|
||||
register(hostId: string, promise: Promise<void>): HostClientOpenTicket {
|
||||
const prior = this.pending.get(hostId)
|
||||
if (prior) {
|
||||
prior.cancelled = true
|
||||
this.pending.delete(hostId)
|
||||
}
|
||||
while (this.pending.size >= Math.max(1, this.maxPending)) {
|
||||
const oldestHostId = this.pending.keys().next().value as string | undefined
|
||||
if (oldestHostId === undefined) {
|
||||
break
|
||||
}
|
||||
const oldest = this.pending.get(oldestHostId)
|
||||
if (oldest) {
|
||||
oldest.cancelled = true
|
||||
}
|
||||
this.pending.delete(oldestHostId)
|
||||
}
|
||||
const ticket = { cancelled: false, promise }
|
||||
this.pending.set(hostId, ticket)
|
||||
return ticket
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES,
|
||||
HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS,
|
||||
loadPendingHostCredentialCleanup,
|
||||
loadPendingHostCredentialCleanupIds,
|
||||
resetHostCredentialCleanupForTests,
|
||||
@@ -272,41 +270,4 @@ describe('host credential cleanup', () => {
|
||||
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('accepts the exact pending-id cap and treats one over as unreadable', async () => {
|
||||
storedPendingIds = Array.from(
|
||||
{ length: HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS },
|
||||
(_, index) => `host-${index}`
|
||||
)
|
||||
|
||||
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
|
||||
ids: storedPendingIds,
|
||||
storageUnreadable: false
|
||||
})
|
||||
|
||||
storedPendingIds = [...storedPendingIds, 'one-over']
|
||||
await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
|
||||
ids: [],
|
||||
storageUnreadable: true
|
||||
})
|
||||
})
|
||||
|
||||
it('caps simultaneous native deletes while preserving excess durable intents', async () => {
|
||||
vi.useFakeTimers()
|
||||
const deleteCredential = vi.fn(() => new Promise<void>(() => undefined))
|
||||
const hostCount = HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES + 1
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: hostCount }, (_, index) =>
|
||||
scheduleHostCredentialCleanup(`host-${index}`, deleteCredential, 3_000)
|
||||
)
|
||||
)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(deleteCredential).toHaveBeenCalledTimes(HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES)
|
||||
expect(storedPendingIds).toHaveLength(hostCount)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await expect(loadPendingHostCredentialCleanupIds()).resolves.toHaveLength(hostCount)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
|
||||
|
||||
const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups'
|
||||
const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000
|
||||
export const HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS = 256
|
||||
export const HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS = 256 * 1024
|
||||
export const HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS = 4_096
|
||||
export const HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES = 16
|
||||
|
||||
type DeleteHostCredential = (hostId: string) => Promise<void>
|
||||
type CleanupAttemptResult = 'cleared' | 'pending'
|
||||
@@ -38,11 +33,7 @@ function notifyPendingListeners(): void {
|
||||
}
|
||||
|
||||
function markUnrecordedPending(hostId: string): void {
|
||||
if (
|
||||
unrecordedPendingIds.has(hostId) ||
|
||||
!isTrackableHostId(hostId) ||
|
||||
unrecordedPendingIds.size >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS
|
||||
) {
|
||||
if (unrecordedPendingIds.has(hostId)) {
|
||||
return
|
||||
}
|
||||
unrecordedPendingIds.add(hostId)
|
||||
@@ -56,55 +47,17 @@ function clearUnrecordedPending(hostId: string): void {
|
||||
}
|
||||
|
||||
function parsePendingIds(raw: string): string[] | null {
|
||||
if (raw.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = parseMobileJsonTextWithinLimits(raw)
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const ids: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const value of parsed) {
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
if (!isTrackableHostId(value)) {
|
||||
return null
|
||||
}
|
||||
if (!seen.has(value)) {
|
||||
if (ids.length >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS) {
|
||||
return null
|
||||
}
|
||||
seen.add(value)
|
||||
ids.push(value)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isTrackableHostId(hostId: string): boolean {
|
||||
return hostId.length > 0 && hostId.length <= HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS
|
||||
}
|
||||
|
||||
function serializePendingIds(ids: string[]): string {
|
||||
if (
|
||||
ids.length > HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS ||
|
||||
ids.some((id) => !isTrackableHostId(id))
|
||||
) {
|
||||
throw new Error('pending host credential cleanup limit exceeded')
|
||||
}
|
||||
const serialized = JSON.stringify(ids)
|
||||
if (serialized.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) {
|
||||
throw new Error('pending host credential cleanup storage limit exceeded')
|
||||
}
|
||||
return serialized
|
||||
}
|
||||
|
||||
function sameIdList(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((id, index) => id === b[index])
|
||||
}
|
||||
@@ -150,7 +103,7 @@ async function mutatePendingIds(update: (ids: string[]) => string[]): Promise<vo
|
||||
if (sameIdList(current.ids, next)) {
|
||||
return
|
||||
}
|
||||
await AsyncStorage.setItem(PENDING_STORAGE_KEY, serializePendingIds(next))
|
||||
await AsyncStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(next))
|
||||
notifyPendingListeners()
|
||||
})
|
||||
pendingMutation = mutation.catch(() => {})
|
||||
@@ -189,9 +142,6 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
if (inflightDeletes.size >= HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) {
|
||||
return Promise.reject(new Error('host credential cleanup concurrency limit reached'))
|
||||
}
|
||||
const cleanup = Promise.resolve()
|
||||
.then(() => deleteCredential(hostId))
|
||||
.finally(() => {
|
||||
@@ -204,9 +154,6 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
|
||||
}
|
||||
|
||||
async function recordCleanupIntent(hostId: string): Promise<boolean> {
|
||||
if (!isTrackableHostId(hostId)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await addPendingId(hostId)
|
||||
return true
|
||||
@@ -287,10 +234,10 @@ export async function retryPendingHostCredentialCleanups(
|
||||
deleteCredential: DeleteHostCredential
|
||||
): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> {
|
||||
const pending = await loadPendingCleanupState()
|
||||
const outcomes = await retryCleanupIds(
|
||||
pending.ids,
|
||||
deleteCredential,
|
||||
HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES
|
||||
const outcomes = await Promise.all(
|
||||
// Why: these ids are already durable (or a session-scoped fallback). Re-adding
|
||||
// intent can race a late success and recreate a ghost row after deletion.
|
||||
pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS))
|
||||
)
|
||||
const remaining = await loadPendingCleanupState()
|
||||
return {
|
||||
@@ -300,29 +247,6 @@ export async function retryPendingHostCredentialCleanups(
|
||||
}
|
||||
}
|
||||
|
||||
async function retryCleanupIds(
|
||||
ids: string[],
|
||||
deleteCredential: DeleteHostCredential,
|
||||
concurrency: number
|
||||
): Promise<CleanupAttemptResult[]> {
|
||||
const outcomes: CleanupAttemptResult[] = []
|
||||
let nextIndex = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < ids.length) {
|
||||
const index = nextIndex++
|
||||
// Why: these ids already have recovery intent; re-adding can race a late
|
||||
// success and recreate a ghost row after deletion.
|
||||
outcomes[index] = await confirmNativeCleanup(
|
||||
ids[index]!,
|
||||
deleteCredential,
|
||||
CLEANUP_CONFIRM_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()))
|
||||
return outcomes
|
||||
}
|
||||
|
||||
/** Test-only: drop module listeners/in-flight state between cases. */
|
||||
export function resetHostCredentialCleanupForTests(): void {
|
||||
inflightDeletes.clear()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user