mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(mobile-web): serve page-safe file searches and text reads from desktop
Reuse host file methods behind Desktop-owned identity redaction and move list/text presentation into the hosted page. Preserve older shells and Desktop versions through legacy fallback, without changing the generic bridge contract.
This commit is contained in:
@@ -21,6 +21,7 @@ pages; no protocol or manifest bump is planned.
|
||||
- [x] First complete generic unary slice: `9910fccc298`.
|
||||
Desktop catalog, opaque workspace binding, source-control status/diff,
|
||||
page-side presentation, legacy fallback, hard payload and concurrency bounds.
|
||||
- [x] Directory and binary chunk reads use generic forwarding: `a8bbed52da4`.
|
||||
- [ ] Complete the generic bridge and migrate remaining domain consumers.
|
||||
- [ ] Complete iOS end-to-end evidence and Android final smoke check.
|
||||
|
||||
@@ -157,8 +158,11 @@ pnpm test src/shared/mobile-web src/mobile-web src/main/runtime/rpc
|
||||
pnpm run build:mobile-web
|
||||
```
|
||||
|
||||
Run mobile tests separately from the web export: a previous overlapping run
|
||||
failed React Native resolution while an isolated rerun passed. Build the terminal
|
||||
Run mobile tests and simulator Metro separately from the web export. The root
|
||||
`build:mobile-web-rnw` script runs `pnpm --dir mobile install --frozen-lockfile`,
|
||||
which replaces dependency directories and can invalidate a live Metro resolver.
|
||||
A previous overlapping mobile test run also failed React Native resolution while
|
||||
an isolated rerun passed. Build the terminal
|
||||
WebView engine if mobile typechecking needs it. Run Kotlin unit tests with the
|
||||
configured JDK 17/Android SDK; prebuild Android when required. Run native Swift
|
||||
store tests when native package/CSP behavior changes. Format only changed files
|
||||
@@ -187,3 +191,24 @@ with `pnpm exec oxfmt --write`.
|
||||
Exact command tails: `/tmp/orca-ota-e2e/file-gates/`.
|
||||
- iOS retry reached native compilation but failed at React-RCTFabric
|
||||
`RCTFabricSurface.mm`; capturing full compiler diagnostics before proceeding.
|
||||
|
||||
- iOS native build now passes after regenerating Pods and replacing stale derived
|
||||
compiler caches. The old derived data is preserved at
|
||||
`/tmp/orca-ota-e2e/stale-ios-derived-data`; no dependency source patches needed.
|
||||
Full successful log: `/tmp/orca-ota-e2e/ios-native-build-clean.log`.
|
||||
- Running the existing iOS adversarial-content journey (which includes source
|
||||
control) on that build, with `--skip-native-build`, in
|
||||
`/tmp/orca-ota-e2e/ios-journey`. Pairing runtime started and Metro is loading.
|
||||
- In progress: file list/search and text reads via Desktop privacy adapters
|
||||
`mobileWeb.files.searchPaths` / `mobileWeb.files.read`. They reuse existing
|
||||
host methods and remove private workspace/root fields before forwarding.
|
||||
Future result fields remain available to the page; native request contract
|
||||
stays unchanged. Focused tests pass; full gates running.
|
||||
|
||||
- File list/search/text migration passes every required gate: mobile 831 files /
|
||||
5,493 tests; root 317 files / 2,699 tests. Page build:
|
||||
`8143c37da629651d2e672268423846e8d44fbf33e90637f4436791b4a33e7a53`,
|
||||
52 assets / 9,688,231 bytes. Logs: `/tmp/orca-ota-e2e/file-text-gates/`.
|
||||
- First iOS hosted run paired successfully, then page export's dependency
|
||||
reinstall invalidated the live Metro resolver (`InitializeCore` not found).
|
||||
Retired that launcher; rerun after all builds, with no concurrent install/export.
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { sanitizeListResult } from '../../../src/shared/mobile-web/file-list-presentation'
|
||||
import { Buffer } from 'buffer/'
|
||||
import {
|
||||
MOBILE_WEB_FILE_CONTENT_MAX_BYTES,
|
||||
MobileWebFileChunkPayloadSchema,
|
||||
MobileWebFileDirectoryPayloadSchema,
|
||||
MobileWebFileEntrySchema,
|
||||
MobileWebFileListPayloadSchema,
|
||||
MobileWebFileListResultSchema,
|
||||
MobileWebFileOpenPayloadSchema,
|
||||
MobileWebFileReadPayloadSchema,
|
||||
MobileWebFileReadResultSchema,
|
||||
MobileWebFileSearchPayloadSchema,
|
||||
type MobileWebFileChunkWireResult,
|
||||
type MobileWebFileDirectoryResult,
|
||||
type MobileWebFileEntry,
|
||||
type MobileWebFileListResult,
|
||||
type MobileWebFileReadWireResult
|
||||
} from '../../../src/shared/mobile-web/bridge-operation-contract'
|
||||
@@ -143,41 +141,6 @@ async function listFiles(
|
||||
return sanitizeListResult(response.result, pageWorkspaceId, hostWorkspaceId, limit)
|
||||
}
|
||||
|
||||
function sanitizeListResult(
|
||||
result: unknown,
|
||||
pageWorkspaceId: string,
|
||||
hostWorkspaceId: string,
|
||||
limit: number
|
||||
): MobileWebFileListResult {
|
||||
if (!isRecord(result) || result.worktree !== hostWorkspaceId || !Array.isArray(result.files)) {
|
||||
throw new MobileWebBrokerError('host_error')
|
||||
}
|
||||
const files = result.files.slice(0, limit).flatMap((value): MobileWebFileEntry[] => {
|
||||
if (!isRecord(value) || typeof value.relativePath !== 'string') {
|
||||
return []
|
||||
}
|
||||
const parsed = MobileWebFileEntrySchema.safeParse({
|
||||
relativePath: value.relativePath,
|
||||
basename: value.relativePath.split('/').at(-1)?.slice(0, 255),
|
||||
kind: value.kind === 'binary' ? 'binary' : 'text'
|
||||
})
|
||||
return parsed.success ? [parsed.data] : []
|
||||
})
|
||||
const totalCount =
|
||||
typeof result.totalCount === 'number' &&
|
||||
Number.isSafeInteger(result.totalCount) &&
|
||||
result.totalCount >= 0
|
||||
? result.totalCount
|
||||
: result.files.length
|
||||
return MobileWebFileListResultSchema.parse({
|
||||
workspaceId: pageWorkspaceId,
|
||||
files,
|
||||
totalCount,
|
||||
truncated:
|
||||
result.truncated === true || result.files.length > files.length || totalCount > files.length
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeReadResult(
|
||||
result: unknown,
|
||||
pageWorkspaceId: string,
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UPDATER_METHODS } from './updater'
|
||||
import { AGENT_SESSION_METHODS } from './agent-session'
|
||||
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
|
||||
import { ARTIFACT_METHODS } from './artifacts'
|
||||
import { MOBILE_WEB_FILE_READ_METHODS } from './mobile-web-file-reads'
|
||||
import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog'
|
||||
import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package'
|
||||
import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged'
|
||||
@@ -103,5 +104,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...PAIRING_METHODS,
|
||||
...UPDATER_METHODS,
|
||||
MOBILE_WEB_HOST_CATALOG_METHOD,
|
||||
...MOBILE_WEB_FILE_READ_METHODS,
|
||||
...MOBILE_WEB_PACKAGE_METHODS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_WEB_FILE_READ_METHODS } from './mobile-web-file-reads'
|
||||
import { FILE_METHODS } from './files'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('page-safe host file reads', () => {
|
||||
it.each(['searchPaths', 'read'])(
|
||||
'reuses files.%s while keeping host identity off the page',
|
||||
async (operation) => {
|
||||
const source = FILE_METHODS.find((method) => method.name === `files.${operation}`)!
|
||||
const method = MOBILE_WEB_FILE_READ_METHODS.find(
|
||||
(entry) => entry.name === `mobileWeb.files.${operation}`
|
||||
)!
|
||||
const handler = vi.spyOn(source, 'handler').mockResolvedValue({
|
||||
worktree: 'host-workspace-id',
|
||||
rootPath: '/private/host/repository',
|
||||
relativePath: 'docs/readme.md',
|
||||
content: 'hello',
|
||||
files: [],
|
||||
futureField: { kind: 'new-domain-shape' }
|
||||
})
|
||||
const context = { signal: new AbortController().signal } as RpcContext
|
||||
const params = {
|
||||
worktree: 'id:host-workspace-id',
|
||||
relativePath: 'docs/readme.md',
|
||||
query: 'docs'
|
||||
}
|
||||
expect(method.params).toBe(source.params)
|
||||
const result = await method.handler(params, context)
|
||||
expect(handler).toHaveBeenCalledWith(params, context)
|
||||
expect(result).toEqual({
|
||||
relativePath: 'docs/readme.md',
|
||||
content: 'hello',
|
||||
files: [],
|
||||
futureField: { kind: 'new-domain-shape' }
|
||||
})
|
||||
expect(JSON.stringify(result)).not.toMatch(/host-workspace-id|private\/host/)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves an execution-host failure instead of answering locally', async () => {
|
||||
const source = FILE_METHODS.find((method) => method.name === 'files.read')!
|
||||
vi.spyOn(source, 'handler').mockRejectedValue(new Error('SSH provider unavailable'))
|
||||
const method = MOBILE_WEB_FILE_READ_METHODS.find(
|
||||
(entry) => entry.name === 'mobileWeb.files.read'
|
||||
)!
|
||||
await expect(
|
||||
method.handler({ worktree: 'id:remote', relativePath: 'a.txt' }, {} as RpcContext)
|
||||
).rejects.toThrow('SSH provider unavailable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineMethod, isStreamingMethod } from '../core'
|
||||
import { FILE_METHODS } from './files'
|
||||
|
||||
// Desktop owns identity redaction; installed shells need no file-result vocabulary.
|
||||
export const MOBILE_WEB_FILE_READ_METHODS = ['searchPaths', 'read'].map((operation) => {
|
||||
const source = FILE_METHODS.find((method) => method.name === `files.${operation}`)
|
||||
if (!source || isStreamingMethod(source)) {
|
||||
throw new Error(`Missing unary file method: ${operation}`)
|
||||
}
|
||||
return defineMethod({
|
||||
name: `mobileWeb.files.${operation}`,
|
||||
params: source.params,
|
||||
handler: async (params, context) => {
|
||||
const result = await source.handler(params, context)
|
||||
if (typeof result !== 'object' || result === null || Array.isArray(result)) {
|
||||
throw new Error('Invalid file read result')
|
||||
}
|
||||
const pageResult: Record<string, unknown> = { ...result }
|
||||
delete pageResult.worktree
|
||||
delete pageResult.rootPath
|
||||
return pageResult
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ describe('mobile web host catalog', () => {
|
||||
'git.diff',
|
||||
'files.readDir',
|
||||
'files.readChunk',
|
||||
'mobileWeb.files.searchPaths',
|
||||
'mobileWeb.files.read',
|
||||
'git.status',
|
||||
'pairing.getEndpoints',
|
||||
'files.searchPaths',
|
||||
@@ -21,14 +23,28 @@ describe('mobile web host catalog', () => {
|
||||
{} as RpcContext
|
||||
)
|
||||
expect(result).toEqual({
|
||||
grants: ['git.status', 'git.diff', 'files.readDir', 'files.readChunk'].map((method) => ({
|
||||
grants: [
|
||||
'git.status',
|
||||
'git.diff',
|
||||
'files.readDir',
|
||||
'files.readChunk',
|
||||
'mobileWeb.files.searchPaths',
|
||||
'mobileWeb.files.read'
|
||||
].map((method) => ({
|
||||
method,
|
||||
workspaceParam: 'worktree',
|
||||
maxRequestBytes: 16 * 1024,
|
||||
maxResponseBytes: 512 * 1024
|
||||
}))
|
||||
})
|
||||
for (const method of ['git.status', 'git.diff', 'files.readDir', 'files.readChunk']) {
|
||||
for (const method of [
|
||||
'git.status',
|
||||
'git.diff',
|
||||
'files.readDir',
|
||||
'files.readChunk',
|
||||
'mobileWeb.files.searchPaths',
|
||||
'mobileWeb.files.read'
|
||||
]) {
|
||||
expect(ALL_RPC_METHODS.some((entry) => entry.name === method)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,14 @@ import { defineMethod } from '../core'
|
||||
|
||||
// Only page-safe results belong here; transport credentials never enter this catalog.
|
||||
const PAGE_METHODS = new Map(
|
||||
['git.status', 'git.diff', 'files.readDir', 'files.readChunk'].map((method) => [
|
||||
[
|
||||
'git.status',
|
||||
'git.diff',
|
||||
'files.readDir',
|
||||
'files.readChunk',
|
||||
'mobileWeb.files.searchPaths',
|
||||
'mobileWeb.files.read'
|
||||
].map((method) => [
|
||||
method,
|
||||
{
|
||||
method,
|
||||
|
||||
@@ -32,6 +32,9 @@ function fixture(generic = true) {
|
||||
context,
|
||||
grants: [
|
||||
{ capability: 'file', operation: 'directory', limits },
|
||||
{ capability: 'file', operation: 'list', limits },
|
||||
{ capability: 'file', operation: 'search', limits },
|
||||
{ capability: 'file', operation: 'read', limits },
|
||||
{ capability: 'file', operation: 'readChunk', limits },
|
||||
...(generic ? [{ capability: 'workspace' as const, operation: 'hostRequest', limits }] : [])
|
||||
],
|
||||
@@ -140,3 +143,75 @@ describe('page-owned generic file reads', () => {
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('page-safe file listing and text', () => {
|
||||
it('searches through the host privacy adapter using an opaque workspace', async () => {
|
||||
const { client, messages, respond } = fixture()
|
||||
const result = client.fileSearch({
|
||||
workspaceId: directory.workspaceId,
|
||||
query: 'report',
|
||||
limit: 10
|
||||
})
|
||||
expect(messages[0]).toMatchObject({
|
||||
operation: 'hostRequest',
|
||||
payload: {
|
||||
method: 'mobileWeb.files.searchPaths',
|
||||
workspaceId: directory.workspaceId,
|
||||
params: { query: 'report', limit: 10 }
|
||||
}
|
||||
})
|
||||
respond({
|
||||
files: [{ relativePath: 'docs/report.md', kind: 'text' }],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
futureField: true
|
||||
})
|
||||
await expect(result).resolves.toEqual({
|
||||
workspaceId: directory.workspaceId,
|
||||
files: [{ relativePath: 'docs/report.md', basename: 'report.md', kind: 'text' }],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
})
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('reads Unicode content directly without a shell base64 projection', async () => {
|
||||
const { client, messages, respond } = fixture()
|
||||
const result = client.fileRead({
|
||||
workspaceId: directory.workspaceId,
|
||||
relativePath: 'report.txt'
|
||||
})
|
||||
expect(messages[0]).toMatchObject({
|
||||
operation: 'hostRequest',
|
||||
payload: { method: 'mobileWeb.files.read' }
|
||||
})
|
||||
const content = '\uFEFFhello 🌍'
|
||||
const byteLength = new TextEncoder().encode(content).byteLength
|
||||
respond({
|
||||
relativePath: 'report.txt',
|
||||
content,
|
||||
truncated: false,
|
||||
byteLength,
|
||||
futureField: 'new'
|
||||
})
|
||||
await expect(result).resolves.toEqual({
|
||||
workspaceId: directory.workspaceId,
|
||||
relativePath: 'report.txt',
|
||||
content,
|
||||
truncated: false,
|
||||
byteLength
|
||||
})
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('rejects inconsistent content size', async () => {
|
||||
const { client, respond } = fixture()
|
||||
const result = client.fileRead({
|
||||
workspaceId: directory.workspaceId,
|
||||
relativePath: 'report.txt'
|
||||
})
|
||||
respond({ relativePath: 'report.txt', content: '🌍', truncated: false, byteLength: 1 })
|
||||
await expect(result).rejects.toMatchObject({ code: 'invalid_message' })
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ export class MobileWebFileReadClient {
|
||||
)
|
||||
}
|
||||
|
||||
private readHost<T>(
|
||||
protected readHost<T>(
|
||||
method: string,
|
||||
workspaceId: string,
|
||||
params: Record<string, unknown>,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { sanitizeListResult } from '../../shared/mobile-web/file-list-presentation'
|
||||
import { projectMobileWebHostFileContent } from './mobile-web-host-file-content'
|
||||
import {
|
||||
MOBILE_WEB_FILE_CHUNK_MAX_BYTES,
|
||||
MobileWebFileListPayloadSchema,
|
||||
@@ -46,49 +48,85 @@ export class MobileWebFileRequestClient extends MobileWebFileReadClient {
|
||||
payload: MobileWebFileListPayload,
|
||||
options?: MobileWebBridgeRequestOptions
|
||||
): Promise<MobileWebFileListResult> {
|
||||
return this.requests
|
||||
.request(
|
||||
'file',
|
||||
'list',
|
||||
payload,
|
||||
MobileWebFileListPayloadSchema,
|
||||
MobileWebFileListResultSchema,
|
||||
options
|
||||
)
|
||||
.then((result) => matchingFileList(payload, result))
|
||||
const legacy = () =>
|
||||
this.requests
|
||||
.request(
|
||||
'file',
|
||||
'list',
|
||||
payload,
|
||||
MobileWebFileListPayloadSchema,
|
||||
MobileWebFileListResultSchema,
|
||||
options
|
||||
)
|
||||
.then((result) => matchingFileList(payload, result))
|
||||
if (!MobileWebFileListPayloadSchema.safeParse(payload).success) {
|
||||
return legacy()
|
||||
}
|
||||
return this.readHost(
|
||||
'mobileWeb.files.searchPaths',
|
||||
payload.workspaceId,
|
||||
{ query: '', limit: payload.limit },
|
||||
(result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit),
|
||||
legacy,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
search(
|
||||
payload: MobileWebFileSearchPayload,
|
||||
options?: MobileWebBridgeRequestOptions
|
||||
): Promise<MobileWebFileListResult> {
|
||||
return this.requests
|
||||
.request(
|
||||
'file',
|
||||
'search',
|
||||
payload,
|
||||
MobileWebFileSearchPayloadSchema,
|
||||
MobileWebFileListResultSchema,
|
||||
options
|
||||
)
|
||||
.then((result) => matchingFileList(payload, result))
|
||||
const legacy = () =>
|
||||
this.requests
|
||||
.request(
|
||||
'file',
|
||||
'search',
|
||||
payload,
|
||||
MobileWebFileSearchPayloadSchema,
|
||||
MobileWebFileListResultSchema,
|
||||
options
|
||||
)
|
||||
.then((result) => matchingFileList(payload, result))
|
||||
if (!MobileWebFileSearchPayloadSchema.safeParse(payload).success) {
|
||||
return legacy()
|
||||
}
|
||||
return this.readHost(
|
||||
'mobileWeb.files.searchPaths',
|
||||
payload.workspaceId,
|
||||
{ query: payload.query, limit: payload.limit },
|
||||
(result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit),
|
||||
legacy,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
read(
|
||||
payload: MobileWebFileReadPayload,
|
||||
options?: MobileWebBridgeRequestOptions
|
||||
): Promise<MobileWebFileReadResult> {
|
||||
return this.requests
|
||||
.request(
|
||||
'file',
|
||||
'read',
|
||||
payload,
|
||||
MobileWebFileReadPayloadSchema,
|
||||
MobileWebFileReadResultSchema,
|
||||
options
|
||||
)
|
||||
.then(decodeMobileWebFileContent)
|
||||
.then((result) => matchingFile(payload, result))
|
||||
const legacy = () =>
|
||||
this.requests
|
||||
.request(
|
||||
'file',
|
||||
'read',
|
||||
payload,
|
||||
MobileWebFileReadPayloadSchema,
|
||||
MobileWebFileReadResultSchema,
|
||||
options
|
||||
)
|
||||
.then(decodeMobileWebFileContent)
|
||||
.then((result) => matchingFile(payload, result))
|
||||
if (!MobileWebFileReadPayloadSchema.safeParse(payload).success) {
|
||||
return legacy()
|
||||
}
|
||||
return this.readHost(
|
||||
'mobileWeb.files.read',
|
||||
payload.workspaceId,
|
||||
{ relativePath: payload.relativePath },
|
||||
(result) => projectMobileWebHostFileContent(result, payload),
|
||||
legacy,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
open(payload: MobileWebFileOpenPayload, options?: MobileWebBridgeRequestOptions): Promise<null> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MOBILE_WEB_FILE_CONTENT_MAX_BYTES,
|
||||
type MobileWebFileReadPayload,
|
||||
type MobileWebFileReadResult
|
||||
} from '../../shared/mobile-web/file-operation-contract'
|
||||
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
|
||||
|
||||
const HostContentSchema = z.object({
|
||||
relativePath: z.string(),
|
||||
content: z.string(),
|
||||
truncated: z.boolean(),
|
||||
byteLength: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
export function projectMobileWebHostFileContent(
|
||||
result: unknown,
|
||||
payload: MobileWebFileReadPayload
|
||||
): MobileWebFileReadResult {
|
||||
const parsed = HostContentSchema.safeParse(result)
|
||||
if (!parsed.success || parsed.data.relativePath !== payload.relativePath) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
const bytes = new TextEncoder().encode(parsed.data.content)
|
||||
if (bytes.byteLength > parsed.data.byteLength) {
|
||||
throw new MobileWebBridgeClientError('invalid_message', false)
|
||||
}
|
||||
if (bytes.byteLength > MOBILE_WEB_FILE_CONTENT_MAX_BYTES) {
|
||||
throw new MobileWebBridgeClientError('too_large', false)
|
||||
}
|
||||
return { ...parsed.data, workspaceId: payload.workspaceId }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
MobileWebFileEntrySchema,
|
||||
MobileWebFileListResultSchema,
|
||||
type MobileWebFileEntry,
|
||||
type MobileWebFileListResult
|
||||
} from './file-operation-contract'
|
||||
import { MobileWebBrokerError } from './bridge-operation-error'
|
||||
|
||||
export function sanitizeListResult(
|
||||
result: unknown,
|
||||
pageWorkspaceId: string,
|
||||
hostWorkspaceId: string | undefined,
|
||||
limit: number
|
||||
): MobileWebFileListResult {
|
||||
if (!isRecord(result) || result.worktree !== hostWorkspaceId || !Array.isArray(result.files)) {
|
||||
throw new MobileWebBrokerError('host_error')
|
||||
}
|
||||
const files = result.files.slice(0, limit).flatMap((value): MobileWebFileEntry[] => {
|
||||
if (!isRecord(value) || typeof value.relativePath !== 'string') {
|
||||
return []
|
||||
}
|
||||
const parsed = MobileWebFileEntrySchema.safeParse({
|
||||
relativePath: value.relativePath,
|
||||
basename: value.relativePath.split('/').at(-1)?.slice(0, 255),
|
||||
kind: value.kind === 'binary' ? 'binary' : 'text'
|
||||
})
|
||||
return parsed.success ? [parsed.data] : []
|
||||
})
|
||||
const totalCount =
|
||||
typeof result.totalCount === 'number' &&
|
||||
Number.isSafeInteger(result.totalCount) &&
|
||||
result.totalCount >= 0
|
||||
? result.totalCount
|
||||
: result.files.length
|
||||
return MobileWebFileListResultSchema.parse({
|
||||
workspaceId: pageWorkspaceId,
|
||||
files,
|
||||
totalCount,
|
||||
truncated:
|
||||
result.truncated === true || result.files.length > files.length || totalCount > files.length
|
||||
})
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
Reference in New Issue
Block a user