diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 0167692c868..8e2f77e80ed 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -4,6 +4,7 @@ import { MOBILE_WEB_SESSION_CAPABILITIES_METHOD } from './mobile-web-session-cap import { MOBILE_WEB_SESSION_QUICK_COMMAND_METHODS } from './mobile-web-session-quick-commands' import { MOBILE_WEB_SESSION_BROWSER_CREATE_METHOD } from './mobile-web-session-browser-create' import { MOBILE_WEB_SOURCE_CONTROL_READ_METHODS } from './mobile-web-source-control-reads' +import { MOBILE_WEB_TASK_PROJECT_TABLE_METHOD } from './mobile-web-task-project-table' import { MOBILE_WEB_SESSION_TERMINAL_CREATION_METHODS } from './mobile-web-session-terminal-creation' import { MOBILE_WEB_NATIVE_CHAT_FILE_METHODS } from './mobile-web-native-chat-files' import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions' @@ -116,6 +117,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...UPDATER_METHODS, ...MOBILE_WEB_FILE_READ_METHODS, ...MOBILE_WEB_SOURCE_CONTROL_READ_METHODS, + MOBILE_WEB_TASK_PROJECT_TABLE_METHOD, MOBILE_WEB_FILE_WATCH_METHOD, ...MOBILE_WEB_TERMINAL_ACTION_METHODS, ...MOBILE_WEB_NATIVE_CHAT_METHODS, diff --git a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts index 73535dccf59..0f258f71009 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts @@ -9,6 +9,7 @@ export const MOBILE_WEB_HOST_RPC_METHODS = new Set([ 'mobileWeb.files.searchPaths', 'mobileWeb.files.read', 'mobileWeb.files.watch', + 'mobileWeb.tasks.projectTable', 'mobileWeb.terminal.action', 'mobileWeb.nativeChat.read', 'mobileWeb.nativeChat.mutate', diff --git a/src/main/runtime/rpc/methods/mobile-web-task-project-table.test.ts b/src/main/runtime/rpc/methods/mobile-web-task-project-table.test.ts new file mode 100644 index 00000000000..cc38adf3813 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-task-project-table.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' +import { GITHUB_PROJECT_METHODS } from './github-project-methods' +import { MOBILE_WEB_TASK_PROJECT_TABLE_METHOD } from './mobile-web-task-project-table' + +const context = { signal: new AbortController().signal } as RpcContext +const request = { + owner: 'octo', + ownerType: 'organization', + projectNumber: 4, + viewId: 'PVTV_1' +} + +function fixture(raw: unknown) { + const source = GITHUB_PROJECT_METHODS.find( + (method) => method.name === 'github.project.viewTable' + )! + const handler = vi.spyOn(source, 'handler').mockResolvedValue(raw) + return { + handler, + run: (params: Record = {}) => + MOBILE_WEB_TASK_PROJECT_TABLE_METHOD.handler( + MOBILE_WEB_TASK_PROJECT_TABLE_METHOD.params!.parse({ ...request, ...params }), + context + ) as Promise> + } +} + +function table(rowCount: number, titleLength = 8) { + return { + data: { + project: { owner: 'octo', ownerType: 'organization', number: 4, host: 'github.com' }, + selectedView: { id: 'PVTV_1', name: 'Board' }, + totalCount: rowCount, + rows: Array.from({ length: rowCount }, (_, index) => ({ + id: `row-${index}`, + content: { title: 'x'.repeat(titleLength), repository: 'octo/app', number: index } + })) + } + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('mobileWeb.tasks.projectTable', () => { + it('returns the whole table in one window when it fits the bridge envelope', async () => { + const f = fixture(table(3)) + const result = await f.run() + expect((result.data as { rows: unknown[] }).rows).toHaveLength(3) + expect(result.nextRowOffset).toBeUndefined() + }) + + it('clips an oversized table and reports where the next window starts', async () => { + const raw = table(500, 4_000) + expect(Buffer.byteLength(JSON.stringify(raw.data))).toBeGreaterThan(512 * 1024) + const f = fixture(raw) + const first = await f.run() + const rows = (first.data as { rows: { id: string }[] }).rows + expect(rows.length).toBeLessThan(500) + expect(rows[0]!.id).toBe('row-0') + expect(Buffer.byteLength(JSON.stringify(first.data))).toBeLessThanOrEqual(512 * 1024) + expect(first.nextRowOffset).toBe(rows.length) + + const second = await f.run({ rowOffset: first.nextRowOffset }) + expect((second.data as { rows: { id: string }[] }).rows[0]!.id).toBe(`row-${rows.length}`) + }) + + it('keeps the table metadata on every window so the caller can rebuild the table', async () => { + const f = fixture(table(500, 4_000)) + const second = await f.run({ rowOffset: 400 }) + expect(second.data).toMatchObject({ totalCount: 500, selectedView: { id: 'PVTV_1' } }) + }) + + it('advances by one row even when a single row exceeds the budget', async () => { + const f = fixture(table(3, 600 * 1024)) + const result = await f.run() + expect((result.data as { rows: unknown[] }).rows).toHaveLength(1) + expect(result.nextRowOffset).toBe(1) + }) + + it('passes a host failure envelope through untouched', async () => { + const failure = { ok: false, error: { message: 'View has 900 items.', type: 'too_large' } } + const f = fixture(failure) + expect(await f.run()).toEqual(failure) + }) + + it('never forwards the page row offset to the GitHub query', async () => { + const f = fixture(table(3)) + await f.run({ rowOffset: 2 }) + expect(f.handler).toHaveBeenCalledWith( + expect.not.objectContaining({ rowOffset: expect.anything() }), + context + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-task-project-table.ts b/src/main/runtime/rpc/methods/mobile-web-task-project-table.ts new file mode 100644 index 00000000000..3c6e033bad1 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-task-project-table.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' +import { defineMethod, isStreamingMethod } from '../core' +import { GITHUB_PROJECT_METHODS } from './github-project-methods' + +// The bridge envelope is 600 KB, and a 500-item project view can exceed it, so the page asks for +// one row window at a time and this handler decides where each window ends. +const MAX_RESULT_BYTES = 512 * 1024 + +const ProjectTableWindow = z.object({ + owner: z.string().min(1).max(512), + host: z.string().max(512).optional(), + ownerType: z.enum(['organization', 'user']), + projectNumber: z.number().int().positive(), + viewId: z.string().min(1).max(240), + queryOverride: z.string().max(4_096).optional(), + rowOffset: z.number().int().nonnegative().max(100_000).optional() +}) + +type ProjectTableRow = Record + +function projectViewTableMethod() { + const method = GITHUB_PROJECT_METHODS.find((entry) => entry.name === 'github.project.viewTable') + if (!method || isStreamingMethod(method)) { + throw new Error('Missing unary method: github.project.viewTable') + } + return method +} +const viewTable = projectViewTableMethod() + +export const MOBILE_WEB_TASK_PROJECT_TABLE_METHOD = defineMethod({ + name: 'mobileWeb.tasks.projectTable', + params: ProjectTableWindow, + handler: async (params, context) => { + const { rowOffset = 0, ...request } = params + const raw = await viewTable.handler(request, context) + const table = tableOf(raw) + if (!table) { + return raw + } + const rows = Array.isArray(table.rows) ? (table.rows as ProjectTableRow[]) : [] + const window = rowWindow(table, rows, rowOffset) + const nextRowOffset = rowOffset + window.length + return { + ...(raw as Record), + data: { ...table, rows: window }, + ...(nextRowOffset < rows.length ? { nextRowOffset } : {}) + } + } +}) + +function tableOf(raw: unknown): Record | null { + const envelope = raw as { ok?: boolean; data?: unknown } | null + if (!envelope || envelope.ok === false) { + return null + } + const table = envelope.data + return typeof table === 'object' && table !== null ? (table as Record) : null +} + +/** Always yields at least one row so a caller that has not reached the end always advances. */ +function rowWindow( + table: Record, + rows: ProjectTableRow[], + offset: number +): ProjectTableRow[] { + const window: ProjectTableRow[] = [] + for (const row of rows.slice(offset)) { + window.push(row) + if ( + window.length > 1 && + Buffer.byteLength(JSON.stringify({ ...table, rows: window })) > MAX_RESULT_BYTES + ) { + window.pop() + break + } + } + return window +}