refactor(mobile): remove duplicate web presentation

This commit is contained in:
OrcaWin
2026-08-09 18:34:59 -04:00
committed by Jinwoo-H
parent a4fc2950f4
commit a7dcd591b9
80 changed files with 88 additions and 12665 deletions
@@ -1,184 +0,0 @@
import { createHash } from 'node:crypto'
import { posix } from 'node:path'
import type { Plugin, Rolldown } from 'vite'
import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from '../../src/shared/mobile-web/bridge-protocol-version'
import {
MOBILE_WEB_MANIFEST_SCHEMA_VERSION,
MobileWebManifestSchema,
serializeMobileWebManifestForBuildId,
type MobileWebAsset,
type MobileWebManifest
} from '../../src/shared/mobile-web/manifest-contract'
type OutputAsset = Rolldown.OutputAsset
type OutputBundle = Rolldown.OutputBundle
type OutputChunk = Rolldown.OutputChunk
const CONTENT_TYPE_BY_EXTENSION = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml; charset=utf-8',
'.wasm': 'application/wasm',
'.webp': 'image/webp',
'.woff2': 'font/woff2'
} as const
const ROLE_BY_EXTENSION = {
'.css': 'style',
'.html': 'document',
'.js': 'script',
'.png': 'image',
'.svg': 'image',
'.wasm': 'wasm',
'.webp': 'image',
'.woff2': 'font'
} as const
export function createMobileWebContentAddressedPlugin(): Plugin {
return {
name: 'orca-mobile-web-content-addressed',
enforce: 'post',
generateBundle(_options, bundle) {
const entryChunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk' && item.isEntry
)
if (entryChunks.length !== 1 || entryChunks[0]!.imports.length > 0) {
this.error('Mobile web build must emit one self-contained entry chunk')
}
if (entryChunks[0]!.dynamicImports.length > 0) {
this.error('Mobile web build cannot emit runtime-loaded JavaScript chunks')
}
const renames = new Map<string, string>()
const binaryAssets = Object.values(bundle).filter(
(item): item is OutputAsset =>
item.type === 'asset' && !['.css', '.html'].includes(posix.extname(item.fileName))
)
for (const asset of binaryAssets) {
renameContentAddressedAsset(bundle, asset, assetBytes(asset), renames)
}
const styles = Object.values(bundle).filter(
(item): item is OutputAsset =>
item.type === 'asset' && posix.extname(item.fileName) === '.css'
)
for (const style of styles) {
style.source = replaceAssetReferences(String(style.source), renames)
renameContentAddressedAsset(bundle, style, assetBytes(style), renames)
}
const entry = entryChunks[0]!
entry.code = replaceAssetReferences(entry.code, renames)
renameContentAddressedChunk(bundle, entry, renames)
const document = Object.values(bundle).find(
(item): item is OutputAsset => item.type === 'asset' && item.fileName === 'index.html'
)
if (!document) {
this.error('Mobile web build did not emit index.html')
}
document.source = replaceAssetReferences(String(document.source), renames)
const assets = manifestAssets(bundle)
const manifestWithoutIdentity: MobileWebManifest = {
schemaVersion: MOBILE_WEB_MANIFEST_SCHEMA_VERSION,
buildId: '0'.repeat(64),
bridge: {
minimum: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
testedThrough: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION
},
entrypoint: 'index.html',
totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0),
assets
}
const manifest = MobileWebManifestSchema.parse({
...manifestWithoutIdentity,
buildId: sha256(serializeMobileWebManifestForBuildId(manifestWithoutIdentity))
})
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
source: `${JSON.stringify(manifest, null, 2)}\n`
})
}
}
}
function renameContentAddressedAsset(
bundle: OutputBundle,
asset: OutputAsset,
bytes: Uint8Array,
renames: Map<string, string>
): void {
const extension = posix.extname(asset.fileName)
const nextName = `assets/${sha256(bytes)}${extension}`
renameBundleItem(bundle, asset.fileName, nextName, asset, renames)
}
function renameContentAddressedChunk(
bundle: OutputBundle,
chunk: OutputChunk,
renames: Map<string, string>
): void {
const nextName = `assets/${sha256(chunk.code)}.js`
renameBundleItem(bundle, chunk.fileName, nextName, chunk, renames)
}
function renameBundleItem(
bundle: OutputBundle,
oldName: string,
nextName: string,
item: OutputAsset | OutputChunk,
renames: Map<string, string>
): void {
if (bundle[nextName]) {
throw new Error(`Duplicate mobile web content-addressed path: ${nextName}`)
}
delete bundle[oldName]
item.fileName = nextName
bundle[nextName] = item
renames.set(oldName, nextName)
}
function replaceAssetReferences(source: string, renames: ReadonlyMap<string, string>): string {
let output = source
for (const [oldName, nextName] of renames) {
output = output.replaceAll(oldName, nextName)
output = output.replaceAll(posix.basename(oldName), posix.basename(nextName))
}
return output
}
function manifestAssets(bundle: OutputBundle): MobileWebAsset[] {
return Object.values(bundle)
.filter((item) => item.fileName !== 'manifest.json')
.map((item) => {
const extension = posix.extname(item.fileName) as keyof typeof CONTENT_TYPE_BY_EXTENSION
const contentType = CONTENT_TYPE_BY_EXTENSION[extension]
const role = ROLE_BY_EXTENSION[extension]
if (!contentType || !role) {
throw new Error(`Unsupported mobile web asset type: ${item.fileName}`)
}
const bytes = item.type === 'chunk' ? new TextEncoder().encode(item.code) : assetBytes(item)
return {
path: item.fileName,
sha256: sha256(bytes),
byteLength: bytes.byteLength,
contentType,
role
}
})
.sort((left, right) => left.path.localeCompare(right.path))
}
function assetBytes(asset: OutputAsset): Uint8Array {
return typeof asset.source === 'string'
? new TextEncoder().encode(asset.source)
: new Uint8Array(asset.source)
}
function sha256(value: string | Uint8Array): string {
return createHash('sha256').update(value).digest('hex')
}
@@ -1,30 +0,0 @@
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { mobileWebImportBoundaryViolation } from './mobile-web-import-boundary'
const MOBILE_IMPORTER = resolve('src/mobile-web/src/mobile-web-shell.tsx')
const CARD_IMPORTER = resolve('src/renderer/src/components/ui/card.tsx')
describe('mobile web import boundary', () => {
it.each([
['./mobile-web-shell', MOBILE_IMPORTER],
['../../shared/mobile-web/bridge-contract', MOBILE_IMPORTER],
['@renderer/components/ui/card', MOBILE_IMPORTER],
['@/lib/utils', CARD_IMPORTER],
['react', MOBILE_IMPORTER]
])('allows approved import %s', (source, importer) => {
expect(mobileWebImportBoundaryViolation(source, importer)).toBeNull()
})
it.each([
['electron', MOBILE_IMPORTER],
['node:fs', MOBILE_IMPORTER],
['@electron-toolkit/preload', MOBILE_IMPORTER],
['../../main/index', MOBILE_IMPORTER],
['../../preload/index', MOBILE_IMPORTER],
['@renderer/App', MOBILE_IMPORTER],
['@/store', CARD_IMPORTER]
])('rejects desktop or native import %s', (source, importer) => {
expect(mobileWebImportBoundaryViolation(source, importer)).not.toBeNull()
})
})
@@ -1,69 +0,0 @@
import { isAbsolute, relative, resolve } from 'node:path'
import type { Plugin } from 'vite'
const MOBILE_WEB_ROOT = resolve('src/mobile-web')
const SHARED_ROOT = resolve('src/shared')
const SOURCE_ROOT = resolve('src')
const APPROVED_RENDERER_ROOTS = [
resolve('src/renderer/src/assets'),
resolve('src/renderer/src/components/ui'),
resolve('src/renderer/src/lib/utils.ts')
]
export function createMobileWebImportBoundaryPlugin(): Plugin {
return {
name: 'orca-mobile-web-import-boundary',
enforce: 'pre',
resolveId(source, importer) {
const violation = mobileWebImportBoundaryViolation(source, importer)
if (violation) {
this.error(violation)
}
return null
}
}
}
export function mobileWebImportBoundaryViolation(
source: string,
importer: string | undefined
): string | null {
if (!importer || importer.includes('node_modules')) {
return null
}
if (source === 'electron' || source.startsWith('node:') || source.startsWith('@electron')) {
return `Mobile web code cannot import ${source}`
}
const resolved = resolveSourcePath(source, importer)
if (!resolved || isInside(resolved, MOBILE_WEB_ROOT) || isInside(resolved, SHARED_ROOT)) {
return null
}
if (APPROVED_RENDERER_ROOTS.some((root) => isApprovedRendererImport(resolved, root))) {
return null
}
return isInside(resolved, SOURCE_ROOT)
? `Mobile web import crosses its approved boundary: ${source}`
: null
}
function isApprovedRendererImport(candidate: string, root: string): boolean {
return isInside(candidate, root) || candidate === root.replace(/\.(?:ts|tsx)$/, '')
}
function resolveSourcePath(source: string, importer: string): string | null {
if (source.startsWith('@renderer/')) {
return resolve('src/renderer/src', source.slice('@renderer/'.length))
}
if (source.startsWith('@/')) {
return resolve('src/renderer/src', source.slice(2))
}
if (source.startsWith('.')) {
return resolve(importer, '..', source)
}
return null
}
function isInside(candidate: string, root: string): boolean {
const child = relative(root, candidate)
return child === '' || (!child.startsWith('..') && !isAbsolute(child))
}
@@ -1,53 +0,0 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { Plugin } from 'vite'
const CANONICAL_STYLE_PATH = resolve('src/renderer/src/assets/main.css')
const MOBILE_WEB_RENDERER_STYLE_SOURCES = [
"@source '../components/ui/badge.tsx';",
"@source '../components/ui/button.tsx';",
"@source '../components/ui/card.tsx';",
"@source '../components/ui/dialog-foundation.tsx';",
"@source '../components/ui/input.tsx';",
"@source '../components/ui/select.tsx';",
"@source '../components/ui/tabs.tsx';"
]
const DESKTOP_ONLY_IMPORTS = [
"@import '@xterm/xterm/css/xterm.css';",
"@import 'katex/dist/katex.min.css';",
"@import './rich-markdown-editor.css';",
"@import './markdown-preview.css';",
"@import './terminal.css';",
"@import './mobile-page.css';"
]
const DESKTOP_SYMBOL_FONT =
/\n@font-face \{\n font-family: 'Orca Nerd Font Symbols';[\s\S]*?\n\}\n/
export function createMobileWebStyleBoundaryPlugin(): Plugin {
return {
name: 'orca-mobile-web-style-boundary',
enforce: 'pre',
async load(id) {
if (id.split('?')[0] !== CANONICAL_STYLE_PATH) {
return null
}
const source = await readFile(CANONICAL_STYLE_PATH, 'utf8')
const withoutDesktopImports = source
.split('\n')
.filter((line) => !DESKTOP_ONLY_IMPORTS.includes(line))
.join('\n')
const withApprovedSources = withoutDesktopImports.replace(
"@import 'tailwindcss';",
["@import 'tailwindcss';", ...MOBILE_WEB_RENDERER_STYLE_SOURCES].join('\n')
)
if (withApprovedSources === withoutDesktopImports) {
this.error('Canonical styles no longer contain the expected Tailwind import')
}
const mobileStyles = withApprovedSources.replace(DESKTOP_SYMBOL_FONT, '\n')
if (mobileStyles === withApprovedSources) {
this.error('Canonical styles no longer contain the expected desktop symbol font block')
}
return mobileStyles
}
}
}
@@ -724,7 +724,7 @@ async function createMobileWebResourceFixture(resourcesDir) {
"frame-ancestors 'none'"
].join(';')
const document = Buffer.from(
`<!doctype html><meta http-equiv="Content-Security-Policy" content="${csp}"><script src="./${scriptPath}"></script>`,
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"><meta http-equiv="Content-Security-Policy" content="${csp}"><script src="./${scriptPath}"></script>`,
'utf8'
)
const assets = [
-139
View File
@@ -1,139 +0,0 @@
import { createHash } from 'node:crypto'
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
import {
MobileWebManifestSchema,
serializeMobileWebManifestForBuildId
} from '../../src/shared/mobile-web/manifest-contract.ts'
const outputRoot = path.resolve('out/mobile-web')
const maxUncompressedBytes = 2 * 1024 * 1024
const maxCompressedBytes = 512 * 1024
const maxScriptBytes = 1024 * 1024
const maxStyleBytes = 256 * 1024
const manifestPath = path.join(outputRoot, 'manifest.json')
const manifest = MobileWebManifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')))
const expectedBuildId = sha256(serializeMobileWebManifestForBuildId(manifest))
if (manifest.buildId !== expectedBuildId) {
throw new Error('Mobile web manifest buildId does not match canonical manifest content')
}
const assetBytes = new Map()
for (const asset of manifest.assets) {
const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/')))
assetBytes.set(asset.path, bytes)
if (bytes.byteLength !== asset.byteLength) {
throw new Error(`Mobile web asset length mismatch: ${asset.path}`)
}
if (sha256(bytes) !== asset.sha256) {
throw new Error(`Mobile web asset hash mismatch: ${asset.path}`)
}
}
const scriptBytes = manifest.assets
.filter((asset) => asset.role === 'script')
.reduce((total, asset) => total + asset.byteLength, 0)
const styleBytes = manifest.assets
.filter((asset) => asset.role === 'style')
.reduce((total, asset) => total + asset.byteLength, 0)
const compressedBytes = manifest.assets
.map((asset) => gzipSync(assetBytes.get(asset.path)))
.reduce((total, bytes) => total + bytes.byteLength, 0)
if (
manifest.totalBytes > maxUncompressedBytes ||
compressedBytes > maxCompressedBytes ||
scriptBytes > maxScriptBytes ||
styleBytes > maxStyleBytes
) {
throw new Error(
`Mobile web bundle exceeds budget: total=${manifest.totalBytes}, compressed=${compressedBytes}, script=${scriptBytes}, style=${styleBytes}`
)
}
const actualPaths = (await listFiles(outputRoot)).filter((file) => file !== 'manifest.json').sort()
const declaredPaths = manifest.assets.map((asset) => asset.path)
if (JSON.stringify(actualPaths) !== JSON.stringify(declaredPaths)) {
throw new Error('Mobile web output contains undeclared or missing assets')
}
const html = await readFile(path.join(outputRoot, 'index.html'), 'utf8')
const requiredCsp = [
"default-src 'none'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data: blob:",
"connect-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
"frame-src 'none'"
]
for (const directive of requiredCsp) {
if (!html.includes(directive)) {
throw new Error(`Mobile web CSP is missing: ${directive}`)
}
}
for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) {
const reference = match[1]
if (!reference?.startsWith('./assets/')) {
throw new Error(`Mobile web document contains a non-relative asset reference: ${reference}`)
}
if (!declaredPaths.includes(reference.slice(2))) {
throw new Error(`Mobile web document references an undeclared asset: ${reference}`)
}
}
for (const asset of manifest.assets.filter((candidate) => candidate.role === 'script')) {
const source = await readFile(path.join(outputRoot, ...asset.path.split('/')), 'utf8')
if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(source)) {
throw new Error(`Mobile web executable asset contains runtime code generation: ${asset.path}`)
}
if (/sourceMappingURL/.test(source)) {
throw new Error(`Mobile web executable asset contains a source map reference: ${asset.path}`)
}
}
const styles = manifest.assets
.filter((asset) => asset.role === 'style')
.map((asset) => assetBytes.get(asset.path).toString('utf8'))
.join('\n')
const requiredDialogStyles = [
/\.fixed\{position:fixed\}/,
/\.inset-0\{inset:calc\(var\(--spacing\)\s*\*\s*0\)\}/,
/\.top-\\\[50\\%\\\]\{top:50%\}/,
/\.left-\\\[50\\%\\\]\{left:50%\}/,
/\.translate-x-\\\[-50\\%\\\]\{[^}]*--tw-translate-x:-50%/,
/\.translate-y-\\\[-50\\%\\\]\{[^}]*--tw-translate-y:-50%/,
/\.max-w-\\\[calc\\\(100\\%-2rem\\\)\\\]\{max-width:calc\(100% - 2rem\)\}/,
/\.z-50\{z-index:50\}/
]
for (const requiredStyle of requiredDialogStyles) {
if (!requiredStyle.test(styles)) {
throw new Error(`Mobile web styles are missing dialog utility: ${requiredStyle}`)
}
}
console.log(
`Mobile web build verified: ${manifest.assets.length} assets, ${manifest.totalBytes} bytes (${compressedBytes} compressed), build ${manifest.buildId}`
)
async function listFiles(root, relative = '') {
const entries = await readdir(path.join(root, relative), { withFileTypes: true })
const files = []
for (const entry of entries) {
const child = relative ? path.join(relative, entry.name) : entry.name
if (entry.isDirectory()) {
files.push(...(await listFiles(root, child)))
} else {
files.push(child.split(path.sep).join('/'))
}
}
return files
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex')
}
+1 -14
View File
@@ -2,14 +2,6 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"../src/mobile-web/src/**/*",
"../src/renderer/src/components/ui/badge.tsx",
"../src/renderer/src/components/ui/button.tsx",
"../src/renderer/src/components/ui/card.tsx",
"../src/renderer/src/components/ui/dialog-foundation.tsx",
"../src/renderer/src/components/ui/input.tsx",
"../src/renderer/src/components/ui/select.tsx",
"../src/renderer/src/components/ui/tabs.tsx",
"../src/renderer/src/lib/utils.ts",
"../src/shared/agent-status-limits.ts",
"../src/shared/clipboard-text.ts",
"../src/shared/event-loop-yield.ts",
@@ -23,11 +15,6 @@
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"types": ["vite/client"],
"paths": {
"@mobile-web/*": ["../src/mobile-web/src/*"],
"@renderer/*": ["../src/renderer/src/*"],
"@/*": ["../src/renderer/src/*"]
}
"types": []
}
}
@@ -71,7 +71,7 @@ At the end of every implementation session:
| ---------------------------------- | ----------- | --------: | ------------------------------------------------------ |
| Prototype evidence | Complete | 8/8 | Simulator-only bounded vertical slice |
| Contracts and parity inventory | Complete | 10/10 | Route, RPC, native, state, and failure ledgers frozen |
| Mobile web build | In progress | 9/12 | Packaged macOS/SSH lookup verified |
| Mobile web build | In progress | 10/12 | Duplicate validation UI removed; packaging gates open |
| Package delivery RPC | In progress | 9/10 | Production names and RPC pass focused validation |
| Native asset origin and cache | In progress | 7/15 | Cross-platform unit faults pass; device drills remain |
| Capability bridge | In progress | 10/17 | Replay/race hardening passes; binary matrix remains |
@@ -83,7 +83,7 @@ At the end of every implementation session:
| Security and adversarial review | In progress | 1/15 | Executable isolation passes; broader review open |
| Device and topology validation | In progress | 0/18 | Direct iOS and Android emulators pass core journeys |
| App Store validation | Not started | 0/10 | Production submission, not TestFlight |
| Cutover and cleanup | Not started | 0/12 | Last implementation workstream |
| Cutover and cleanup | In progress | 1/12 | Duplicate validation UI removed |
| Release evidence and documentation | Not started | 0/10 | Required before merge |
**Current workstream:** Complete remaining parity, cutover cleanup, and the
@@ -99,7 +99,8 @@ a second platform boundary: native retains those authorities, while the hosted
page receives shell-owned identity and no page persistence. Leaving a host,
reconnect, pairing repair, and paired-host removal now use a third narrow
shell-operations boundary without changing rendered JSX. The purpose-built web
screens remain infrastructure fixtures and cannot be the cutover UI. The
screens and Vite-only package path are removed; only production page clients
and transport state remain under `src/mobile-web/`. The
existing `NewWorktreeModalController`, `NewWorktreeModal`, Smart source drawer,
and their view models now consume a named
`HostWorkspaceCreationOperations` domain interface. Native maps that interface
@@ -535,14 +536,14 @@ recovery, or physical-device gates.
RPC returned build `c24ff987…`, then the actual iOS WKWebView rendered the
unchanged Docker SSH workspace, mutated its remote terminal, and
recovered native chat across SSH disconnect/reconnect.
- [~] Replace the Vite validation presentation with the React Native Web build
and remove equivalent `src/mobile-web/` UI components. The existing
mobile UI now packages deterministically through the production asset
format. `build:mobile-web`, development lookup, and Electron packaging now
select the RNW artifact by default; the Vite presentation remains only as an
explicitly named infrastructure fixture until its tests migrate. Cutover is
still blocked on full route parity, device evidence, and distribution
packaging.
- [x] Replace the Vite validation presentation with the React Native Web build
and remove equivalent `src/mobile-web/` UI components. The existing mobile
UI packages deterministically through the production asset format.
`build:mobile-web`, development lookup, and Electron packaging select the
RNW artifact. The retired Vite entry, package plugin/verifier, duplicate
workspace/session/files/source-control UI, and UI-only tests are removed.
Production bridge clients and transport tests remain. The rebuilt package
retains build `b17ead7a…`.
## 3. Production Package Delivery RPC
@@ -1676,9 +1677,10 @@ copy.
- [ ] Remove the Experimental Settings entry and prototype route.
- [ ] Remove prototype contracts, package generator, RPC names, cache, bridge,
and test fixtures superseded by production implementations.
- [ ] Remove the parallel `src/mobile-web/` presentation after parity and App
Store gates pass; retain the shared React Native component source rendered
through React Native Web.
- [x] Remove the parallel `src/mobile-web/` presentation while retaining its
production bridge clients and the shared React Native component source
rendered through React Native Web. The native workspace fallback remains
untouched until the App Store and release gates pass.
- [ ] Keep native pairing, recovery, permissions, settings, and diagnostics.
- [~] Drill automatic rollback from a crash-looping staged package. The exact
Pixel 9 Pro API 36 Debug app crashed three distinct Chromium renderer
@@ -2620,4 +2622,8 @@ and diff hygiene pass. A fresh production RNW build remains
| 2026-07-28 | Finding | Native manifest parsing enforced asset count and field bounds only after parsing both supplied JSON documents. Swift and Kotlin now reject either raw manifest above 256 KiB before handing it to `JSONSerialization` or `JSONObject`. |
| 2026-07-28 | Complete | Mirrored oversized primary/canonical manifest regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No staging directory is created for the rejected Android inputs. |
| 2026-07-28 | Complete | The RNW packager, build verifier, iOS response policy, and Android response policy now share one exact document-CSP contract. Focused packager/native-source tests pass, and a fresh production RNW build retains build `9ed8c7f7…`, 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. |
| 2026-07-28 | Next | Complete the remaining parity inventory and cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. |
| 2026-07-28 | Finding | Persisted native primary/canonical manifests, activation metadata, and generation assets used whole-file APIs before their post-read bounds. Both stores now read only each limit plus one overflow byte and preserve stable staging, generation, and activation failures. |
| 2026-07-28 | Complete | Mirrored oversized persisted-file faults pass the Swift executable and Android 76-task module suite. Shared contracts pass 24 files / 203 tests; native source parity passes 1 file / 4 tests; all relevant typechecks, lints, formatting, max-lines, and diff hygiene pass. |
| 2026-07-28 | Complete | The obsolete standalone renderer-based mobile-web presentation, its Vite-only package path, and 12,621 lines of duplicate UI/tests are removed. Production bridge clients and transport state remain, and a boundary test prevents renderer UI from returning. The authoritative RNW package remains `b17ead7a…`: 49 assets, 9,281,663 raw bytes, and 2,684,764 gzip bytes. |
| 2026-07-28 | Complete | Post-removal validation passes 568 mobile files / 3,375 tests with 2 expected skips and 3,752 root files / 39,218 tests with 62 expected skips. Root/mobile/mobile-web lint, node/mobile/mobile-web typechecks, reliability, localization, max-lines, changed-file formatting, package verification, and diff hygiene pass. The packaged-resource fixture now matches the required safe-area viewport contract. |
| 2026-07-28 | Next | Complete compatibility release policy and the remaining gated cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. |
@@ -30,8 +30,9 @@ not satisfy parity.
- Existing React Native screen, component, style, and view-model source is
shared by the native and web runtimes. Target ownership identifies where the
feature executes, not permission to reimplement its presentation.
- `src/mobile-web/` presentation components are temporary validation fixtures
and must be removed as shared mobile screens replace them.
- The obsolete `src/mobile-web/` validation presentation is removed. Its
remaining modules are production bridge clients and transport state used by
the shared React Native Web routes, not a second product UI.
- A native settings route may configure a web-owned feature when the setting
controls native input, permission, lifecycle, or recovery behavior.
- Compatibility redirects disappear when all supported deep links route through
@@ -521,6 +522,11 @@ activation records use a 1 KiB bounded reader, and assets use their declared
length on both native platforms. Each path reads only one overflow byte before
failing with its stable error, and mirrored Swift/Kotlin oversized-file faults
pass.
The standalone renderer-based workspace, session, files, terminal,
source-control, and review presentation is also removed with its Vite-only
package path. A production-source boundary requires `src/mobile-web/` to remain
renderer-independent, while the authoritative RNW package still verifies as
build `b17ead7a…`.
## Next Inventory Action
@@ -66,9 +66,10 @@ it reaches Desktop authority; it does not authorize a redesign.
review any intentional product UI change separately against both runtimes.
- Keep the native workspace route as the behavior oracle and working fallback
until the final parity, security, device, and App Store gates pass.
- The current `src/mobile-web/` presentation is a security and transport
validation harness. Its workspace, session, file, diff, source-control, and
review components must not become the production UI.
- The former `src/mobile-web/` validation presentation is removed. Its
surviving modules are production bridge clients and transport state; the
workspace, session, file, diff, source-control, and review UI comes only from
the shared React Native source.
- Automated screenshot comparisons and interaction fixtures against the
existing mobile routes are merge gates. Functional parity without visual and
interaction parity is insufficient.
@@ -181,7 +182,8 @@ prototype:
activate, and close controls.
- Those purpose-built screens are a validation harness only. They prove the
bridge operations and package lifecycle but do not satisfy the UI preservation
contract and are not eligible for production cutover.
contract and are not eligible for production cutover. They were retired after
the authoritative React Native Web route graph replaced their evidence.
- Direct, Relay, and stable logical RPC clients expose a typed
`terminal.multiplex` transport. The native broker resolves only workspace and
tab IDs; terminal handles, cwd, credentials, connection identity, and input
@@ -591,9 +593,9 @@ crossing that boundary.
Build `abd43c62…` packages that shared route as 49 content-addressed assets,
7,201,072 bytes raw and 1,537,245 bytes with gzip. This is a production,
minified Metro bundle, not a debug artifact. The earlier 2 MiB / 512 KiB
ceiling remains appropriate for the purpose-built infrastructure fixture, but
it cannot hold the complete authoritative mobile route graph without removing
features or reintroducing a parallel UI. That checkpoint introduced an 8 MiB
ceiling measured the now-retired infrastructure fixture; it could not hold the
complete authoritative mobile route graph without removing features or
reintroducing a parallel UI. That checkpoint introduced an 8 MiB
total / 2 MiB gzip / 7.5 MiB script RNW ceiling. The later locally bundled
Mermaid engine required the reviewed current ceiling of 10 MiB total, 3 MiB
gzip, 9.5 MiB of scripts, 256 KiB of styles, and 64 assets. The verifier
@@ -1286,13 +1288,11 @@ workspace scoping, input leases, provider checks, or filesystem boundaries.
Use the dedicated host-only Expo Router entry to export the existing mobile
presentation through React Native Web, then post-process it into
`out/mobile-web-rnw/` as part of every desktop build and release. The separate
Vite entry under `src/mobile-web/` remains an infrastructure fixture until its
bridge/package tests have migrated and the shared presentation passes cutover.
Vite validation entry, content-addressing plugin, verifier, and duplicate
presentation have been removed after their bridge/package coverage migrated.
Development fallback, `build:mobile-web`, and Electron's macOS/Linux/Windows
resource mapping now select `out/mobile-web-rnw/` without an environment
override. `ORCA_MOBILE_WEB_PACKAGE_ROOT` remains an explicit diagnostic/test
override. The Vite build is available only as `build:mobile-web-fixture`; it
must not become the runtime or release presentation.
resource mapping select `out/mobile-web-rnw/` without an environment override.
`ORCA_MOBILE_WEB_PACKAGE_ROOT` remains an explicit diagnostic/test override.
Electron `afterPack` now runs the production package verifier against the
copied `<resources>/mobile-web` tree, so a missing, corrupt, non-content-
addressed, over-budget, or CSP-invalid resource fails packaging rather than
@@ -2108,8 +2108,8 @@ measurement above its ceiling. The current 49-asset package is 9,281,663 bytes
/ 2,684,764 bytes gzip and build
`b17ead7a3c85071f5cfc45dd695bd457e37a49c4895ad3ac979689ca2a13805f`.
`build:mobile-web` and release resource mapping therefore select the RNW
package; the original 2 MiB ceiling continues to govern only the isolated Vite
infrastructure fixture. Workspace snapshots now page through
package; the original 2 MiB Vite-fixture ceiling is retired with that fixture.
Workspace snapshots now page through
opaque single-use cursors:
each page is capped at 200 rows and 120 KiB, the stable native-only source
snapshot is capped at 10,000 rows and 8 MiB, and lifecycle cleanup revokes every
@@ -2351,9 +2351,9 @@ config/
mobile-web-package.ts deterministic Expo Web package post-processing
```
The existing `src/mobile-web/` DOM presentation is a temporary infrastructure
validation harness. Remove it when its bridge consumers have moved behind the
shared React Native presentation adapters.
The `src/mobile-web/` DOM presentation and Vite-only package path are removed.
The remaining directory contains production bridge clients and transport state
consumed by the shared React Native presentation adapters.
Production names must drop `prototype`. Do not leave production behavior split
between the experimental screen and a second implementation.
@@ -2434,7 +2434,8 @@ feature UI.
the shared source and rerun the parity fixtures; do not preserve an older
forked snapshot for the web runtime.
- Remove the purpose-built `src/mobile-web/` presentation as each shared screen
reaches parity; keep only the web entry, bridge client, and runtime adapters.
reaches parity. This is complete: only bridge clients and runtime transport
adapters remain, while the host-only Expo Router entry owns the web route.
### 7. Adapt workspace and session surfaces
@@ -2578,9 +2579,9 @@ silent WebKit miss.
### 12. Cut over and remove duplicate workspace UI
- Make the hybrid workspace route the default only after every gate passes.
- Remove the experimental entry and the parallel `src/mobile-web/`
presentation. Retain the shared React Native screen/component source used by
React Native Web; remove only superseded route/runtime adapters.
- Remove the experimental entry at cutover. The parallel `src/mobile-web/`
presentation is already removed; retain its production bridge clients and the
shared React Native screen/component source used by React Native Web.
- Keep native pairing, recovery, permissions, settings, and diagnostics.
- Build the final exact release candidate, rerun smoke/performance/security
checks, and resubmit if the binary materially differs from the accepted
@@ -181,6 +181,17 @@ activation metadata with a 1 KiB ceiling, and assets with their exact declared
length plus one overflow byte. Oversized files fail with the existing stable
generation or activation error before whole-file allocation; mirrored Swift
and Kotlin fault suites pass.
The obsolete standalone `src/mobile-web/` presentation and Vite-only package
path are removed. The directory now contains only production bridge clients,
transport state, and focused tests consumed by the real React Native Web route
graph. A source boundary prevents the duplicate renderer-based UI from
returning, and the production package remains build `b17ead7a…`.
Post-removal validation passes 568 mobile files / 3,375 tests with 2 expected
skips and 3,752 root files / 39,218 tests with 62 expected skips. Root, mobile,
and mobile-web lint; node, mobile, and mobile-web typechecks; reliability,
localization, max-lines, formatting, package verification, and diff hygiene
pass. The packaged-resource fixture now includes the same required safe-area
viewport contract as the production document.
The remaining security work below is release-app corpus testing, fuzzing,
cross-scope races, privacy/authorization audit, and independent review.
@@ -197,9 +208,6 @@ cross-scope races, privacy/authorization audit, and independent review.
- [ ] Remove the Experimental Settings entry and `hybrid-prototype` route.
- [ ] Remove superseded prototype contracts, package generation, RPC names,
cache, bridge code, and fixtures.
- [ ] Remove the purpose-built `src/mobile-web/` validation presentation while
retaining the shared React Native components rendered through React
Native Web.
- [ ] Confirm production source and imports contain no `prototype` names.
- [ ] Document Desktop web-package rollback and native store-rollout rollback.
@@ -1,4 +1,4 @@
import { readFileSync, readdirSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { extname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -19,6 +19,17 @@ const forbiddenProductionReferences = [
'MobileWebPrototype',
'mobileWeb.prototype'
]
const standaloneClientRoot = new URL('../../src/mobile-web', import.meta.url)
const retiredStandaloneArtifacts = [
new URL('../../src/mobile-web/index.html', import.meta.url),
new URL('../../src/mobile-web/src/entry.tsx', import.meta.url),
new URL('../../src/mobile-web/src/mobile-web-shell.tsx', import.meta.url),
new URL('../../vite.mobile-web.config.ts', import.meta.url),
new URL('../../config/scripts/verify-mobile-web-build.mjs', import.meta.url),
new URL('../../build-plugins/mobile-web-content-addressed.ts', import.meta.url),
new URL('../../build-plugins/mobile-web-import-boundary.ts', import.meta.url),
new URL('../../build-plugins/mobile-web-style-boundary.ts', import.meta.url)
]
function sourceFiles(root: URL): URL[] {
if (extname(root.pathname)) {
@@ -44,4 +55,14 @@ describe('mobile web production prototype boundary', () => {
expect(violations).toEqual([])
})
it('keeps the retired standalone presentation out of production', () => {
expect(retiredStandaloneArtifacts.filter((artifact) => existsSync(artifact))).toEqual([])
const rendererImports = sourceFiles(standaloneClientRoot).flatMap((file) => {
const source = readFileSync(file, 'utf8')
return source.includes('@renderer') || source.includes('src/renderer') ? [file.pathname] : []
})
expect(rendererImports).toEqual([])
})
})
+1 -3
View File
@@ -23,7 +23,7 @@
"doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:mobile-web": "oxlint src/mobile-web config/build-plugins/mobile-web-content-addressed.ts config/build-plugins/mobile-web-import-boundary.ts config/build-plugins/mobile-web-import-boundary.test.ts config/build-plugins/mobile-web-style-boundary.ts vite.mobile-web.config.ts config/scripts/verify-mobile-web-build.mjs config/scripts/verify-mobile-web-rnw-build.mjs",
"lint:mobile-web": "oxlint src/mobile-web config/scripts/verify-mobile-web-rnw-build.mjs",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
@@ -54,7 +54,6 @@
"dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"dev:mobile-web": "vite --config vite.mobile-web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
@@ -75,7 +74,6 @@
"build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs",
"build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs",
"build:mobile-web": "pnpm run build:mobile-web-rnw",
"build:mobile-web-fixture": "vite build --config vite.mobile-web.config.ts && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/verify-mobile-web-build.mjs",
"build:mobile-web-rnw": "pnpm --dir mobile export:host-web -- ../out/mobile-web-rnw-export && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/package-mobile-web-rnw.mjs && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/verify-mobile-web-rnw-build.mjs",
"build:desktop": "pnpm run typecheck && pnpm run typecheck:mobile-web && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:cli-runtime && pnpm run build:web-from-renderer && pnpm run build:mobile-web",
"build": "pnpm run build:desktop && pnpm run build:native",
-17
View File
@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data: blob:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'"
/>
<title>Orca Workspace</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entry.tsx"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@renderer/assets/main.css'
import '@xterm/xterm/css/xterm.css'
import { MobileWebShell } from './mobile-web-shell'
document.documentElement.classList.add('dark')
const root = document.getElementById('root')
if (!root) {
throw new Error('Mobile web root is missing')
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<MobileWebShell />
</React.StrictMode>
)
@@ -1,147 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { ChevronRight, File, FileSymlink, Folder, Loader2 } from 'lucide-react'
import React from 'react'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { joinMobileWebFilePath, mobileWebFileBreadcrumbs } from './mobile-web-file-path'
import type { MobileWebDirectoryView } from './use-mobile-web-file-directory'
export function MobileWebFileDirectory({
directory,
connected,
onNavigate,
onOpenFile,
onRetry
}: {
directory: MobileWebDirectoryView
connected: boolean
onNavigate: (relativePath: string) => void
onOpenFile: (relativePath: string) => void
onRetry: () => void
}): React.JSX.Element {
const breadcrumbs = mobileWebFileBreadcrumbs(directory.relativePath)
return (
<section aria-label="Workspace files">
<nav
aria-label="File path"
className="flex min-h-10 items-center gap-1 overflow-x-auto border-t border-border px-4 py-1 scrollbar-sleek"
>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.relativePath || 'root'}>
{index > 0 ? (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
) : null}
{index === breadcrumbs.length - 1 ? (
<span className="max-w-48 truncate px-2 text-xs font-medium" aria-current="page">
{breadcrumb.label}
</span>
) : (
<Button
variant="ghost"
size="xs"
className="max-w-48 shrink-0"
disabled={!connected}
onClick={() => onNavigate(breadcrumb.relativePath)}
>
<span className="truncate">{breadcrumb.label}</span>
</Button>
)}
</React.Fragment>
))}
{directory.loading ? (
<Loader2 className="ml-auto size-3.5 shrink-0 animate-spin text-muted-foreground" />
) : null}
</nav>
{directory.error ? (
<div
role="alert"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-3 text-xs text-destructive"
>
<span>{directoryErrorCopy(directory.error)}</span>
{directory.error.retryable ? (
<Button variant="outline" size="xs" disabled={!connected} onClick={onRetry}>
Try again
</Button>
) : null}
</div>
) : null}
<DirectoryEntries
directory={directory}
connected={connected}
onNavigate={onNavigate}
onOpenFile={onOpenFile}
/>
</section>
)
}
function DirectoryEntries({
directory,
connected,
onNavigate,
onOpenFile
}: {
directory: MobileWebDirectoryView
connected: boolean
onNavigate: (relativePath: string) => void
onOpenFile: (relativePath: string) => void
}): React.JSX.Element | null {
const result = directory.result
if (!result) {
return null
}
if (result.entries.length === 0) {
return (
<p className="border-t border-border px-6 py-8 text-center text-xs text-muted-foreground">
This folder is empty.
</p>
)
}
return (
<>
<ul
key={result.revision}
data-directory-revision={result.revision}
className="border-t border-border"
>
{result.entries.map((entry) => {
const relativePath = joinMobileWebFilePath(result.relativePath, entry.name)
return (
<li key={entry.name} className="border-b border-border last:border-b-0">
<Button
variant="ghost"
className="h-auto w-full justify-start rounded-none px-6 py-2 text-left"
disabled={!connected || entry.isSymlink}
onClick={() =>
entry.isDirectory ? onNavigate(relativePath) : onOpenFile(relativePath)
}
>
{entry.isSymlink ? <FileSymlink /> : entry.isDirectory ? <Folder /> : <File />}
<span className="min-w-0 truncate text-xs font-medium">{entry.name}</span>
{entry.isSymlink ? (
<span className="ml-auto text-[11px] text-muted-foreground">
Link unavailable
</span>
) : null}
</Button>
</li>
)
})}
</ul>
{result.truncated ? (
<p className="border-t border-border bg-muted px-6 py-2 text-xs text-muted-foreground">
This folder exceeds the 128-entry mobile listing limit.
</p>
) : null}
</>
)
}
function directoryErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile shell cannot browse folders.'
}
if (error.code === 'not_connected') {
return 'Reconnect to the paired desktop to browse folders.'
}
return 'The paired desktop could not provide this folder.'
}
@@ -1,129 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES,
appendMobileWebFileChunk,
mobileWebFileNextChunkLength
} from './mobile-web-file-document'
describe('mobile web file document', () => {
it('assembles ordered chunks without corrupting split UTF-8 characters', () => {
const first = appendMobileWebFileChunk(null, {
workspaceId: 'workspace-1',
relativePath: 'notes.txt',
offset: 0,
bytes: Uint8Array.from([0x41, 0xe2, 0x82]),
bytesRead: 3,
eof: false
})
expect(first.content).toBe('A')
const complete = appendMobileWebFileChunk(first, {
workspaceId: 'workspace-1',
relativePath: 'notes.txt',
offset: 3,
bytes: Uint8Array.from([0xac, 0x42]),
bytesRead: 2,
eof: true
})
expect(complete.content).toBe('A€B')
expect(complete.kind).toBe('text')
expect(complete.eof).toBe(true)
})
it('rejects reordered, empty non-final, and cross-file chunks', () => {
const initial = appendMobileWebFileChunk(null, {
workspaceId: 'workspace-1',
relativePath: 'notes.txt',
offset: 0,
bytes: Uint8Array.from([65]),
bytesRead: 1,
eof: false
})
expect(() =>
appendMobileWebFileChunk(initial, {
workspaceId: 'workspace-1',
relativePath: 'other.txt',
offset: 1,
bytes: new Uint8Array(),
bytesRead: 0,
eof: false
})
).toThrow()
expect(() =>
appendMobileWebFileChunk(initial, {
workspaceId: 'workspace-1',
relativePath: 'notes.txt',
offset: 0,
bytes: Uint8Array.from([66]),
bytesRead: 1,
eof: true
})
).toThrow()
})
it('detects binary content and enforces the page document cap', () => {
const binary = appendMobileWebFileChunk(null, {
workspaceId: 'workspace-1',
relativePath: 'image.bin',
offset: 0,
bytes: Uint8Array.from([65, 0, 66]),
bytesRead: 3,
eof: true
})
expect(binary.kind).toBe('binary')
const nearLimit = {
workspaceId: 'workspace-1',
relativePath: 'large.txt',
bytes: new Uint8Array(MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES - 1),
content: '',
kind: 'text' as const,
eof: false,
limitReached: false,
revision: null
}
expect(mobileWebFileNextChunkLength(nearLimit)).toBe(1)
const capped = appendMobileWebFileChunk(nearLimit, {
workspaceId: 'workspace-1',
relativePath: 'large.txt',
offset: MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES - 1,
bytes: Uint8Array.from([65]),
bytesRead: 1,
eof: false
})
expect(capped.limitReached).toBe(true)
expect(mobileWebFileNextChunkLength(capped)).toBe(0)
})
it('supports a separate bounded assembly limit for raster images', () => {
const maximum = 4
const initial = appendMobileWebFileChunk(
null,
{
workspaceId: 'workspace-1',
relativePath: 'image.png',
offset: 0,
bytes: Uint8Array.from([0x89, 0x50]),
bytesRead: 2,
eof: false
},
maximum
)
expect(mobileWebFileNextChunkLength(initial, maximum)).toBe(2)
const capped = appendMobileWebFileChunk(
initial,
{
workspaceId: 'workspace-1',
relativePath: 'image.png',
offset: 2,
bytes: Uint8Array.from([0x4e, 0x47]),
bytesRead: 2,
eof: false
},
maximum
)
expect(capped.limitReached).toBe(true)
expect(mobileWebFileNextChunkLength(capped, maximum)).toBe(0)
})
})
@@ -1,74 +0,0 @@
import {
MOBILE_WEB_FILE_CHUNK_MAX_BYTES,
type MobileWebFileChunkResult
} from '../../shared/mobile-web/bridge-operation-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { mobileWebFileRevision } from './mobile-web-file-edit-content'
export const MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES = 1024 * 1024
export type MobileWebFileDocument = {
workspaceId: string
relativePath: string
bytes: Uint8Array
content: string
kind: 'text' | 'binary'
eof: boolean
limitReached: boolean
revision: string | null
}
export function mobileWebFileNextChunkLength(
document: MobileWebFileDocument | null,
maximumBytes = MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES
): number {
const remaining = maximumBytes - (document?.bytes.byteLength ?? 0)
return Math.min(MOBILE_WEB_FILE_CHUNK_MAX_BYTES, remaining)
}
export function appendMobileWebFileChunk(
document: MobileWebFileDocument | null,
chunk: MobileWebFileChunkResult,
maximumBytes = MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES
): MobileWebFileDocument {
const expectedOffset = document?.bytes.byteLength ?? 0
if (
chunk.offset !== expectedOffset ||
(document &&
(document.workspaceId !== chunk.workspaceId ||
document.relativePath !== chunk.relativePath)) ||
(chunk.bytesRead === 0 && !chunk.eof) ||
chunk.bytesRead > maximumBytes - expectedOffset
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const bytes = new Uint8Array(expectedOffset + chunk.bytesRead)
if (document) {
bytes.set(document.bytes)
}
bytes.set(chunk.bytes, expectedOffset)
const limitReached = bytes.byteLength >= maximumBytes && !chunk.eof
const decoded = decodeMobileWebText(bytes, chunk.eof)
return {
workspaceId: chunk.workspaceId,
relativePath: chunk.relativePath,
bytes,
content: decoded ?? '',
kind: document?.kind === 'binary' || decoded === null ? 'binary' : 'text',
eof: chunk.eof,
limitReached,
revision: chunk.eof && decoded !== null ? mobileWebFileRevision(bytes) : null
}
}
function decodeMobileWebText(bytes: Uint8Array, eof: boolean): string | null {
if (bytes.includes(0)) {
return null
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: !eof })
} catch {
return null
}
}
@@ -1,90 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { Loader2 } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import { MOBILE_WEB_FILE_EDIT_MAX_BYTES } from '../../shared/mobile-web/file-edit-contract'
import type { MobileWebFileEditorState } from './use-mobile-web-file-editor'
export function MobileWebFileEditor({
editor,
connected,
onChange,
onSave,
onCancel
}: {
editor: Exclude<MobileWebFileEditorState, { status: 'idle' }>
connected: boolean
onChange: (value: string) => void
onSave: () => void
onCancel: () => void
}): React.JSX.Element {
const [showSaving, setShowSaving] = useState(false)
const byteLength = new TextEncoder().encode(editor.draft).byteLength
const tooLarge = byteLength > MOBILE_WEB_FILE_EDIT_MAX_BYTES
const unchanged = editor.draft === editor.initialContent
useEffect(() => {
if (editor.status !== 'saving') {
setShowSaving(false)
return
}
const timer = window.setTimeout(() => setShowSaving(true), 200)
return () => window.clearTimeout(timer)
}, [editor.status])
return (
<div className="border-t border-border">
<textarea
aria-label={`Edit ${editor.relativePath}`}
className="min-h-64 w-full resize-y bg-input px-4 py-3 font-mono text-xs text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={editor.draft}
disabled={editor.status === 'saving'}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onChange={(event) => onChange(event.currentTarget.value)}
/>
{editor.status === 'error' ? (
<p role="alert" className="border-t border-border px-4 py-2 text-xs text-destructive">
{fileEditErrorCopy(editor.error.code)}
</p>
) : null}
<div className="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
<p className={tooLarge ? 'text-xs text-destructive' : 'text-xs text-muted-foreground'}>
{byteLength.toLocaleString()} / {MOBILE_WEB_FILE_EDIT_MAX_BYTES.toLocaleString()} bytes
</p>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
disabled={editor.status === 'saving'}
onClick={onCancel}
>
Cancel
</Button>
<Button
className="w-20"
size="sm"
disabled={!connected || unchanged || tooLarge || editor.status === 'saving'}
onClick={onSave}
>
{showSaving ? <Loader2 className="animate-spin" /> : null}
{showSaving ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
</div>
)
}
function fileEditErrorCopy(code: string): string {
if (code === 'conflict') {
return 'This file changed on the desktop. Cancel and reopen it before saving.'
}
if (code === 'too_large') {
return 'Mobile editing is limited to 128 KiB of UTF-8 text.'
}
if (code === 'not_connected') {
return 'Reconnect to the paired desktop before saving.'
}
return 'The paired desktop could not save this file.'
}
@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest'
import { joinMobileWebFilePath, mobileWebFileBreadcrumbs } from './mobile-web-file-path'
describe('mobile web file paths', () => {
it('joins relative directory entries and derives navigable breadcrumbs', () => {
expect(joinMobileWebFilePath('', 'src')).toBe('src')
expect(joinMobileWebFilePath('src/components', 'button.tsx')).toBe('src/components/button.tsx')
expect(mobileWebFileBreadcrumbs('src/components')).toEqual([
{ label: 'Workspace', relativePath: '' },
{ label: 'src', relativePath: 'src' },
{ label: 'components', relativePath: 'src/components' }
])
})
})
@@ -1,19 +0,0 @@
export type MobileWebFileBreadcrumb = {
label: string
relativePath: string
}
export function joinMobileWebFilePath(directory: string, name: string): string {
return directory ? `${directory}/${name}` : name
}
export function mobileWebFileBreadcrumbs(relativePath: string): MobileWebFileBreadcrumb[] {
const breadcrumbs: MobileWebFileBreadcrumb[] = [{ label: 'Workspace', relativePath: '' }]
const segments = relativePath ? relativePath.split('/') : []
let current = ''
for (const segment of segments) {
current = joinMobileWebFilePath(current, segment)
breadcrumbs.push({ label: segment, relativePath: current })
}
return breadcrumbs
}
@@ -1,162 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { FileWarning, Loader2, Pencil, X } from 'lucide-react'
import React from 'react'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { MobileWebFileEditor } from './mobile-web-file-editor'
import {
MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES,
type MobileWebFileDocument
} from './mobile-web-file-document'
import { isMobileWebRasterImagePath } from './mobile-web-raster-image'
import { MobileWebRasterImagePreview } from './mobile-web-raster-image-preview'
import { MobileWebFileTextPreview } from './mobile-web-file-text-preview'
import type { MobileWebFilePreviewState } from './use-mobile-web-file-document'
import type { MobileWebFileEditorState } from './use-mobile-web-file-editor'
export function MobileWebFilePreview({
preview,
connected,
editor,
onLoadMore,
onCancel,
onBeginEdit,
onEditChange,
onSaveEdit,
onCancelEdit
}: {
preview: MobileWebFilePreviewState
connected: boolean
editor: MobileWebFileEditorState
onLoadMore: () => void
onCancel: () => void
onBeginEdit: () => void
onEditChange: (value: string) => void
onSaveEdit: () => void
onCancelEdit: () => void
}): React.JSX.Element | null {
if (preview.status === 'idle') {
return null
}
const document = preview.document
const relativePath =
preview.status === 'loading' || preview.status === 'error'
? preview.relativePath
: preview.document.relativePath
return (
<section className="border-t border-border" aria-label={`Preview ${relativePath}`}>
<div className="flex items-center justify-between gap-3 px-6 py-2">
<p className="truncate font-mono text-xs">{relativePath}</p>
{document ? (
<div className="flex shrink-0 items-center gap-2">
<p className="text-[11px] text-muted-foreground">
{document.bytes.byteLength.toLocaleString()} bytes loaded
</p>
{canEdit(document) && editor.status === 'idle' ? (
<Button variant="outline" size="xs" disabled={!connected} onClick={onBeginEdit}>
<Pencil />
Edit
</Button>
) : null}
</div>
) : null}
</div>
{preview.status === 'loading' ? (
<div
role="status"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-2 text-xs text-muted-foreground"
>
<span className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
Loading file content
</span>
<Button variant="ghost" size="xs" onClick={onCancel}>
<X />
Cancel
</Button>
</div>
) : null}
{preview.status === 'error' ? (
<div
role="alert"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-2 text-xs text-destructive"
>
<span>{fileErrorCopy(preview.error)}</span>
{preview.error.retryable ? (
<Button variant="outline" size="xs" disabled={!connected} onClick={onLoadMore}>
Try again
</Button>
) : null}
</div>
) : null}
{document ? (
<>
{editor.status !== 'idle' && editor.relativePath === document.relativePath ? (
<MobileWebFileEditor
editor={editor}
connected={connected}
onChange={onEditChange}
onSave={onSaveEdit}
onCancel={onCancelEdit}
/>
) : document.kind === 'binary' ? (
isMobileWebRasterImagePath(document.relativePath) ? (
<MobileWebRasterImagePreview document={document} />
) : (
<p className="flex items-center gap-2 border-t border-border px-6 py-6 text-xs text-muted-foreground">
<FileWarning className="size-4" />
Binary preview is not available for this file type.
</p>
)
) : (
<MobileWebFileTextPreview key={document.relativePath} document={document} />
)}
{editor.status === 'idle' && document.limitReached ? (
<p className="border-t border-border bg-muted px-6 py-2 text-xs text-muted-foreground">
Preview stopped at the {formatByteLimit(MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES)} mobile
document limit.
</p>
) : null}
{editor.status === 'idle' &&
!document.eof &&
!document.limitReached &&
document.kind === 'text' ? (
<div className="border-t border-border px-6 py-3">
<Button
variant="outline"
size="sm"
disabled={!connected || preview.status === 'loading'}
onClick={onLoadMore}
>
Load more
</Button>
</div>
) : null}
</>
) : null}
</section>
)
}
function canEdit(document: MobileWebFileDocument): boolean {
return (
document.kind === 'text' && document.eof && !document.limitReached && document.revision !== null
)
}
function fileErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile shell cannot stream file content.'
}
if (error.code === 'not_connected') {
return 'Reconnect to the paired desktop to load this file.'
}
if (error.code === 'cancelled') {
return 'File loading was cancelled.'
}
return 'The paired desktop could not provide this file.'
}
function formatByteLimit(bytes: number): string {
return `${bytes / (1024 * 1024)} MiB`
}
@@ -1,35 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildMobileWebFileSyntax,
MOBILE_WEB_SYNTAX_MAX_CHARACTERS,
mobileWebSyntaxLanguage
} from './mobile-web-file-syntax'
describe('mobile web file syntax', () => {
it('detects curated languages and degrades unsupported files to plaintext', () => {
expect(mobileWebSyntaxLanguage('src/app.TSX')).toBe('typescript')
expect(mobileWebSyntaxLanguage('scripts/release.sh')).toBe('bash')
expect(mobileWebSyntaxLanguage('Dockerfile')).toBe('bash')
expect(mobileWebSyntaxLanguage('src/main.rs')).toBe('plaintext')
})
it('highlights source without changing or interpreting its text', () => {
const content = 'const answer: number = 42\n// <script>still text</script>'
const syntax = buildMobileWebFileSyntax('src/app.ts', content)
expect(syntax.highlighted).toBe(true)
expect(syntax.segments.map((segment) => segment.text).join('')).toBe(content)
expect(syntax.segments.some((segment) => segment.kind === 'keyword')).toBe(true)
expect(syntax.segments.some((segment) => segment.kind === 'comment')).toBe(true)
})
it('bounds highlighting while retaining the complete loaded source', () => {
const content = `const first = 1\n${'const value = 2\n'.repeat(
MOBILE_WEB_SYNTAX_MAX_CHARACTERS
)}`
const syntax = buildMobileWebFileSyntax('large.ts', content)
expect(syntax.segments.map((segment) => segment.text).join('')).toBe(content)
expect(syntax.segments.at(-1)?.kind).toBe('plain')
})
})
@@ -1,209 +0,0 @@
import bash from 'highlight.js/lib/languages/bash'
import css from 'highlight.js/lib/languages/css'
import javascript from 'highlight.js/lib/languages/javascript'
import json from 'highlight.js/lib/languages/json'
import markdown from 'highlight.js/lib/languages/markdown'
import python from 'highlight.js/lib/languages/python'
import typescript from 'highlight.js/lib/languages/typescript'
import xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
import { createLowlight } from 'lowlight'
export const MOBILE_WEB_SYNTAX_MAX_CHARACTERS = 48_000
export const MOBILE_WEB_SYNTAX_MAX_SEGMENTS = 3_000
export type MobileWebSyntaxKind =
| 'plain'
| 'comment'
| 'keyword'
| 'string'
| 'number'
| 'type'
| 'function'
| 'variable'
| 'meta'
export type MobileWebSyntaxSegment = {
text: string
kind: MobileWebSyntaxKind
}
export type MobileWebFileSyntax = {
language: string
segments: MobileWebSyntaxSegment[]
highlighted: boolean
}
type LowlightNode = {
type: string
value?: string
properties?: { className?: unknown }
children?: LowlightNode[]
}
const lowlight = createLowlight({
bash,
css,
javascript,
json,
markdown,
python,
typescript,
xml,
yaml
})
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
bash: 'bash',
cjs: 'javascript',
css: 'css',
htm: 'xml',
html: 'xml',
js: 'javascript',
json: 'json',
jsonc: 'json',
jsx: 'javascript',
markdown: 'markdown',
md: 'markdown',
mdown: 'markdown',
mjs: 'javascript',
mkd: 'markdown',
py: 'python',
sh: 'bash',
svg: 'xml',
ts: 'typescript',
tsx: 'typescript',
xml: 'xml',
yaml: 'yaml',
yml: 'yaml',
zsh: 'bash'
}
export function buildMobileWebFileSyntax(
relativePath: string,
content: string
): MobileWebFileSyntax {
const language = mobileWebSyntaxLanguage(relativePath)
if (language === 'plaintext' || content.length === 0) {
return plainSyntax(language, content)
}
const highlightLength = highlightBoundary(content)
try {
const tree = lowlight.highlight(language, content.slice(0, highlightLength)) as LowlightNode
const segments = mergeAdjacent(flattenNodes(tree.children ?? [], 'plain'))
if (segments.length > MOBILE_WEB_SYNTAX_MAX_SEGMENTS) {
return plainSyntax(language, content)
}
if (highlightLength < content.length) {
appendSegment(segments, { text: content.slice(highlightLength), kind: 'plain' })
}
return { language, segments, highlighted: true }
} catch {
return plainSyntax(language, content)
}
}
export function mobileWebSyntaxLanguage(relativePath: string): string {
const basename = relativePath.split(/[\\/]/).at(-1)?.toLowerCase() ?? ''
if (basename === 'dockerfile' || basename === 'makefile') {
return 'bash'
}
const extension = basename.includes('.') ? basename.split('.').at(-1) : undefined
return extension ? (LANGUAGE_BY_EXTENSION[extension] ?? 'plaintext') : 'plaintext'
}
function highlightBoundary(content: string): number {
if (content.length <= MOBILE_WEB_SYNTAX_MAX_CHARACTERS) {
return content.length
}
const boundary = content.lastIndexOf('\n', MOBILE_WEB_SYNTAX_MAX_CHARACTERS)
return boundary > 0 ? boundary + 1 : MOBILE_WEB_SYNTAX_MAX_CHARACTERS
}
function flattenNodes(
nodes: LowlightNode[],
inheritedKind: MobileWebSyntaxKind
): MobileWebSyntaxSegment[] {
const segments: MobileWebSyntaxSegment[] = []
for (const node of nodes) {
if (node.type === 'text') {
appendSegment(segments, { text: node.value ?? '', kind: inheritedKind })
continue
}
if (node.type !== 'element') {
continue
}
const kind = syntaxKind(node.properties?.className) ?? inheritedKind
for (const segment of flattenNodes(node.children ?? [], kind)) {
appendSegment(segments, segment)
}
}
return segments
}
function syntaxKind(className: unknown): MobileWebSyntaxKind | null {
const classes = Array.isArray(className)
? className.filter((value): value is string => typeof value === 'string')
: typeof className === 'string'
? className.split(/\s+/)
: []
const values = new Set(classes.map((value) => value.replace(/^hljs-/, '')))
if (hasAny(values, ['comment', 'quote'])) {
return 'comment'
}
if (hasAny(values, ['keyword', 'selector-tag', 'tag', 'name'])) {
return 'keyword'
}
if (hasAny(values, ['string', 'regexp', 'symbol', 'bullet'])) {
return 'string'
}
if (hasAny(values, ['number', 'literal'])) {
return 'number'
}
if (hasAny(values, ['type', 'built_in', 'class', 'title.class'])) {
return 'type'
}
if (hasAny(values, ['title.function', 'function', 'title'])) {
return 'function'
}
if (hasAny(values, ['attr', 'attribute', 'property', 'variable', 'params'])) {
return 'variable'
}
if (hasAny(values, ['meta', 'doctag', 'subst', 'section'])) {
return 'meta'
}
return null
}
function hasAny(values: Set<string>, candidates: string[]): boolean {
return candidates.some((candidate) => values.has(candidate))
}
function mergeAdjacent(segments: MobileWebSyntaxSegment[]): MobileWebSyntaxSegment[] {
const result: MobileWebSyntaxSegment[] = []
for (const segment of segments) {
appendSegment(result, segment)
}
return result
}
function appendSegment(segments: MobileWebSyntaxSegment[], segment: MobileWebSyntaxSegment): void {
if (!segment.text) {
return
}
const previous = segments.at(-1)
if (previous?.kind === segment.kind) {
previous.text += segment.text
return
}
segments.push({ ...segment })
}
function plainSyntax(language: string, content: string): MobileWebFileSyntax {
return {
language,
segments: [{ text: content, kind: 'plain' }],
highlighted: false
}
}
@@ -1,109 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import type { MobileWebFileDocument } from './mobile-web-file-document'
import { isMobileWebMarkdownPath, MobileWebFileTextPreview } from './mobile-web-file-text-preview'
import { MOBILE_WEB_MARKDOWN_PREVIEW_MAX_NODES } from './mobile-web-inert-markdown'
afterEach(cleanup)
describe('MobileWebFileTextPreview', () => {
it('classifies supported Markdown paths without treating other dotted files as Markdown', () => {
expect(isMobileWebMarkdownPath('README.MD')).toBe(true)
expect(isMobileWebMarkdownPath('docs/plan.markdown')).toBe(true)
expect(isMobileWebMarkdownPath('src/markdown.ts')).toBe(false)
expect(isMobileWebMarkdownPath('.markdownlint.json')).toBe(false)
})
it('renders GFM while dropping raw HTML and all repository-controlled navigation targets', () => {
const view = render(
createElement(MobileWebFileTextPreview, {
document: fileDocument(
'README.md',
[
'# Safe preview',
'',
'| State | Value |',
'| --- | --- |',
'| Ready | yes |',
'',
'[External](https://example.com/path)',
'![Secret](https://example.com/secret.png)',
'<script>window.pwned = true</script>',
'<iframe src="https://example.com"></iframe>'
].join('\n')
)
})
)
expect(screen.getByRole('heading', { name: 'Safe preview' })).toBeDefined()
expect(screen.getByRole('table')).toBeDefined()
expect(screen.getByText('External')).toBeDefined()
expect(screen.getByLabelText('Image: Secret')).toBeDefined()
expect(view.container.querySelector('a')).toBeNull()
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('script')).toBeNull()
expect(view.container.querySelector('iframe')).toBeNull()
expect(view.container.querySelector('[href], [src]')).toBeNull()
})
it('switches between rendered Markdown and inert source without executing source HTML', () => {
const source = '# Heading\n\n<script>not executable</script>'
const view = render(
createElement(MobileWebFileTextPreview, {
document: fileDocument('README.md', source)
})
)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(screen.getByLabelText('File source preview').textContent).toBe(source)
expect(view.container.querySelector('script')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
expect(screen.getByRole('heading', { name: 'Heading' })).toBeDefined()
})
it('keeps non-Markdown source in an inert code surface', () => {
const source = '<img src="https://example.com/tracker.png">'
const view = render(
createElement(MobileWebFileTextPreview, {
document: fileDocument('src/app.ts', source)
})
)
expect(screen.getByLabelText('File source preview').textContent).toBe(source)
expect(view.container.querySelector('img')).toBeNull()
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull()
})
it('stops repository-controlled Markdown before it can create an unbounded document', () => {
const headingCount = MOBILE_WEB_MARKDOWN_PREVIEW_MAX_NODES + 100
const view = render(
createElement(MobileWebFileTextPreview, {
document: fileDocument(
'large.md',
Array.from({ length: headingCount }, () => '# x').join('\n')
)
})
)
expect(view.container.querySelectorAll('h1').length).toBeLessThan(headingCount)
expect(screen.getByRole('status').textContent).toContain('mobile Markdown limit')
})
})
function fileDocument(relativePath: string, content: string): MobileWebFileDocument {
const bytes = new TextEncoder().encode(content)
return {
workspaceId: 'workspace-1',
relativePath,
bytes,
content,
kind: 'text',
eof: true,
limitReached: false,
revision: 'a'.repeat(64)
}
}
@@ -1,123 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { Code, Eye } from 'lucide-react'
import React, { useState } from 'react'
import type { MobileWebFileDocument } from './mobile-web-file-document'
import { buildMobileWebFileSyntax, type MobileWebSyntaxKind } from './mobile-web-file-syntax'
import { MobileWebInertMarkdown } from './mobile-web-inert-markdown'
const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown', 'mdown', 'mkd'])
export function MobileWebFileTextPreview({
document
}: {
document: MobileWebFileDocument
}): React.JSX.Element {
const markdown = isMobileWebMarkdownPath(document.relativePath)
const [mode, setMode] = useState<'preview' | 'source'>(() => (markdown ? 'preview' : 'source'))
if (!markdown) {
return (
<MobileWebFileSourcePreview relativePath={document.relativePath} content={document.content} />
)
}
return (
<div className="border-t border-border">
<div
className="flex items-center gap-2 border-b border-border px-6 py-2"
aria-label="Markdown view"
>
<Button
variant={mode === 'preview' ? 'secondary' : 'ghost'}
size="xs"
aria-pressed={mode === 'preview'}
onClick={() => setMode('preview')}
>
<Eye />
Preview
</Button>
<Button
variant={mode === 'source' ? 'secondary' : 'ghost'}
size="xs"
aria-pressed={mode === 'source'}
onClick={() => setMode('source')}
>
<Code />
Source
</Button>
</div>
{mode === 'preview' ? (
<article
aria-label={`Rendered Markdown preview of ${document.relativePath}`}
className="max-h-96 overflow-auto bg-[var(--editor-surface)] px-6 py-4 text-sm scrollbar-editor"
>
<MobileWebInertMarkdown content={document.content} />
</article>
) : (
<MobileWebFileSourcePreview
relativePath={document.relativePath}
content={document.content}
border={false}
/>
)}
</div>
)
}
export function isMobileWebMarkdownPath(relativePath: string): boolean {
const basename = relativePath.split(/[\\/]/).at(-1) ?? ''
const extension = basename.includes('.') ? basename.split('.').at(-1)?.toLowerCase() : ''
return extension ? MARKDOWN_EXTENSIONS.has(extension) : false
}
function MobileWebFileSourcePreview({
relativePath,
content,
border = true
}: {
relativePath: string
content: string
border?: boolean
}): React.JSX.Element {
const syntax = buildMobileWebFileSyntax(relativePath, content)
return (
<pre
aria-label="File source preview"
className={`max-h-96 overflow-auto whitespace-pre-wrap break-words bg-[var(--editor-surface)] p-4 font-mono text-xs scrollbar-editor ${
border ? 'border-t border-border' : ''
}`}
>
{syntax.segments.map((segment, index) => (
<span
key={`${index}-${segment.kind}`}
data-syntax-kind={segment.kind}
className={syntaxClassName(segment.kind)}
>
{segment.text}
</span>
))}
</pre>
)
}
function syntaxClassName(kind: MobileWebSyntaxKind): string | undefined {
if (kind === 'comment') {
return 'text-muted-foreground italic'
}
if (kind === 'keyword' || kind === 'type') {
return 'font-semibold'
}
if (kind === 'function' || kind === 'meta') {
return 'font-medium underline decoration-border'
}
if (kind === 'variable') {
return 'font-medium'
}
if (kind === 'string') {
return 'text-muted-foreground'
}
if (kind === 'number') {
return 'tabular-nums'
}
return undefined
}
@@ -1,333 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
MobileWebFileChunkResult,
MobileWebFileDirectoryResult,
MobileWebFileListResult
} from '../../shared/mobile-web/bridge-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { mobileWebFileRevision } from './mobile-web-file-edit-content'
import { MobileWebFiles } from './mobile-web-files'
beforeEach(() => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:private-image'),
revokeObjectURL: vi.fn()
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('MobileWebFiles', () => {
it('keeps path search literal on mobile keyboards', () => {
const client = fileClient()
render(createElement(MobileWebFiles, { client, workspaceId: 'workspace-1', connected: true }))
const input = screen.getByRole('textbox', { name: 'Search workspace files' })
expect(input.getAttribute('autocapitalize')).toBe('none')
expect(input.getAttribute('autocorrect')).toBe('off')
expect(input.getAttribute('spellcheck')).toBe('false')
})
it('navigates bounded directories, searches paths, and renders file bytes as inert text', async () => {
const client = fileClient()
render(createElement(MobileWebFiles, { client, workspaceId: 'workspace-1', connected: true }))
fireEvent.click(await screen.findByText('src'))
expect(await screen.findByText('app.ts')).toBeDefined()
fireEvent.click(screen.getByText('app.ts'))
expect(await screen.findByText('<script>not executable</script>')).toBeDefined()
const search = screen.getByLabelText('Search workspace files')
fireEvent.change(search, { target: { value: 'logo' } })
fireEvent.submit(search.closest('form')!)
await vi.waitFor(() =>
expect(client.fileSearch).toHaveBeenCalledWith(
{ workspaceId: 'workspace-1', query: 'logo', limit: 32 },
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
)
fireEvent.click(await screen.findByText('logo.png'))
expect(await screen.findByRole('img', { name: 'Preview of logo.png' })).toBeDefined()
expect(client.fileReadChunk).toHaveBeenLastCalledWith(
expect.objectContaining({ relativePath: 'assets/logo.png', offset: 0 }),
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('loads ordered chunks on demand without corrupting split UTF-8', async () => {
const firstBytes = Uint8Array.from([0x41, 0xe2, 0x82])
const secondBytes = Uint8Array.from([0xac, 0x42])
const client = fileClient()
client.fileReadChunk
.mockResolvedValueOnce(fileChunk('large.txt', 0, firstBytes, false))
.mockResolvedValueOnce(fileChunk('large.txt', firstBytes.byteLength, secondBytes, true))
render(createElement(MobileWebFiles, { client, workspaceId: 'workspace-1', connected: true }))
fireEvent.click(await screen.findByText('large.txt'))
expect(await screen.findByText('A')).toBeDefined()
fireEvent.click(screen.getByText('Load more'))
expect(await screen.findByText('A€B')).toBeDefined()
expect(client.fileReadChunk).toHaveBeenLastCalledWith(
expect.objectContaining({ relativePath: 'large.txt', offset: 3 }),
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('aborts and drops a delayed file chunk after the workspace client changes', async () => {
let resolveChunk: ((result: MobileWebFileChunkResult) => void) | undefined
const first = fileClient()
first.fileReadChunk.mockReturnValue(
new Promise((resolve) => {
resolveChunk = resolve
})
)
const second = fileClient(emptyDirectory('workspace-2'))
const view = render(
createElement(MobileWebFiles, {
client: first,
workspaceId: 'workspace-1',
connected: true
})
)
fireEvent.click(await screen.findByText('large.txt'))
const requestSignal = first.fileReadChunk.mock.calls[0]?.[1]?.signal as AbortSignal
view.rerender(
createElement(MobileWebFiles, {
client: second,
workspaceId: 'workspace-2',
connected: true
})
)
expect(requestSignal.aborted).toBe(true)
resolveChunk?.(
fileChunk('large.txt', 0, new TextEncoder().encode('old-host-secret'), true, 'workspace-1')
)
await Promise.resolve()
expect(screen.queryByText('old-host-secret')).toBeNull()
})
it('aborts an image read and revokes its object URL when the workspace changes', async () => {
let resolveSecondChunk: ((result: MobileWebFileChunkResult) => void) | undefined
const first = fileClient()
const signature = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
first.fileReadChunk
.mockResolvedValueOnce(fileChunk('assets/logo.png', 0, signature, false))
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondChunk = resolve
})
)
const second = fileClient(emptyDirectory('workspace-2'))
const view = render(
createElement(MobileWebFiles, {
client: first,
workspaceId: 'workspace-1',
connected: true
})
)
const search = screen.getByLabelText('Search workspace files')
fireEvent.change(search, { target: { value: 'logo' } })
fireEvent.submit(search.closest('form')!)
fireEvent.click(await screen.findByText('logo.png'))
await vi.waitFor(() => expect(first.fileReadChunk).toHaveBeenCalledTimes(2))
const requestSignal = first.fileReadChunk.mock.calls[1]?.[1]?.signal as AbortSignal
view.rerender(
createElement(MobileWebFiles, {
client: second,
workspaceId: 'workspace-2',
connected: true
})
)
expect(requestSignal.aborted).toBe(true)
resolveSecondChunk?.(
fileChunk('assets/logo.png', signature.byteLength, Uint8Array.from([0]), true)
)
await Promise.resolve()
expect(screen.queryByRole('img', { name: 'Preview of logo.png' })).toBeNull()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('revokes a completed image URL when switching workspace clients', async () => {
const first = fileClient()
const second = fileClient(emptyDirectory('workspace-2'))
const view = render(
createElement(MobileWebFiles, {
client: first,
workspaceId: 'workspace-1',
connected: true
})
)
const search = screen.getByLabelText('Search workspace files')
fireEvent.change(search, { target: { value: 'logo' } })
fireEvent.submit(search.closest('form')!)
fireEvent.click(await screen.findByText('logo.png'))
expect(await screen.findByRole('img', { name: 'Preview of logo.png' })).toBeDefined()
view.rerender(
createElement(MobileWebFiles, {
client: second,
workspaceId: 'workspace-2',
connected: true
})
)
await vi.waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:private-image'))
})
it('edits a complete text file with its loaded revision and reloads after save', async () => {
const client = fileClient()
render(createElement(MobileWebFiles, { client, workspaceId: 'workspace-1', connected: true }))
fireEvent.click(await screen.findByText('large.txt'))
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const editor = screen.getByRole('textbox', { name: 'Edit large.txt' })
fireEvent.change(editor, { target: { value: 'updated content' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await vi.waitFor(() =>
expect(client.fileWrite).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
relativePath: 'large.txt',
expectedRevision: mobileWebFileRevision(
new TextEncoder().encode('<script>not executable</script>')
),
contentBase64: btoa('updated content')
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
)
expect(await screen.findByText('<script>not executable</script>')).toBeDefined()
})
it('keeps the draft and shows an actionable conflict when Desktop changed the file', async () => {
const client = fileClient()
client.fileWrite.mockRejectedValueOnce(new MobileWebBridgeClientError('conflict', false))
render(createElement(MobileWebFiles, { client, workspaceId: 'workspace-1', connected: true }))
fireEvent.click(await screen.findByText('large.txt'))
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByRole('textbox', { name: 'Edit large.txt' }), {
target: { value: 'stale draft' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(
await screen.findByText(
'This file changed on the desktop. Cancel and reopen it before saving.'
)
).toBeDefined()
expect(screen.getByDisplayValue('stale draft')).toBeDefined()
})
})
function fileClient(root = rootDirectory()): MobileWebBridgeClient & {
fileDirectory: ReturnType<typeof vi.fn>
fileSearch: ReturnType<typeof vi.fn>
fileReadChunk: ReturnType<typeof vi.fn>
fileWrite: ReturnType<typeof vi.fn>
} {
return {
fileDirectory: vi.fn().mockImplementation(({ workspaceId, relativePath }) => {
if (relativePath === 'src') {
return Promise.resolve({
workspaceId,
relativePath,
revision: 'b'.repeat(64),
entries: [{ name: 'app.ts', isDirectory: false, isSymlink: false }],
truncated: false
})
}
return Promise.resolve({ ...root, workspaceId })
}),
fileSearch: vi.fn().mockResolvedValue(searchResult()),
fileWrite: vi.fn().mockImplementation(({ workspaceId, relativePath, contentBase64 }) => {
const bytes = Uint8Array.from(atob(contentBase64), (character) => character.charCodeAt(0))
return Promise.resolve({
workspaceId,
relativePath,
revision: mobileWebFileRevision(bytes),
byteLength: bytes.byteLength,
outcome: 'updated'
})
}),
fileReadChunk: vi.fn().mockImplementation(({ workspaceId, relativePath, offset }) => {
const bytes =
relativePath === 'assets/logo.png'
? Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
: new TextEncoder().encode('<script>not executable</script>')
return Promise.resolve({
workspaceId,
relativePath,
offset,
bytes,
bytesRead: bytes.byteLength,
eof: true
})
})
} as unknown as MobileWebBridgeClient & {
fileDirectory: ReturnType<typeof vi.fn>
fileSearch: ReturnType<typeof vi.fn>
fileReadChunk: ReturnType<typeof vi.fn>
fileWrite: ReturnType<typeof vi.fn>
}
}
function rootDirectory(): MobileWebFileDirectoryResult {
return {
workspaceId: 'workspace-1',
relativePath: '',
revision: 'a'.repeat(64),
entries: [
{ name: 'src', isDirectory: true, isSymlink: false },
{ name: 'large.txt', isDirectory: false, isSymlink: false }
],
truncated: false
}
}
function emptyDirectory(workspaceId: string): MobileWebFileDirectoryResult {
return {
workspaceId,
relativePath: '',
revision: 'c'.repeat(64),
entries: [],
truncated: false
}
}
function searchResult(): MobileWebFileListResult {
return {
workspaceId: 'workspace-1',
files: [{ relativePath: 'assets/logo.png', basename: 'logo.png', kind: 'binary' }],
totalCount: 1,
truncated: false
}
}
function fileChunk(
relativePath: string,
offset: number,
bytes: Uint8Array,
eof: boolean,
workspaceId = 'workspace-1'
): MobileWebFileChunkResult {
return {
workspaceId,
relativePath,
offset,
bytes,
bytesRead: bytes.byteLength,
eof
}
}
-284
View File
@@ -1,284 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Input } from '@renderer/components/ui/input'
import { File, FileWarning, Loader2, Search, X } from 'lucide-react'
import React, { useEffect, useState } from 'react'
import type {
MobileWebFileEntry,
MobileWebFileListResult
} from '../../shared/mobile-web/bridge-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { MobileWebFileDirectory } from './mobile-web-file-directory'
import { MobileWebFilePreview } from './mobile-web-file-preview'
import { useMobileWebFileDirectory } from './use-mobile-web-file-directory'
import { useMobileWebFileDocument } from './use-mobile-web-file-document'
import { useMobileWebFileEditor } from './use-mobile-web-file-editor'
type SearchState = {
loading: boolean
result: MobileWebFileListResult | null
error: MobileWebBridgeClientError | null
}
export function MobileWebFiles({
client,
workspaceId,
connected
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
}): React.JSX.Element {
const [query, setQuery] = useState('')
const [submittedQuery, setSubmittedQuery] = useState('')
const [searchRetry, setSearchRetry] = useState(0)
const [search, setSearch] = useState<SearchState>({
loading: false,
result: null,
error: null
})
const directory = useMobileWebFileDirectory({ client, workspaceId, connected })
const fileDocument = useMobileWebFileDocument({ client, workspaceId, connected })
const fileEditor = useMobileWebFileEditor({
client,
workspaceId,
connected,
onSaved: fileDocument.openTextFile
})
useEffect(() => {
setQuery('')
setSubmittedQuery('')
setSearch({ loading: false, result: null, error: null })
}, [client, workspaceId])
useEffect(() => {
if (!connected || !submittedQuery) {
setSearch((current) => ({ ...current, loading: false }))
return
}
const controller = new AbortController()
setSearch((current) => ({ ...current, loading: true, error: null }))
void client
.fileSearch({ workspaceId, query: submittedQuery, limit: 32 }, { signal: controller.signal })
.then((result) => {
if (result.workspaceId !== workspaceId) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
if (!controller.signal.aborted) {
setSearch({ loading: false, result, error: null })
}
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setSearch({ loading: false, result: null, error: bridgeClientError(error) })
}
})
return () => controller.abort()
}, [client, connected, searchRetry, submittedQuery, workspaceId])
const searching = submittedQuery.length > 0
const loading = searching ? search.loading : directory.loading
const openSearchFile = (file: MobileWebFileEntry): void => {
fileEditor.cancel()
if (file.kind === 'binary') {
fileDocument.openBinaryFile(file.relativePath)
} else {
fileDocument.openTextFile(file.relativePath)
}
}
return (
<Card className="mt-4" aria-busy={loading}>
<CardHeader>
<div className="space-y-1">
<CardTitle>Files</CardTitle>
<CardDescription>
{fileCountCopy(searching ? search.result : null, submittedQuery)}
</CardDescription>
</div>
<CardAction>
{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : null}
</CardAction>
</CardHeader>
<CardContent className="px-0">
<form
className="flex gap-2 border-t border-border px-6 py-3"
onSubmit={(event) => {
event.preventDefault()
fileDocument.cancelLoad()
fileEditor.cancel()
setSubmittedQuery(query.trim())
}}
>
<Input
aria-label="Search workspace files"
placeholder="Search files"
value={query}
disabled={!connected}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
{searching ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setQuery('')
setSubmittedQuery('')
}}
>
<X />
<span className="sr-only">Clear file search</span>
</Button>
) : null}
<Button type="submit" variant="outline" size="icon" disabled={!connected}>
<Search />
<span className="sr-only">Search files</span>
</Button>
</form>
{searching ? (
<MobileWebFileSearchResults
search={search}
connected={connected}
onOpen={openSearchFile}
onRetry={() => setSearchRetry((value) => value + 1)}
/>
) : (
<MobileWebFileDirectory
directory={directory}
connected={connected}
onNavigate={(relativePath) => {
fileDocument.cancelLoad()
fileEditor.cancel()
directory.navigate(relativePath)
}}
onOpenFile={(relativePath) => {
fileEditor.cancel()
fileDocument.openFile(relativePath)
}}
onRetry={directory.retry}
/>
)}
<MobileWebFilePreview
preview={fileDocument.preview}
connected={connected}
editor={fileEditor.editor}
onLoadMore={fileDocument.loadMore}
onCancel={fileDocument.cancelLoad}
onBeginEdit={() => {
const preview = fileDocument.preview
if (preview.status === 'ready') {
fileEditor.begin(preview.document)
}
}}
onEditChange={fileEditor.setDraft}
onSaveEdit={fileEditor.save}
onCancelEdit={fileEditor.cancel}
/>
</CardContent>
</Card>
)
}
function MobileWebFileSearchResults({
search,
connected,
onOpen,
onRetry
}: {
search: SearchState
connected: boolean
onOpen: (file: MobileWebFileEntry) => void
onRetry: () => void
}): React.JSX.Element | null {
if (search.error) {
return (
<div role="alert" className="border-t border-border px-6 py-3 text-xs text-destructive">
{fileErrorCopy(search.error)}
{search.error.retryable ? (
<Button
className="ml-2"
variant="outline"
size="xs"
disabled={!connected}
onClick={onRetry}
>
Try again
</Button>
) : null}
</div>
)
}
if (!search.result) {
return null
}
if (search.result.files.length === 0) {
return (
<p className="border-t border-border px-6 py-8 text-center text-xs text-muted-foreground">
No matching files.
</p>
)
}
return (
<ul className="border-t border-border">
{search.result.files.map((file) => (
<li key={file.relativePath} className="border-b border-border last:border-b-0">
<Button
variant="ghost"
className="h-auto w-full justify-start rounded-none px-6 py-2 text-left"
disabled={!connected}
onClick={() => onOpen(file)}
>
{file.kind === 'binary' ? <FileWarning /> : <File />}
<span className="min-w-0">
<span className="block truncate text-xs font-medium">{file.basename}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{file.relativePath}
</span>
</span>
</Button>
</li>
))}
</ul>
)
}
function fileCountCopy(result: MobileWebFileListResult | null, query: string): string {
if (!query) {
return 'Browse workspace folders or search by path'
}
if (!result) {
return `Searching for “${query}”`
}
return `${result.totalCount.toLocaleString()} ${
result.totalCount === 1 ? 'file' : 'files'
} matching “${query}”`
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
function fileErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile shell does not expose file search.'
}
if (error.code === 'not_connected') {
return 'Reconnect to the paired desktop to search files.'
}
return 'The paired desktop could not search these files.'
}
@@ -1,286 +0,0 @@
import { Image as ImageIcon } from 'lucide-react'
import { Lexer, type Token, type Tokens } from 'marked'
import React from 'react'
export const MOBILE_WEB_MARKDOWN_PREVIEW_MAX_CHARACTERS = 128 * 1024
export const MOBILE_WEB_MARKDOWN_PREVIEW_MAX_NODES = 4_000
type RenderBudget = {
remaining: number
exhausted: boolean
}
export function MobileWebInertMarkdown({ content }: { content: string }): React.JSX.Element {
const boundedContent = content.slice(0, MOBILE_WEB_MARKDOWN_PREVIEW_MAX_CHARACTERS)
const tokens = Lexer.lex(boundedContent, { gfm: true })
const budget: RenderBudget = {
remaining: MOBILE_WEB_MARKDOWN_PREVIEW_MAX_NODES,
exhausted: false
}
return (
<>
{renderTokens(tokens, budget, 'document')}
{content.length > boundedContent.length || budget.exhausted ? (
<p role="status" className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
Rendered preview stopped at the mobile Markdown limit. Source view retains the loaded
text.
</p>
) : null}
</>
)
}
function renderTokens(tokens: Token[], budget: RenderBudget, keyPrefix: string): React.ReactNode[] {
const result: React.ReactNode[] = []
for (const [index, token] of tokens.entries()) {
const node = renderToken(token, budget, `${keyPrefix}-${index}`)
if (node !== null) {
result.push(node)
}
if (budget.exhausted) {
break
}
}
return result
}
function renderToken(token: Token, budget: RenderBudget, key: string): React.ReactNode {
if (token.type === 'space' || token.type === 'def' || token.type === 'html') {
return null
}
if (!reserveNode(budget)) {
return null
}
switch (token.type) {
case 'heading':
return renderHeading(token as Tokens.Heading, budget, key)
case 'paragraph':
return (
<p key={key} className="my-3 break-words leading-6 first:mt-0 last:mb-0">
{renderTokens((token as Tokens.Paragraph).tokens, budget, key)}
</p>
)
case 'text': {
const text = token as Tokens.Text
return (
<React.Fragment key={key}>
{text.tokens ? renderTokens(text.tokens, budget, key) : text.text}
</React.Fragment>
)
}
case 'escape':
return <React.Fragment key={key}>{(token as Tokens.Escape).text}</React.Fragment>
case 'strong':
return (
<strong key={key} className="font-semibold">
{renderTokens((token as Tokens.Strong).tokens, budget, key)}
</strong>
)
case 'em':
return <em key={key}>{renderTokens((token as Tokens.Em).tokens, budget, key)}</em>
case 'del':
return <del key={key}>{renderTokens((token as Tokens.Del).tokens, budget, key)}</del>
case 'codespan':
return (
<code key={key} className="rounded-sm bg-muted px-1 py-0.5 font-mono text-xs">
{(token as Tokens.Codespan).text}
</code>
)
case 'code':
return (
<pre
key={key}
className="my-3 overflow-auto rounded-md border border-border bg-muted p-3 font-mono text-xs scrollbar-editor"
>
<code>{(token as Tokens.Code).text}</code>
</pre>
)
case 'blockquote':
return (
<blockquote key={key} className="my-3 border-l-2 border-border pl-4 text-muted-foreground">
{renderTokens((token as Tokens.Blockquote).tokens, budget, key)}
</blockquote>
)
case 'list':
return renderList(token as Tokens.List, budget, key)
case 'table':
return renderTable(token as Tokens.Table, budget, key)
case 'link':
return (
<span key={key} className="font-medium underline decoration-border">
{renderTokens((token as Tokens.Link).tokens, budget, key)}
</span>
)
case 'image':
return renderImage(token as Tokens.Image, key)
case 'br':
return <br key={key} />
case 'hr':
return <hr key={key} className="my-4 border-border" />
case 'checkbox':
return (
<input
key={key}
type="checkbox"
checked={(token as Tokens.Checkbox).checked}
readOnly
disabled
className="mr-2 align-middle"
/>
)
default: {
const children = childTokens(token)
return children ? (
<React.Fragment key={key}>{renderTokens(children, budget, key)}</React.Fragment>
) : null
}
}
}
function renderHeading(token: Tokens.Heading, budget: RenderBudget, key: string): React.ReactNode {
const children = renderTokens(token.tokens, budget, key)
if (token.depth === 1) {
return (
<h1 key={key} className="mb-3 mt-5 text-2xl font-semibold first:mt-0">
{children}
</h1>
)
}
if (token.depth === 2) {
return (
<h2 key={key} className="mb-2 mt-5 text-xl font-semibold first:mt-0">
{children}
</h2>
)
}
if (token.depth === 3) {
return (
<h3 key={key} className="mb-2 mt-4 text-lg font-semibold first:mt-0">
{children}
</h3>
)
}
return (
<h4 key={key} className="mb-2 mt-4 text-base font-semibold">
{children}
</h4>
)
}
function renderList(token: Tokens.List, budget: RenderBudget, key: string): React.ReactNode {
const items: React.ReactNode[] = []
for (const [index, item] of token.items.entries()) {
if (!reserveNode(budget)) {
break
}
items.push(
<li key={`${key}-${index}`} className="pl-1">
{item.task ? (
<input
type="checkbox"
checked={item.checked === true}
readOnly
disabled
className="mr-2 align-middle"
/>
) : null}
{renderTokens(item.tokens, budget, `${key}-${index}`)}
</li>
)
}
return token.ordered ? (
<ol key={key} className="my-3 list-decimal space-y-1 pl-5">
{items}
</ol>
) : (
<ul key={key} className="my-3 list-disc space-y-1 pl-5">
{items}
</ul>
)
}
function renderTable(token: Tokens.Table, budget: RenderBudget, key: string): React.ReactNode {
const headerCells = reserveNode(budget)
? renderTableCells(token.header, budget, `${key}-header`, true)
: []
const rows: React.ReactNode[] = []
for (const [rowIndex, row] of token.rows.entries()) {
if (!reserveNode(budget)) {
break
}
rows.push(
<tr key={`${key}-row-${rowIndex}`}>
{renderTableCells(row, budget, `${key}-row-${rowIndex}`, false)}
</tr>
)
}
return (
<table key={key} className="my-3 w-full border-collapse text-left text-xs">
<thead>
<tr>{headerCells}</tr>
</thead>
<tbody>{rows}</tbody>
</table>
)
}
function renderTableCells(
cells: Tokens.TableCell[],
budget: RenderBudget,
key: string,
header: boolean
): React.ReactNode[] {
const result: React.ReactNode[] = []
for (const [index, cell] of cells.entries()) {
if (!reserveNode(budget)) {
break
}
const children = renderTokens(cell.tokens, budget, `${key}-${index}`)
result.push(
header ? (
<th
key={`${key}-${index}`}
className="border border-border bg-muted px-2 py-1.5 font-semibold"
>
{children}
</th>
) : (
<td key={`${key}-${index}`} className="border border-border px-2 py-1.5">
{children}
</td>
)
)
}
return result
}
function renderImage(token: Tokens.Image, key: string): React.ReactNode {
const label = token.text ? `Image: ${token.text}` : 'Image omitted from Markdown preview'
return (
<span
key={key}
role="img"
aria-label={token.text ? `Image: ${token.text}` : 'Markdown image'}
className="my-3 flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground"
>
<ImageIcon className="size-4" />
{label}
</span>
)
}
function reserveNode(budget: RenderBudget): boolean {
if (budget.remaining <= 0) {
budget.exhausted = true
return false
}
budget.remaining -= 1
return true
}
function childTokens(token: Token): Token[] | null {
const candidate = token as { tokens?: unknown }
return Array.isArray(candidate.tokens) ? (candidate.tokens as Token[]) : null
}
@@ -1,334 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import React, { useId, useState } from 'react'
import {
MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS,
type MobileWebProviderReview,
type MobileWebProviderReviewComment
} from '../../shared/mobile-web/provider-review-contract'
export function MobileWebProviderReviewConversation({
review,
connected,
mutationKey,
mutationError,
mutationErrorKey,
canOpenDiff,
onOpenDiff,
onComment,
onReply,
onSetThreadResolved
}: {
review: MobileWebProviderReview
connected: boolean
mutationKey: string | null
mutationError: string | null
mutationErrorKey: string | null
canOpenDiff: (comment: MobileWebProviderReviewComment) => boolean
onOpenDiff: (comment: MobileWebProviderReviewComment) => void
onComment: (body: string) => Promise<boolean>
onReply: (comment: MobileWebProviderReviewComment, body: string) => Promise<boolean>
onSetThreadResolved: (
comment: MobileWebProviderReviewComment,
resolved: boolean
) => Promise<boolean>
}): React.JSX.Element {
return (
<>
<section className="space-y-2 border-t border-border pt-4" aria-label="Review conversation">
<h3 className="text-sm font-medium">Conversation</h3>
{review.comments.length === 0 ? (
<p className="text-sm text-muted-foreground">No comments yet.</p>
) : (
<div className="space-y-3">
{review.comments.map((comment) => (
<ReviewComment
key={`${comment.id}:${comment.threadId ?? ''}`}
comment={comment}
connected={connected}
mutationKey={mutationKey}
error={commentMutationError(comment, mutationError, mutationErrorKey)}
canOpenDiff={canOpenDiff(comment)}
onOpenDiff={() => onOpenDiff(comment)}
onReply={onReply}
onSetThreadResolved={onSetThreadResolved}
/>
))}
</div>
)}
{review.commentsTruncated ? (
<p className="text-xs text-muted-foreground">Showing the 32 most recent comments.</p>
) : null}
</section>
<CommentComposer
connected={connected}
busy={mutationKey !== null}
posting={mutationKey === 'comment'}
error={mutationErrorKey === 'comment' ? mutationError : null}
onComment={onComment}
/>
</>
)
}
function ReviewComment({
comment,
connected,
mutationKey,
error,
canOpenDiff,
onOpenDiff,
onReply,
onSetThreadResolved
}: {
comment: MobileWebProviderReviewComment
connected: boolean
mutationKey: string | null
error: string | null
canOpenDiff: boolean
onOpenDiff: () => void
onReply: (comment: MobileWebProviderReviewComment, body: string) => Promise<boolean>
onSetThreadResolved: (
comment: MobileWebProviderReviewComment,
resolved: boolean
) => Promise<boolean>
}): React.JSX.Element {
const [replying, setReplying] = useState(false)
const replyKey = comment.threadId ? `reply:${comment.threadId}:${comment.id}` : ''
const threadKey = comment.threadId ? `thread:${comment.threadId}` : ''
const busy = mutationKey !== null
return (
<article className="space-y-2 rounded-md border border-border p-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-xs font-medium">{comment.author || 'Unknown author'}</p>
<p className="text-[11px] text-muted-foreground">{displayTimestamp(comment.createdAt)}</p>
</div>
{comment.kind === 'inline' ? (
<InlineCommentLocation
comment={comment}
canOpenDiff={canOpenDiff}
onOpenDiff={onOpenDiff}
/>
) : null}
<p className="whitespace-pre-wrap break-words text-sm">{comment.body}</p>
{comment.allowedActions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{comment.allowedActions.includes('reply') ? (
<Button
variant="outline"
size="xs"
disabled={!connected || busy}
onClick={() => setReplying((value) => !value)}
>
Reply
</Button>
) : null}
{comment.allowedActions.includes('set-resolved') ? (
<Button
variant="outline"
size="xs"
disabled={!connected || busy}
onClick={() => void onSetThreadResolved(comment, comment.threadState !== 'resolved')}
>
{mutationKey === threadKey
? 'Updating…'
: comment.threadState === 'resolved'
? 'Reopen'
: 'Resolve'}
</Button>
) : null}
</div>
) : null}
{replying ? (
<ReplyComposer
comment={comment}
connected={connected}
busy={busy}
posting={mutationKey === replyKey}
error={error}
onReply={onReply}
onClose={() => setReplying(false)}
/>
) : error ? (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
) : null}
</article>
)
}
function InlineCommentLocation({
comment,
canOpenDiff,
onOpenDiff
}: {
comment: MobileWebProviderReviewComment
canOpenDiff: boolean
onOpenDiff: () => void
}): React.JSX.Element {
return (
<div className="flex flex-wrap items-center gap-2">
<p className="font-mono text-[11px] text-muted-foreground">
{comment.path ?? 'Inline comment'}
{comment.line ? `:${comment.line}` : ''}
{comment.threadState ? ` · ${comment.threadState}` : ''}
</p>
{canOpenDiff ? (
<Button variant="link" size="xs" className="h-auto p-0" onClick={onOpenDiff}>
View in diff
</Button>
) : null}
</div>
)
}
function ReplyComposer({
comment,
connected,
busy,
posting,
error,
onReply,
onClose
}: {
comment: MobileWebProviderReviewComment
connected: boolean
busy: boolean
posting: boolean
error: string | null
onReply: (comment: MobileWebProviderReviewComment, body: string) => Promise<boolean>
onClose: () => void
}): React.JSX.Element {
const inputId = useId()
const [body, setBody] = useState('')
const submit = async () => {
if (await onReply(comment, body)) {
setBody('')
onClose()
}
}
return (
<div className="space-y-2 border-t border-border pt-3">
<label htmlFor={inputId} className="text-xs font-medium">
Reply to {comment.author || 'comment'}
</label>
<MobileWebProviderReviewTextarea
id={inputId}
value={body}
disabled={!connected || busy}
onChange={setBody}
/>
{error ? <MobileWebProviderReviewMutationError message={error} /> : null}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="xs" disabled={busy} onClick={onClose}>
Cancel
</Button>
<Button
size="xs"
disabled={!connected || busy || body.trim().length === 0}
onClick={() => void submit()}
>
{posting ? 'Posting…' : 'Reply'}
</Button>
</div>
</div>
)
}
function CommentComposer({
connected,
busy,
posting,
error,
onComment
}: {
connected: boolean
busy: boolean
posting: boolean
error: string | null
onComment: (body: string) => Promise<boolean>
}): React.JSX.Element {
const [body, setBody] = useState('')
const submit = async () => {
if (await onComment(body)) {
setBody('')
}
}
return (
<section className="space-y-2 border-t border-border pt-4" aria-label="Add review comment">
<label htmlFor="mobile-web-review-comment" className="text-sm font-medium">
Add comment
</label>
<MobileWebProviderReviewTextarea
id="mobile-web-review-comment"
value={body}
disabled={!connected || busy}
onChange={setBody}
/>
{error ? <MobileWebProviderReviewMutationError message={error} /> : null}
<div className="flex justify-end">
<Button
size="sm"
disabled={!connected || busy || body.trim().length === 0}
onClick={() => void submit()}
>
{posting ? 'Posting…' : 'Comment'}
</Button>
</div>
</section>
)
}
export function MobileWebProviderReviewTextarea({
id,
value,
disabled,
onChange
}: {
id: string
value: string
disabled: boolean
onChange: (value: string) => void
}): React.JSX.Element {
return (
<textarea
id={id}
className="min-h-24 w-full resize-y rounded-md border border-input bg-input px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
value={value}
maxLength={MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
)
}
export function MobileWebProviderReviewMutationError({
message
}: {
message: string
}): React.JSX.Element {
return (
<p role="alert" className="text-xs text-destructive">
{message}
</p>
)
}
function commentMutationError(
comment: MobileWebProviderReviewComment,
error: string | null,
errorKey: string | null
): string | null {
if (!comment.threadId || !errorKey) {
return null
}
return errorKey === `reply:${comment.threadId}:${comment.id}` ||
errorKey === `thread:${comment.threadId}`
? error
: null
}
function displayTimestamp(value: string): string {
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? value : new Date(parsed).toLocaleString()
}
@@ -1,124 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { FileWarning, Loader2, X } from 'lucide-react'
import React from 'react'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { MobileWebDiffRows } from './mobile-web-source-control-diff'
import type { useMobileWebProviderReviewDiff } from './use-mobile-web-provider-review-diff'
type ReviewDiffState = ReturnType<typeof useMobileWebProviderReviewDiff>
export function MobileWebProviderReviewDiff({
diff,
connected
}: {
diff: ReviewDiffState
connected: boolean
}): React.JSX.Element | null {
if (!diff.file) {
return null
}
return (
<section className="space-y-0 border-t border-border pt-4" aria-label="Hosted review diff">
<div className="flex items-center justify-between gap-3 pb-2">
<div className="min-w-0">
<h3 className="text-sm font-medium">Review diff</h3>
<p className="truncate font-mono text-xs text-muted-foreground">{diff.file.path}</p>
</div>
<Button variant="ghost" size="icon-xs" aria-label="Close review diff" onClick={diff.close}>
<X />
</Button>
</div>
{diff.loading ? (
<div
role="status"
className="flex items-center justify-between gap-3 border border-border px-3 py-2 text-xs text-muted-foreground"
>
<span className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
Loading review diff
</span>
<Button variant="ghost" size="xs" onClick={diff.cancel}>
<X />
Cancel
</Button>
</div>
) : null}
{diff.error ? (
<ReviewDiffError
error={diff.error}
connected={connected}
loading={diff.loading}
onRetry={diff.retry}
/>
) : null}
{diff.document?.kind === 'binary' ? (
<p className="flex items-center gap-2 border border-border px-3 py-6 text-xs text-muted-foreground">
<FileWarning className="size-4" />
Binary review diff preview is unavailable.
</p>
) : null}
{diff.document?.kind === 'too-large' ? (
<p className="border border-border px-3 py-6 text-xs text-muted-foreground">
This review diff exceeds the bounded mobile preview.
{diff.document.characterCount === undefined
? ''
: ` ${diff.document.characterCount.toLocaleString()} characters were reported.`}
</p>
) : null}
{diff.document?.kind === 'text' ? (
<>
<p className="border-x border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
{diff.document.rows.length.toLocaleString()} of{' '}
{diff.document.totalRows.toLocaleString()} rows
</p>
<MobileWebDiffRows
document={diff.document}
connected={connected}
loading={diff.loading}
onLoadMore={diff.loadMore}
focusRowIndex={diff.document.focusRowIndex}
/>
</>
) : null}
</section>
)
}
function ReviewDiffError({
error,
connected,
loading,
onRetry
}: {
error: MobileWebBridgeClientError
connected: boolean
loading: boolean
onRetry: () => void
}): React.JSX.Element {
return (
<div
role="alert"
className="flex items-center justify-between gap-3 border border-border px-3 py-2 text-xs text-destructive"
>
<span>{reviewDiffErrorCopy(error)}</span>
{error.retryable || error.code === 'conflict' ? (
<Button variant="outline" size="xs" disabled={!connected || loading} onClick={onRetry}>
Reload
</Button>
) : null}
</div>
)
}
function reviewDiffErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'conflict') {
return 'The repository or hosted review changed. Refresh before loading this diff.'
}
if (error.code === 'not_connected') {
return 'Reconnect to the paired desktop to load this review diff.'
}
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile shell does not expose hosted-review diffs.'
}
return 'The paired desktop could not provide this review diff.'
}
@@ -1,238 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import React, { useId, useState } from 'react'
import type {
MobileWebProviderReview,
MobileWebProviderReviewFile
} from '../../shared/mobile-web/provider-review-contract'
import {
MobileWebProviderReviewMutationError,
MobileWebProviderReviewTextarea
} from './mobile-web-provider-review-conversation'
export function MobileWebProviderReviewFiles({
review,
connected,
activeDiffPath,
mutationKey,
mutationError,
mutationErrorKey,
onOpenDiff,
onInlineComment
}: {
review: MobileWebProviderReview
connected: boolean
activeDiffPath: string | null
mutationKey: string | null
mutationError: string | null
mutationErrorKey: string | null
onOpenDiff: (file: MobileWebProviderReviewFile) => void
onInlineComment: (
file: MobileWebProviderReviewFile,
line: number,
body: string
) => Promise<boolean>
}): React.JSX.Element | null {
const [selectedPath, setSelectedPath] = useState<string | null>(null)
if (review.files.length === 0) {
return null
}
return (
<section className="space-y-2 border-t border-border pt-4" aria-label="Review files">
<h3 className="text-sm font-medium">Changed files</h3>
<div className="space-y-2">
{review.files.map((file) => (
<ReviewFileRow
key={file.path}
file={file}
reviewHeadAvailable={Boolean(review.headSha)}
connected={connected}
busy={mutationKey !== null}
selected={selectedPath === file.path}
activeDiff={activeDiffPath === file.path}
mutationKey={mutationKey}
error={mutationErrorKey?.startsWith(`inline:${file.path}:`) ? mutationError : null}
onSelect={() => setSelectedPath(file.path)}
onClose={() => setSelectedPath(null)}
onOpenDiff={() => onOpenDiff(file)}
onInlineComment={onInlineComment}
/>
))}
</div>
{review.filesTruncated ? (
<p className="text-xs text-muted-foreground">
Showing the first 48 files returned for this review.
</p>
) : null}
</section>
)
}
function ReviewFileRow({
file,
reviewHeadAvailable,
connected,
busy,
selected,
activeDiff,
mutationKey,
error,
onSelect,
onClose,
onOpenDiff,
onInlineComment
}: {
file: MobileWebProviderReviewFile
reviewHeadAvailable: boolean
connected: boolean
busy: boolean
selected: boolean
activeDiff: boolean
mutationKey: string | null
error: string | null
onSelect: () => void
onClose: () => void
onOpenDiff: () => void
onInlineComment: (
file: MobileWebProviderReviewFile,
line: number,
body: string
) => Promise<boolean>
}): React.JSX.Element {
const commentable = reviewHeadAvailable && file.commentableLines.length > 0 && !file.isBinary
return (
<article className="space-y-3 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="truncate font-mono text-xs">{file.path}</p>
<p className="text-[11px] text-muted-foreground">
{file.status} · +{file.additions} −{file.deletions}
{file.isBinary ? ' · binary' : ''}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
size="xs"
disabled={!connected || busy || !reviewHeadAvailable}
onClick={onOpenDiff}
>
{activeDiff ? 'Reload diff' : 'View diff'}
</Button>
<Button
variant="outline"
size="xs"
disabled={!connected || busy || !commentable}
onClick={onSelect}
>
Comment
</Button>
</div>
</div>
{!commentable ? (
<p className="text-xs text-muted-foreground">
{file.isBinary
? 'Inline comments are unavailable for binary files.'
: 'No commentable modified lines were retained for this file.'}
</p>
) : null}
{selected ? (
<InlineCommentComposer
file={file}
connected={connected}
busy={busy}
mutationKey={mutationKey}
error={error}
onClose={onClose}
onInlineComment={onInlineComment}
/>
) : null}
</article>
)
}
function InlineCommentComposer({
file,
connected,
busy,
mutationKey,
error,
onClose,
onInlineComment
}: {
file: MobileWebProviderReviewFile
connected: boolean
busy: boolean
mutationKey: string | null
error: string | null
onClose: () => void
onInlineComment: (
file: MobileWebProviderReviewFile,
line: number,
body: string
) => Promise<boolean>
}): React.JSX.Element {
const inputId = useId()
const [line, setLine] = useState(file.commentableLines[0]!)
const [body, setBody] = useState('')
const key = `inline:${file.path}:${line}`
const submit = async () => {
if (await onInlineComment(file, line, body)) {
setBody('')
onClose()
}
}
return (
<div className="space-y-2 border-t border-border pt-3">
<label htmlFor={inputId} className="text-xs font-medium">
Comment on {file.path}
</label>
<Select
value={String(line)}
disabled={!connected || busy}
onValueChange={(value) => setLine(Number(value))}
>
<SelectTrigger className="w-full" size="sm" aria-label={`Line for ${file.path}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{file.commentableLines.map((candidate) => (
<SelectItem key={candidate} value={String(candidate)}>
Line {candidate}
</SelectItem>
))}
</SelectContent>
</Select>
<MobileWebProviderReviewTextarea
id={inputId}
value={body}
disabled={!connected || busy}
onChange={setBody}
/>
{file.commentableLinesTruncated ? (
<p className="text-[11px] text-muted-foreground">
Only the retained commentable lines are available.
</p>
) : null}
{error ? <MobileWebProviderReviewMutationError message={error} /> : null}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="xs" disabled={busy} onClick={onClose}>
Cancel
</Button>
<Button
size="xs"
disabled={!connected || busy || body.trim().length === 0}
onClick={() => void submit()}
>
{mutationKey === key ? 'Queueing…' : 'Queue comment'}
</Button>
</div>
</div>
)
}
@@ -1,149 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import React from 'react'
import type { MobileWebProviderReview } from '../../shared/mobile-web/provider-review-contract'
import type { MobileWebProviderReviewSubmissionAction } from '../../shared/mobile-web/provider-review-submission-contract'
import { MobileWebProviderReviewTextarea } from './mobile-web-provider-review-conversation'
import type { useMobileWebProviderReviewSubmission } from './use-mobile-web-provider-review-submission'
type SubmissionState = ReturnType<typeof useMobileWebProviderReviewSubmission>
export function MobileWebProviderReviewSubmission({
review,
connected,
submission
}: {
review: MobileWebProviderReview
connected: boolean
submission: SubmissionState
}): React.JSX.Element | null {
if (review.allowedSubmissionActions.length === 0) {
return null
}
const empty =
submission.comments.length === 0 &&
submission.summary.trim().length === 0 &&
submission.action === 'comment'
const requestChangesWithoutSummary =
submission.action === 'request-changes' && submission.summary.trim().length === 0
return (
<section className="space-y-3 border-t border-border pt-4" aria-label="Submit review">
<div className="space-y-1">
<h3 className="text-sm font-medium">Submit review</h3>
<p className="text-xs text-muted-foreground">
{review.provider === 'github'
? 'Queued comments and the selected verdict are submitted together.'
: 'GitLab receives queued discussions individually; verdict actions are unavailable.'}
</p>
</div>
{submission.comments.length > 0 ? (
<div className="space-y-2" aria-label="Queued review comments">
{submission.comments.map((comment) => (
<article
key={comment.id}
className="flex items-start justify-between gap-3 rounded-md border border-border p-3"
>
<div className="min-w-0 space-y-1">
<p className="truncate font-mono text-[11px] text-muted-foreground">
{comment.path}:{comment.line}
</p>
<p className="whitespace-pre-wrap break-words text-sm">{comment.body}</p>
</div>
<Button
variant="ghost"
size="xs"
disabled={submission.submitting}
onClick={() => submission.removeComment(comment.id)}
>
Remove
</Button>
</article>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
Use Comment on a changed file to queue an inline review comment.
</p>
)}
<div className="space-y-2">
<label htmlFor="mobile-web-review-summary" className="text-xs font-medium">
Review summary
</label>
<MobileWebProviderReviewTextarea
id="mobile-web-review-summary"
value={submission.summary}
disabled={!connected || submission.submitting}
onChange={submission.setSummary}
/>
</div>
{review.allowedSubmissionActions.length > 1 ? (
<Select
value={submission.action}
disabled={!connected || submission.submitting}
onValueChange={(value) =>
submission.setAction(value as MobileWebProviderReviewSubmissionAction)
}
>
<SelectTrigger className="w-full" size="sm" aria-label="Review verdict">
<SelectValue />
</SelectTrigger>
<SelectContent>
{review.allowedSubmissionActions.map((action) => (
<SelectItem key={action} value={action}>
{submissionActionLabel(action)}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
{requestChangesWithoutSummary ? (
<p className="text-xs text-muted-foreground">
Describe the requested changes before submitting.
</p>
) : null}
{submission.error ? (
<p role="alert" className="text-xs text-destructive">
{submission.error}
</p>
) : null}
<div className="flex justify-end gap-2">
{submission.requiresRefresh ? (
<Button
variant="outline"
size="sm"
disabled={!connected || submission.submitting}
onClick={() => void submission.refreshBeforeRetry()}
>
Refresh review
</Button>
) : null}
<Button
size="sm"
disabled={
!connected ||
submission.submitting ||
submission.requiresRefresh ||
empty ||
requestChangesWithoutSummary
}
onClick={() => void submission.submit()}
>
{submission.submitting ? 'Submitting…' : submissionActionLabel(submission.action)}
</Button>
</div>
</section>
)
}
function submissionActionLabel(action: MobileWebProviderReviewSubmissionAction): string {
if (action === 'approve') {
return 'Approve'
}
return action === 'request-changes' ? 'Request changes' : 'Submit comments'
}
@@ -1,437 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebProviderReviewResult } from '../../shared/mobile-web/provider-review-contract'
import type { MobileWebSourceControlStatusResult } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebProviderReviewCard } from './mobile-web-provider-review'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 24,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 24,
size: 24
})),
scrollToIndex: vi.fn()
})
}))
const HEAD = 'a'.repeat(40)
afterEach(cleanup)
describe('mobile web provider review', () => {
it('renders provider content as inert text and posts a GitLab comment with exact identity', async () => {
const client = providerClient(reviewResult('workspace-1', 'gitlab'))
render(
createElement(MobileWebProviderReviewCard, {
client,
workspaceId: 'workspace-1',
connected: true,
status: statusResult('workspace-1')
})
)
expect(await screen.findByText('Merge request #42: Review mobile bridge')).toBeTruthy()
expect(screen.getByText('<script>inert comment</script>')).toBeTruthy()
expect(
screen.getByText(
'GitLab receives queued discussions individually; verdict actions are unavailable.'
)
).toBeTruthy()
expect(screen.queryByLabelText('Review verdict')).toBeNull()
fireEvent.change(screen.getByLabelText('Add comment'), {
target: { value: 'Please verify Relay too.' }
})
fireEvent.click(screen.getByRole('button', { name: 'Comment' }))
await waitFor(() => expect(client.providerMutateReview).toHaveBeenCalledOnce())
expect(client.providerMutateReview).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'feature/review',
provider: 'gitlab',
reviewNumber: 42,
action: 'comment',
body: 'Please verify Relay too.'
},
{ signal: expect.any(AbortSignal) }
)
await waitFor(() => expect(client.providerReview).toHaveBeenCalledTimes(2))
expect((screen.getByLabelText('Add comment') as HTMLTextAreaElement).value).toBe('')
})
it('aborts a pending review when the workspace identity changes', async () => {
const first = providerClient(new Promise(() => undefined))
const second = providerClient(reviewResult('workspace-2', 'github'))
const view = render(
createElement(MobileWebProviderReviewCard, {
client: first,
workspaceId: 'workspace-1',
connected: true,
status: statusResult('workspace-1')
})
)
await waitFor(() => expect(first.providerReview).toHaveBeenCalledOnce())
const signal = first.providerReview.mock.calls[0]?.[1]?.signal as AbortSignal
view.rerender(
createElement(MobileWebProviderReviewCard, {
client: second,
workspaceId: 'workspace-2',
connected: true,
status: statusResult('workspace-2')
})
)
expect(signal.aborted).toBe(true)
expect(await screen.findByText('Pull request #42: Review mobile bridge')).toBeTruthy()
})
it('keeps unsupported provider details read-only', async () => {
const result = reviewResult('workspace-1', 'bitbucket')
if (!result.review) {
throw new Error('Expected review')
}
result.review.detailsState = 'unsupported'
result.review.canComment = false
result.review.comments = []
const client = providerClient(result)
render(
createElement(MobileWebProviderReviewCard, {
client,
workspaceId: 'workspace-1',
connected: true,
status: statusResult('workspace-1')
})
)
expect(
await screen.findByText('Bitbucket review details are not available in this shell.')
).toBeTruthy()
expect(screen.queryByLabelText('Add comment')).toBeNull()
})
it('replies to and resolves only the exact advertised GitHub thread', async () => {
const result = reviewResult('workspace-1', 'github')
if (!result.review) {
throw new Error('Expected review')
}
result.review.comments = [
{
id: '9',
author: 'ada',
body: 'Please check this line.',
createdAt: '2026-07-23T00:01:00.000Z',
kind: 'inline',
path: 'src/review.ts',
line: 12,
threadId: 'thread-1',
threadState: 'open',
allowedActions: ['reply', 'set-resolved']
}
]
const client = providerClient(result)
render(
createElement(MobileWebProviderReviewCard, {
client,
workspaceId: 'workspace-1',
connected: true,
status: statusResult('workspace-1')
})
)
fireEvent.click(await screen.findByRole('button', { name: 'Reply' }))
fireEvent.change(screen.getByLabelText('Reply to ada'), {
target: { value: 'Verified on Relay.' }
})
fireEvent.click(screen.getAllByRole('button', { name: 'Reply' })[1]!)
await waitFor(() => expect(client.providerMutateReview).toHaveBeenCalledOnce())
expect(client.providerMutateReview).toHaveBeenNthCalledWith(
1,
{
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'feature/review',
provider: 'github',
reviewNumber: 42,
action: 'reply',
commentId: '9',
threadId: 'thread-1',
body: 'Verified on Relay.'
},
{ signal: expect.any(AbortSignal) }
)
fireEvent.click(await screen.findByRole('button', { name: 'Resolve' }))
await waitFor(() => expect(client.providerMutateReview).toHaveBeenCalledTimes(2))
expect(client.providerMutateReview).toHaveBeenNthCalledWith(
2,
{
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'feature/review',
provider: 'github',
reviewNumber: 42,
action: 'setThreadResolved',
threadId: 'thread-1',
resolved: true
},
{ signal: expect.any(AbortSignal) }
)
})
it('queues and submits an inline comment for the exact advertised review head, file, and line', async () => {
const result = reviewResult('workspace-1', 'github')
if (!result.review) {
throw new Error('Expected review')
}
result.review.headSha = HEAD
result.review.files = [
{
path: 'src/review.ts',
status: 'modified',
additions: 2,
deletions: 1,
isBinary: false,
commentableLines: [12, 13],
commentableLinesTruncated: false
}
]
const client = providerClient(result)
render(
createElement(MobileWebProviderReviewCard, {
client,
workspaceId: 'workspace-1',
connected: true,
status: statusResult('workspace-1')
})
)
const files = within(await screen.findByRole('region', { name: 'Review files' }))
fireEvent.click(files.getByRole('button', { name: 'Comment' }))
fireEvent.change(files.getByLabelText('Comment on src/review.ts'), {
target: { value: 'Keep this line host-authorized.' }
})
fireEvent.click(files.getByRole('button', { name: 'Queue comment' }))
expect(client.providerMutateReview).not.toHaveBeenCalled()
expect(await screen.findByText('Keep this line host-authorized.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Submit comments' }))
await waitFor(() => expect(client.providerSubmitReview).toHaveBeenCalledOnce())
expect(client.providerSubmitReview).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'feature/review',
provider: 'github',
reviewNumber: 42,
expectedReviewHead: HEAD,
submissionId: expect.stringMatching(/^[a-f0-9]{32}$/),
action: 'comment',
summary: '',
comments: [
{
id: expect.stringMatching(/^[a-f0-9]{32}$/),
path: 'src/review.ts',
line: 12,
body: 'Keep this line host-authorized.'
}
]
},
{ signal: expect.any(AbortSignal) }
)
})
it('navigates a retained inline thread to its exact hosted-review diff line', async () => {
const result = reviewResult('workspace-1', 'github')
if (!result.review) {
throw new Error('Expected review')
}
result.review.headSha = HEAD
result.review.files = [
{
path: 'src/review.ts',
status: 'modified',
additions: 1,
deletions: 0,
isBinary: false,
commentableLines: [12],
commentableLinesTruncated: false
}
]
result.review.comments = [
{
id: '9',
author: 'ada',
body: 'Please inspect this line.',
createdAt: '2026-07-23T00:01:00.000Z',
kind: 'inline',
path: 'src/review.ts',
line: 12,
threadId: 'thread-1',
threadState: 'open',
allowedActions: ['reply', 'set-resolved']
}
]
const client = providerClient(result)
client.providerReviewDiff.mockResolvedValue({
workspaceId: 'workspace-1',
observedHead: HEAD,
branch: 'feature/review',
provider: 'github',
reviewNumber: 42,
reviewHead: HEAD,
path: 'src/review.ts',
kind: 'text',
revision: 'd'.repeat(64),
offset: 0,
totalRows: 1,
rows: [
{
index: 0,
kind: 'add',
text: 'focused code',
textTruncated: false,
newLineNumber: 12
}
],
nextOffset: null,
truncated: false,
focusLine: 12,
focusRowIndex: 0
})
render(
createElement(MobileWebProviderReviewCard, {
client,
workspaceId: 'workspace-1',
connected: true,
status: statusResult('workspace-1')
})
)
fireEvent.click(await screen.findByRole('button', { name: 'View in diff' }))
await waitFor(() => expect(client.providerReviewDiff).toHaveBeenCalledOnce())
expect(client.providerReviewDiff).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: HEAD,
expectedBranch: 'feature/review',
provider: 'github',
reviewNumber: 42,
expectedReviewHead: HEAD,
path: 'src/review.ts',
offset: 0,
limit: 96,
focusLine: 12
},
{ signal: expect.any(AbortSignal) }
)
const diff = await screen.findByRole('region', { name: 'Hosted review diff' })
expect(within(diff).getByText('focused code')).toBeTruthy()
expect(within(diff).getByRole('listitem').getAttribute('aria-current')).toBe('true')
})
})
function providerClient(
result: MobileWebProviderReviewResult | Promise<MobileWebProviderReviewResult>
): MobileWebBridgeClient & {
providerReview: ReturnType<typeof vi.fn>
providerReviewDiff: ReturnType<typeof vi.fn>
providerMutateReview: ReturnType<typeof vi.fn>
providerSubmitReview: ReturnType<typeof vi.fn>
} {
return {
providerReview: vi.fn().mockResolvedValue(result),
providerReviewDiff: vi.fn(),
providerMutateReview: vi.fn().mockResolvedValue({
workspaceId: 'workspace-1',
provider: 'gitlab',
reviewNumber: 42,
action: 'comment',
outcome: 'completed'
}),
providerSubmitReview: vi.fn(async (payload) => ({
workspaceId: payload.workspaceId,
provider: payload.provider,
reviewNumber: payload.reviewNumber,
expectedReviewHead: payload.expectedReviewHead,
submissionId: payload.submissionId,
action: payload.action,
submittedCommentIds: payload.comments.map((comment: { id: string }) => comment.id),
outcome: 'completed'
}))
} as unknown as MobileWebBridgeClient & {
providerReview: ReturnType<typeof vi.fn>
providerReviewDiff: ReturnType<typeof vi.fn>
providerMutateReview: ReturnType<typeof vi.fn>
providerSubmitReview: ReturnType<typeof vi.fn>
}
}
function statusResult(workspaceId: string): MobileWebSourceControlStatusResult {
return {
workspaceId,
branch: 'feature/review',
head: HEAD,
conflictOperation: 'unknown',
entries: [],
totalCount: 0,
truncated: false
}
}
function reviewResult(
workspaceId: string,
provider: 'github' | 'gitlab' | 'bitbucket'
): MobileWebProviderReviewResult {
return {
workspaceId,
observedHead: HEAD,
branch: 'feature/review',
review: {
provider,
number: 42,
title: 'Review mobile bridge',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE',
reviewDecision: null,
updatedAt: '2026-07-23T00:00:00.000Z',
body: 'Review body',
comments: [
{
id: '1',
author: 'ada',
body: '<script>inert comment</script>',
createdAt: '2026-07-23T00:01:00.000Z',
kind: 'conversation',
allowedActions: []
}
],
commentsTruncated: false,
files: [],
filesTruncated: false,
author: null,
reviewRequests: [],
latestReviews: [],
checks: [],
detailsState: 'loaded',
canComment: true,
allowedSubmissionActions:
provider === 'github'
? ['comment', 'approve', 'request-changes']
: provider === 'gitlab'
? ['comment']
: []
}
}
}
@@ -1,266 +0,0 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { RefreshCw } from 'lucide-react'
import React from 'react'
import type { MobileWebProviderReview } from '../../shared/mobile-web/provider-review-contract'
import type { MobileWebSourceControlStatusResult } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebProviderReviewConversation } from './mobile-web-provider-review-conversation'
import { MobileWebProviderReviewDiff } from './mobile-web-provider-review-diff'
import { MobileWebProviderReviewFiles } from './mobile-web-provider-review-files'
import { MobileWebProviderReviewSubmission } from './mobile-web-provider-review-submission'
import { useMobileWebProviderReview } from './use-mobile-web-provider-review'
import { useMobileWebProviderReviewDiff } from './use-mobile-web-provider-review-diff'
import { useMobileWebProviderReviewSubmission } from './use-mobile-web-provider-review-submission'
export function MobileWebProviderReviewCard({
client,
workspaceId,
connected,
status
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
status: MobileWebSourceControlStatusResult | null
}): React.JSX.Element {
const reviewState = useMobileWebProviderReview({ client, workspaceId, connected, status })
const review = reviewState.result?.review ?? null
const diff = useMobileWebProviderReviewDiff({
client,
workspaceId,
connected,
status,
review
})
const submission = useMobileWebProviderReviewSubmission({
client,
workspaceId,
connected,
status,
review,
refreshReview: reviewState.retry
})
return (
<Card
className="mt-4"
aria-busy={reviewState.loading || reviewState.mutationKey !== null || submission.submitting}
>
<CardHeader>
<div className="space-y-1">
<CardTitle>Hosted review</CardTitle>
<CardDescription>
{reviewDescription(status, review, reviewState.loading)}
</CardDescription>
</div>
<CardAction>
<Button
aria-label="Refresh hosted review"
variant="outline"
size="icon-sm"
disabled={
!connected ||
reviewState.loading ||
reviewState.mutationKey !== null ||
!status?.branch
}
onClick={() => void reviewState.retry()}
>
<RefreshCw className={reviewState.loading ? 'animate-spin' : undefined} />
</Button>
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
<ReviewContent
review={review}
connected={connected}
loading={reviewState.loading}
error={reviewState.error}
mutationKey={reviewState.mutationKey}
mutationError={reviewState.mutationError}
mutationErrorKey={reviewState.mutationErrorKey}
diff={diff}
submission={submission}
onComment={reviewState.comment}
onReply={reviewState.reply}
onSetThreadResolved={reviewState.setThreadResolved}
onRetry={reviewState.retry}
/>
</CardContent>
</Card>
)
}
function ReviewContent({
review,
connected,
loading,
error,
mutationKey,
mutationError,
mutationErrorKey,
diff,
submission,
onComment,
onReply,
onSetThreadResolved,
onRetry
}: {
review: MobileWebProviderReview | null
connected: boolean
loading: boolean
error: string | null
mutationKey: ReturnType<typeof useMobileWebProviderReview>['mutationKey']
mutationError: ReturnType<typeof useMobileWebProviderReview>['mutationError']
mutationErrorKey: ReturnType<typeof useMobileWebProviderReview>['mutationErrorKey']
diff: ReturnType<typeof useMobileWebProviderReviewDiff>
submission: ReturnType<typeof useMobileWebProviderReviewSubmission>
onComment: (body: string) => Promise<boolean>
onReply: ReturnType<typeof useMobileWebProviderReview>['reply']
onSetThreadResolved: ReturnType<typeof useMobileWebProviderReview>['setThreadResolved']
onRetry: () => Promise<void>
}): React.JSX.Element {
if (error) {
return (
<div role="alert" className="space-y-2 text-sm">
<p>{error}</p>
<Button variant="outline" size="xs" disabled={!connected || loading} onClick={onRetry}>
Retry
</Button>
</div>
)
}
if (!review) {
return (
<p className="text-sm text-muted-foreground">
{loading ? 'Checking this branch for a hosted review…' : 'No hosted review was found.'}
</p>
)
}
return (
<>
<ReviewSummary review={review} />
{review.detailsState !== 'loaded' ? (
<p role="status" className="text-sm text-muted-foreground">
{review.detailsState === 'unsupported'
? `${providerLabel(review.provider)} review details are not available in this shell.`
: 'Review conversation details are temporarily unavailable.'}
</p>
) : (
<>
<MobileWebProviderReviewFiles
review={review}
connected={connected && review.canComment}
activeDiffPath={diff.file?.path ?? null}
mutationKey={mutationKey}
mutationError={mutationError}
mutationErrorKey={mutationErrorKey}
onOpenDiff={diff.open}
onInlineComment={submission.queueComment}
/>
<MobileWebProviderReviewDiff diff={diff} connected={connected} />
<MobileWebProviderReviewSubmission
review={review}
connected={connected && review.canComment}
submission={submission}
/>
<MobileWebProviderReviewConversation
review={review}
connected={connected && review.canComment}
mutationKey={mutationKey}
mutationError={mutationError}
mutationErrorKey={mutationErrorKey}
canOpenDiff={(comment) =>
Boolean(
review.headSha &&
comment.path &&
comment.line &&
review.files.some((file) => file.path === comment.path)
)
}
onOpenDiff={(comment) => {
const file = review.files.find((candidate) => candidate.path === comment.path)
if (file && comment.line) {
diff.open(file, comment.line)
}
}}
onComment={onComment}
onReply={onReply}
onSetThreadResolved={onSetThreadResolved}
/>
</>
)}
</>
)
}
function ReviewSummary({ review }: { review: MobileWebProviderReview }): React.JSX.Element {
return (
<section className="space-y-3" aria-label="Hosted review summary">
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{providerLabel(review.provider)}</Badge>
<Badge variant="secondary">{stateLabel(review.state)}</Badge>
<Badge variant="outline">{checksLabel(review.checksStatus)}</Badge>
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{reviewLabel(review.provider)} #{review.number}: {review.title || 'Untitled review'}
</h3>
{review.body ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{review.body}</p>
) : null}
</div>
</section>
)
}
function reviewDescription(
status: MobileWebSourceControlStatusResult | null,
review: MobileWebProviderReview | null,
loading: boolean
): string {
if (!status?.branch || !status.head) {
return 'Check out a branch to discover its hosted review'
}
if (review) {
return `${status.branch} · ${providerLabel(review.provider)} #${review.number}`
}
return loading ? `Checking ${status.branch}` : status.branch
}
function providerLabel(provider: MobileWebProviderReview['provider']): string {
switch (provider) {
case 'github':
return 'GitHub'
case 'gitlab':
return 'GitLab'
case 'bitbucket':
return 'Bitbucket'
case 'azure-devops':
return 'Azure DevOps'
case 'gitea':
return 'Gitea'
}
}
function reviewLabel(provider: MobileWebProviderReview['provider']): string {
return provider === 'gitlab' ? 'Merge request' : 'Pull request'
}
function stateLabel(state: MobileWebProviderReview['state']): string {
return state === 'draft' ? 'Draft' : `${state.charAt(0).toUpperCase()}${state.slice(1)}`
}
function checksLabel(status: MobileWebProviderReview['checksStatus']): string {
return status === 'neutral'
? 'No check result'
: `Checks ${status === 'pending' ? 'pending' : status}`
}
@@ -1,91 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebFileDocument } from './mobile-web-file-document'
import { MobileWebRasterImagePreview } from './mobile-web-raster-image-preview'
const createObjectURL = vi.fn()
const revokeObjectURL = vi.fn()
beforeEach(() => {
createObjectURL
.mockReset()
.mockReturnValueOnce('blob:private-one')
.mockReturnValue('blob:private-two')
revokeObjectURL.mockReset()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('MobileWebRasterImagePreview', () => {
it('renders validated bytes from a private object URL and revokes every generation', () => {
const view = render(
createElement(MobileWebRasterImagePreview, { document: pngDocument('assets/one.png') })
)
const image = screen.getByRole('img', { name: 'Preview of one.png' })
expect(image.getAttribute('src')).toBe('blob:private-one')
expect(image.getAttribute('src')).not.toContain('assets/one.png')
expect(image.getAttribute('data-raster-mime')).toBe('image/png')
expect(createObjectURL).toHaveBeenCalledTimes(1)
view.rerender(
createElement(MobileWebRasterImagePreview, { document: pngDocument('assets/two.png') })
)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:private-one')
expect(screen.getByRole('img', { name: 'Preview of two.png' }).getAttribute('src')).toBe(
'blob:private-two'
)
view.unmount()
expect(revokeObjectURL).toHaveBeenLastCalledWith('blob:private-two')
})
it('rejects mismatched signatures before creating an object URL', () => {
render(
createElement(MobileWebRasterImagePreview, {
document: pngDocument('assets/not-really.png', new TextEncoder().encode('<svg/>'))
})
)
expect(
screen.getByText('The file signature does not match its raster image extension.')
).toBeDefined()
expect(screen.queryByRole('img')).toBeNull()
expect(createObjectURL).not.toHaveBeenCalled()
})
it('replaces a browser decode failure with an inert error state', () => {
render(
createElement(MobileWebRasterImagePreview, {
document: pngDocument('assets/broken.png')
})
)
fireEvent.error(screen.getByRole('img'))
expect(screen.getByRole('alert').textContent).toContain('This image could not be decoded.')
expect(screen.queryByRole('img')).toBeNull()
})
})
function pngDocument(
relativePath: string,
bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
): MobileWebFileDocument {
return {
workspaceId: 'workspace-1',
relativePath,
bytes,
content: '',
kind: 'binary',
eof: true,
limitReached: false,
revision: null
}
}
@@ -1,124 +0,0 @@
import { FileWarning, ImageOff } from 'lucide-react'
import React, { useEffect, useMemo, useState } from 'react'
import type { MobileWebFileDocument } from './mobile-web-file-document'
import {
MOBILE_WEB_RASTER_IMAGE_MAX_BYTES,
validateMobileWebRasterImage
} from './mobile-web-raster-image'
export function MobileWebRasterImagePreview({
document
}: {
document: MobileWebFileDocument
}): React.JSX.Element {
const validation = useMemo(
() =>
validateMobileWebRasterImage({
relativePath: document.relativePath,
bytes: document.bytes,
eof: document.eof,
limitReached: document.limitReached
}),
[document.bytes, document.eof, document.limitReached, document.relativePath]
)
const source = useMobileWebRasterImageUrl(
document,
validation.valid && validation.imageType.mimeType
)
const [decodeFailed, setDecodeFailed] = useState(false)
useEffect(() => {
setDecodeFailed(false)
}, [source])
if (!validation.valid) {
return (
<p className="flex items-center gap-2 border-t border-border px-6 py-6 text-xs text-muted-foreground">
<FileWarning className="size-4" />
{imageValidationCopy(validation.reason)}
</p>
)
}
if (!source) {
return (
<p
role="status"
className="flex items-center gap-2 border-t border-border px-6 py-6 text-xs text-muted-foreground"
>
Preparing image preview
</p>
)
}
if (decodeFailed) {
return (
<p
role="alert"
className="flex items-center gap-2 border-t border-border px-6 py-6 text-xs text-muted-foreground"
>
<ImageOff className="size-4" />
This image could not be decoded.
</p>
)
}
return (
<div className="flex max-h-96 justify-center overflow-auto border-t border-border bg-[var(--editor-surface)] p-4 scrollbar-editor">
<img
src={source}
alt={`Preview of ${basename(document.relativePath)}`}
className="max-h-full max-w-full object-contain"
draggable={false}
data-raster-mime={validation.imageType.mimeType}
onError={() => setDecodeFailed(true)}
/>
</div>
)
}
function useMobileWebRasterImageUrl(
document: MobileWebFileDocument,
mimeType: string | false
): string | null {
const [source, setSource] = useState<{
bytes: Uint8Array
mimeType: string
objectUrl: string
} | null>(null)
useEffect(() => {
if (!mimeType) {
setSource(null)
return
}
const bytes = document.bytes.slice().buffer
const objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType }))
setSource({ bytes: document.bytes, mimeType, objectUrl })
return () => URL.revokeObjectURL(objectUrl)
}, [document.bytes, mimeType])
return source?.bytes === document.bytes && source.mimeType === mimeType ? source.objectUrl : null
}
function imageValidationCopy(
reason: 'unsupported' | 'incomplete' | 'too_large' | 'signature_mismatch'
): string {
if (reason === 'too_large') {
return `Image preview stopped at the ${formatMegabytes(
MOBILE_WEB_RASTER_IMAGE_MAX_BYTES
)} mobile image limit.`
}
if (reason === 'signature_mismatch') {
return 'The file signature does not match its raster image extension.'
}
if (reason === 'incomplete') {
return 'The complete image is required before it can be previewed.'
}
return 'Only PNG, JPEG, GIF, WebP, BMP, and ICO images can be previewed.'
}
function basename(relativePath: string): string {
return relativePath.split(/[\\/]/).at(-1) ?? 'image'
}
function formatMegabytes(bytes: number): string {
return `${bytes / (1024 * 1024)} MiB`
}
@@ -1,123 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MobileWebSessionSnapshotResult } from '../../shared/mobile-web/bridge-operation-contract'
import { MobileWebBridgeClientError, type MobileWebBridgeClient } from './mobile-web-bridge-client'
export type MobileWebSessionPendingAction =
| 'create'
| `activate:${string}`
| `close:${string}`
| null
export function useMobileWebSessionActions(args: {
client: MobileWebBridgeClient
connected: boolean
workspaceId: string
onSnapshot: (snapshot: MobileWebSessionSnapshotResult, allowSameVersion: boolean) => void
}): {
pending: MobileWebSessionPendingAction
error: MobileWebBridgeClientError | null
activate: (tabId: string) => void
create: () => void
close: (tabId: string) => void
} {
const { client, connected, workspaceId, onSnapshot } = args
const [pending, setPending] = useState<MobileWebSessionPendingAction>(null)
const [error, setError] = useState<MobileWebBridgeClientError | null>(null)
const context = useRef<{ client: MobileWebBridgeClient; workspaceId: string } | null>({
client,
workspaceId
})
useEffect(() => {
const current = { client, workspaceId }
context.current = current
setPending(null)
setError(null)
return () => {
if (context.current === current) {
context.current = null
}
}
}, [client, workspaceId])
const isCurrent = useCallback(
() => context.current?.client === client && context.current.workspaceId === workspaceId,
[client, workspaceId]
)
const refresh = useCallback(async () => {
try {
const snapshot = await client.sessionSnapshot({ workspaceId })
if (isCurrent()) {
onSnapshot(snapshot, true)
}
} catch {
// The live subscription remains authoritative when a post-mutation refresh races reconnect.
}
}, [client, isCurrent, onSnapshot, workspaceId])
const run = useCallback(
async (key: Exclude<MobileWebSessionPendingAction, null>, operation: () => Promise<void>) => {
if (!connected || pending !== null) {
return
}
setPending(key)
setError(null)
try {
await operation()
} catch (operationError) {
if (isCurrent()) {
setError(asBridgeError(operationError))
}
} finally {
if (isCurrent()) {
setPending(null)
}
}
},
[connected, isCurrent, pending]
)
const activate = useCallback(
(tabId: string) => {
void run(`activate:${tabId}`, async () => {
const snapshot = await client.sessionActivate({
workspaceId,
tabId
})
if (isCurrent()) {
onSnapshot(snapshot, true)
}
})
},
[client, isCurrent, onSnapshot, run, workspaceId]
)
const create = useCallback(() => {
void run('create', async () => {
await client.sessionCreate({ workspaceId })
await refresh()
})
}, [client, refresh, run, workspaceId])
const close = useCallback(
(tabId: string) => {
void run(`close:${tabId}`, async () => {
const result = await client.sessionClose({ workspaceId, tabId })
await refresh()
if (result.outcome === 'refused') {
throw new MobileWebBridgeClientError('conflict', false)
}
})
},
[client, refresh, run, workspaceId]
)
return { pending, error, activate, create, close }
}
function asBridgeError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,77 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { FileCode2, FileText, Globe2, Loader2, TerminalSquare, X } from 'lucide-react'
import React from 'react'
import type { MobileWebSessionTab } from '../../shared/mobile-web/bridge-operation-contract'
import type { MobileWebSessionPendingAction } from './mobile-web-session-actions'
export function MobileWebSessionTabRow({
tab,
connected,
pending,
onActivate,
onClose
}: {
tab: MobileWebSessionTab
connected: boolean
pending: MobileWebSessionPendingAction
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
}): React.JSX.Element {
const Icon = tabIcon(tab.type)
const activating = pending === `activate:${tab.id}`
const closing = pending === `close:${tab.id}`
return (
<li
data-current={tab.isActive}
className="flex items-center border-b border-border last:border-b-0 data-[current=true]:bg-accent"
>
<Button
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start gap-3 rounded-none px-6 py-3 text-left"
disabled={!connected || pending !== null}
onClick={() => onActivate(tab.id)}
>
{activating ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<Icon className="size-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{tab.title}</span>
<span className="mt-0.5 block text-xs capitalize text-muted-foreground">
{tab.type}
{tab.type === 'terminal' && tab.status === 'pending-handle' ? ' · Starting' : ''}
</span>
</span>
{tab.isActive ? (
<span className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
Active
</span>
) : null}
</Button>
<Button
aria-label={`Close ${tab.title}`}
variant="ghost"
size="icon-sm"
className="mr-4 shrink-0"
disabled={!connected || pending !== null}
onClick={() => onClose(tab.id)}
>
{closing ? <Loader2 className="animate-spin" /> : <X />}
</Button>
</li>
)
}
function tabIcon(type: MobileWebSessionTab['type']) {
if (type === 'terminal') {
return TerminalSquare
}
if (type === 'markdown') {
return FileText
}
if (type === 'file') {
return FileCode2
}
return Globe2
}
@@ -1,337 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebWorkspaceSummary } from '../../shared/mobile-web/bridge-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebSession } from './mobile-web-session'
vi.mock('./mobile-web-terminal', () => ({ MobileWebTerminal: () => null }))
const WORKSPACE: MobileWebWorkspaceSummary = {
id: 'workspace-1',
repoId: 'repo-1',
workspaceKind: 'git',
name: 'Mobile rearchitecture',
repo: 'repo',
branch: 'mobile-rearch',
folderName: '',
workspaceStatus: '',
sortOrder: 0,
manualOrder: null,
lastActivityAt: null,
createdAt: null,
isArchived: false,
isMainWorktree: false,
hasHostSidebarActivity: false,
parentWorkspaceId: null,
isActive: true,
liveTerminalCount: 1,
hasAttachedPty: true,
unread: false,
lastOutputAt: null,
isPinned: false,
linkedPR: null,
linkedIssue: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
comment: '',
status: 'active',
agents: []
}
afterEach(cleanup)
describe('mobile web session', () => {
it('activates before loading a bounded session and retains it offline', async () => {
const calls: string[] = []
const workspaceActivate = vi.fn().mockImplementation(async () => {
calls.push('activate')
return {
workspaceId: 'workspace-1',
activated: true,
sleepingAgentWake: 'not-applicable'
}
})
const sessionSnapshot = vi.fn().mockImplementation(async () => {
calls.push('snapshot')
return {
workspaceId: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 4,
activeTabId: 'terminal-1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'terminal-1',
title: 'Codex',
status: 'ready',
isActive: true
}
],
truncated: false
}
})
let onSessionEvent: ((snapshot: unknown) => void) | undefined
const unsubscribe = vi.fn()
const sessionSubscribe = vi.fn().mockImplementation((_payload, onEvent) => {
onSessionEvent = onEvent
return { ready: Promise.resolve(), unsubscribe }
})
const client = {
workspaceActivate,
sessionSnapshot,
sessionSubscribe,
fileList: vi.fn().mockResolvedValue(emptyFileList()),
fileDirectory: vi.fn().mockResolvedValue(emptyFileDirectory()),
sourceControlStatus: vi.fn().mockResolvedValue(emptySourceControlStatus()),
sourceControlBranches: vi.fn().mockResolvedValue(emptyBranches()),
sourceControlHistory: vi.fn().mockResolvedValue(emptyHistory()),
sourceControlUpstream: vi.fn().mockResolvedValue(repositoryState()),
sourceControlBranchCompare: vi.fn(),
sourceControlCommitCompare: vi.fn(),
sourceControlSubscribe: vi
.fn()
.mockReturnValue({ ready: Promise.resolve(), unsubscribe: vi.fn() })
} as unknown as MobileWebBridgeClient
const view = render(
createElement(MobileWebSession, {
client,
connection: 'connected',
workspace: WORKSPACE,
onBack: vi.fn()
})
)
expect(await screen.findByText('Codex')).toBeTruthy()
expect(calls).toEqual(['activate', 'snapshot'])
expect(workspaceActivate).toHaveBeenCalledWith({ workspaceId: 'workspace-1' })
expect(sessionSnapshot).toHaveBeenCalledWith({ workspaceId: 'workspace-1' })
expect(sessionSubscribe).toHaveBeenCalledWith(
{ workspaceId: 'workspace-1' },
expect.any(Function),
expect.any(Function)
)
onSessionEvent?.({
workspaceId: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 5,
activeTabId: 'file-1',
activeTabType: 'file',
tabs: [{ type: 'file', id: 'file-1', title: 'bridge.ts', isActive: true }],
truncated: false
})
expect(await screen.findByText('bridge.ts')).toBeTruthy()
onSessionEvent?.({
workspaceId: 'workspace-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 4,
activeTabId: 'stale-file',
activeTabType: 'file',
tabs: [{ type: 'file', id: 'stale-file', title: 'stale.ts', isActive: true }],
truncated: false
})
expect(screen.queryByText('stale.ts')).toBeNull()
view.rerender(
createElement(MobileWebSession, {
client,
connection: 'offline',
workspace: WORKSPACE,
onBack: vi.fn()
})
)
expect(screen.getByText('bridge.ts')).toBeTruthy()
expect(unsubscribe).toHaveBeenCalledOnce()
expect(screen.getByText('Desktop is offline — showing the last session snapshot.')).toBeTruthy()
})
it('creates, activates, and closes tabs through typed session actions', async () => {
const initial = sessionSnapshot(4, 'terminal-1', [
terminalTab('terminal-1', 'Codex', true),
{ type: 'file', id: 'file-1', title: 'Notes', isActive: false }
])
const activated = sessionSnapshot(4, 'file-1', [
terminalTab('terminal-1', 'Codex', false),
{ type: 'file', id: 'file-1', title: 'Notes', isActive: true }
])
const created = sessionSnapshot(5, 'terminal-2', [
terminalTab('terminal-1', 'Codex', false),
{ type: 'file', id: 'file-1', title: 'Notes', isActive: false },
terminalTab('terminal-2', 'Shell', true)
])
const closed = sessionSnapshot(6, 'terminal-2', [
{ type: 'file', id: 'file-1', title: 'Notes', isActive: false },
terminalTab('terminal-2', 'Shell', true)
])
const sessionSnapshotRequest = vi
.fn()
.mockResolvedValueOnce(initial)
.mockResolvedValueOnce(created)
.mockResolvedValueOnce(closed)
const sessionActivate = vi.fn().mockResolvedValue(activated)
const sessionCreate = vi.fn().mockResolvedValue({
workspaceId: WORKSPACE.id,
tabId: 'terminal-2',
created: true
})
const sessionClose = vi.fn().mockResolvedValue({
workspaceId: WORKSPACE.id,
tabId: 'terminal-1',
outcome: 'closed',
refusalReason: null
})
const client = {
workspaceActivate: vi.fn().mockResolvedValue({
workspaceId: WORKSPACE.id,
activated: true,
sleepingAgentWake: 'not-applicable'
}),
sessionSnapshot: sessionSnapshotRequest,
sessionSubscribe: vi.fn().mockReturnValue({
ready: Promise.resolve(),
unsubscribe: vi.fn()
}),
sessionActivate,
sessionCreate,
sessionClose,
fileList: vi.fn().mockResolvedValue(emptyFileList()),
fileDirectory: vi.fn().mockResolvedValue(emptyFileDirectory()),
sourceControlStatus: vi.fn().mockResolvedValue(emptySourceControlStatus()),
sourceControlBranches: vi.fn().mockResolvedValue(emptyBranches()),
sourceControlHistory: vi.fn().mockResolvedValue(emptyHistory()),
sourceControlUpstream: vi.fn().mockResolvedValue(repositoryState()),
sourceControlBranchCompare: vi.fn(),
sourceControlCommitCompare: vi.fn(),
sourceControlSubscribe: vi
.fn()
.mockReturnValue({ ready: Promise.resolve(), unsubscribe: vi.fn() })
} as unknown as MobileWebBridgeClient
render(
createElement(MobileWebSession, {
client,
connection: 'connected',
workspace: WORKSPACE,
onBack: vi.fn()
})
)
expect(await screen.findByText('Notes')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Notes file' }))
await vi.waitFor(() => {
expect(sessionActivate).toHaveBeenCalledWith({
workspaceId: WORKSPACE.id,
tabId: 'file-1'
})
expect(screen.getByText('Notes').closest('li')?.getAttribute('data-current')).toBe('true')
})
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
expect(await screen.findByText('Shell')).toBeTruthy()
expect(sessionCreate).toHaveBeenCalledWith({ workspaceId: WORKSPACE.id })
fireEvent.click(screen.getByRole('button', { name: 'Close Codex' }))
await vi.waitFor(() => expect(screen.queryByText('Codex')).toBeNull())
expect(sessionClose).toHaveBeenCalledWith({
workspaceId: WORKSPACE.id,
tabId: 'terminal-1'
})
})
})
function sessionSnapshot(
snapshotVersion: number,
activeTabId: string,
tabs: (
| ReturnType<typeof terminalTab>
| { type: 'file'; id: string; title: string; isActive: boolean }
)[]
) {
return {
workspaceId: WORKSPACE.id,
publicationEpoch: 'epoch-1',
snapshotVersion,
activeTabId,
activeTabType: tabs.find((tab) => tab.id === activeTabId)?.type ?? null,
tabs,
truncated: false
}
}
function terminalTab(id: string, title: string, isActive: boolean) {
return { type: 'terminal' as const, id, title, status: 'ready' as const, isActive }
}
function emptyFileList() {
return {
workspaceId: WORKSPACE.id,
files: [],
totalCount: 0,
truncated: false
}
}
function emptyFileDirectory() {
return {
workspaceId: WORKSPACE.id,
relativePath: '',
revision: '0'.repeat(64),
entries: [],
truncated: false
}
}
function emptySourceControlStatus() {
return {
workspaceId: WORKSPACE.id,
conflictOperation: 'unknown',
entries: [],
totalCount: 0,
truncated: false
}
}
function emptyBranches() {
return {
workspaceId: WORKSPACE.id,
current: 'main',
branches: ['main'],
totalCount: 1,
truncated: false
}
}
function emptyHistory() {
return {
workspaceId: WORKSPACE.id,
items: [],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
}
}
function repositoryState() {
return {
workspaceId: WORKSPACE.id,
head: null,
branch: 'main',
conflictOperation: 'unknown',
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
}
}
-394
View File
@@ -1,394 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ArrowLeft, Loader2, Plus, RefreshCw } from 'lucide-react'
import React, { useCallback, useEffect, useState } from 'react'
import type {
MobileWebSessionSnapshotResult,
MobileWebWorkspaceActivationResult,
MobileWebWorkspaceSummary
} from '../../shared/mobile-web/bridge-operation-contract'
import { MobileWebBridgeClientError, type MobileWebBridgeClient } from './mobile-web-bridge-client'
import {
type MobileWebSessionPendingAction,
useMobileWebSessionActions
} from './mobile-web-session-actions'
import { MobileWebFiles } from './mobile-web-files'
import { MobileWebSourceControl } from './mobile-web-source-control'
import { MobileWebSessionTabRow } from './mobile-web-session-tab-row'
import { MobileWebTerminal } from './mobile-web-terminal'
import type { MobileWebNativeShellState } from './native-shell-channel'
type SessionRequestState = {
client: MobileWebBridgeClient
workspaceId: string
loading: boolean
snapshot: MobileWebSessionSnapshotResult | null
activation: MobileWebWorkspaceActivationResult | null
error: MobileWebBridgeClientError | null
}
export function MobileWebSession({
client,
connection,
workspace,
onBack
}: {
client: MobileWebBridgeClient
connection: MobileWebNativeShellState['connection']
workspace: MobileWebWorkspaceSummary
onBack: () => void
}): React.JSX.Element {
const [retry, setRetry] = useState(0)
const [request, setRequest] = useState<SessionRequestState>({
client,
workspaceId: workspace.id,
loading: false,
snapshot: null,
activation: null,
error: null
})
const acceptSnapshot = useCallback(
(nextSnapshot: MobileWebSessionSnapshotResult, allowSameVersion = false) => {
setRequest((state) => {
if (state.client !== client || state.workspaceId !== workspace.id) {
return state
}
return {
...state,
snapshot: shouldAcceptSnapshot(state.snapshot, nextSnapshot, allowSameVersion)
? nextSnapshot
: state.snapshot,
error: null
}
})
},
[client, workspace.id]
)
const actions = useMobileWebSessionActions({
client,
connected: connection === 'connected',
workspaceId: workspace.id,
onSnapshot: acceptSnapshot
})
useEffect(() => {
if (connection !== 'connected') {
setRequest((current) => ({ ...current, loading: false }))
return
}
let current = true
let unsubscribe = (): void => {}
setRequest((state) => ({
client,
workspaceId: workspace.id,
loading: true,
snapshot:
state.client === client && state.workspaceId === workspace.id ? state.snapshot : null,
activation:
state.client === client && state.workspaceId === workspace.id ? state.activation : null,
error: null
}))
void enterWorkspace(client, workspace.id).then(
({ activation, snapshot }) => {
if (current) {
setRequest({
client,
workspaceId: workspace.id,
loading: false,
snapshot,
activation,
error: null
})
const subscription = client.sessionSubscribe(
{ workspaceId: workspace.id },
(nextSnapshot) => {
if (!current) {
return
}
acceptSnapshot(nextSnapshot)
},
(subscriptionError) => {
if (current) {
setRequest((state) => ({ ...state, error: subscriptionError }))
}
}
)
unsubscribe = subscription.unsubscribe
void subscription.ready.catch(() => null)
}
},
(error: unknown) => {
if (current) {
setRequest((state) => ({
...state,
loading: false,
error: bridgeClientError(error)
}))
}
}
)
return () => {
current = false
unsubscribe()
}
}, [acceptSnapshot, client, connection, retry, workspace.id])
const tryAgain = useCallback(() => setRetry((value) => value + 1), [])
const matchesWorkspace = request.client === client && request.workspaceId === workspace.id
const snapshot = matchesWorkspace ? request.snapshot : null
const error = matchesWorkspace ? request.error : null
const activation = matchesWorkspace ? request.activation : null
const displayError = actions.error ?? error
return (
<section className="mx-auto w-full max-w-2xl p-4">
<div className="mb-3 flex items-center gap-2">
<Button aria-label="Back to workspaces" variant="ghost" size="icon-sm" onClick={onBack}>
<ArrowLeft />
</Button>
<div className="min-w-0">
<h1 className="truncate text-sm font-medium">{workspace.name}</h1>
<p className="truncate font-mono text-xs text-muted-foreground">{workspace.branch}</p>
</div>
</div>
{connection !== 'connected' ? (
<p
role="status"
className="mb-3 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground"
>
{connectionCopy(connection)}
</p>
) : null}
{activation?.sleepingAgentWake === 'unsupported-headless' ? (
<p
role="status"
className="mb-3 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground"
>
Sleeping agents need an open Orca Desktop window before they can resume.
</p>
) : null}
{displayError ? (
<SessionError
error={displayError}
canRetry={connection === 'connected' && !request.loading}
onRetry={tryAgain}
/>
) : null}
<Card aria-busy={request.loading}>
<CardHeader>
<div className="space-y-1">
<CardTitle>Session</CardTitle>
<CardDescription>{sessionDescription(snapshot, request.loading)}</CardDescription>
</div>
<CardAction>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
disabled={request.loading || actions.pending !== null || connection !== 'connected'}
onClick={actions.create}
>
{actions.pending === 'create' ? <Loader2 className="animate-spin" /> : <Plus />}
Terminal
</Button>
<Button
aria-label="Refresh session"
variant="outline"
size="icon-sm"
disabled={request.loading || actions.pending !== null || connection !== 'connected'}
onClick={tryAgain}
>
<RefreshCw className={request.loading ? 'animate-spin' : undefined} />
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent className="px-0">
<SessionBody
client={client}
workspaceId={workspace.id}
snapshot={snapshot}
loading={request.loading}
connected={connection === 'connected'}
pending={actions.pending}
onActivate={actions.activate}
onClose={actions.close}
/>
</CardContent>
</Card>
<MobileWebSourceControl
client={client}
workspaceId={workspace.id}
connected={connection === 'connected'}
/>
<MobileWebFiles
client={client}
workspaceId={workspace.id}
connected={connection === 'connected'}
/>
</section>
)
}
async function enterWorkspace(
client: MobileWebBridgeClient,
workspaceId: string
): Promise<{
activation: MobileWebWorkspaceActivationResult
snapshot: MobileWebSessionSnapshotResult
}> {
const activation = await client.workspaceActivate({ workspaceId })
const snapshot = await client.sessionSnapshot({ workspaceId })
return { activation, snapshot }
}
function SessionBody({
client,
workspaceId,
snapshot,
loading,
connected,
pending,
onActivate,
onClose
}: {
client: MobileWebBridgeClient
workspaceId: string
snapshot: MobileWebSessionSnapshotResult | null
loading: boolean
connected: boolean
pending: MobileWebSessionPendingAction
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
}): React.JSX.Element {
if (!snapshot) {
return (
<div className="flex min-h-40 items-center justify-center border-t border-border px-6 py-10">
{loading ? <Loader2 className="size-5 animate-spin text-muted-foreground" /> : null}
</div>
)
}
if (snapshot.tabs.length === 0) {
return (
<div className="border-t border-border px-6 py-10 text-center">
<p className="text-sm font-medium">No open session tabs</p>
<p className="mt-1 text-xs text-muted-foreground">
Open a terminal, file, or browser tab on this workspace.
</p>
</div>
)
}
return (
<>
<ul className="border-t border-border">
{snapshot.tabs.map((tab) => (
<MobileWebSessionTabRow
key={`${tab.type}:${tab.id}`}
tab={tab}
connected={connected}
pending={pending}
onActivate={onActivate}
onClose={onClose}
/>
))}
</ul>
{snapshot.truncated ? (
<p className="border-t border-border px-6 py-3 text-xs text-muted-foreground">
Showing the first 200 session tabs.
</p>
) : null}
{snapshot.activeTabType === 'terminal' && snapshot.activeTabId ? (
<MobileWebTerminal
client={client}
workspaceId={workspaceId}
tabId={snapshot.activeTabId}
connected={connected}
/>
) : null}
</>
)
}
function SessionError({
error,
canRetry,
onRetry
}: {
error: MobileWebBridgeClientError
canRetry: boolean
onRetry: () => void
}): React.JSX.Element {
return (
<div
role="alert"
className="mb-3 flex items-center justify-between gap-3 rounded-md border border-border bg-muted px-3 py-2"
>
<p className="text-xs text-muted-foreground">{sessionErrorCopy(error)}</p>
{error.retryable ? (
<Button variant="outline" size="xs" disabled={!canRetry} onClick={onRetry}>
Try again
</Button>
) : null}
</div>
)
}
function sessionDescription(
snapshot: MobileWebSessionSnapshotResult | null,
loading: boolean
): string {
if (!snapshot) {
return loading ? 'Opening workspace on the paired desktop…' : 'Session unavailable'
}
const count = snapshot.tabs.length
return `${count} open ${count === 1 ? 'tab' : 'tabs'} · version ${snapshot.snapshotVersion}`
}
function shouldAcceptSnapshot(
current: MobileWebSessionSnapshotResult | null,
next: MobileWebSessionSnapshotResult,
allowSameVersion: boolean
): boolean {
return (
!current ||
current.publicationEpoch !== next.publicationEpoch ||
next.snapshotVersion > current.snapshotVersion ||
(allowSameVersion && next.snapshotVersion === current.snapshotVersion)
)
}
function connectionCopy(connection: MobileWebNativeShellState['connection']): string {
if (connection === 'offline') {
return 'Desktop is offline — showing the last session snapshot.'
}
if (connection === 'recovering') {
return 'Restoring the secure connection — session is retained.'
}
return 'Connecting to the desktop — session is retained.'
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
function sessionErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile build does not expose workspace sessions.'
}
if (error.code === 'invalid_message') {
return 'The desktop returned an incompatible session response.'
}
if (error.code === 'conflict') {
return 'The desktop kept this tab open because its session state changed.'
}
return 'The workspace session could not be loaded from the paired desktop.'
}
-51
View File
@@ -1,51 +0,0 @@
import React, { useState } from 'react'
import type { MobileWebWorkspaceSummary } from '../../shared/mobile-web/bridge-operation-contract'
import { MobileWebSession } from './mobile-web-session'
import { MobileWebNativeShellProvider, useMobileWebNativeShell } from './native-shell-channel'
import { MobileWebWorkspaces } from './mobile-web-workspaces'
export function MobileWebShell(): React.JSX.Element {
return (
<MobileWebNativeShellProvider>
<MobileWebShellContent />
</MobileWebNativeShellProvider>
)
}
function MobileWebShellContent(): React.JSX.Element {
const shell = useMobileWebNativeShell()
const key = shell.context ? `${shell.context.shellSessionId}:${shell.context.buildId}` : 'opening'
return (
<main className="h-dvh overflow-y-auto bg-background text-foreground scrollbar-sleek">
<MobileWebHostApplication key={key} client={shell.client} connection={shell.connection} />
</main>
)
}
function MobileWebHostApplication({
client,
connection
}: Pick<ReturnType<typeof useMobileWebNativeShell>, 'client' | 'connection'>): React.JSX.Element {
const [workspace, setWorkspace] = useState<MobileWebWorkspaceSummary | null>(null)
if (workspace && client) {
return (
<MobileWebSession
client={client}
connection={connection}
workspace={workspace}
onBack={() => setWorkspace(null)}
/>
)
}
return (
<MobileWebWorkspaces
client={client}
connection={connection}
onOpen={(nextWorkspace) => {
void client?.native.hapticSelection().catch(() => null)
setWorkspace(nextWorkspace)
}}
/>
)
}
@@ -1,113 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog-foundation'
import React from 'react'
import type {
MobileWebSourceControlPendingAction,
MobileWebSourceControlSync
} from './use-mobile-web-source-control-sync'
export function MobileWebSourceControlActionDialog({
sync
}: {
sync: MobileWebSourceControlSync
}): React.JSX.Element {
const action = sync.pending
const copy = actionCopy(action, sync)
return (
<Dialog
open={action !== null}
onOpenChange={(open) => {
if (!open && sync.busy === null) {
sync.cancelPending()
}
}}
>
<DialogContent className="max-w-sm" showCloseButton={sync.busy === null}>
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="ghost" disabled={sync.busy !== null} onClick={sync.cancelPending}>
Cancel
</Button>
<Button
variant="default"
disabled={!action || sync.busy !== null}
onClick={() => void sync.confirmPending()}
>
{copy.confirm}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function actionCopy(
action: MobileWebSourceControlPendingAction | null,
sync: MobileWebSourceControlSync
): { title: string; description: string; confirm: string } {
if (!action) {
return { title: 'Repository action', description: '', confirm: 'Continue' }
}
if (action.kind === 'checkout') {
return {
title: `Switch to ${action.branch}?`,
description:
'Uncommitted changes remain in the working tree. Git will refuse the switch if they would be overwritten.',
confirm: 'Switch branch'
}
}
if (action.kind === 'pull') {
const behind = sync.repository?.upstream.behind ?? 0
return action.strategy === 'fast-forward'
? {
title: `Pull ${behind.toLocaleString()} ${behind === 1 ? 'commit' : 'commits'}?`,
description: 'This fast-forwards the current branch from its configured upstream.',
confirm: 'Pull'
}
: {
title: 'Pull and merge divergent changes?',
description:
'The local and upstream branches both contain commits. Git may create a merge commit or stop for conflicts.',
confirm: 'Pull and merge'
}
}
if (action.kind === 'push') {
if (action.mode === 'publish') {
return {
title: `Publish ${sync.repository?.branch ?? 'this branch'}?`,
description:
'This sends the current branch to the configured remote and establishes its upstream.',
confirm: 'Publish branch'
}
}
return {
title: 'Push local commits?',
description:
'This sends commits to the configured remote. Force push is not available from this mobile flow.',
confirm: 'Push'
}
}
if (action.kind === 'rebase') {
return {
title: `Rebase onto ${action.baseRef}?`,
description:
'This rewrites local commits on the configured base and may stop for conflicts. It does not force push.',
confirm: 'Rebase'
}
}
return {
title: `Abort ${action.conflictOperation}?`,
description: `Git will stop the ${action.conflictOperation} and restore its pre-operation state.`,
confirm: `Abort ${action.conflictOperation}`
}
}
@@ -1,213 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { MobileWebSourceControl } from './mobile-web-source-control'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: () => ({
getTotalSize: () => 0,
getVirtualItems: () => []
})
}))
afterEach(cleanup)
describe('MobileWebSourceControl commit', () => {
it('generates and commits against the exact visible staged snapshot', async () => {
const client = commitClient()
renderSourceControl(client)
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }))
await waitFor(() => {
const input = screen.getByRole('textbox', { name: 'Commit message' }) as HTMLInputElement
expect(input.value).toBe('feat: generated')
})
expect(client.sourceControlGenerateCommitMessage).toHaveBeenCalledWith(
commitSnapshot(),
expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 70_000 })
)
fireEvent.click(screen.getByRole('button', { name: 'Commit' }))
await waitFor(() => expect(client.sourceControlCommit).toHaveBeenCalledOnce())
expect(client.sourceControlCommit).toHaveBeenCalledWith(
{ ...commitSnapshot(), message: 'feat: generated' },
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
await waitFor(() => expect(client.sourceControlStatus).toHaveBeenCalledTimes(2))
})
it('cancels generation locally and requests host cancellation', async () => {
const client = commitClient()
let resolveGeneration: ((value: unknown) => void) | undefined
client.sourceControlGenerateCommitMessage.mockReturnValue(
new Promise((resolve) => {
resolveGeneration = resolve
})
)
renderSourceControl(client)
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }))
const signal = client.sourceControlGenerateCommitMessage.mock.calls[0]?.[1]
?.signal as AbortSignal
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
expect(signal.aborted).toBe(true)
await waitFor(() =>
expect(client.sourceControlCancelCommitMessageGeneration).toHaveBeenCalledWith({
workspaceId: 'workspace-1'
})
)
resolveGeneration?.({
workspaceId: 'workspace-1',
previousHead: 'a'.repeat(40),
status: 'generated',
message: 'late draft'
})
await Promise.resolve()
const input = screen.getByRole('textbox', { name: 'Commit message' }) as HTMLInputElement
expect(input.value).toBe('')
})
it('refreshes and explains a repository conflict without committing', async () => {
const client = commitClient()
client.sourceControlCommit.mockRejectedValue(new MobileWebBridgeClientError('conflict', true))
renderSourceControl(client)
const input = await screen.findByRole('textbox', { name: 'Commit message' })
fireEvent.change(input, { target: { value: 'feat: stale' } })
fireEvent.click(screen.getByRole('button', { name: 'Commit' }))
expect(
await screen.findByText(
'The repository changed. Review the refreshed status before trying again.'
)
).toBeDefined()
await waitFor(() => expect(client.sourceControlStatus).toHaveBeenCalledTimes(2))
})
})
type MockCommitClient = MobileWebBridgeClient &
Record<
| 'sourceControlStatus'
| 'sourceControlSubscribe'
| 'sourceControlDiff'
| 'sourceControlBranches'
| 'sourceControlHistory'
| 'sourceControlBranchCompare'
| 'sourceControlCommitCompare'
| 'sourceControlUpstream'
| 'sourceControlStage'
| 'sourceControlUnstage'
| 'sourceControlDiscard'
| 'sourceControlCommit'
| 'sourceControlGenerateCommitMessage'
| 'sourceControlCancelCommitMessageGeneration',
ReturnType<typeof vi.fn>
>
function commitClient(): MockCommitClient {
return {
sourceControlStatus: vi.fn().mockResolvedValue(statusResult()),
sourceControlSubscribe: vi.fn().mockReturnValue({
ready: Promise.resolve(),
unsubscribe: vi.fn()
}),
sourceControlDiff: vi.fn(),
sourceControlBranches: vi.fn().mockResolvedValue(emptyBranches()),
sourceControlHistory: vi.fn().mockResolvedValue(emptyHistory()),
sourceControlUpstream: vi.fn().mockResolvedValue(repositoryState()),
sourceControlBranchCompare: vi.fn(),
sourceControlCommitCompare: vi.fn(),
sourceControlStage: vi.fn(),
sourceControlUnstage: vi.fn(),
sourceControlDiscard: vi.fn(),
sourceControlCommit: vi.fn().mockResolvedValue({
workspaceId: 'workspace-1',
previousHead: 'a'.repeat(40),
status: 'committed',
head: 'b'.repeat(40)
}),
sourceControlGenerateCommitMessage: vi.fn().mockResolvedValue({
workspaceId: 'workspace-1',
previousHead: 'a'.repeat(40),
status: 'generated',
message: 'feat: generated'
}),
sourceControlCancelCommitMessageGeneration: vi.fn().mockResolvedValue({
workspaceId: 'workspace-1',
cancellationRequested: true
})
} as unknown as MockCommitClient
}
function renderSourceControl(client: MockCommitClient) {
return render(
createElement(MobileWebSourceControl, {
client,
workspaceId: 'workspace-1',
connected: true
})
)
}
function commitSnapshot() {
return {
workspaceId: 'workspace-1',
expectedHead: 'a'.repeat(40),
stagedEntries: [{ relativePath: 'src/app.ts', status: 'modified', area: 'staged' }]
}
}
function statusResult() {
return {
workspaceId: 'workspace-1',
branch: 'mobile-rearch',
head: 'a'.repeat(40),
conflictOperation: 'unknown' as const,
entries: [{ relativePath: 'src/app.ts', status: 'modified' as const, area: 'staged' as const }],
totalCount: 1,
truncated: false
}
}
function emptyBranches() {
return {
workspaceId: 'workspace-1',
current: 'main',
branches: ['main'],
totalCount: 1,
truncated: false
}
}
function emptyHistory() {
return {
workspaceId: 'workspace-1',
items: [],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
}
}
function repositoryState() {
return {
workspaceId: 'workspace-1',
head: 'a'.repeat(40),
branch: 'main',
conflictOperation: 'unknown',
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
}
}
@@ -1,72 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { Loader2, Sparkles } from 'lucide-react'
import React from 'react'
import { MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS } from '../../shared/mobile-web/source-control-commit-contract'
import type { MobileWebSourceControlCommit } from './use-mobile-web-source-control-commit'
export function MobileWebSourceControlCommitComposer({
commit
}: {
commit: MobileWebSourceControlCommit
}): React.JSX.Element {
const generating = commit.busy === 'generate'
const committing = commit.busy === 'commit'
return (
<form
aria-busy={commit.busy !== null}
className="space-y-2 border-t border-border px-6 py-3"
onSubmit={(event) => {
event.preventDefault()
void commit.commit()
}}
>
<div className="flex items-center gap-2">
<Input
aria-label="Commit message"
value={commit.message}
maxLength={MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS}
placeholder={commit.stagedCount > 0 ? 'Commit message' : 'No staged files'}
disabled={commit.stagedCount === 0 || commit.busy !== null}
onChange={(event) => commit.setMessage(event.target.value)}
/>
<Button
type="button"
variant="outline"
className="w-24"
disabled={!generating && !commit.canGenerate}
onClick={() => {
if (generating) {
commit.cancelGeneration()
} else {
void commit.generate()
}
}}
>
{generating ? <Loader2 className="animate-spin" /> : <Sparkles />}
{generating ? 'Cancel' : 'Generate'}
</Button>
<Button type="submit" className="w-20" disabled={!commit.canCommit}>
{committing ? <Loader2 className="animate-spin" /> : null}
Commit
</Button>
</div>
<div className="flex min-h-6 items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{commit.blockedReason ??
`${commit.stagedCount.toLocaleString()} staged ${
commit.stagedCount === 1 ? 'file' : 'files'
}`}
</p>
{commit.error ? (
<div role="alert" className="flex items-center gap-2 text-xs text-destructive">
<span>{commit.error}</span>
<Button type="button" variant="ghost" size="xs" onClick={commit.clearError}>
Dismiss
</Button>
</div>
) : null}
</div>
</form>
)
}
@@ -1,147 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { Loader2 } from 'lucide-react'
import React from 'react'
import type { MobileWebSourceControlCompareEntry } from '../../shared/mobile-web/source-control-history-contract'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import type {
MobileWebRepositoryComparison,
MobileWebRepositorySelection
} from './use-mobile-web-source-control-repository'
export function MobileWebSourceControlCompare({
selection,
comparison,
loading,
error,
connected,
onRetry
}: {
selection: MobileWebRepositorySelection | null
comparison: MobileWebRepositoryComparison | null
loading: boolean
error: MobileWebBridgeClientError | null
connected: boolean
onRetry: () => void
}): React.JSX.Element | null {
if (!selection) {
return null
}
return (
<section aria-label="Repository comparison" className="border-t border-border">
<div className="flex items-start justify-between gap-3 px-6 py-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-medium">{comparisonTitle(selection)}</h3>
<p className="mt-1 text-xs text-muted-foreground">
{comparisonDescription(comparison, loading, error)}
</p>
</div>
{loading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : null}
</div>
{error ? (
<div className="px-6 pb-3">
<Button variant="outline" size="xs" disabled={!connected} onClick={onRetry}>
Retry comparison
</Button>
</div>
) : null}
{comparison?.status === 'ready' ? <CompareEntries entries={comparison.entries} /> : null}
{comparison?.truncated ? (
<p className="border-t border-border px-6 py-2 text-xs text-muted-foreground">
Showing {comparison.entries.length.toLocaleString()} of{' '}
{comparison.changedFiles.toLocaleString()} changed files.
</p>
) : null}
</section>
)
}
function CompareEntries({
entries
}: {
entries: MobileWebSourceControlCompareEntry[]
}): React.JSX.Element {
if (entries.length === 0) {
return <p className="px-6 pb-4 text-sm text-muted-foreground">No file changes.</p>
}
return (
<ul aria-label="Comparison files" className="max-h-72 overflow-y-auto scrollbar-sleek">
{entries.map((entry) => (
<li
key={`${entry.status}:${entry.oldRelativePath ?? ''}:${entry.relativePath}`}
className="flex min-h-8 items-center gap-2 border-t border-border/50 px-6 py-1.5 text-xs"
>
<span className="w-4 shrink-0 font-mono text-muted-foreground">
{statusLabel(entry.status)}
</span>
<span className="min-w-0 flex-1 truncate font-mono">{entry.relativePath}</span>
<span className="shrink-0 font-mono">
{entry.added ? (
<span className="text-[var(--git-decoration-added)]">+{entry.added} </span>
) : null}
{entry.removed ? (
<span className="text-[var(--git-decoration-deleted)]">-{entry.removed}</span>
) : null}
</span>
</li>
))}
</ul>
)
}
function comparisonTitle(selection: MobileWebRepositorySelection): string {
return selection.kind === 'branch' ? `Changes from ${selection.label}` : selection.label
}
function comparisonDescription(
comparison: MobileWebRepositoryComparison | null,
loading: boolean,
error: MobileWebBridgeClientError | null
): string {
if (loading) {
return 'Loading provider-neutral comparison'
}
if (error) {
return 'Comparison is unavailable.'
}
if (!comparison) {
return 'Select a branch or commit to compare.'
}
if (comparison.status !== 'ready') {
return comparisonStatusCopy(comparison.status)
}
const fileCopy = `${comparison.changedFiles.toLocaleString()} ${
comparison.changedFiles === 1 ? 'changed file' : 'changed files'
}`
return 'commitsAhead' in comparison && comparison.commitsAhead !== undefined
? `${comparison.commitsAhead.toLocaleString()} commits ahead · ${fileCopy}`
: fileCopy
}
function comparisonStatusCopy(status: MobileWebRepositoryComparison['status']): string {
if (status === 'invalid-base') {
return 'The selected branch cannot be resolved.'
}
if (status === 'unborn-head') {
return 'The current branch does not have a committed HEAD.'
}
if (status === 'no-merge-base') {
return 'The branches do not share a merge base.'
}
if (status === 'invalid-commit') {
return 'The selected commit cannot be resolved.'
}
return 'Comparison is unavailable.'
}
function statusLabel(status: MobileWebSourceControlCompareEntry['status']): string {
const labels = {
modified: 'M',
added: 'A',
deleted: 'D',
renamed: 'R',
copied: 'C'
} as const
return labels[status]
}
@@ -1,219 +0,0 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button } from '@renderer/components/ui/button'
import { FileWarning, Loader2, X } from 'lucide-react'
import React, { useEffect, useRef } from 'react'
import type { MobileWebSourceControlStatusEntry } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import type { MobileWebDiffDocument } from './use-mobile-web-diff-document'
const DIFF_ROW_HEIGHT = 24
export function MobileWebSourceControlDiff({
entry,
document,
loading,
error,
connected,
onLoadMore,
onRetry,
onCancel
}: {
entry: MobileWebSourceControlStatusEntry | null
document: MobileWebDiffDocument | null
loading: boolean
error: MobileWebBridgeClientError | null
connected: boolean
onLoadMore: () => void
onRetry: () => void
onCancel: () => void
}): React.JSX.Element | null {
if (!entry) {
return null
}
return (
<section className="border-t border-border" aria-label={`Diff ${entry.relativePath}`}>
<div className="flex items-center justify-between gap-3 px-6 py-2">
<p className="truncate font-mono text-xs">{entry.relativePath}</p>
{document?.kind === 'text' ? (
<p className="shrink-0 text-[11px] text-muted-foreground">
{document.rows.length.toLocaleString()} of {document.totalRows.toLocaleString()} rows
</p>
) : null}
</div>
{loading ? (
<div
role="status"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-2 text-xs text-muted-foreground"
>
<span className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
Loading diff
</span>
<Button variant="ghost" size="xs" onClick={onCancel}>
<X />
Cancel
</Button>
</div>
) : null}
{error ? (
<div
role="alert"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-2 text-xs text-destructive"
>
<span>{diffErrorCopy(error)}</span>
{error.retryable || error.code === 'conflict' ? (
<Button variant="outline" size="xs" disabled={!connected} onClick={onRetry}>
Reload
</Button>
) : null}
</div>
) : null}
{document?.kind === 'binary' ? (
<p className="flex items-center gap-2 border-t border-border px-6 py-6 text-xs text-muted-foreground">
<FileWarning className="size-4" />
Binary diff preview is not available yet.
</p>
) : null}
{document?.kind === 'too-large' ? (
<p className="border-t border-border px-6 py-6 text-xs text-muted-foreground">
This diff exceeds the bounded mobile preview.
{document.characterCount === undefined
? ''
: ` ${document.characterCount.toLocaleString()} characters were reported.`}
</p>
) : null}
{document?.kind === 'text' ? (
<MobileWebDiffRows
document={document}
connected={connected}
loading={loading}
onLoadMore={onLoadMore}
/>
) : null}
</section>
)
}
export function MobileWebDiffRows({
document,
connected,
loading,
onLoadMore,
focusRowIndex
}: {
document: Extract<MobileWebDiffDocument, { kind: 'text' }>
connected: boolean
loading: boolean
onLoadMore: () => void
focusRowIndex?: number
}): React.JSX.Element {
const viewportRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: document.rows.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => DIFF_ROW_HEIGHT,
overscan: 12,
getItemKey: (index) => document.rows[index]?.index ?? index
})
useEffect(() => {
if (focusRowIndex === undefined) {
return
}
const localIndex = document.rows.findIndex((row) => row.index === focusRowIndex)
if (localIndex >= 0) {
virtualizer.scrollToIndex(localIndex, { align: 'center' })
}
}, [document.rows, focusRowIndex, virtualizer])
return (
<>
<div
ref={viewportRef}
role="list"
aria-label="Diff rows"
aria-setsize={document.totalRows}
className="max-h-[32rem] overflow-auto border-t border-border bg-editor-surface font-mono text-xs scrollbar-editor"
>
<div className="relative min-w-max" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = document.rows[virtualRow.index]
if (!row) {
return null
}
const focused = row.index === focusRowIndex
return (
<div
key={row.index}
role="listitem"
aria-posinset={row.index + 1}
aria-current={focused || undefined}
data-diff-row-index={row.index}
data-focused={focused || undefined}
className={`absolute left-0 top-0 flex h-6 min-w-full items-center ${
focused ? 'bg-accent ring-1 ring-inset ring-ring' : ''
}`}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<span className="w-10 shrink-0 pr-2 text-right text-muted-foreground">
{row.oldLineNumber ?? ''}
</span>
<span className="w-10 shrink-0 pr-2 text-right text-muted-foreground">
{row.newLineNumber ?? ''}
</span>
<span className={diffMarkerClass(row.kind)}>{diffMarker(row.kind)}</span>
<span className="pr-4 whitespace-pre">
{row.text}
{row.textTruncated ? '…' : ''}
</span>
</div>
)
})}
</div>
</div>
{document.truncated ? (
<p className="border-t border-border bg-muted px-6 py-2 text-xs text-muted-foreground">
Diff preview is capped at 4,000 rows.
</p>
) : null}
{document.retentionLimitReached ? (
<p className="border-t border-border bg-muted px-6 py-2 text-xs text-muted-foreground">
Diff loading stopped at the 1,000,000-character retained-state limit.
</p>
) : null}
{document.nextOffset !== null ? (
<div className="border-t border-border px-6 py-3">
<Button variant="outline" size="sm" disabled={!connected || loading} onClick={onLoadMore}>
Load more diff
</Button>
</div>
) : null}
</>
)
}
function diffMarker(kind: 'context' | 'add' | 'delete'): string {
return kind === 'add' ? '+' : kind === 'delete' ? '−' : ' '
}
function diffMarkerClass(kind: 'context' | 'add' | 'delete'): string {
const color =
kind === 'add'
? 'text-[color:var(--git-decoration-added)]'
: kind === 'delete'
? 'text-[color:var(--git-decoration-deleted)]'
: 'text-muted-foreground'
return `w-5 shrink-0 text-center ${color}`
}
function diffErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'conflict') {
return 'This diff changed while more rows were loading.'
}
if (error.code === 'not_connected') {
return 'Reconnect to the paired desktop to load this diff.'
}
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile shell does not expose source-control diffs.'
}
return 'The paired desktop could not provide this diff.'
}
@@ -1,78 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@renderer/components/ui/dialog-foundation'
import { Loader2 } from 'lucide-react'
import React from 'react'
import type { MobileWebSourceControlStatusEntry } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
export function MobileWebSourceControlDiscardDialog({
targets,
busy,
error,
onCancel,
onConfirm
}: {
targets: MobileWebSourceControlStatusEntry[] | null
busy: boolean
error: MobileWebBridgeClientError | null
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
const count = targets?.length ?? 0
return (
<Dialog
open={targets !== null}
onOpenChange={(open) => {
if (!open && !busy) {
onCancel()
}
}}
>
<DialogContent className="max-w-sm" showCloseButton={!busy}>
<DialogHeader>
<DialogTitle>Discard {count === 1 ? 'change' : `${count} changes`}?</DialogTitle>
<DialogDescription>
{discardDescription(targets)}
{error ? ` ${discardErrorCopy(error)}` : ''}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="ghost" disabled={busy} onClick={onCancel}>
Cancel
</Button>
<Button variant="destructive" disabled={busy || count === 0} onClick={onConfirm}>
{busy ? <Loader2 className="animate-spin" /> : null}
Discard
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function discardDescription(targets: MobileWebSourceControlStatusEntry[] | null): string {
if (!targets || targets.length === 0) {
return 'This action cannot be undone.'
}
if (targets.length === 1) {
return `This removes the uncommitted content in “${targets[0]!.relativePath}” from the paired Desktop and cannot be undone.`
}
return `This removes the selected uncommitted content from the paired Desktop and cannot be undone.`
}
function discardErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'conflict') {
return 'The repository changed; review the refreshed status before trying again.'
}
if (error.code === 'not_connected') {
return 'Reconnect before trying again.'
}
return 'The paired Desktop did not discard the selected changes.'
}
@@ -1,238 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebSourceControlStatusEntry } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { MobileWebSourceControl } from './mobile-web-source-control'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: () => ({
getTotalSize: () => 0,
getVirtualItems: () => []
})
}))
afterEach(cleanup)
describe('MobileWebSourceControl mutations', () => {
it('stages and unstages a single provider-neutral status snapshot', async () => {
const client = mutationClient()
client.sourceControlStatus
.mockResolvedValueOnce(statusResult('workspace-1', [entry('unstaged')]))
.mockResolvedValueOnce(statusResult('workspace-1', [entry('staged')]))
.mockResolvedValue(statusResult('workspace-1', [entry('unstaged')]))
renderSourceControl(client)
fireEvent.click(await screen.findByRole('button', { name: 'Stage' }))
await waitFor(() => expect(client.sourceControlStage).toHaveBeenCalledOnce())
expect(client.sourceControlStage).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: 'b'.repeat(40),
entries: [{ relativePath: 'src/app.ts', status: 'modified', area: 'unstaged' }]
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
fireEvent.click(await screen.findByRole('button', { name: 'Unstage' }))
await waitFor(() => expect(client.sourceControlUnstage).toHaveBeenCalledOnce())
expect(client.sourceControlUnstage).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: 'b'.repeat(40),
entries: [{ relativePath: 'src/app.ts', status: 'modified', area: 'staged' }]
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('bounds bulk stage and unstage requests to the negotiated mutation limit', async () => {
const client = mutationClient()
const entries = [
...Array.from({ length: 34 }, (_, index) => entry('unstaged', `src/unstaged-${index}.ts`)),
...Array.from({ length: 34 }, (_, index) => entry('staged', `src/staged-${index}.ts`))
]
client.sourceControlStatus.mockResolvedValue(statusResult('workspace-1', entries))
renderSourceControl(client)
fireEvent.click(await screen.findByRole('button', { name: 'Stage 32' }))
await waitFor(() => expect(client.sourceControlStage).toHaveBeenCalledOnce())
expect(client.sourceControlStage.mock.calls[0]?.[0].entries).toHaveLength(32)
fireEvent.click(screen.getByRole('button', { name: 'Unstage 32' }))
await waitFor(() => expect(client.sourceControlUnstage).toHaveBeenCalledOnce())
expect(client.sourceControlUnstage.mock.calls[0]?.[0].entries).toHaveLength(32)
})
it('requires a visible confirmation before sending discard', async () => {
const client = mutationClient()
renderSourceControl(client)
fireEvent.click(await screen.findByRole('button', { name: 'Discard' }))
expect(client.sourceControlDiscard).not.toHaveBeenCalled()
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByText('Discard change?')).toBeDefined()
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
expect(client.sourceControlDiscard).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Discard' }))
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Discard' }))
await waitFor(() => expect(client.sourceControlDiscard).toHaveBeenCalledOnce())
expect(client.sourceControlDiscard).toHaveBeenCalledWith(
expect.objectContaining({ confirmation: 'discard-confirmed' }),
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('surfaces repository conflicts and refreshes status', async () => {
const client = mutationClient()
client.sourceControlStage.mockRejectedValue(new MobileWebBridgeClientError('conflict', true))
renderSourceControl(client)
fireEvent.click(await screen.findByRole('button', { name: 'Stage' }))
expect(
await screen.findByText(
'The repository changed. Review the refreshed status before trying again.'
)
).toBeDefined()
await waitFor(() => expect(client.sourceControlStatus).toHaveBeenCalledTimes(2))
})
it('aborts an in-flight mutation when client and workspace identity change', async () => {
let resolveMutation: (() => void) | undefined
const first = mutationClient()
first.sourceControlStage.mockReturnValue(
new Promise((resolve) => {
resolveMutation = () => resolve(mutationResult('stage', ['src/app.ts']))
})
)
const view = renderSourceControl(first)
fireEvent.click(await screen.findByRole('button', { name: 'Stage' }))
await waitFor(() => expect(first.sourceControlStage).toHaveBeenCalledOnce())
const signal = first.sourceControlStage.mock.calls[0]?.[1]?.signal as AbortSignal
const second = mutationClient('workspace-2')
view.rerender(
createElement(MobileWebSourceControl, {
client: second,
workspaceId: 'workspace-2',
connected: true
})
)
expect(signal.aborted).toBe(true)
resolveMutation?.()
await Promise.resolve()
expect(first.sourceControlStatus).toHaveBeenCalledOnce()
expect(await screen.findByText('src/app.ts')).toBeDefined()
})
})
type MockBridgeClient = MobileWebBridgeClient &
Record<
| 'sourceControlStatus'
| 'sourceControlSubscribe'
| 'sourceControlDiff'
| 'sourceControlBranches'
| 'sourceControlHistory'
| 'sourceControlBranchCompare'
| 'sourceControlCommitCompare'
| 'sourceControlUpstream'
| 'sourceControlStage'
| 'sourceControlUnstage'
| 'sourceControlDiscard',
ReturnType<typeof vi.fn>
>
function mutationClient(workspaceId = 'workspace-1'): MockBridgeClient {
return {
sourceControlStatus: vi.fn().mockResolvedValue(statusResult(workspaceId, [entry('unstaged')])),
sourceControlSubscribe: vi.fn().mockReturnValue({
ready: Promise.resolve(),
unsubscribe: vi.fn()
}),
sourceControlDiff: vi.fn(),
sourceControlBranches: vi.fn().mockResolvedValue(emptyBranches(workspaceId)),
sourceControlHistory: vi.fn().mockResolvedValue(emptyHistory(workspaceId)),
sourceControlUpstream: vi.fn().mockResolvedValue(repositoryState(workspaceId)),
sourceControlBranchCompare: vi.fn(),
sourceControlCommitCompare: vi.fn(),
sourceControlStage: vi.fn().mockResolvedValue(mutationResult('stage', ['src/app.ts'])),
sourceControlUnstage: vi.fn().mockResolvedValue(mutationResult('unstage', ['src/app.ts'])),
sourceControlDiscard: vi.fn().mockResolvedValue(mutationResult('discard', ['src/app.ts']))
} as unknown as MockBridgeClient
}
function renderSourceControl(client: MockBridgeClient) {
return render(
createElement(MobileWebSourceControl, {
client,
workspaceId: 'workspace-1',
connected: true
})
)
}
function statusResult(workspaceId: string, entries: MobileWebSourceControlStatusEntry[]) {
return {
workspaceId,
branch: 'mobile-rearch',
head: 'b'.repeat(40),
conflictOperation: 'unknown' as const,
entries,
totalCount: entries.length,
truncated: false
}
}
function entry(
area: MobileWebSourceControlStatusEntry['area'],
relativePath = 'src/app.ts'
): MobileWebSourceControlStatusEntry {
return { relativePath, status: 'modified', area }
}
function mutationResult(operation: 'stage' | 'unstage' | 'discard', relativePaths: string[]) {
return {
workspaceId: 'workspace-1',
operation,
relativePaths,
mutated: true as const
}
}
function emptyBranches(workspaceId: string) {
return { workspaceId, current: 'main', branches: ['main'], totalCount: 1, truncated: false }
}
function emptyHistory(workspaceId: string) {
return {
workspaceId,
items: [],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
}
}
function repositoryState(workspaceId: string) {
return {
workspaceId,
head: 'b'.repeat(40),
branch: 'main',
conflictOperation: 'unknown',
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
}
}
@@ -1,134 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { Loader2 } from 'lucide-react'
import React from 'react'
import type { MobileWebSourceControlSync } from './use-mobile-web-source-control-sync'
export function MobileWebSourceControlRemoteActions({
sync,
connected
}: {
sync: MobileWebSourceControlSync
connected: boolean
}): React.JSX.Element {
const repository = sync.repository
const upstream = repository?.upstream
const conflict = repository?.conflictOperation
const blocked = !connected || sync.loading || sync.busy !== null || !repository
const hasConflict = conflict !== undefined && conflict !== 'unknown'
const canPull = !hasConflict && upstream?.hasUpstream === true && upstream.behind > 0
const canPush =
!hasConflict &&
upstream !== undefined &&
repository?.head !== null &&
repository?.branch !== null &&
(upstream.hasUpstream ? upstream.ahead > 0 : true)
const canAbort = conflict === 'merge' || conflict === 'rebase'
return (
<section aria-label="Remote repository actions" className="border-y border-border px-6 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-medium">Remote</h3>
<p className="mt-1 truncate text-xs text-muted-foreground">{remoteDescription(sync)}</p>
</div>
{sync.loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : null}
</div>
<div className="mt-3 flex flex-wrap gap-2" aria-busy={sync.busy !== null}>
<ActionButton
label="Fetch"
busy={sync.busy === 'fetch'}
disabled={blocked}
onClick={() => void sync.fetch()}
/>
<ActionButton
label={upstream?.behind ? `Pull ${upstream.behind}` : 'Pull'}
busy={sync.busy === 'pull'}
disabled={blocked || !canPull}
onClick={sync.requestPull}
/>
<ActionButton
label={
upstream && !upstream.hasUpstream && !upstream.hasConfiguredPushTarget
? 'Publish'
: upstream?.ahead
? `Push ${upstream.ahead}`
: 'Push'
}
busy={sync.busy === 'push'}
disabled={blocked || !canPush}
onClick={sync.requestPush}
/>
<ActionButton
label="Rebase"
busy={sync.busy === 'rebase'}
disabled={blocked || hasConflict || !repository?.baseRef || !repository.head}
onClick={sync.requestRebase}
/>
{canAbort ? (
<ActionButton
label={`Abort ${conflict}`}
busy={sync.busy === 'abort'}
disabled={blocked}
onClick={sync.requestAbort}
/>
) : null}
</div>
{sync.error ? (
<div
role="alert"
className="mt-3 flex items-center justify-between gap-3 text-xs text-destructive"
>
<span>{remoteErrorCopy(sync.error.code)}</span>
<Button variant="ghost" size="xs" onClick={sync.clearError}>
Dismiss
</Button>
</div>
) : null}
</section>
)
}
function ActionButton({
label,
busy,
disabled,
onClick
}: {
label: string
busy: boolean
disabled: boolean
onClick: () => void
}): React.JSX.Element {
return (
<Button variant="outline" size="xs" disabled={disabled} onClick={onClick}>
{busy ? <Loader2 className="animate-spin" /> : null}
{label}
</Button>
)
}
function remoteDescription(sync: MobileWebSourceControlSync): string {
const repository = sync.repository
if (!repository) {
return sync.loading ? 'Checking upstream state' : 'Upstream state unavailable'
}
if (repository.conflictOperation !== 'unknown') {
return `${repository.conflictOperation} in progress`
}
const upstream = repository.upstream
if (!upstream.hasUpstream) {
return upstream.hasConfiguredPushTarget ? 'Configured push target' : 'No tracked upstream'
}
const target = upstream.upstreamName ?? 'Configured upstream'
return `${target} · ↓${upstream.behind.toLocaleString()} ↑${upstream.ahead.toLocaleString()}`
}
function remoteErrorCopy(code: string): string {
if (code === 'conflict') {
return 'The repository changed. Review the refreshed state before trying again.'
}
if (code === 'not_connected') {
return 'Reconnect before changing the repository.'
}
return 'The paired Desktop could not complete the repository action.'
}
@@ -1,195 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { cn } from '@renderer/lib/utils'
import { GitBranch, GitCommitHorizontal } from 'lucide-react'
import React from 'react'
import type {
MobileWebSourceControlBranchesResult,
MobileWebSourceControlHistoryItem,
MobileWebSourceControlHistoryResult
} from '../../shared/mobile-web/source-control-history-contract'
import type { MobileWebRepositorySelection } from './use-mobile-web-source-control-repository'
const commitDate = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' })
export function MobileWebBranchList({
result,
loading,
connected,
selection,
actionsDisabled,
onCompare,
onSwitch
}: {
result: MobileWebSourceControlBranchesResult | null
loading: boolean
connected: boolean
selection: MobileWebRepositorySelection | null
actionsDisabled: boolean
onCompare: (branch: string) => void
onSwitch: (branch: string) => void
}): React.JSX.Element {
if (!result) {
return (
<RepositoryListState>
{loading ? 'Loading branches…' : offlineCopy(connected)}
</RepositoryListState>
)
}
if (result.branches.length === 0) {
return <RepositoryListState>No local branches.</RepositoryListState>
}
return (
<>
<ul aria-label="Local branches" className="max-h-72 overflow-y-auto scrollbar-sleek">
{result.branches.map((branch) => {
const current = branch === result.current
const selected = selection?.kind === 'branch' && selection.id === branch
return (
<li key={branch}>
{current ? (
<div
data-current="true"
className="flex min-h-9 items-center gap-2 bg-accent px-6 py-2 text-sm"
>
<GitBranch className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate font-mono text-xs">{branch}</span>
<span className="text-xs text-muted-foreground">Current</span>
</div>
) : (
<div className={cn('flex items-center pr-2', selected && 'bg-accent')}>
<Button
aria-label={`Compare ${branch}`}
variant="ghost"
size="sm"
className="h-auto min-h-9 min-w-0 flex-1 justify-start rounded-none px-6 py-2"
data-current={selected ? 'true' : undefined}
aria-pressed={selected}
disabled={!connected || actionsDisabled}
onClick={() => onCompare(branch)}
>
<GitBranch className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left font-mono text-xs">
{branch}
</span>
</Button>
<Button
variant="outline"
size="xs"
disabled={!connected || actionsDisabled}
onClick={() => onSwitch(branch)}
>
Switch
</Button>
</div>
)}
</li>
)
})}
</ul>
{result.truncated ? (
<p className="border-t border-border px-6 py-2 text-xs text-muted-foreground">
Showing {result.branches.length.toLocaleString()} of {result.totalCount.toLocaleString()}{' '}
local branches.
</p>
) : null}
</>
)
}
export function MobileWebHistoryList({
result,
loading,
connected,
selection,
canLoadMore,
onCompare,
onLoadMore
}: {
result: MobileWebSourceControlHistoryResult | null
loading: boolean
connected: boolean
selection: MobileWebRepositorySelection | null
canLoadMore: boolean
onCompare: (commit: MobileWebSourceControlHistoryItem) => void
onLoadMore: () => void
}): React.JSX.Element {
if (!result) {
return (
<RepositoryListState>
{loading ? 'Loading history…' : offlineCopy(connected)}
</RepositoryListState>
)
}
if (result.items.length === 0) {
return <RepositoryListState>No commits.</RepositoryListState>
}
return (
<>
<ul aria-label="Commit history" className="max-h-96 overflow-y-auto scrollbar-sleek">
{result.items.map((commit) => (
<li key={commit.id}>
<Button
variant="ghost"
className={cn(
'h-auto min-h-12 w-full justify-start rounded-none px-6 py-2 text-left',
selection?.kind === 'commit' && selection.id === commit.id && 'bg-accent'
)}
data-current={
selection?.kind === 'commit' && selection.id === commit.id ? 'true' : undefined
}
aria-pressed={selection?.kind === 'commit' && selection.id === commit.id}
disabled={!connected}
onClick={() => onCompare(commit)}
>
<GitCommitHorizontal className="size-4 shrink-0 self-start" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-normal">{commit.subject}</span>
<span className="mt-1 block truncate font-mono text-xs text-muted-foreground">
{commitMetadata(commit)}
</span>
</span>
</Button>
</li>
))}
</ul>
{canLoadMore ? (
<div className="border-t border-border px-6 py-2">
<Button
variant="ghost"
size="xs"
className="w-full"
disabled={!connected || loading}
onClick={onLoadMore}
>
Load older commits
</Button>
</div>
) : result.hasMore ? (
<p className="border-t border-border px-6 py-2 text-xs text-muted-foreground">
Showing the newest {result.items.length.toLocaleString()} commits.
</p>
) : null}
</>
)
}
function RepositoryListState({ children }: { children: React.ReactNode }): React.JSX.Element {
return <p className="px-6 py-8 text-center text-sm text-muted-foreground">{children}</p>
}
function commitMetadata(commit: MobileWebSourceControlHistoryItem): string {
const parts = [commit.displayId, commit.author, formattedCommitDate(commit.timestamp)]
return parts.filter((value): value is string => Boolean(value)).join(' · ')
}
function formattedCommitDate(timestamp: number | undefined): string | undefined {
if (timestamp === undefined) {
return undefined
}
const date = new Date(timestamp)
return Number.isNaN(date.getTime()) ? undefined : commitDate.format(date)
}
function offlineCopy(connected: boolean): string {
return connected ? 'Repository data is unavailable.' : 'Reconnect to load repository data.'
}
@@ -1,287 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebSourceControlRepository } from './mobile-web-source-control-repository'
afterEach(cleanup)
describe('MobileWebSourceControlRepository', () => {
it('loads branches and history and compares each through the typed client', async () => {
const client = repositoryClient()
render(
createElement(MobileWebSourceControlRepository, {
client,
workspaceId: 'workspace-1',
connected: true,
onRepositoryChanged: vi.fn()
})
)
expect(await screen.findByText('feature/mobile')).toBeDefined()
expect(screen.getByText('2 branches · 1 recent commit')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Compare feature/mobile' }))
expect(await screen.findByText('src/branch.ts')).toBeDefined()
expect(client.sourceControlBranchCompare).toHaveBeenCalledWith(
{ workspaceId: 'workspace-1', baseRef: 'feature/mobile', offset: 0, limit: 128 },
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
fireEvent.mouseDown(screen.getByRole('tab', { name: 'History' }), { button: 0 })
fireEvent.click(await screen.findByRole('button', { name: /feat: history/ }))
expect(await screen.findByText('src/commit.ts')).toBeDefined()
expect(client.sourceControlCommitCompare).toHaveBeenCalledWith(
{ workspaceId: 'workspace-1', commitId: 'a'.repeat(40) },
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('aborts delayed repository and comparison reads on workspace replacement', async () => {
let resolveBranches: ((value: ReturnType<typeof branchesResult>) => void) | undefined
const first = repositoryClient()
first.sourceControlBranches.mockReturnValue(
new Promise((resolve) => {
resolveBranches = resolve
})
)
const second = repositoryClient('workspace-2')
const view = render(
createElement(MobileWebSourceControlRepository, {
client: first,
workspaceId: 'workspace-1',
connected: true,
onRepositoryChanged: vi.fn()
})
)
const initialSignal = first.sourceControlBranches.mock.calls[0]?.[1]?.signal as AbortSignal
view.rerender(
createElement(MobileWebSourceControlRepository, {
client: second,
workspaceId: 'workspace-2',
connected: true,
onRepositoryChanged: vi.fn()
})
)
expect(initialSignal.aborted).toBe(true)
resolveBranches?.(branchesResult('workspace-1'))
expect(await screen.findByText('release/mobile')).toBeDefined()
expect(screen.queryByText('feature/mobile')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Compare release/mobile' }))
const comparisonSignal = second.sourceControlBranchCompare.mock.calls[0]?.[1]
?.signal as AbortSignal
view.rerender(
createElement(MobileWebSourceControlRepository, {
client: first,
workspaceId: 'workspace-1',
connected: true,
onRepositoryChanged: vi.fn()
})
)
await waitFor(() => expect(comparisonSignal.aborted).toBe(true))
})
it('confirms checkout and sends the exact displayed repository identity', async () => {
const client = repositoryClient()
const onRepositoryChanged = vi.fn()
render(
createElement(MobileWebSourceControlRepository, {
client,
workspaceId: 'workspace-1',
connected: true,
onRepositoryChanged
})
)
await screen.findByText('feature/mobile')
fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
expect(await screen.findByText('Switch to feature/mobile?')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Switch branch' }))
await waitFor(() =>
expect(client.sourceControlCheckout).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: 'b'.repeat(40),
expectedBranch: 'main',
branch: 'feature/mobile',
confirmation: 'checkout-confirmed'
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
)
expect(onRepositoryChanged).toHaveBeenCalled()
})
it('confirms a non-force push and keeps fetch as a direct bounded action', async () => {
const client = repositoryClient()
render(
createElement(MobileWebSourceControlRepository, {
client,
workspaceId: 'workspace-1',
connected: true,
onRepositoryChanged: vi.fn()
})
)
expect(await screen.findByText('origin/main · ↓0 ↑1')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Fetch' }))
await waitFor(() =>
expect(client.sourceControlFetch).toHaveBeenCalledWith(
{
workspaceId: 'workspace-1',
expectedHead: 'b'.repeat(40),
expectedBranch: 'main'
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
)
fireEvent.click(await screen.findByRole('button', { name: 'Push 1' }))
expect(await screen.findByText('Push local commits?')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Push' }))
await waitFor(() => expect(client.sourceControlPush).toHaveBeenCalled())
const payload = client.sourceControlPush.mock.calls[0]?.[0] as Record<string, unknown>
expect(payload).toMatchObject({
workspaceId: 'workspace-1',
expectedHead: 'b'.repeat(40),
expectedBranch: 'main',
mode: 'push',
confirmation: 'push-confirmed'
})
expect(payload).not.toHaveProperty('forceWithLease')
})
})
function repositoryClient(workspaceId = 'workspace-1') {
const branchName = workspaceId === 'workspace-1' ? 'feature/mobile' : 'release/mobile'
return {
sourceControlBranches: vi.fn().mockResolvedValue(branchesResult(workspaceId, branchName)),
sourceControlHistory: vi.fn().mockResolvedValue(historyResult(workspaceId)),
sourceControlBranchCompare: vi.fn().mockImplementation(({ workspaceId: requested }) =>
Promise.resolve({
workspaceId: requested,
baseRef: branchName,
compareRef: 'HEAD',
baseOid: 'a'.repeat(40),
headOid: 'b'.repeat(40),
mergeBase: 'a'.repeat(40),
changedFiles: 1,
commitsAhead: 1,
status: 'ready',
entries: [{ relativePath: 'src/branch.ts', status: 'modified' }],
truncated: false
})
),
sourceControlCommitCompare: vi.fn().mockImplementation(({ workspaceId: requested, commitId }) =>
Promise.resolve({
workspaceId: requested,
commitId,
commitOid: commitId,
parentOid: null,
compareRef: commitId.slice(0, 7),
baseRef: 'empty tree',
changedFiles: 1,
status: 'ready',
entries: [{ relativePath: 'src/commit.ts', status: 'added' }],
truncated: false
})
),
sourceControlUpstream: vi.fn().mockResolvedValue(repositoryState(workspaceId)),
sourceControlCheckout: vi
.fn()
.mockImplementation((payload) =>
Promise.resolve(actionResult(payload.workspaceId, 'branch', payload.branch))
),
sourceControlFetch: vi
.fn()
.mockImplementation((payload) => Promise.resolve(actionResult(payload.workspaceId, 'fetch'))),
sourceControlPull: vi
.fn()
.mockImplementation((payload) => Promise.resolve(actionResult(payload.workspaceId, 'pull'))),
sourceControlPush: vi
.fn()
.mockImplementation((payload) => Promise.resolve(actionResult(payload.workspaceId, 'push'))),
sourceControlRebase: vi
.fn()
.mockImplementation((payload) =>
Promise.resolve(actionResult(payload.workspaceId, 'rebase'))
),
sourceControlAbort: vi
.fn()
.mockImplementation((payload) => Promise.resolve(actionResult(payload.workspaceId, 'abort')))
} as unknown as MobileWebBridgeClient & {
sourceControlBranches: ReturnType<typeof vi.fn>
sourceControlHistory: ReturnType<typeof vi.fn>
sourceControlBranchCompare: ReturnType<typeof vi.fn>
sourceControlCommitCompare: ReturnType<typeof vi.fn>
sourceControlCheckout: ReturnType<typeof vi.fn>
sourceControlFetch: ReturnType<typeof vi.fn>
sourceControlPush: ReturnType<typeof vi.fn>
}
}
function branchesResult(workspaceId: string, branch = 'feature/mobile') {
return {
workspaceId,
current: 'main',
branches: ['main', branch],
totalCount: 2,
truncated: false
}
}
function historyResult(workspaceId: string) {
return {
workspaceId,
items: [
{
id: 'a'.repeat(40),
parentIds: [],
displayId: 'aaaaaaa',
subject: 'feat: history',
message: 'feat: history',
author: 'Orca',
timestamp: Date.UTC(2026, 6, 23),
references: []
}
],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
}
}
function repositoryState(workspaceId: string) {
return {
workspaceId,
head: 'b'.repeat(40),
branch: 'main',
conflictOperation: 'unknown' as const,
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 1,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
}
}
function actionResult(workspaceId: string, operation: string, branch?: string) {
return {
workspaceId,
operation,
previousHead: 'b'.repeat(40),
previousBranch: 'main',
...(branch ? { branch } : {}),
repository: repositoryState(workspaceId),
completed: true
}
}
@@ -1,148 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { RefreshCw } from 'lucide-react'
import React from 'react'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebSourceControlActionDialog } from './mobile-web-source-control-action-dialog'
import { MobileWebSourceControlCompare } from './mobile-web-source-control-compare'
import { MobileWebSourceControlRemoteActions } from './mobile-web-source-control-remote-actions'
import {
MobileWebBranchList,
MobileWebHistoryList
} from './mobile-web-source-control-repository-lists'
import { useMobileWebSourceControlRepository } from './use-mobile-web-source-control-repository'
export function MobileWebSourceControlRepository({
client,
workspaceId,
connected,
onRepositoryChanged
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
onRepositoryChanged: () => void
}): React.JSX.Element {
const repository = useMobileWebSourceControlRepository({
client,
workspaceId,
connected,
onRepositoryChanged
})
return (
<>
<Card
className="mt-4"
aria-busy={repository.loading || repository.sync.loading || repository.sync.busy !== null}
>
<CardHeader>
<div className="space-y-1">
<CardTitle>Repository</CardTitle>
<CardDescription>{repositoryDescription(repository)}</CardDescription>
</div>
<CardAction>
<Button
aria-label="Refresh branches, history, and upstream"
variant="outline"
size="icon-sm"
disabled={
!connected ||
repository.loading ||
repository.comparisonLoading ||
repository.sync.loading ||
repository.sync.busy !== null
}
onClick={() => {
repository.retry()
repository.sync.retry()
}}
>
<RefreshCw
className={
repository.loading || repository.sync.loading ? 'animate-spin' : undefined
}
/>
</Button>
</CardAction>
</CardHeader>
<CardContent className="px-0">
{repository.error ? (
<div role="alert" className="border-y border-border px-6 py-3 text-sm">
<p>Branches and history are unavailable.</p>
<Button
variant="outline"
size="xs"
className="mt-2"
disabled={!connected || repository.loading}
onClick={repository.retry}
>
Retry
</Button>
</div>
) : null}
<MobileWebSourceControlRemoteActions sync={repository.sync} connected={connected} />
<Tabs defaultValue="branches">
<TabsList className="mx-6 flex w-auto">
<TabsTrigger value="branches">Branches</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
</TabsList>
<TabsContent value="branches">
<MobileWebBranchList
result={repository.branches}
loading={repository.loading}
connected={connected}
selection={repository.selection}
actionsDisabled={
repository.sync.busy !== null ||
repository.sync.loading ||
!repository.sync.repository
}
onCompare={repository.compareBranch}
onSwitch={repository.sync.requestCheckout}
/>
</TabsContent>
<TabsContent value="history">
<MobileWebHistoryList
result={repository.history}
loading={repository.loading}
connected={connected}
selection={repository.selection}
canLoadMore={repository.canLoadMoreHistory}
onCompare={repository.compareCommit}
onLoadMore={repository.loadMoreHistory}
/>
</TabsContent>
</Tabs>
<MobileWebSourceControlCompare
selection={repository.selection}
comparison={repository.comparison}
loading={repository.comparisonLoading}
error={repository.comparisonError}
connected={connected}
onRetry={repository.retryComparison}
/>
</CardContent>
</Card>
<MobileWebSourceControlActionDialog sync={repository.sync} />
</>
)
}
function repositoryDescription(
repository: ReturnType<typeof useMobileWebSourceControlRepository>
): string {
if (!repository.branches || !repository.history) {
return repository.loading ? 'Loading branches and history' : 'Provider-neutral Git metadata'
}
const branches = repository.branches.totalCount
const commits = repository.history.items.length
return `${branches.toLocaleString()} ${branches === 1 ? 'branch' : 'branches'} · ${commits.toLocaleString()} recent ${commits === 1 ? 'commit' : 'commits'}`
}
@@ -1,285 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { FileDiff, Loader2 } from 'lucide-react'
import React from 'react'
import type { MobileWebSourceControlStatusEntry } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import {
eligibleMutationEntries,
type MobileWebSourceControlMutations
} from './use-mobile-web-source-control-mutations'
export function MobileWebSourceControlStatusList({
entries,
loading,
error,
connected,
selected,
mutations,
externalBusy,
onOpen,
onRetry
}: {
entries: MobileWebSourceControlStatusEntry[] | null
loading: boolean
error: MobileWebBridgeClientError | null
connected: boolean
selected: MobileWebSourceControlStatusEntry | null
mutations: MobileWebSourceControlMutations
externalBusy: boolean
onOpen: (entry: MobileWebSourceControlStatusEntry) => void
onRetry: () => void
}): React.JSX.Element {
if (error && !entries) {
return (
<div
role="alert"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-3 text-xs text-destructive"
>
<span>{statusErrorCopy(error)}</span>
{error.retryable ? (
<Button variant="outline" size="xs" disabled={!connected} onClick={onRetry}>
Try again
</Button>
) : null}
</div>
)
}
if (!entries) {
return (
<div className="flex min-h-24 items-center justify-center border-t border-border">
{loading ? <Loader2 className="size-5 animate-spin text-muted-foreground" /> : null}
</div>
)
}
if (entries.length === 0) {
return (
<p className="border-t border-border px-6 py-8 text-center text-xs text-muted-foreground">
No uncommitted changes.
</p>
)
}
return (
<>
<MutationToolbar
entries={entries}
connected={connected}
mutations={mutations}
externalBusy={externalBusy}
/>
{mutations.error ? (
<div
role="alert"
className="flex items-center justify-between gap-3 border-t border-border px-6 py-2 text-xs text-destructive"
>
<span>{mutationErrorCopy(mutations.error)}</span>
<Button variant="ghost" size="xs" onClick={mutations.clearError}>
Dismiss
</Button>
</div>
) : null}
<ul className="border-t border-border">
{entries.map((entry) => (
<StatusRow
key={`${entry.area}:${entry.relativePath}`}
entry={entry}
connected={connected}
current={selected?.area === entry.area && selected.relativePath === entry.relativePath}
mutations={mutations}
externalBusy={externalBusy}
onOpen={onOpen}
/>
))}
</ul>
</>
)
}
function MutationToolbar({
entries,
connected,
mutations,
externalBusy
}: {
entries: MobileWebSourceControlStatusEntry[]
connected: boolean
mutations: MobileWebSourceControlMutations
externalBusy: boolean
}): React.JSX.Element | null {
const stageable = eligibleMutationEntries(entries, 'stage')
const unstageable = eligibleMutationEntries(entries, 'unstage')
const discardable = eligibleMutationEntries(entries, 'discard')
const busy = mutations.busyOperation !== null || externalBusy
if (stageable.length < 2 && unstageable.length < 2 && discardable.length < 2) {
return null
}
return (
<div
aria-label="Bulk source control actions"
className="flex flex-wrap gap-2 border-t border-border px-6 py-2"
>
{stageable.length > 1 ? (
<Button
variant="outline"
size="xs"
disabled={!connected || busy}
onClick={() => void mutations.stage(stageable)}
>
{mutations.busyOperation === 'stage' ? <Loader2 className="animate-spin" /> : null}
Stage {stageable.length}
</Button>
) : null}
{unstageable.length > 1 ? (
<Button
variant="outline"
size="xs"
disabled={!connected || busy}
onClick={() => void mutations.unstage(unstageable)}
>
{mutations.busyOperation === 'unstage' ? <Loader2 className="animate-spin" /> : null}
Unstage {unstageable.length}
</Button>
) : null}
{discardable.length > 1 ? (
<Button
variant="destructive"
size="xs"
disabled={!connected || busy}
onClick={() => mutations.requestDiscard(discardable)}
>
Discard {discardable.length}
</Button>
) : null}
</div>
)
}
function StatusRow({
entry,
connected,
current,
mutations,
externalBusy,
onOpen
}: {
entry: MobileWebSourceControlStatusEntry
connected: boolean
current: boolean
mutations: MobileWebSourceControlMutations
externalBusy: boolean
onOpen: (entry: MobileWebSourceControlStatusEntry) => void
}): React.JSX.Element {
const unresolved = entry.conflictStatus === 'unresolved'
const busy = mutations.busyOperation !== null || externalBusy
const rowBusy = mutations.isBusyPath(entry.relativePath)
return (
<li className="flex items-center border-b border-border pr-2 last:border-b-0">
<Button
variant="ghost"
data-current={current ? 'true' : undefined}
aria-label={`Open diff for ${entry.relativePath}`}
className="h-auto min-w-0 flex-1 justify-start rounded-none px-6 py-2 text-left data-[current=true]:bg-accent"
disabled={!connected || unresolved}
onClick={() => onOpen(entry)}
>
<FileDiff className={statusColorClass(entry.status)} />
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-xs">{entry.relativePath}</span>
<span className="block text-[11px] text-muted-foreground">
{areaLabel(entry.area)}
{unresolved ? ' · Resolve the conflict on Desktop' : ''}
</span>
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{lineStats(entry)}</span>
</Button>
{!unresolved ? (
<div className="flex shrink-0 gap-1 pl-1">
{entry.area === 'staged' ? (
<Button
variant="outline"
size="xs"
className="w-20"
disabled={!connected || busy}
onClick={() => void mutations.unstage([entry])}
>
{rowBusy ? <Loader2 className="animate-spin" /> : null}
Unstage
</Button>
) : (
<>
<Button
variant="outline"
size="xs"
className="w-16"
disabled={!connected || busy}
onClick={() => void mutations.stage([entry])}
>
{rowBusy && mutations.busyOperation === 'stage' ? (
<Loader2 className="animate-spin" />
) : null}
Stage
</Button>
<Button
variant="destructive"
size="xs"
className="w-20"
disabled={!connected || busy}
onClick={() => mutations.requestDiscard([entry])}
>
Discard
</Button>
</>
)}
</div>
) : null}
</li>
)
}
function lineStats(entry: MobileWebSourceControlStatusEntry): string {
if (entry.added === undefined && entry.removed === undefined) {
return statusLabel(entry.status)
}
return `+${entry.added ?? 0} −${entry.removed ?? 0}`
}
function statusLabel(status: MobileWebSourceControlStatusEntry['status']): string {
return { modified: 'M', added: 'A', deleted: 'D', renamed: 'R', untracked: 'U', copied: 'C' }[
status
]
}
function areaLabel(area: MobileWebSourceControlStatusEntry['area']): string {
return area === 'staged' ? 'Staged' : area === 'untracked' ? 'Untracked' : 'Changes'
}
function statusColorClass(status: MobileWebSourceControlStatusEntry['status']): string {
const classes = {
added: 'text-[color:var(--git-decoration-added)]',
deleted: 'text-[color:var(--git-decoration-deleted)]',
renamed: 'text-[color:var(--git-decoration-renamed)]',
untracked: 'text-[color:var(--git-decoration-untracked)]',
copied: 'text-[color:var(--git-decoration-copied)]',
modified: 'text-[color:var(--git-decoration-modified)]'
}
return classes[status]
}
function statusErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'not_connected') {
return 'Reconnect to refresh source control.'
}
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile shell does not expose source control.'
}
return 'The paired desktop could not load source control.'
}
function mutationErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'conflict') {
return 'The repository changed. Review the refreshed status before trying again.'
}
if (error.code === 'not_connected') {
return 'Reconnect before changing source control.'
}
return 'The paired desktop could not complete the source-control action.'
}
@@ -1,263 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebSourceControl } from './mobile-web-source-control'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 24,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 24,
size: 24
}))
})
}))
afterEach(cleanup)
describe('MobileWebSourceControl', () => {
it('loads bounded status and revision-checked virtual diff pages', async () => {
const client = sourceControlClient()
render(
createElement(MobileWebSourceControl, {
client,
workspaceId: 'workspace-1',
connected: true
})
)
expect(await screen.findByText('src/app.ts')).toBeDefined()
expect(screen.getByText('mobile-rearch · 1 change')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Open diff for src/app.ts' }))
expect(await screen.findByText('before')).toBeDefined()
expect(screen.getByText('after')).toBeDefined()
expect(screen.getByRole('list', { name: 'Diff rows' }).children[0]?.children).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'Load more diff' }))
expect(await screen.findByText('same')).toBeDefined()
expect(client.sourceControlDiff).toHaveBeenLastCalledWith(
{
workspaceId: 'workspace-1',
relativePath: 'src/app.ts',
area: 'unstaged',
offset: 2,
limit: 96,
expectedRevision: 'a'.repeat(64)
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
expect(screen.queryByRole('button', { name: 'Load more diff' })).toBeNull()
})
it('aborts and drops delayed status when the workspace identity changes', async () => {
let resolveFirst: ((value: ReturnType<typeof statusResult>) => void) | undefined
const first = sourceControlClient()
first.sourceControlStatus.mockReturnValue(
new Promise((resolve) => {
resolveFirst = resolve
})
)
const second = sourceControlClient()
second.sourceControlStatus.mockResolvedValue(statusResult('workspace-2', []))
const view = render(
createElement(MobileWebSourceControl, {
client: first,
workspaceId: 'workspace-1',
connected: true
})
)
const firstSignal = first.sourceControlStatus.mock.calls[0]?.[1]?.signal as AbortSignal
view.rerender(
createElement(MobileWebSourceControl, {
client: second,
workspaceId: 'workspace-2',
connected: true
})
)
expect(firstSignal.aborted).toBe(true)
resolveFirst?.(statusResult('workspace-1'))
await Promise.resolve()
expect(screen.queryByText('src/app.ts')).toBeNull()
expect(await screen.findByText('No uncommitted changes.')).toBeDefined()
})
it('refreshes from watcher invalidations and retires stale workspace subscriptions', async () => {
let onEvent: ((event: { workspaceId: string; reason: 'changed' }) => void) | undefined
const unsubscribe = vi.fn()
const first = sourceControlClient()
first.sourceControlSubscribe.mockImplementation((_payload, nextEvent) => {
onEvent = nextEvent
return { ready: Promise.resolve(), unsubscribe }
})
const view = render(
createElement(MobileWebSourceControl, {
client: first,
workspaceId: 'workspace-1',
connected: true
})
)
await screen.findByText('src/app.ts')
onEvent?.({ workspaceId: 'workspace-1', reason: 'changed' })
await waitFor(() => expect(first.sourceControlStatus).toHaveBeenCalledTimes(2))
const second = sourceControlClient()
second.sourceControlStatus.mockResolvedValue(statusResult('workspace-2', []))
view.rerender(
createElement(MobileWebSourceControl, {
client: second,
workspaceId: 'workspace-2',
connected: true
})
)
expect(unsubscribe).toHaveBeenCalledOnce()
onEvent?.({ workspaceId: 'workspace-1', reason: 'changed' })
await Promise.resolve()
expect(first.sourceControlStatus).toHaveBeenCalledTimes(2)
})
})
function sourceControlClient(): MobileWebBridgeClient & {
sourceControlStatus: ReturnType<typeof vi.fn>
sourceControlDiff: ReturnType<typeof vi.fn>
sourceControlSubscribe: ReturnType<typeof vi.fn>
} {
return {
sourceControlStatus: vi.fn().mockResolvedValue(statusResult('workspace-1')),
sourceControlSubscribe: vi.fn().mockReturnValue({
ready: Promise.resolve(),
unsubscribe: vi.fn()
}),
sourceControlBranches: vi
.fn()
.mockImplementation(({ workspaceId }) => Promise.resolve(emptyBranches(workspaceId))),
sourceControlHistory: vi
.fn()
.mockImplementation(({ workspaceId }) => Promise.resolve(emptyHistory(workspaceId))),
sourceControlUpstream: vi
.fn()
.mockImplementation(({ workspaceId }) => Promise.resolve(repositoryState(workspaceId))),
sourceControlBranchCompare: vi.fn(),
sourceControlCommitCompare: vi.fn(),
sourceControlDiff: vi.fn().mockImplementation(({ offset }) => {
if (offset === 0) {
return Promise.resolve({
workspaceId: 'workspace-1',
relativePath: 'src/app.ts',
area: 'unstaged',
kind: 'text',
revision: 'a'.repeat(64),
offset: 0,
totalRows: 3,
rows: [
{
index: 0,
kind: 'delete',
text: 'before',
textTruncated: false,
oldLineNumber: 1
},
{
index: 1,
kind: 'add',
text: 'after',
textTruncated: false,
newLineNumber: 1
}
],
nextOffset: 2,
truncated: false
})
}
return Promise.resolve({
workspaceId: 'workspace-1',
relativePath: 'src/app.ts',
area: 'unstaged',
kind: 'text',
revision: 'a'.repeat(64),
offset: 2,
totalRows: 3,
rows: [
{
index: 2,
kind: 'context',
text: 'same',
textTruncated: false,
oldLineNumber: 2,
newLineNumber: 2
}
],
nextOffset: null,
truncated: false
})
})
} as unknown as MobileWebBridgeClient & {
sourceControlStatus: ReturnType<typeof vi.fn>
sourceControlDiff: ReturnType<typeof vi.fn>
sourceControlSubscribe: ReturnType<typeof vi.fn>
}
}
function statusResult(workspaceId: string, entries = [statusEntry()]) {
return {
workspaceId,
branch: 'mobile-rearch',
head: 'b'.repeat(40),
conflictOperation: 'unknown' as const,
entries,
totalCount: entries.length,
truncated: false
}
}
function statusEntry() {
return {
relativePath: 'src/app.ts',
status: 'modified' as const,
area: 'unstaged' as const,
added: 1,
removed: 1
}
}
function emptyBranches(workspaceId: string) {
return { workspaceId, current: 'main', branches: ['main'], totalCount: 1, truncated: false }
}
function emptyHistory(workspaceId: string) {
return {
workspaceId,
items: [],
hasIncomingChanges: false,
hasOutgoingChanges: false,
hasMore: false,
limit: 50
}
}
function repositoryState(workspaceId: string) {
return {
workspaceId,
head: 'b'.repeat(40),
branch: 'main',
conflictOperation: 'unknown',
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
}
}
@@ -1,148 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { RefreshCw } from 'lucide-react'
import React from 'react'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebProviderReviewCard } from './mobile-web-provider-review'
import { MobileWebSourceControlDiff } from './mobile-web-source-control-diff'
import { MobileWebSourceControlCommitComposer } from './mobile-web-source-control-commit'
import { MobileWebSourceControlDiscardDialog } from './mobile-web-source-control-discard-dialog'
import { MobileWebSourceControlRepository } from './mobile-web-source-control-repository'
import { MobileWebSourceControlStatusList } from './mobile-web-source-control-status-list'
import { useMobileWebDiffDocument } from './use-mobile-web-diff-document'
import { useMobileWebSourceControlMutations } from './use-mobile-web-source-control-mutations'
import { useMobileWebSourceControlCommit } from './use-mobile-web-source-control-commit'
import { useMobileWebSourceControlStatus } from './use-mobile-web-source-control-status'
export function MobileWebSourceControl({
client,
workspaceId,
connected
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
}): React.JSX.Element {
const status = useMobileWebSourceControlStatus({ client, workspaceId, connected })
const diff = useMobileWebDiffDocument({ client, workspaceId, connected })
const mutations = useMobileWebSourceControlMutations({
client,
workspaceId,
connected,
status: status.result,
onRefresh: status.retry
})
const commit = useMobileWebSourceControlCommit({
client,
workspaceId,
connected,
status: status.result,
statusLoading: status.loading,
mutationBusy: mutations.busyOperation !== null,
onRefresh: status.retry
})
return (
<>
<Card className="mt-4" aria-busy={status.loading}>
<CardHeader>
<div className="space-y-1">
<CardTitle>Changes</CardTitle>
<CardDescription>{statusDescription(status.result)}</CardDescription>
</div>
<CardAction>
<Button
aria-label="Refresh changes"
variant="outline"
size="icon-sm"
disabled={
!connected ||
status.loading ||
mutations.busyOperation !== null ||
commit.busy !== null
}
onClick={status.retry}
>
<RefreshCw className={status.loading ? 'animate-spin' : undefined} />
</Button>
</CardAction>
</CardHeader>
<CardContent className="px-0">
<MobileWebSourceControlCommitComposer commit={commit} />
<MobileWebSourceControlStatusList
entries={status.result?.entries ?? null}
loading={status.loading}
error={status.error}
connected={connected}
selected={diff.entry}
mutations={mutations}
externalBusy={commit.busy !== null}
onOpen={diff.open}
onRetry={status.retry}
/>
{status.result?.truncated ? (
<p className="border-t border-border px-6 py-2 text-xs text-muted-foreground">
Showing {status.result.entries.length.toLocaleString()} of{' '}
{status.result.totalCount.toLocaleString()} changes.
</p>
) : null}
{status.liveRefresh === 'unavailable' ? (
<p
role="status"
className="border-t border-border px-6 py-2 text-xs text-muted-foreground"
>
Live refresh is unavailable. Use Refresh to check again.
</p>
) : null}
<MobileWebSourceControlDiff
entry={diff.entry}
document={diff.document}
loading={diff.loading}
error={diff.error}
connected={connected}
onLoadMore={diff.loadMore}
onRetry={diff.retry}
onCancel={diff.cancel}
/>
</CardContent>
</Card>
<MobileWebSourceControlRepository
client={client}
workspaceId={workspaceId}
connected={connected}
onRepositoryChanged={status.retry}
/>
<MobileWebProviderReviewCard
client={client}
workspaceId={workspaceId}
connected={connected}
status={status.result}
/>
<MobileWebSourceControlDiscardDialog
targets={mutations.discardTargets}
busy={mutations.busyOperation === 'discard'}
error={mutations.error}
onCancel={mutations.cancelDiscard}
onConfirm={() => void mutations.confirmDiscard()}
/>
</>
)
}
function statusDescription(
result: ReturnType<typeof useMobileWebSourceControlStatus>['result']
): string {
if (!result) {
return 'Loading provider-neutral Git status'
}
const count = result.totalCount
const branch = result.branch ? `${result.branch} · ` : ''
return `${branch}${count.toLocaleString()} ${count === 1 ? 'change' : 'changes'}`
}
@@ -1,127 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { IBuffer, IBufferLine, Terminal } from '@xterm/xterm'
import {
MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_ROWS,
buildMobileWebTerminalFileLinks
} from './mobile-web-terminal-file-links'
describe('mobile web terminal file links', () => {
it('maps an ASCII path to one-based xterm cells', () => {
const activate = vi.fn()
const links = buildMobileWebTerminalFileLinks(
terminalWithLines([asciiLine('created /tmp/report.txt')]),
1,
activate
)
expect(links).toHaveLength(1)
expect(links?.[0]).toMatchObject({
range: { start: { x: 9, y: 1 }, end: { x: 23, y: 1 } },
text: '/tmp/report.txt'
})
links?.[0]?.activate({} as MouseEvent, '/tmp/report.txt')
expect(activate).toHaveBeenCalledWith({
pathText: '/tmp/report.txt',
line: null,
column: null
})
})
it('keeps adjacent absolute paths as independent links', () => {
const links = buildMobileWebTerminalFileLinks(
terminalWithLines([asciiLine('/etc/hosts /tmp/output.png')]),
1,
vi.fn()
)
expect(links?.map((link) => ({ range: link.range, text: link.text }))).toEqual([
{
range: { start: { x: 1, y: 1 }, end: { x: 10, y: 1 } },
text: '/etc/hosts'
},
{
range: { start: { x: 12, y: 1 }, end: { x: 26, y: 1 } },
text: '/tmp/output.png'
}
])
})
it('uses cell widths instead of UTF-16 indices after emoji', () => {
const links = buildMobileWebTerminalFileLinks(
terminalWithLines([
lineFromCells([
{ characters: '🙂', width: 2 },
{ characters: '', width: 0 },
{ characters: ' ', width: 1 },
...asciiCells('/tmp/a.ts')
])
]),
1,
vi.fn()
)
expect(links?.[0]?.range).toEqual({
start: { x: 4, y: 1 },
end: { x: 12, y: 1 }
})
})
it('creates one link across a bounded soft-wrapped logical line', () => {
const terminal = terminalWithLines([asciiLine('/tmp/very/'), asciiLine('long.txt', true)])
const firstRow = buildMobileWebTerminalFileLinks(terminal, 1, vi.fn())
const secondRow = buildMobileWebTerminalFileLinks(terminal, 2, vi.fn())
expect(firstRow?.[0]?.range).toEqual({
start: { x: 1, y: 1 },
end: { x: 8, y: 2 }
})
expect(secondRow?.[0]?.range).toEqual(firstRow?.[0]?.range)
})
it('fails closed when a logical line exceeds the row bound', () => {
const lines = Array.from(
{ length: MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_ROWS + 1 },
(_, index) =>
asciiLine(index === 0 ? '/tmp/' : index === 8 ? 'file.txt' : 'segment/', index > 0)
)
expect(buildMobileWebTerminalFileLinks(terminalWithLines(lines), 5, vi.fn())).toBeUndefined()
})
})
type Cell = { characters: string; width: number }
function terminalWithLines(lines: IBufferLine[]): Pick<Terminal, 'buffer' | 'cols'> {
const buffer = {
length: lines.length,
getLine: (index: number) => lines[index]
} as unknown as IBuffer
return {
cols: Math.max(...lines.map((line) => line.length)),
buffer: { active: buffer }
} as Pick<Terminal, 'buffer' | 'cols'>
}
function asciiLine(value: string, isWrapped = false): IBufferLine {
return lineFromCells(asciiCells(value), isWrapped)
}
function asciiCells(value: string): Cell[] {
return [...value].map((characters) => ({ characters, width: 1 }))
}
function lineFromCells(cells: Cell[], isWrapped = false): IBufferLine {
return {
isWrapped,
length: cells.length,
getCell: (index: number) => {
const cell = cells[index]
return cell
? ({
getChars: () => cell.characters,
getWidth: () => cell.width
} as ReturnType<IBufferLine['getCell']>)
: undefined
}
} as IBufferLine
}
@@ -1,126 +0,0 @@
import type { IBuffer, IBufferCellPosition, ILink, ILinkProvider, Terminal } from '@xterm/xterm'
import {
findTerminalFileLinks,
type TerminalFileLinkTarget
} from '../../shared/terminal-file-link-matcher'
import { MOBILE_WEB_TERMINAL_PATH_MAX_CHARACTERS } from '../../shared/mobile-web/terminal-artifact-contract'
export const MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_ROWS = 8
export const MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_CHARACTERS = 4096
type TerminalLinkSource = Pick<Terminal, 'buffer' | 'cols'>
type LogicalTerminalLine = {
text: string
positions: { start: IBufferCellPosition; end: IBufferCellPosition }[]
}
export function createMobileWebTerminalFileLinkProvider(
terminal: TerminalLinkSource,
activate: (target: TerminalFileLinkTarget) => void
): ILinkProvider {
return {
provideLinks(bufferLineNumber, callback) {
callback(buildMobileWebTerminalFileLinks(terminal, bufferLineNumber, activate))
}
}
}
export function buildMobileWebTerminalFileLinks(
terminal: TerminalLinkSource,
bufferLineNumber: number,
activate: (target: TerminalFileLinkTarget) => void
): ILink[] | undefined {
const logicalLine = readLogicalTerminalLine(
terminal.buffer.active,
terminal.cols,
bufferLineNumber
)
if (!logicalLine) {
return undefined
}
const links = findTerminalFileLinks(logicalLine.text).flatMap((match): ILink[] => {
if (match.pathText.length > MOBILE_WEB_TERMINAL_PATH_MAX_CHARACTERS) {
return []
}
const start = logicalLine.positions[match.startIndex]
const end = logicalLine.positions[match.endIndex - 1]
if (!start || !end || bufferLineNumber < start.start.y || bufferLineNumber > end.end.y) {
return []
}
const target = {
pathText: match.pathText,
line: match.line,
column: match.column
}
return [
{
range: { start: start.start, end: end.end },
text: logicalLine.text.slice(match.startIndex, match.endIndex),
activate: () => activate(target)
}
]
})
return links.length > 0 ? links : undefined
}
function readLogicalTerminalLine(
buffer: IBuffer,
columns: number,
requestedLineNumber: number
): LogicalTerminalLine | null {
const requestedIndex = requestedLineNumber - 1
if (requestedIndex < 0 || requestedIndex >= buffer.length || !buffer.getLine(requestedIndex)) {
return null
}
let startIndex = requestedIndex
let rowCount = 1
while (startIndex > 0 && buffer.getLine(startIndex)?.isWrapped) {
if (rowCount >= MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_ROWS) {
return null
}
startIndex -= 1
rowCount += 1
}
let endIndex = requestedIndex
while (endIndex + 1 < buffer.length && buffer.getLine(endIndex + 1)?.isWrapped) {
if (rowCount >= MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_ROWS) {
return null
}
endIndex += 1
rowCount += 1
}
let text = ''
const positions: LogicalTerminalLine['positions'] = []
for (let rowIndex = startIndex; rowIndex <= endIndex; rowIndex += 1) {
const line = buffer.getLine(rowIndex)
if (!line) {
return null
}
const width = Math.min(columns, line.length)
for (let columnIndex = 0; columnIndex < width; columnIndex += 1) {
const cell = line.getCell(columnIndex)
if (!cell || cell.getWidth() === 0) {
continue
}
const characters = cell.getChars() || ' '
const position = {
start: { x: columnIndex + 1, y: rowIndex + 1 },
end: { x: columnIndex + cell.getWidth(), y: rowIndex + 1 }
}
text += characters
for (let characterIndex = 0; characterIndex < characters.length; characterIndex += 1) {
positions.push(position)
}
if (text.length > MOBILE_WEB_TERMINAL_LINK_MAX_LOGICAL_CHARACTERS) {
return null
}
}
}
while (text.endsWith(' ')) {
text = text.slice(0, -1)
positions.pop()
}
return text ? { text, positions } : null
}
@@ -1,100 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import { FileWarning, Loader2, X } from 'lucide-react'
import React from 'react'
import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { MobileWebFileTextPreview } from './mobile-web-file-text-preview'
import { MobileWebRasterImagePreview } from './mobile-web-raster-image-preview'
import type { MobileWebTerminalPathPreviewState } from './use-mobile-web-terminal-path-preview'
export function MobileWebTerminalPathPreview({
preview,
onClose
}: {
preview: MobileWebTerminalPathPreviewState
onClose: () => void
}): React.JSX.Element | null {
if (preview.status === 'idle') {
return null
}
const target = preview.status === 'resolving' ? null : preview.target
const document =
preview.status === 'loading' || preview.status === 'ready' ? preview.document : null
const title =
target?.kind === 'worktree-file'
? target.relativePath
: (target?.displayName ?? 'Terminal file')
return (
<section className="border-t border-border" aria-label={`Terminal file preview: ${title}`}>
<div className="flex items-center justify-between gap-3 px-4 py-2">
<div className="min-w-0">
<p className="truncate font-mono text-xs">{title}</p>
{target && (target.line !== null || target.column !== null) ? (
<p className="text-[11px] text-muted-foreground">
{terminalLocationCopy(target.line, target.column)}
</p>
) : null}
</div>
<Button variant="ghost" size="icon-xs" onClick={onClose}>
<X />
<span className="sr-only">Close terminal file preview</span>
</Button>
</div>
{preview.status === 'resolving' || preview.status === 'loading' ? (
<p
role="status"
className="flex items-center gap-2 border-t border-border px-4 py-2 text-xs text-muted-foreground"
>
<Loader2 className="size-3.5 animate-spin" />
{preview.status === 'resolving' ? 'Resolving terminal file' : 'Loading file content'}
</p>
) : null}
{preview.status === 'error' ? (
<p
role="alert"
className="flex items-center gap-2 border-t border-border px-4 py-3 text-xs text-destructive"
>
<FileWarning className="size-4" />
{terminalPreviewErrorCopy(preview.error)}
</p>
) : null}
{document ? (
document.kind === 'binary' ? (
target?.previewKind === 'raster' ? (
<MobileWebRasterImagePreview document={document} />
) : (
<p className="flex items-center gap-2 border-t border-border px-4 py-4 text-xs text-muted-foreground">
<FileWarning className="size-4" />
Binary preview is unavailable for this terminal file.
</p>
)
) : (
<MobileWebFileTextPreview key={title} document={document} />
)
) : null}
</section>
)
}
function terminalLocationCopy(line: number | null, column: number | null): string {
if (line !== null && column !== null) {
return `Line ${line}, column ${column}`
}
return line !== null ? `Line ${line}` : `Column ${column}`
}
function terminalPreviewErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'not_found') {
return 'This terminal file is no longer available.'
}
if (error.code === 'too_large') {
return 'This terminal file exceeds the mobile preview limit.'
}
if (error.code === 'not_connected') {
return 'Reconnect to the paired desktop to open this terminal file.'
}
if (error.code === 'unsupported_capability') {
return 'Update Orca Desktop and Mobile to preview terminal files.'
}
return 'The paired desktop could not provide this terminal file.'
}
@@ -1,240 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebTerminalEvent } from '../../shared/mobile-web/terminal-stream-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebTerminal } from './mobile-web-terminal'
const STREAM_ID = 'T'.repeat(22)
const xterm = vi.hoisted(() => {
class FakeTerminal {
readonly cols = 80
readonly rows = 24
readonly writes: { data: Uint8Array; callback?: () => void }[] = []
input: ((data: string) => void) | null = null
disposed = false
focused = false
linkProvider: unknown = null
linkProviderDisposed = false
resetCount = 0
constructor() {
terminals.push(this)
}
loadAddon(): void {}
open(): void {}
focus(): void {
this.focused = true
}
onData(input: (data: string) => void): { dispose: () => void } {
this.input = input
return { dispose: () => (this.input = null) }
}
registerLinkProvider(provider: unknown): { dispose: () => void } {
this.linkProvider = provider
return { dispose: () => (this.linkProviderDisposed = true) }
}
write(data: Uint8Array, callback?: () => void): void {
this.writes.push({ data, callback })
}
reset(): void {
this.resetCount += 1
}
dispose(): void {
this.disposed = true
}
}
class FakeFitAddon {
fit(): void {}
}
const terminals: FakeTerminal[] = []
return { FakeTerminal, FakeFitAddon, terminals }
})
vi.mock('@xterm/xterm', () => ({ Terminal: xterm.FakeTerminal }))
vi.mock('@xterm/addon-fit', () => ({ FitAddon: xterm.FakeFitAddon }))
class FakeResizeObserver {
static instances: FakeResizeObserver[] = []
disconnected = false
constructor(readonly callback: ResizeObserverCallback) {
FakeResizeObserver.instances.push(this)
}
observe(): void {}
unobserve(): void {}
disconnect(): void {
this.disconnected = true
}
}
beforeEach(() => {
xterm.terminals.length = 0
FakeResizeObserver.instances.length = 0
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
})
afterEach(cleanup)
describe('MobileWebTerminal', () => {
it('ACKs output only after xterm writes and tears down its subscription', async () => {
const harness = clientHarness()
const view = render(
createElement(MobileWebTerminal, {
client: harness.client,
workspaceId: 'workspace-1',
tabId: 'terminal-1',
connected: true
})
)
const terminal = xterm.terminals[0]!
expect(harness.terminalSubscribe).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-1',
tabId: 'terminal-1',
viewport: { cols: 80, rows: 24 },
visible: true
}),
expect.any(Function),
expect.any(Function)
)
expect(terminal.focused).toBe(true)
act(() => harness.event(subscribed()))
act(() =>
harness.event({
type: 'output',
streamId: STREAM_ID,
startSequence: 0,
endSequence: 2,
data: 'b2s='
})
)
expect(harness.terminalRequest).not.toHaveBeenCalledWith(
expect.objectContaining({ operation: 'ack' })
)
act(() => terminal.writes[0]!.callback?.())
await vi.waitFor(() =>
expect(harness.terminalRequest).toHaveBeenCalledWith({
operation: 'ack',
streamId: STREAM_ID,
throughSequence: 2
})
)
view.unmount()
expect(harness.unsubscribe).toHaveBeenCalledOnce()
expect(terminal.disposed).toBe(true)
expect(terminal.linkProviderDisposed).toBe(true)
expect(FakeResizeObserver.instances[0]?.disconnected).toBe(true)
})
it('sends one resync per gap and revokes input while hidden', async () => {
const harness = clientHarness()
render(
createElement(MobileWebTerminal, {
client: harness.client,
workspaceId: 'workspace-1',
tabId: 'terminal-1',
connected: true
})
)
const terminal = xterm.terminals[0]!
act(() => harness.event(subscribed()))
act(() => {
harness.event(outputAt(4))
harness.event(outputAt(4))
})
await vi.waitFor(() => {
const resyncs = harness.terminalRequest.mock.calls.filter(
([request]) => request.operation === 'resync'
)
expect(resyncs).toHaveLength(1)
})
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' })
act(() => document.dispatchEvent(new Event('visibilitychange')))
await vi.waitFor(() =>
expect(harness.terminalRequest).toHaveBeenCalledWith({
operation: 'visibility',
streamId: STREAM_ID,
visible: false
})
)
act(() => terminal.input?.('x'))
expect(
harness.terminalRequest.mock.calls.filter(([request]) => request.operation === 'input')
).toHaveLength(0)
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
act(() => document.dispatchEvent(new Event('visibilitychange')))
await vi.waitFor(() =>
expect(harness.terminalRequest).toHaveBeenCalledWith({
operation: 'visibility',
streamId: STREAM_ID,
visible: true
})
)
act(() => harness.event(subscribed()))
act(() => terminal.input?.('x'))
await vi.waitFor(() =>
expect(harness.terminalRequest).toHaveBeenCalledWith({
operation: 'input',
streamId: STREAM_ID,
sequence: 0,
data: 'eA=='
})
)
})
})
function clientHarness(): {
client: MobileWebBridgeClient
terminalSubscribe: ReturnType<typeof vi.fn>
terminalRequest: ReturnType<typeof vi.fn>
unsubscribe: ReturnType<typeof vi.fn>
event: (event: MobileWebTerminalEvent) => void
} {
let onEvent = (_event: MobileWebTerminalEvent): void => {}
const unsubscribe = vi.fn()
const terminalSubscribe = vi.fn((_payload, nextEvent: typeof onEvent) => {
onEvent = nextEvent
return { streamId: STREAM_ID, ready: Promise.resolve(), unsubscribe }
})
const terminalRequest = vi.fn().mockResolvedValue(null)
const client = { terminalSubscribe, terminalRequest } as unknown as MobileWebBridgeClient
return {
client,
terminalSubscribe,
terminalRequest,
unsubscribe,
event: (event) => onEvent(event)
}
}
function subscribed(): MobileWebTerminalEvent {
return {
type: 'subscribed',
streamId: STREAM_ID,
viewport: { cols: 80, rows: 24 },
startSequence: 0,
maxOutstandingBytes: 256 * 1024,
inputFloor: 'held',
queryReplyAuthority: true
}
}
function outputAt(startSequence: number): MobileWebTerminalEvent {
return {
type: 'output',
streamId: STREAM_ID,
startSequence,
endSequence: startSequence + 1,
data: 'eA=='
}
}
-179
View File
@@ -1,179 +0,0 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import React, { useEffect, useRef, useState } from 'react'
import { isTerminalQueryReply } from '../../shared/terminal-query-reply'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import {
MobileWebTerminalEventState,
type MobileWebTerminalEffect
} from './mobile-web-terminal-event-state'
import { MobileWebTerminalRequestScheduler } from './mobile-web-terminal-request-scheduler'
import { createMobileWebTerminalFileLinkProvider } from './mobile-web-terminal-file-links'
import { MobileWebTerminalPathPreview } from './mobile-web-terminal-path-preview'
import { useMobileWebTerminalPathPreview } from './use-mobile-web-terminal-path-preview'
export function MobileWebTerminal({
client,
workspaceId,
tabId,
connected
}: {
client: MobileWebBridgeClient
workspaceId: string
tabId: string
connected: boolean
}): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState(false)
const terminalPathPreview = useMobileWebTerminalPathPreview({
client,
workspaceId,
tabId,
connected
})
useEffect(() => {
const container = containerRef.current
if (!container || !connected) {
return
}
setError(false)
const terminal = new Terminal(terminalOptions())
const fit = new FitAddon()
terminal.loadAddon(fit)
terminal.open(container)
fit.fit()
const fileLinks = terminal.registerLinkProvider(
createMobileWebTerminalFileLinkProvider(terminal, terminalPathPreview.openPath)
)
let eventState: MobileWebTerminalEventState
let scheduler: MobileWebTerminalRequestScheduler
const visible = document.visibilityState === 'visible'
const subscription = client.terminalSubscribe(
{
operation: 'subscribe',
workspaceId,
tabId,
viewport: viewport(terminal),
visible
},
(event) => {
const effect = eventState.apply(event)
applyEffect(effect, terminal, scheduler)
if (effect.type === 'error' || effect.type === 'closed') {
setError(true)
}
},
() => setError(true)
)
eventState = new MobileWebTerminalEventState(subscription.streamId)
scheduler = new MobileWebTerminalRequestScheduler(client, subscription.streamId, () =>
setError(true)
)
const input = terminal.onData((data) => {
const encoded = new TextEncoder().encode(data)
const operation = isTerminalQueryReply(data) ? 'queryReply' : 'input'
scheduler.sendInput(operation, base64(encoded))
})
const resize = new ResizeObserver(() => {
fit.fit()
scheduler.resize(viewport(terminal))
})
resize.observe(container)
const visibility = () => {
scheduler.setVisible(document.visibilityState === 'visible')
}
document.addEventListener('visibilitychange', visibility)
if (visible) {
terminal.focus()
}
void subscription.ready.then(
() => scheduler.markBridgeReady(),
() => setError(true)
)
return () => {
document.removeEventListener('visibilitychange', visibility)
resize.disconnect()
input.dispose()
fileLinks.dispose()
scheduler.dispose()
subscription.unsubscribe()
terminal.dispose()
}
}, [client, connected, tabId, terminalPathPreview.openPath, workspaceId])
return (
<div className="border-t border-border bg-editor-surface p-2">
<div
ref={containerRef}
aria-label="Terminal"
className="h-80 overflow-hidden rounded-md border border-border bg-background p-1"
/>
{error ? (
<p role="alert" className="px-1 pt-2 text-xs text-destructive">
Terminal stream needs to reconnect.
</p>
) : null}
<MobileWebTerminalPathPreview
preview={terminalPathPreview.preview}
onClose={terminalPathPreview.closePreview}
/>
</div>
)
}
function applyEffect(
effect: MobileWebTerminalEffect,
terminal: Terminal,
scheduler: MobileWebTerminalRequestScheduler
): void {
if (effect.type === 'ready') {
scheduler.markHostReady(effect.inputFloor, effect.queryReplyAuthority)
} else if (effect.type === 'authority') {
scheduler.setAuthority(effect.inputFloor, effect.queryReplyAuthority)
} else if (effect.type === 'write') {
terminal.write(effect.data, () => scheduler.acknowledge(effect.throughSequence))
} else if (effect.type === 'replace') {
terminal.reset()
terminal.write(effect.data, () => {
scheduler.markResynced()
scheduler.acknowledge(effect.throughSequence)
})
} else if (effect.type === 'resync') {
scheduler.requestResync(effect.fromSequence, effect.reason)
}
}
function viewport(terminal: Terminal): { cols: number; rows: number } {
return {
cols: Math.max(2, terminal.cols),
rows: Math.max(1, terminal.rows)
}
}
function terminalOptions(): ConstructorParameters<typeof Terminal>[0] {
const styles = getComputedStyle(document.documentElement)
const token = (name: string) => styles.getPropertyValue(name).trim()
return {
allowProposedApi: false,
cursorBlink: true,
fontFamily: token('--font-mono') || 'monospace',
fontSize: 13,
scrollback: 5_000,
theme: {
background: token('--editor-surface') || token('--background'),
foreground: token('--foreground'),
cursor: token('--foreground'),
selectionBackground: token('--accent')
}
}
}
function base64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
@@ -1,62 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileWebBridgeClientError, type MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebWorkspaces } from './mobile-web-workspaces'
afterEach(cleanup)
describe('mobile web workspaces', () => {
it('renders a real workspace snapshot and retains it while offline', async () => {
const workspaceSnapshot = vi.fn().mockResolvedValue({
workspaces: [
{
id: 'workspace-1',
name: 'Mobile rearchitecture',
repo: '/repo',
branch: 'mobile-rearch',
isActive: true,
liveTerminalCount: 2
}
],
truncated: false
})
const client = { workspaceSnapshot } as unknown as MobileWebBridgeClient
const onOpen = vi.fn()
const view = render(
createElement(MobileWebWorkspaces, { client, connection: 'connected', onOpen })
)
expect(await screen.findByText('Mobile rearchitecture')).toBeTruthy()
expect(screen.getByText('mobile-rearch')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Mobile rearchitecture/ }))
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 'workspace-1' }))
view.rerender(createElement(MobileWebWorkspaces, { client, connection: 'offline', onOpen }))
expect(screen.getByText('Mobile rearchitecture')).toBeTruthy()
expect(screen.getByText('Offline — showing the last workspace list.')).toBeTruthy()
})
it('offers retry for a transient bridge failure', async () => {
const workspaceSnapshot = vi
.fn()
.mockRejectedValueOnce(new MobileWebBridgeClientError('timeout', true))
.mockResolvedValueOnce({ workspaces: [], truncated: false })
const client = { workspaceSnapshot } as unknown as MobileWebBridgeClient
render(
createElement(MobileWebWorkspaces, {
client,
connection: 'connected',
onOpen: vi.fn()
})
)
expect(await screen.findByText('Workspaces unavailable')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(await screen.findByText('No workspaces available')).toBeTruthy()
expect(workspaceSnapshot).toHaveBeenCalledTimes(2)
})
})
@@ -1,298 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
CardAction,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { GitBranch, Loader2, RefreshCw, TerminalSquare } from 'lucide-react'
import React, { useCallback, useEffect, useState } from 'react'
import type {
MobileWebWorkspaceSnapshotResult,
MobileWebWorkspaceSummary
} from '../../shared/mobile-web/bridge-operation-contract'
import { MobileWebBridgeClientError, type MobileWebBridgeClient } from './mobile-web-bridge-client'
import type { MobileWebNativeShellState } from './native-shell-channel'
type WorkspaceRequestState = {
client: MobileWebBridgeClient | null
loading: boolean
snapshot: MobileWebWorkspaceSnapshotResult | null
error: MobileWebBridgeClientError | null
}
export function MobileWebWorkspaces({
client,
connection,
onOpen
}: Pick<MobileWebNativeShellState, 'client' | 'connection'> & {
onOpen: (workspace: MobileWebWorkspaceSummary) => void
}): React.JSX.Element {
const [retry, setRetry] = useState(0)
const [request, setRequest] = useState<WorkspaceRequestState>({
client: null,
loading: false,
snapshot: null,
error: null
})
useEffect(() => {
if (!client) {
setRequest({ client: null, loading: false, snapshot: null, error: null })
return
}
if (connection !== 'connected') {
setRequest((current) =>
current.client === client
? { ...current, loading: false }
: { client, loading: false, snapshot: null, error: null }
)
return
}
let current = true
setRequest((state) => ({
client,
loading: true,
snapshot: state.client === client ? state.snapshot : null,
error: null
}))
void client.workspaceSnapshot({ limit: 100 }).then(
(snapshot) => {
if (current) {
setRequest({ client, loading: false, snapshot, error: null })
}
},
(error: unknown) => {
if (current) {
setRequest((state) => ({
client,
loading: false,
snapshot: state.client === client ? state.snapshot : null,
error: bridgeClientError(error)
}))
}
}
)
return () => {
current = false
}
}, [client, connection, retry])
const tryAgain = useCallback(() => setRetry((value) => value + 1), [])
const snapshot = request.client === client ? request.snapshot : null
const error = request.client === client ? request.error : null
if (!client) {
return <WorkspaceStatus title="Opening workspaces…" body="Waiting for the mobile shell." />
}
if (!snapshot && connection !== 'connected') {
return (
<WorkspaceStatus
title={connection === 'offline' ? 'Desktop is offline' : 'Connecting to desktop…'}
body="Workspaces will appear when the secure connection is available."
/>
)
}
if (!snapshot && request.loading) {
return <WorkspaceStatus title="Loading workspaces…" body="Reading the paired desktop." busy />
}
if (!snapshot && error) {
return (
<WorkspaceStatus
title="Workspaces unavailable"
body={workspaceErrorCopy(error)}
action={
error.retryable ? (
<Button variant="outline" size="sm" onClick={tryAgain}>
<RefreshCw />
Try again
</Button>
) : undefined
}
/>
)
}
if (!snapshot) {
return <WorkspaceStatus title="Loading workspaces…" body="Reading the paired desktop." />
}
return (
<section className="mx-auto w-full max-w-2xl p-4">
{connection !== 'connected' ? (
<p
role="status"
className="mb-3 rounded-md border border-border bg-muted px-3 py-2 text-xs text-muted-foreground"
>
{connectionCopy(connection)}
</p>
) : null}
{error ? (
<div
role="alert"
className="mb-3 flex items-center justify-between gap-3 rounded-md border border-border bg-muted px-3 py-2"
>
<p className="text-xs text-muted-foreground">{workspaceErrorCopy(error)}</p>
{error.retryable ? (
<Button
className="shrink-0"
variant="outline"
size="xs"
disabled={request.loading || connection !== 'connected'}
onClick={tryAgain}
>
Try again
</Button>
) : null}
</div>
) : null}
<Card aria-busy={request.loading}>
<CardHeader>
<div className="space-y-1">
<CardTitle>Workspaces</CardTitle>
<CardDescription>{workspaceCountCopy(snapshot)}</CardDescription>
</div>
<CardAction>
<Button
aria-label="Refresh workspaces"
variant="outline"
size="icon-sm"
disabled={request.loading || connection !== 'connected'}
onClick={tryAgain}
>
<RefreshCw className={request.loading ? 'animate-spin' : undefined} />
</Button>
</CardAction>
</CardHeader>
<CardContent className="px-0">
{snapshot.workspaces.length === 0 ? (
<div className="border-t border-border px-6 py-10 text-center">
<p className="text-sm font-medium">No workspaces available</p>
<p className="mt-1 text-xs text-muted-foreground">
Open a workspace on the paired desktop, then refresh this list.
</p>
<Button
className="mt-4"
variant="outline"
size="sm"
disabled={connection !== 'connected'}
onClick={tryAgain}
>
<RefreshCw />
Refresh
</Button>
</div>
) : (
<ul className="border-t border-border">
{snapshot.workspaces.map((workspace) => (
<li
key={workspace.id}
data-current={workspace.isActive}
className="border-b border-border last:border-b-0 data-[current=true]:bg-accent"
>
<Button
variant="ghost"
className="h-auto w-full justify-start rounded-none px-6 py-3 text-left"
disabled={connection !== 'connected'}
onClick={() => onOpen(workspace)}
>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{workspace.name}</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{workspace.repo}
</p>
</div>
{workspace.isActive ? (
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
Current
</span>
) : null}
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex min-w-0 items-center gap-1">
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate">{workspace.branch}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
<TerminalSquare className="size-3.5" />
{workspace.liveTerminalCount}
</span>
</div>
</div>
</Button>
</li>
))}
</ul>
)}
{snapshot.truncated ? (
<p className="border-t border-border px-6 py-3 text-xs text-muted-foreground">
Showing the first 100 workspaces.
</p>
) : null}
</CardContent>
</Card>
</section>
)
}
function WorkspaceStatus({
title,
body,
busy = false,
action
}: {
title: string
body: string
busy?: boolean
action?: React.ReactNode
}): React.JSX.Element {
return (
<section className="mx-auto flex min-h-[60dvh] w-full max-w-md items-center p-4">
<Card className="w-full" aria-busy={busy}>
<CardHeader>
<div className="flex items-center gap-2">
{busy ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : null}
<CardTitle>{title}</CardTitle>
</div>
<CardDescription>{body}</CardDescription>
</CardHeader>
{action ? <CardContent>{action}</CardContent> : null}
</Card>
</section>
)
}
function workspaceCountCopy(snapshot: MobileWebWorkspaceSnapshotResult): string {
const count = snapshot.workspaces.length
return `${count} ${count === 1 ? 'workspace' : 'workspaces'} on this desktop`
}
function connectionCopy(connection: MobileWebNativeShellState['connection']): string {
if (connection === 'offline') {
return 'Offline — showing the last workspace list.'
}
if (connection === 'recovering') {
return 'Restoring the secure connection — showing the last workspace list.'
}
return 'Connecting to the desktop — showing the last workspace list.'
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
function workspaceErrorCopy(error: MobileWebBridgeClientError): string {
if (error.code === 'unsupported_capability') {
return 'This Orca Mobile build does not expose workspace access.'
}
if (error.code === 'invalid_message') {
return 'The desktop returned an incompatible workspace response.'
}
return 'The workspace list could not be loaded from the paired desktop.'
}
@@ -1,260 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MOBILE_WEB_DIFF_MAX_ROWS,
MOBILE_WEB_DIFF_PAGE_LIMIT,
type MobileWebDiffRow,
type MobileWebSourceControlDiffResult,
type MobileWebSourceControlStatusEntry
} from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
export const MOBILE_WEB_DIFF_RETAINED_MAX_CHARACTERS = 1_000_000
export type MobileWebDiffDocument =
| {
kind: 'text'
revision: string
rows: MobileWebDiffRow[]
totalRows: number
nextOffset: number | null
truncated: boolean
retainedCharacters: number
retentionLimitReached: boolean
}
| { kind: 'binary' }
| { kind: 'too-large'; characterCount?: number }
type DiffState = {
client: MobileWebBridgeClient
workspaceId: string
entry: MobileWebSourceControlStatusEntry | null
loading: boolean
document: MobileWebDiffDocument | null
error: MobileWebBridgeClientError | null
}
export function useMobileWebDiffDocument(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
}) {
const { client, workspaceId, connected } = args
const controllerRef = useRef<AbortController | null>(null)
const [state, setState] = useState<DiffState>({
client,
workspaceId,
entry: null,
loading: false,
document: null,
error: null
})
const cancel = useCallback(() => {
controllerRef.current?.abort()
controllerRef.current = null
setState((current) => ({ ...current, loading: false }))
}, [])
useEffect(() => {
controllerRef.current?.abort()
controllerRef.current = null
setState({
client,
workspaceId,
entry: null,
loading: false,
document: null,
error: null
})
}, [client, workspaceId])
useEffect(() => {
if (!connected) {
cancel()
}
}, [cancel, connected])
const requestPage = useCallback(
(entry: MobileWebSourceControlStatusEntry, offset: number, expectedRevision?: string) => {
if (!connected) {
return
}
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
setState((current) => ({
client,
workspaceId,
entry,
loading: true,
document: offset === 0 ? null : current.document,
error: null
}))
void client
.sourceControlDiff(
{
workspaceId,
relativePath: entry.relativePath,
area: entry.area,
offset,
limit: MOBILE_WEB_DIFF_PAGE_LIMIT,
...(expectedRevision ? { expectedRevision } : {})
},
{ signal: controller.signal }
)
.then(
(result) => {
if (controller.signal.aborted) {
return
}
setState((current) => {
if (current.client !== client || current.workspaceId !== workspaceId) {
return current
}
try {
return {
client,
workspaceId,
entry,
loading: false,
document: acceptDiffPage(current, entry, result, offset),
error: null
}
} catch (error) {
return { ...current, loading: false, error: bridgeClientError(error) }
}
})
},
(error: unknown) => {
if (!controller.signal.aborted) {
setState((current) => ({
...current,
loading: false,
error: bridgeClientError(error)
}))
}
}
)
},
[client, connected, workspaceId]
)
const open = useCallback(
(entry: MobileWebSourceControlStatusEntry) => requestPage(entry, 0),
[requestPage]
)
const loadMore = useCallback(() => {
const current = state
if (
current.client !== client ||
current.workspaceId !== workspaceId ||
!current.entry ||
current.document?.kind !== 'text' ||
current.document.nextOffset === null ||
current.document.retentionLimitReached ||
current.loading
) {
return
}
requestPage(current.entry, current.document.nextOffset, current.document.revision)
}, [client, requestPage, state, workspaceId])
const retry = useCallback(() => {
if (state.entry) {
requestPage(state.entry, 0)
}
}, [requestPage, state.entry])
const matches = state.client === client && state.workspaceId === workspaceId
return {
client,
workspaceId,
entry: matches ? state.entry : null,
loading: matches ? state.loading : false,
document: matches ? state.document : null,
error: matches ? state.error : null,
open,
loadMore,
retry,
cancel
}
}
function acceptDiffPage(
current: DiffState,
entry: MobileWebSourceControlStatusEntry,
result: MobileWebSourceControlDiffResult,
requestedOffset: number
): MobileWebDiffDocument {
if (
result.workspaceId !== current.workspaceId ||
result.relativePath !== entry.relativePath ||
result.area !== entry.area
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
if (result.kind === 'binary') {
return { kind: 'binary' }
}
if (result.kind === 'too-large') {
return {
kind: 'too-large',
...(result.characterCount === undefined ? {} : { characterCount: result.characterCount })
}
}
if (result.offset !== requestedOffset || !hasContiguousRows(result.rows, requestedOffset)) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const previous = requestedOffset === 0 ? null : current.document
if (
previous &&
(previous.kind !== 'text' ||
previous.revision !== result.revision ||
previous.nextOffset !== requestedOffset)
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const retained = retainBoundedRows(previous?.kind === 'text' ? previous : null, result.rows)
return {
kind: 'text',
revision: result.revision,
rows: retained.rows,
totalRows: result.totalRows,
nextOffset: retained.limitReached ? null : result.nextOffset,
truncated: result.truncated,
retainedCharacters: retained.characters,
retentionLimitReached: retained.limitReached
}
}
function retainBoundedRows(
previous: Extract<MobileWebDiffDocument, { kind: 'text' }> | null,
rows: MobileWebDiffRow[]
) {
const retained = previous?.rows.slice() ?? []
let characters = previous?.retainedCharacters ?? 0
let limitReached = false
for (const row of rows) {
if (
retained.length >= MOBILE_WEB_DIFF_MAX_ROWS ||
characters + row.text.length > MOBILE_WEB_DIFF_RETAINED_MAX_CHARACTERS
) {
limitReached = true
break
}
retained.push(row)
characters += row.text.length
}
return { rows: retained, characters, limitReached }
}
function hasContiguousRows(rows: MobileWebDiffRow[], offset: number): boolean {
return rows.every((row, index) => row.index === offset + index)
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,132 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { MobileWebFileDirectoryResult } from '../../shared/mobile-web/bridge-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
type DirectoryState = {
client: MobileWebBridgeClient
workspaceId: string
relativePath: string
loading: boolean
result: MobileWebFileDirectoryResult | null
error: MobileWebBridgeClientError | null
}
export type MobileWebDirectoryView = {
relativePath: string
loading: boolean
result: MobileWebFileDirectoryResult | null
error: MobileWebBridgeClientError | null
}
export function useMobileWebFileDirectory(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
}): MobileWebDirectoryView & {
navigate: (relativePath: string) => void
retry: () => void
} {
const { client, workspaceId, connected } = args
const [relativePath, navigate] = useState('')
const [retryCount, setRetryCount] = useState(0)
const [state, setState] = useState<DirectoryState>({
client,
workspaceId,
relativePath,
loading: false,
result: null,
error: null
})
useEffect(() => {
navigate('')
}, [client, workspaceId])
useEffect(() => {
if (!connected) {
setState((current) => ({ ...current, loading: false }))
return
}
const controller = new AbortController()
setState((current) => ({
client,
workspaceId,
relativePath,
loading: true,
result: matchesDirectory(current, client, workspaceId, relativePath) ? current.result : null,
error: null
}))
void client
.fileDirectory({ workspaceId, relativePath, limit: 128 }, { signal: controller.signal })
.then((result) => {
if (
result.workspaceId !== workspaceId ||
result.relativePath !== relativePath ||
controller.signal.aborted
) {
if (!controller.signal.aborted) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return
}
setState((current) => ({
client,
workspaceId,
relativePath,
loading: false,
result:
matchesDirectory(current, client, workspaceId, relativePath) &&
current.result?.revision === result.revision
? current.result
: result,
error: null
}))
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((current) => ({
client,
workspaceId,
relativePath,
loading: false,
result: matchesDirectory(current, client, workspaceId, relativePath)
? current.result
: null,
error: bridgeClientError(error)
}))
}
})
return () => controller.abort()
}, [client, connected, relativePath, retryCount, workspaceId])
const retry = useCallback(() => setRetryCount((value) => value + 1), [])
const matches = matchesDirectory(state, client, workspaceId, relativePath)
return {
relativePath,
loading: matches ? state.loading : connected,
result: matches ? state.result : null,
error: matches ? state.error : null,
navigate,
retry
}
}
function matchesDirectory(
state: DirectoryState,
client: MobileWebBridgeClient,
workspaceId: string,
relativePath: string
): boolean {
return (
state.client === client &&
state.workspaceId === workspaceId &&
state.relativePath === relativePath
)
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,265 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import {
appendMobileWebFileChunk,
mobileWebFileNextChunkLength,
type MobileWebFileDocument
} from './mobile-web-file-document'
import {
MOBILE_WEB_RASTER_IMAGE_MAX_BYTES,
isMobileWebRasterImagePath
} from './mobile-web-raster-image'
export type MobileWebFilePreviewState =
| { status: 'idle' }
| { status: 'loading'; relativePath: string; document: MobileWebFileDocument | null }
| { status: 'ready'; document: MobileWebFileDocument }
| {
status: 'error'
relativePath: string
document: MobileWebFileDocument | null
error: MobileWebBridgeClientError
}
export function useMobileWebFileDocument(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
}): {
preview: MobileWebFilePreviewState
openFile: (relativePath: string) => void
openTextFile: (relativePath: string) => void
openBinaryFile: (relativePath: string) => void
loadMore: () => void
cancelLoad: () => void
} {
const { client, workspaceId, connected } = args
const [preview, setPreview] = useState<MobileWebFilePreviewState>({ status: 'idle' })
const scope = useRef(0)
const controller = useRef<AbortController | null>(null)
const cancelLoad = useCallback(() => {
scope.current += 1
controller.current?.abort()
controller.current = null
setPreview((current) =>
current.status === 'loading'
? current.document?.kind === 'text'
? { status: 'ready', document: current.document }
: { status: 'idle' }
: current
)
}, [])
useEffect(() => {
scope.current += 1
controller.current?.abort()
controller.current = null
setPreview({ status: 'idle' })
return () => {
scope.current += 1
controller.current?.abort()
controller.current = null
}
}, [client, workspaceId])
useEffect(() => {
if (!connected) {
cancelLoad()
}
}, [cancelLoad, connected])
const readNext = useCallback(
(relativePath: string, document: MobileWebFileDocument | null) => {
const length = mobileWebFileNextChunkLength(document)
if (!connected || length === 0 || document?.eof || document?.kind === 'binary') {
return
}
controller.current?.abort()
const requestController = new AbortController()
controller.current = requestController
const requestScope = ++scope.current
const offset = document?.bytes.byteLength ?? 0
setPreview({ status: 'loading', relativePath, document })
void client
.fileReadChunk(
{ workspaceId, relativePath, offset, length },
{ signal: requestController.signal }
)
.then((chunk) => {
if (scope.current !== requestScope) {
return
}
if (
chunk.workspaceId !== workspaceId ||
chunk.relativePath !== relativePath ||
chunk.offset !== offset
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
setPreview({ status: 'ready', document: appendMobileWebFileChunk(document, chunk) })
})
.catch((error: unknown) => {
if (scope.current === requestScope) {
setPreview({
status: 'error',
relativePath,
document,
error: bridgeClientError(error)
})
}
})
.finally(() => {
if (scope.current === requestScope) {
controller.current = null
}
})
},
[client, connected, workspaceId]
)
const openTextFile = useCallback(
(relativePath: string) => {
readNext(relativePath, null)
},
[readNext]
)
const readRasterImage = useCallback(
(relativePath: string) => {
if (!connected) {
return
}
controller.current?.abort()
const requestController = new AbortController()
controller.current = requestController
const requestScope = ++scope.current
setPreview({ status: 'loading', relativePath, document: null })
void readRasterImageChunks({
client,
workspaceId,
relativePath,
signal: requestController.signal,
onProgress: (document) => {
if (scope.current === requestScope) {
setPreview({ status: 'loading', relativePath, document })
}
}
})
.then((document) => {
if (scope.current === requestScope) {
setPreview({ status: 'ready', document })
}
})
.catch((error: unknown) => {
if (scope.current === requestScope) {
setPreview({
status: 'error',
relativePath,
document: null,
error: bridgeClientError(error)
})
}
})
.finally(() => {
if (scope.current === requestScope) {
controller.current = null
}
})
},
[client, connected, workspaceId]
)
const openFile = useCallback(
(relativePath: string) => {
if (isMobileWebRasterImagePath(relativePath)) {
readRasterImage(relativePath)
return
}
openTextFile(relativePath)
},
[openTextFile, readRasterImage]
)
const openBinaryFile = useCallback(
(relativePath: string) => {
if (isMobileWebRasterImagePath(relativePath)) {
readRasterImage(relativePath)
return
}
cancelLoad()
setPreview({
status: 'ready',
document: {
workspaceId,
relativePath,
bytes: new Uint8Array(),
content: '',
kind: 'binary',
eof: true,
limitReached: false,
revision: null
}
})
},
[cancelLoad, readRasterImage, workspaceId]
)
const loadMore = useCallback(() => {
if (preview.status === 'ready') {
readNext(preview.document.relativePath, preview.document)
} else if (preview.status === 'error') {
readNext(preview.relativePath, preview.document)
}
}, [preview, readNext])
return { preview, openFile, openTextFile, openBinaryFile, loadMore, cancelLoad }
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
async function readRasterImageChunks(args: {
client: MobileWebBridgeClient
workspaceId: string
relativePath: string
signal: AbortSignal
onProgress: (document: MobileWebFileDocument) => void
}): Promise<MobileWebFileDocument> {
let document: MobileWebFileDocument | null = null
while (!document?.eof && !document?.limitReached) {
const offset = document?.bytes.byteLength ?? 0
const length = mobileWebFileNextChunkLength(document, MOBILE_WEB_RASTER_IMAGE_MAX_BYTES)
if (length === 0) {
break
}
const chunk = await args.client.fileReadChunk(
{
workspaceId: args.workspaceId,
relativePath: args.relativePath,
offset,
length
},
{ signal: args.signal }
)
if (
chunk.workspaceId !== args.workspaceId ||
chunk.relativePath !== args.relativePath ||
chunk.offset !== offset
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const assembled = appendMobileWebFileChunk(document, chunk, MOBILE_WEB_RASTER_IMAGE_MAX_BYTES)
document = {
...assembled,
content: '',
kind: 'binary',
revision: null
}
args.onProgress(document)
}
if (!document) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return document
}
@@ -1,138 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { encodeMobileWebFileEdit } from './mobile-web-file-edit-content'
import type { MobileWebFileDocument } from './mobile-web-file-document'
type EditableFileState = {
relativePath: string
expectedRevision: string
initialContent: string
draft: string
}
export type MobileWebFileEditorState =
| { status: 'idle' }
| ({ status: 'editing' | 'saving' } & EditableFileState)
| ({ status: 'error'; error: MobileWebBridgeClientError } & EditableFileState)
export function useMobileWebFileEditor({
client,
workspaceId,
connected,
onSaved
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
onSaved: (relativePath: string) => void
}) {
const [editor, setEditor] = useState<MobileWebFileEditorState>({ status: 'idle' })
const controller = useRef<AbortController | null>(null)
const scope = useRef(0)
const cancel = useCallback(() => {
scope.current += 1
controller.current?.abort()
controller.current = null
setEditor({ status: 'idle' })
}, [])
useEffect(() => {
cancel()
return cancel
}, [cancel, client, workspaceId])
useEffect(() => {
if (!connected && editor.status === 'saving') {
controller.current?.abort()
controller.current = null
setEditor({
...editor,
status: 'error',
error: new MobileWebBridgeClientError('not_connected', true)
})
}
}, [connected, editor])
const begin = useCallback((document: MobileWebFileDocument) => {
if (document.kind !== 'text' || !document.eof || document.limitReached || !document.revision) {
return
}
setEditor({
status: 'editing',
relativePath: document.relativePath,
expectedRevision: document.revision,
initialContent: document.content,
draft: document.content
})
}, [])
const setDraft = useCallback((draft: string) => {
setEditor((current) =>
current.status === 'idle' || current.status === 'saving'
? current
: { ...current, status: 'editing', draft }
)
}, [])
const save = useCallback(() => {
if (!connected || editor.status === 'idle' || editor.status === 'saving') {
return
}
let encoded: ReturnType<typeof encodeMobileWebFileEdit>
try {
encoded = encodeMobileWebFileEdit(editor.draft)
} catch (error) {
setEditor({ ...editor, status: 'error', error: bridgeClientError(error) })
return
}
controller.current?.abort()
const requestController = new AbortController()
const requestScope = ++scope.current
controller.current = requestController
setEditor({ ...editor, status: 'saving' })
void client
.fileWrite(
{
workspaceId,
relativePath: editor.relativePath,
expectedRevision: editor.expectedRevision,
contentBase64: encoded.contentBase64
},
{ signal: requestController.signal }
)
.then((result) => {
if (
scope.current !== requestScope ||
result.revision !== encoded.revision ||
result.byteLength !== encoded.byteLength
) {
if (scope.current === requestScope) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return
}
setEditor({ status: 'idle' })
onSaved(editor.relativePath)
})
.catch((error: unknown) => {
if (scope.current === requestScope) {
setEditor({ ...editor, status: 'error', error: bridgeClientError(error) })
}
})
.finally(() => {
if (scope.current === requestScope) {
controller.current = null
}
})
}, [client, connected, editor, onSaved, workspaceId])
return { editor, begin, setDraft, save, cancel }
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,284 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
MobileWebProviderReview,
MobileWebProviderReviewFile
} from '../../shared/mobile-web/provider-review-contract'
import type { MobileWebProviderReviewDiffResult } from '../../shared/mobile-web/provider-review-diff-contract'
import {
MOBILE_WEB_DIFF_MAX_ROWS,
MOBILE_WEB_DIFF_PAGE_LIMIT,
type MobileWebDiffRow,
type MobileWebSourceControlStatusResult
} from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import {
MOBILE_WEB_DIFF_RETAINED_MAX_CHARACTERS,
type MobileWebDiffDocument
} from './use-mobile-web-diff-document'
type ReviewDiffDocument =
| (Extract<MobileWebDiffDocument, { kind: 'text' }> & {
focusLine?: number
focusRowIndex?: number
})
| Exclude<MobileWebDiffDocument, { kind: 'text' }>
type ReviewDiffState = {
client: MobileWebBridgeClient
workspaceId: string
file: MobileWebProviderReviewFile | null
loading: boolean
document: ReviewDiffDocument | null
error: MobileWebBridgeClientError | null
}
export function useMobileWebProviderReviewDiff(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
status: MobileWebSourceControlStatusResult | null
review: MobileWebProviderReview | null
}) {
const { client, workspaceId, connected, status, review } = args
const controllerRef = useRef<AbortController | null>(null)
const [state, setState] = useState<ReviewDiffState>(() => initialState(client, workspaceId))
const cancel = useCallback(() => {
controllerRef.current?.abort()
controllerRef.current = null
setState((current) => ({ ...current, loading: false }))
}, [])
const close = useCallback(() => {
controllerRef.current?.abort()
controllerRef.current = null
setState(initialState(client, workspaceId))
}, [client, workspaceId])
useEffect(
() => close(),
[
client,
workspaceId,
status?.head,
status?.branch,
review?.provider,
review?.number,
review?.headSha,
close
]
)
useEffect(() => {
if (!connected) {
cancel()
}
}, [cancel, connected])
const requestPage = useCallback(
(
file: MobileWebProviderReviewFile,
offset: number,
expectedRevision?: string,
focusLine?: number
) => {
if (
!connected ||
!status?.head ||
!status.branch ||
!review?.headSha ||
typeof client.providerReviewDiff !== 'function'
) {
return
}
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
setState((current) => ({
client,
workspaceId,
file,
loading: true,
document: offset === 0 ? null : current.document,
error: null
}))
void client
.providerReviewDiff(
{
workspaceId,
expectedHead: status.head,
expectedBranch: status.branch,
provider: review.provider,
reviewNumber: review.number,
expectedReviewHead: review.headSha,
path: file.path,
offset,
limit: MOBILE_WEB_DIFF_PAGE_LIMIT,
...(expectedRevision ? { expectedRevision } : {}),
...(focusLine === undefined ? {} : { focusLine })
},
{ signal: controller.signal }
)
.then(
(result) => {
if (controller.signal.aborted) {
return
}
setState((current) => {
if (current.client !== client || current.workspaceId !== workspaceId) {
return current
}
try {
return {
client,
workspaceId,
file,
loading: false,
document: acceptReviewDiffPage(current, file, result, offset, focusLine),
error: null
}
} catch (error) {
return { ...current, loading: false, error: bridgeClientError(error) }
}
})
},
(error: unknown) => {
if (!controller.signal.aborted) {
setState((current) => ({
...current,
loading: false,
error: bridgeClientError(error)
}))
}
}
)
},
[client, connected, review, status, workspaceId]
)
const open = useCallback(
(file: MobileWebProviderReviewFile, focusLine?: number) =>
requestPage(file, 0, undefined, focusLine),
[requestPage]
)
const loadMore = useCallback(() => {
if (
!state.file ||
state.document?.kind !== 'text' ||
state.document.nextOffset === null ||
state.document.retentionLimitReached ||
state.loading
) {
return
}
requestPage(state.file, state.document.nextOffset, state.document.revision)
}, [requestPage, state])
const retry = useCallback(() => {
if (state.file) {
requestPage(
state.file,
0,
undefined,
state.document?.kind === 'text' ? state.document.focusLine : undefined
)
}
}, [requestPage, state.document, state.file])
const matches = state.client === client && state.workspaceId === workspaceId
return {
file: matches ? state.file : null,
loading: matches ? state.loading : false,
document: matches ? state.document : null,
error: matches ? state.error : null,
open,
loadMore,
retry,
cancel,
close
}
}
function acceptReviewDiffPage(
current: ReviewDiffState,
file: MobileWebProviderReviewFile,
result: MobileWebProviderReviewDiffResult,
requestedOffset: number,
requestedFocusLine?: number
): ReviewDiffDocument {
if (result.workspaceId !== current.workspaceId || result.path !== file.path) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
if (result.kind === 'binary') {
return { kind: 'binary' }
}
if (result.kind === 'too-large') {
return {
kind: 'too-large',
...(result.characterCount === undefined ? {} : { characterCount: result.characterCount })
}
}
if (
(requestedFocusLine === undefined && result.offset !== requestedOffset) ||
!hasContiguousRows(result.rows, result.offset) ||
(requestedFocusLine !== undefined &&
(result.focusLine !== requestedFocusLine || result.focusRowIndex === undefined))
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const previous = requestedOffset === 0 ? null : current.document
if (
previous &&
(previous.kind !== 'text' ||
previous.revision !== result.revision ||
previous.nextOffset !== requestedOffset)
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const retained = retainRows(previous?.kind === 'text' ? previous : null, result.rows)
return {
kind: 'text',
revision: result.revision,
rows: retained.rows,
totalRows: result.totalRows,
nextOffset: retained.limitReached ? null : result.nextOffset,
truncated: result.truncated,
retainedCharacters: retained.characters,
retentionLimitReached: retained.limitReached,
...(result.focusLine === undefined ? {} : { focusLine: result.focusLine }),
...(result.focusRowIndex === undefined ? {} : { focusRowIndex: result.focusRowIndex })
}
}
function retainRows(
previous: Extract<ReviewDiffDocument, { kind: 'text' }> | null,
rows: MobileWebDiffRow[]
) {
const retained = previous?.rows.slice() ?? []
let characters = previous?.retainedCharacters ?? 0
let limitReached = false
for (const row of rows) {
if (
retained.length >= MOBILE_WEB_DIFF_MAX_ROWS ||
characters + row.text.length > MOBILE_WEB_DIFF_RETAINED_MAX_CHARACTERS
) {
limitReached = true
break
}
retained.push(row)
characters += row.text.length
}
return { rows: retained, characters, limitReached }
}
function initialState(client: MobileWebBridgeClient, workspaceId: string): ReviewDiffState {
return { client, workspaceId, file: null, loading: false, document: null, error: null }
}
function hasContiguousRows(rows: MobileWebDiffRow[], offset: number): boolean {
return rows.every((row, index) => row.index === offset + index)
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,279 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS,
type MobileWebProviderReview,
type MobileWebProviderReviewFile
} from '../../shared/mobile-web/provider-review-contract'
import {
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_COMMENT_LIMIT,
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_SUMMARY_MAX_CHARACTERS
} from '../../shared/mobile-web/provider-review-submission-contract'
import type { MobileWebSourceControlStatusResult } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { useMobileWebProviderReviewSubmission } from './use-mobile-web-provider-review-submission'
const HEAD = 'a'.repeat(40)
const NEXT_HEAD = 'b'.repeat(40)
const THIRD_HEAD = 'c'.repeat(40)
const FILE: MobileWebProviderReviewFile = {
path: 'src/review.ts',
status: 'modified',
additions: 2,
deletions: 1,
isBinary: false,
commentableLines: [12],
commentableLinesTruncated: false
}
afterEach(cleanup)
describe('useMobileWebProviderReviewSubmission', () => {
it('preserves drafts through transient null state and resets on repository identity changes', async () => {
const refreshReview = vi.fn().mockResolvedValue(undefined)
const client = submissionClient()
const initial = submissionProps(client, refreshReview)
const hook = renderHook((props: HookProps) => useMobileWebProviderReviewSubmission(props), {
initialProps: initial
})
await queue(hook.result.current, 'Keep this draft.')
hook.rerender({ ...initial, review: null })
expect(hook.result.current.comments).toHaveLength(1)
hook.rerender({ ...initial, status: null, review: null })
expect(hook.result.current.comments).toHaveLength(1)
hook.rerender({ ...initial, review: providerReview(HEAD) })
expect(hook.result.current.comments).toHaveLength(1)
hook.rerender({ ...initial, status: sourceControlStatus('workspace-1', NEXT_HEAD) })
await waitFor(() => expect(hook.result.current.comments).toHaveLength(0))
})
it('resets drafts on review-head and workspace changes', async () => {
const refreshReview = vi.fn().mockResolvedValue(undefined)
const client = submissionClient()
const initial = submissionProps(client, refreshReview)
const hook = renderHook((props: HookProps) => useMobileWebProviderReviewSubmission(props), {
initialProps: initial
})
await queue(hook.result.current, 'Bound to the first review head.')
hook.rerender({ ...initial, review: providerReview(NEXT_HEAD) })
await waitFor(() => expect(hook.result.current.comments).toHaveLength(0))
await queue(hook.result.current, 'Bound to the first workspace.')
hook.rerender({
...initial,
workspaceId: 'workspace-2',
status: sourceControlStatus('workspace-2', HEAD),
review: providerReview(THIRD_HEAD)
})
await waitFor(() => expect(hook.result.current.comments).toHaveLength(0))
})
it('rejects empty, oversized, aggregate-overflow, and excess queued comments', async () => {
const hook = renderSubmissionHook()
await act(async () => {
expect(await hook.result.current.queueComment(FILE, 12, ' ')).toBe(false)
})
expect(hook.result.current.error).toContain('Enter a review comment')
await act(async () => {
expect(
await hook.result.current.queueComment(
FILE,
12,
'x'.repeat(MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS + 1)
)
).toBe(false)
})
expect(hook.result.current.error).toContain('8,192 characters')
await act(async () => {
for (let index = 0; index < MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_COMMENT_LIMIT; index += 1) {
expect(await hook.result.current.queueComment(FILE, 12, `Comment ${index}`)).toBe(true)
}
})
expect(hook.result.current.comments).toHaveLength(
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_COMMENT_LIMIT
)
await act(async () => {
expect(await hook.result.current.queueComment(FILE, 12, 'One too many')).toBe(false)
})
expect(hook.result.current.error).toContain('maximum of 32')
act(() => {
for (const comment of hook.result.current.comments) {
hook.result.current.removeComment(comment.id)
}
hook.result.current.setSummary(
's'.repeat(MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_SUMMARY_MAX_CHARACTERS)
)
})
await act(async () => {
for (let index = 0; index < 7; index += 1) {
expect(
await hook.result.current.queueComment(
FILE,
12,
'x'.repeat(MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS)
)
).toBe(true)
}
})
await act(async () => {
expect(
await hook.result.current.queueComment(
FILE,
12,
'x'.repeat(MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS)
)
).toBe(false)
})
expect(hook.result.current.error).toContain('65,536 retained characters')
})
it('requires a refresh after an ambiguous failure before allowing a new submission', async () => {
const refreshReview = vi.fn().mockResolvedValue(undefined)
const providerSubmitReview = vi
.fn()
.mockRejectedValueOnce(new Error('provider response lost'))
.mockImplementationOnce(async (payload) => submissionResult(payload))
const client = submissionClient(providerSubmitReview)
const hook = renderHook(
() => useMobileWebProviderReviewSubmission(submissionProps(client, refreshReview)),
{}
)
await queue(hook.result.current, 'Do not replay this blindly.')
await act(async () => {
expect(await hook.result.current.submit()).toBe(false)
})
expect(hook.result.current.requiresRefresh).toBe(true)
expect(hook.result.current.comments).toHaveLength(1)
expect(providerSubmitReview).toHaveBeenCalledOnce()
await act(async () => {
expect(await hook.result.current.submit()).toBe(false)
})
expect(providerSubmitReview).toHaveBeenCalledOnce()
await act(async () => {
await hook.result.current.refreshBeforeRetry()
})
expect(hook.result.current.requiresRefresh).toBe(false)
expect(hook.result.current.comments).toHaveLength(1)
await act(async () => {
expect(await hook.result.current.submit()).toBe(true)
})
expect(providerSubmitReview).toHaveBeenCalledTimes(2)
expect(providerSubmitReview.mock.calls[1]?.[0].submissionId).not.toBe(
providerSubmitReview.mock.calls[0]?.[0].submissionId
)
expect(hook.result.current.comments).toHaveLength(0)
})
})
type HookProps = Parameters<typeof useMobileWebProviderReviewSubmission>[0]
type SubmissionState = ReturnType<typeof useMobileWebProviderReviewSubmission>
function renderSubmissionHook() {
const client = submissionClient()
return renderHook(() =>
useMobileWebProviderReviewSubmission(
submissionProps(client, vi.fn().mockResolvedValue(undefined))
)
)
}
async function queue(submission: SubmissionState, body: string): Promise<void> {
await act(async () => {
expect(await submission.queueComment(FILE, 12, body)).toBe(true)
})
}
function submissionProps(
client: MobileWebBridgeClient,
refreshReview: () => Promise<void>
): HookProps {
return {
client,
workspaceId: 'workspace-1',
connected: true,
status: sourceControlStatus('workspace-1', HEAD),
review: providerReview(HEAD),
refreshReview
}
}
function sourceControlStatus(
workspaceId: string,
head: string
): MobileWebSourceControlStatusResult {
return {
workspaceId,
branch: 'feature/review',
head,
conflictOperation: 'unknown',
entries: [],
totalCount: 0,
truncated: false
}
}
function providerReview(headSha: string): MobileWebProviderReview {
return {
provider: 'github',
number: 42,
title: 'Review mobile bridge',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE',
reviewDecision: null,
updatedAt: '2026-07-23T00:00:00.000Z',
headSha,
body: '',
comments: [],
commentsTruncated: false,
files: [FILE],
filesTruncated: false,
author: null,
reviewRequests: [],
latestReviews: [],
checks: [],
detailsState: 'loaded',
canComment: true,
allowedSubmissionActions: ['comment', 'approve', 'request-changes']
}
}
function submissionClient(
providerSubmitReview = vi.fn(async (payload) => submissionResult(payload))
) {
return { providerSubmitReview } as unknown as MobileWebBridgeClient
}
function submissionResult(payload: {
workspaceId: string
provider: string
reviewNumber: number
expectedReviewHead: string
submissionId: string
action: string
comments: { id: string }[]
}) {
return {
workspaceId: payload.workspaceId,
provider: payload.provider,
reviewNumber: payload.reviewNumber,
expectedReviewHead: payload.expectedReviewHead,
submissionId: payload.submissionId,
action: payload.action,
submittedCommentIds: payload.comments.map((comment) => comment.id),
outcome: 'completed'
}
}
@@ -1,296 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS,
type MobileWebProviderReview,
type MobileWebProviderReviewFile
} from '../../shared/mobile-web/provider-review-contract'
import {
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_COMMENT_LIMIT,
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_SUMMARY_MAX_CHARACTERS,
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_TOTAL_MAX_CHARACTERS,
type MobileWebProviderReviewQueuedComment,
type MobileWebProviderReviewSubmissionAction
} from '../../shared/mobile-web/provider-review-submission-contract'
import type { MobileWebSourceControlStatusResult } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
export function useMobileWebProviderReviewSubmission({
client,
workspaceId,
connected,
status,
review,
refreshReview
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
status: MobileWebSourceControlStatusResult | null
review: MobileWebProviderReview | null
refreshReview: () => Promise<void>
}) {
const [comments, setComments] = useState<MobileWebProviderReviewQueuedComment[]>([])
const [summary, setSummaryState] = useState('')
const [action, setActionState] = useState<MobileWebProviderReviewSubmissionAction>('comment')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [requiresRefresh, setRequiresRefresh] = useState(false)
const controller = useRef<AbortController | null>(null)
const submissionId = useRef<string | null>(null)
const activeRepositoryIdentity = useRef<string | null>(null)
const activeReviewIdentity = useRef<string | null>(null)
const repositoryIdentity =
status?.head && status.branch ? `${workspaceId}\0${status.head}\0${status.branch}` : ''
const reviewIdentity = review?.headSha
? `${workspaceId}\0${review.provider}\0${review.number}\0${review.headSha}`
: ''
const allowedActionsKey = review?.allowedSubmissionActions.join('\0') ?? ''
const resetDraft = useCallback(() => {
controller.current?.abort()
setComments([])
setSummaryState('')
setActionState('comment')
setSubmitting(false)
setError(null)
setRequiresRefresh(false)
submissionId.current = null
}, [])
useEffect(() => {
activeRepositoryIdentity.current = null
activeReviewIdentity.current = null
resetDraft()
}, [resetDraft, workspaceId])
useEffect(() => {
if (!repositoryIdentity) {
return
}
if (
activeRepositoryIdentity.current !== null &&
activeRepositoryIdentity.current !== repositoryIdentity
) {
activeReviewIdentity.current = null
resetDraft()
}
activeRepositoryIdentity.current = repositoryIdentity
}, [repositoryIdentity, resetDraft])
useEffect(() => {
if (!reviewIdentity) {
return
}
if (activeReviewIdentity.current !== reviewIdentity) {
activeReviewIdentity.current = reviewIdentity
resetDraft()
}
setActionState((current) =>
review?.allowedSubmissionActions.includes(current)
? current
: (review?.allowedSubmissionActions[0] ?? 'comment')
)
}, [allowedActionsKey, resetDraft, review, reviewIdentity])
useEffect(
() => () => {
controller.current?.abort()
},
[]
)
const resetSubmissionAttempt = useCallback(() => {
submissionId.current = null
setError(null)
setRequiresRefresh(false)
}, [])
const queueComment = useCallback(
async (file: MobileWebProviderReviewFile, line: number, body: string) => {
const normalizedBody = body.trim()
if (normalizedBody.length === 0) {
setError('Enter a review comment before queueing it.')
return false
}
if (normalizedBody.length > MOBILE_WEB_PROVIDER_COMMENT_BODY_MAX_CHARACTERS) {
setError('Review comments are limited to 8,192 characters.')
return false
}
if (
!review?.headSha ||
review.allowedSubmissionActions.length === 0 ||
!file.commentableLines.includes(line)
) {
return false
}
if (comments.length >= MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_COMMENT_LIMIT) {
setError('This review already contains the maximum of 32 queued comments.')
return false
}
if (
retainedSubmissionCharacters(summary, comments) + normalizedBody.length >
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_TOTAL_MAX_CHARACTERS
) {
setError('The queued review is limited to 65,536 retained characters.')
return false
}
setComments((current) => [
...current,
{ id: createReviewSubmissionId(), path: file.path, line, body: normalizedBody }
])
resetSubmissionAttempt()
return true
},
[comments, resetSubmissionAttempt, review, summary]
)
const removeComment = useCallback(
(id: string) => {
setComments((current) => current.filter((comment) => comment.id !== id))
resetSubmissionAttempt()
},
[resetSubmissionAttempt]
)
const setSummary = useCallback(
(value: string) => {
if (value.length > MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_SUMMARY_MAX_CHARACTERS) {
setError('The review summary is limited to 8,192 characters.')
return
}
if (
retainedSubmissionCharacters(value, comments) >
MOBILE_WEB_PROVIDER_REVIEW_SUBMISSION_TOTAL_MAX_CHARACTERS
) {
setError('The queued review is limited to 65,536 retained characters.')
return
}
setSummaryState(value)
resetSubmissionAttempt()
},
[comments, resetSubmissionAttempt]
)
const setAction = useCallback(
(value: MobileWebProviderReviewSubmissionAction) => {
setActionState(value)
resetSubmissionAttempt()
},
[resetSubmissionAttempt]
)
const submit = useCallback(async () => {
const head = status?.head
const branch = status?.branch
if (
!connected ||
!head ||
!branch ||
!review?.headSha ||
!review.allowedSubmissionActions.includes(action) ||
requiresRefresh ||
typeof client.providerSubmitReview !== 'function'
) {
return false
}
controller.current?.abort()
const nextController = new AbortController()
controller.current = nextController
submissionId.current ??= createReviewSubmissionId()
setSubmitting(true)
setError(null)
try {
await client.providerSubmitReview(
{
workspaceId,
expectedHead: head,
expectedBranch: branch,
provider: review.provider,
reviewNumber: review.number,
expectedReviewHead: review.headSha,
submissionId: submissionId.current,
action,
summary,
comments
},
{ signal: nextController.signal }
)
if (nextController.signal.aborted || controller.current !== nextController) {
return false
}
setComments([])
setSummaryState('')
submissionId.current = null
await refreshReview()
return true
} catch (submissionError) {
if (!nextController.signal.aborted && controller.current === nextController) {
setError(reviewSubmissionErrorMessage(submissionError))
setRequiresRefresh(true)
}
return false
} finally {
if (controller.current === nextController) {
controller.current = null
setSubmitting(false)
}
}
}, [
action,
client,
comments,
connected,
refreshReview,
requiresRefresh,
review,
status,
summary,
workspaceId
])
const refreshBeforeRetry = useCallback(async () => {
await refreshReview()
submissionId.current = null
setError(null)
setRequiresRefresh(false)
}, [refreshReview])
return {
comments,
summary,
action,
submitting,
error,
requiresRefresh,
queueComment,
removeComment,
setSummary,
setAction,
submit,
refreshBeforeRetry
}
}
export function createReviewSubmissionId(): string {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')
}
function reviewSubmissionErrorMessage(error: unknown): string {
if (error instanceof MobileWebBridgeClientError && error.code === 'conflict') {
return 'The repository or review changed before submission. Refresh the review before retrying.'
}
if (error instanceof MobileWebBridgeClientError && error.code === 'unsupported_capability') {
return 'This provider does not support the selected review action.'
}
return 'The review may have been partially submitted. Refresh the hosted review before retrying.'
}
function retainedSubmissionCharacters(
summary: string,
comments: readonly MobileWebProviderReviewQueuedComment[]
): number {
return summary.trim().length + comments.reduce((total, comment) => total + comment.body.length, 0)
}
@@ -1,236 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
MobileWebProviderReviewComment,
MobileWebProviderReviewFile,
MobileWebProviderReviewMutationPayload,
MobileWebProviderReviewResult
} from '../../shared/mobile-web/provider-review-contract'
import type { MobileWebSourceControlStatusResult } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
export function useMobileWebProviderReview({
client,
workspaceId,
connected,
status
}: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
status: MobileWebSourceControlStatusResult | null
}) {
const head = status?.head
const branch = status?.branch
const [result, setResult] = useState<MobileWebProviderReviewResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mutationKey, setMutationKey] = useState<string | null>(null)
const [mutationError, setMutationError] = useState<string | null>(null)
const [mutationErrorKey, setMutationErrorKey] = useState<string | null>(null)
const readController = useRef<AbortController | null>(null)
const mutationController = useRef<AbortController | null>(null)
const retry = useCallback(async () => {
readController.current?.abort()
if (!connected || !head || !branch || typeof client.providerReview !== 'function') {
setResult(null)
setLoading(false)
setError(null)
return
}
const controller = new AbortController()
readController.current = controller
setLoading(true)
setError(null)
try {
const next = await client.providerReview(
{ workspaceId, expectedHead: head, expectedBranch: branch },
{ signal: controller.signal }
)
if (!controller.signal.aborted && readController.current === controller) {
setResult(next)
}
} catch (requestError) {
if (!controller.signal.aborted && readController.current === controller) {
setResult(null)
setError(reviewRequestErrorMessage(requestError))
}
} finally {
if (readController.current === controller) {
readController.current = null
setLoading(false)
}
}
}, [branch, client, connected, head, workspaceId])
useEffect(() => {
void retry()
return () => {
readController.current?.abort()
mutationController.current?.abort()
}
}, [retry])
const mutate = useCallback(
async (payload: MobileWebProviderReviewMutationPayload, key: string) => {
const review = result?.review
if (
!connected ||
!head ||
!branch ||
!review?.canComment ||
typeof client.providerMutateReview !== 'function'
) {
return false
}
mutationController.current?.abort()
const controller = new AbortController()
mutationController.current = controller
setMutationKey(key)
setMutationError(null)
setMutationErrorKey(null)
try {
await client.providerMutateReview(payload, { signal: controller.signal })
if (controller.signal.aborted || mutationController.current !== controller) {
return false
}
await retry()
return true
} catch (requestError) {
if (!controller.signal.aborted && mutationController.current === controller) {
setMutationError(reviewMutationErrorMessage(requestError, payload.action))
setMutationErrorKey(key)
}
return false
} finally {
if (mutationController.current === controller) {
mutationController.current = null
setMutationKey(null)
}
}
},
[branch, client, connected, head, result, retry]
)
const mutationIdentity = useCallback(() => {
const review = result?.review
return review && head && branch
? {
workspaceId,
expectedHead: head,
expectedBranch: branch,
provider: review.provider,
reviewNumber: review.number
}
: null
}, [branch, head, result, workspaceId])
const comment = useCallback(
async (body: string) => {
const identity = mutationIdentity()
return identity ? mutate({ ...identity, action: 'comment', body }, 'comment') : false
},
[mutate, mutationIdentity]
)
const reply = useCallback(
async (target: MobileWebProviderReviewComment, body: string) => {
const identity = mutationIdentity()
if (!identity || !target.threadId || !target.allowedActions.includes('reply')) {
return false
}
return mutate(
{
...identity,
action: 'reply',
commentId: target.id,
threadId: target.threadId,
body
},
`reply:${target.threadId}:${target.id}`
)
},
[mutate, mutationIdentity]
)
const inlineComment = useCallback(
async (file: MobileWebProviderReviewFile, line: number, body: string) => {
const identity = mutationIdentity()
const reviewHead = result?.review?.headSha
if (!identity || !reviewHead || !file.commentableLines.includes(line)) {
return false
}
return mutate(
{
...identity,
action: 'inlineComment',
expectedReviewHead: reviewHead,
path: file.path,
line,
body
},
`inline:${file.path}:${line}`
)
},
[mutate, mutationIdentity, result]
)
const setThreadResolved = useCallback(
async (target: MobileWebProviderReviewComment, resolved: boolean) => {
const identity = mutationIdentity()
if (!identity || !target.threadId || !target.allowedActions.includes('set-resolved')) {
return false
}
return mutate(
{
...identity,
action: 'setThreadResolved',
threadId: target.threadId,
resolved
},
`thread:${target.threadId}`
)
},
[mutate, mutationIdentity]
)
return {
result,
loading,
error,
mutationKey,
mutationError,
mutationErrorKey,
retry,
comment,
reply,
inlineComment,
setThreadResolved
}
}
function reviewRequestErrorMessage(error: unknown): string {
if (error instanceof MobileWebBridgeClientError && error.code === 'conflict') {
return 'Repository state changed. Refresh changes before loading the review.'
}
if (error instanceof MobileWebBridgeClientError && error.code === 'not_connected') {
return 'Reconnect to load the hosted review.'
}
return 'Hosted review details are unavailable.'
}
function reviewMutationErrorMessage(
error: unknown,
action: MobileWebProviderReviewMutationPayload['action']
): string {
if (error instanceof MobileWebBridgeClientError && error.code === 'conflict') {
return 'The repository, hosted review, or thread changed. Refresh before trying again.'
}
if (error instanceof MobileWebBridgeClientError && error.code === 'not_connected') {
return 'Reconnect before updating the hosted review.'
}
return action === 'setThreadResolved'
? 'The thread state could not be confirmed. Refresh before retrying.'
: 'The comment may not have been posted. Check the hosted review before retrying.'
}
@@ -1,241 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS,
MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT,
type MobileWebSourceControlCommitEntry
} from '../../shared/mobile-web/source-control-commit-contract'
import type { MobileWebSourceControlStatusResult } from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
const COMMIT_MESSAGE_GENERATION_WEB_TIMEOUT_MS = 70_000
type CommitAction = {
kind: 'commit' | 'generate'
controller: AbortController
}
export function useMobileWebSourceControlCommit(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
status: MobileWebSourceControlStatusResult | null
statusLoading: boolean
mutationBusy: boolean
onRefresh: () => void
}) {
const { client, workspaceId, connected, status, statusLoading, mutationBusy, onRefresh } = args
const [message, setMessageState] = useState('')
const [busy, setBusy] = useState<CommitAction['kind'] | null>(null)
const [error, setError] = useState<string | null>(null)
const actionRef = useRef<CommitAction | null>(null)
const stagedEntries = useMemo(() => commitEntrySnapshot(status), [status])
const blockedReason = commitBlockedReason(status, stagedEntries)
const snapshot = useMemo(
() =>
status?.head && !blockedReason
? { workspaceId, expectedHead: status.head, stagedEntries }
: null,
[blockedReason, stagedEntries, status?.head, workspaceId]
)
useEffect(() => {
setMessageState('')
setBusy(null)
setError(null)
return () => {
actionRef.current?.controller.abort()
actionRef.current = null
}
}, [client, workspaceId])
const commit = useCallback(async (): Promise<boolean> => {
const trimmedMessage = message.trim()
if (
!snapshot ||
!connected ||
statusLoading ||
mutationBusy ||
actionRef.current ||
trimmedMessage.length === 0
) {
return false
}
const action = startAction('commit', actionRef, setBusy, setError)
try {
const result = await client.sourceControlCommit(
{ ...snapshot, message: trimmedMessage },
{ signal: action.controller.signal }
)
if (action.controller.signal.aborted) {
return false
}
if (result.status === 'failed') {
setError(result.error)
return false
}
setMessageState('')
onRefresh()
return true
} catch (caught) {
return handleCommitError(caught, action.controller, setError, onRefresh)
} finally {
finishAction(action, actionRef, setBusy)
}
}, [client, connected, message, mutationBusy, onRefresh, snapshot, statusLoading])
const generate = useCallback(async (): Promise<boolean> => {
if (!snapshot || !connected || statusLoading || mutationBusy || actionRef.current) {
return false
}
const action = startAction('generate', actionRef, setBusy, setError)
try {
const result = await client.sourceControlGenerateCommitMessage(snapshot, {
signal: action.controller.signal,
timeoutMs: COMMIT_MESSAGE_GENERATION_WEB_TIMEOUT_MS
})
if (action.controller.signal.aborted || result.status === 'cancelled') {
return false
}
if (result.status === 'failed') {
setError(result.error)
return false
}
setMessageState(result.message)
return true
} catch (caught) {
return handleCommitError(caught, action.controller, setError, onRefresh)
} finally {
finishAction(action, actionRef, setBusy)
}
}, [client, connected, mutationBusy, onRefresh, snapshot, statusLoading])
const cancelGeneration = useCallback(() => {
const action = actionRef.current
if (!action || action.kind !== 'generate') {
return
}
action.controller.abort()
actionRef.current = null
setBusy(null)
void client.sourceControlCancelCommitMessageGeneration({ workspaceId }).catch(() => undefined)
}, [client, workspaceId])
return {
message,
setMessage: useCallback((next: string) => {
setMessageState(next.slice(0, MOBILE_WEB_COMMIT_MESSAGE_MAX_CHARACTERS))
setError(null)
}, []),
busy,
error,
clearError: useCallback(() => setError(null), []),
stagedCount: stagedEntries.length,
blockedReason,
canCommit:
Boolean(snapshot) &&
connected &&
!statusLoading &&
!mutationBusy &&
busy === null &&
message.trim().length > 0,
canGenerate: Boolean(snapshot) && connected && !statusLoading && !mutationBusy && busy === null,
commit,
generate,
cancelGeneration
}
}
function commitEntrySnapshot(
status: MobileWebSourceControlStatusResult | null
): MobileWebSourceControlCommitEntry[] {
if (!status) {
return []
}
const paths = new Set<string>()
return status.entries
.filter((entry) => {
if (entry.area !== 'staged' || paths.has(entry.relativePath)) {
return false
}
paths.add(entry.relativePath)
return true
})
.slice(0, MOBILE_WEB_COMMIT_STAGED_ENTRY_LIMIT)
.map((entry) => ({
relativePath: entry.relativePath,
...(entry.oldRelativePath ? { oldRelativePath: entry.oldRelativePath } : {}),
status: entry.status,
area: 'staged' as const,
...(entry.conflictStatus ? { conflictStatus: entry.conflictStatus } : {})
}))
}
function commitBlockedReason(
status: MobileWebSourceControlStatusResult | null,
stagedEntries: readonly MobileWebSourceControlCommitEntry[]
): string | null {
if (!status || stagedEntries.length === 0) {
return 'Stage changes to create a commit.'
}
if (status.truncated) {
return 'Commit on Desktop when the mobile change list is truncated.'
}
if (!status.head) {
return 'Commit on Desktop until the repository has a current HEAD.'
}
if (status.entries.some((entry) => entry.conflictStatus === 'unresolved')) {
return 'Resolve conflicts on Desktop before committing.'
}
return null
}
function startAction(
kind: CommitAction['kind'],
ref: React.MutableRefObject<CommitAction | null>,
setBusy: (value: CommitAction['kind'] | null) => void,
setError: (value: string | null) => void
): CommitAction {
const action = { kind, controller: new AbortController() }
ref.current = action
setBusy(kind)
setError(null)
return action
}
function finishAction(
action: CommitAction,
ref: React.MutableRefObject<CommitAction | null>,
setBusy: (value: CommitAction['kind'] | null) => void
): void {
if (ref.current === action) {
ref.current = null
setBusy(null)
}
}
function handleCommitError(
caught: unknown,
controller: AbortController,
setError: (value: string | null) => void,
onRefresh: () => void
): false {
if (controller.signal.aborted) {
return false
}
const error =
caught instanceof MobileWebBridgeClientError
? caught
: new MobileWebBridgeClientError('internal', false)
if (error.code === 'conflict') {
setError('The repository changed. Review the refreshed status before trying again.')
onRefresh()
} else if (error.code === 'not_connected') {
setError('Reconnect before committing.')
} else {
setError('The paired Desktop could not complete the commit action.')
}
return false
}
export type MobileWebSourceControlCommit = ReturnType<typeof useMobileWebSourceControlCommit>
@@ -1,201 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT,
type MobileWebSourceControlMutationEntry,
type MobileWebSourceControlMutationOperation
} from '../../shared/mobile-web/source-control-mutation-contract'
import type {
MobileWebSourceControlStatusEntry,
MobileWebSourceControlStatusResult
} from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
type MutationState = {
client: MobileWebBridgeClient
workspaceId: string
operation: MobileWebSourceControlMutationOperation | null
relativePaths: string[]
error: MobileWebBridgeClientError | null
}
export function useMobileWebSourceControlMutations(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
status: MobileWebSourceControlStatusResult | null
onRefresh: () => void
}) {
const { client, workspaceId, connected, status, onRefresh } = args
const controllerRef = useRef<AbortController | null>(null)
const [discardTargets, setDiscardTargets] = useState<MobileWebSourceControlStatusEntry[] | null>(
null
)
const [state, setState] = useState<MutationState>({
client,
workspaceId,
operation: null,
relativePaths: [],
error: null
})
useEffect(() => {
return () => {
controllerRef.current?.abort()
controllerRef.current = null
setDiscardTargets(null)
}
}, [client, workspaceId])
const run = useCallback(
async (
operation: MobileWebSourceControlMutationOperation,
candidates: readonly MobileWebSourceControlStatusEntry[]
): Promise<boolean> => {
if (!connected || controllerRef.current) {
return false
}
const entries = eligibleMutationEntries(candidates, operation)
if (entries.length === 0) {
return false
}
const controller = new AbortController()
controllerRef.current = controller
const relativePaths = entries.map((entry) => entry.relativePath)
setState({ client, workspaceId, operation, relativePaths, error: null })
try {
const payload = {
workspaceId,
expectedHead: status?.head ?? null,
entries: entries.map(mutationEntrySnapshot)
}
if (operation === 'stage') {
await client.sourceControlStage(payload, { signal: controller.signal })
} else if (operation === 'unstage') {
await client.sourceControlUnstage(payload, { signal: controller.signal })
} else {
await client.sourceControlDiscard(
{ ...payload, confirmation: 'discard-confirmed' },
{ signal: controller.signal }
)
}
if (controller.signal.aborted) {
return false
}
setState({ client, workspaceId, operation: null, relativePaths: [], error: null })
onRefresh()
return true
} catch (error) {
if (controller.signal.aborted) {
return false
}
const bridgeError = mutationError(error)
setState({
client,
workspaceId,
operation: null,
relativePaths: [],
error: bridgeError
})
if (bridgeError.code === 'conflict') {
onRefresh()
}
return false
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null
}
}
},
[client, connected, onRefresh, status?.head, workspaceId]
)
const matches = state.client === client && state.workspaceId === workspaceId
const busyOperation = matches ? state.operation : null
const mutationErrorValue = matches ? state.error : null
const isBusyPath = useCallback(
(relativePath: string) => matches && state.relativePaths.includes(relativePath),
[matches, state.relativePaths]
)
return {
busyOperation,
error: mutationErrorValue,
discardTargets,
stage: useCallback(
(entries: readonly MobileWebSourceControlStatusEntry[]) => run('stage', entries),
[run]
),
unstage: useCallback(
(entries: readonly MobileWebSourceControlStatusEntry[]) => run('unstage', entries),
[run]
),
requestDiscard: useCallback(
(entries: readonly MobileWebSourceControlStatusEntry[]) => {
if (!busyOperation) {
const eligible = eligibleMutationEntries(entries, 'discard')
setDiscardTargets(eligible.length > 0 ? eligible : null)
}
},
[busyOperation]
),
confirmDiscard: useCallback(async () => {
if (discardTargets && (await run('discard', discardTargets))) {
setDiscardTargets(null)
}
}, [discardTargets, run]),
cancelDiscard: useCallback(() => {
if (!busyOperation) {
setDiscardTargets(null)
}
}, [busyOperation]),
clearError: useCallback(() => {
setState((current) =>
current.client === client && current.workspaceId === workspaceId
? { ...current, error: null }
: current
)
}, [client, workspaceId]),
isBusyPath
}
}
export function eligibleMutationEntries(
entries: readonly MobileWebSourceControlStatusEntry[],
operation: MobileWebSourceControlMutationOperation
): MobileWebSourceControlStatusEntry[] {
const paths = new Set<string>()
return entries
.filter((entry) => {
const eligible =
operation === 'unstage'
? entry.area === 'staged'
: entry.area !== 'staged' && entry.conflictStatus !== 'unresolved'
if (!eligible || paths.has(entry.relativePath)) {
return false
}
paths.add(entry.relativePath)
return true
})
.slice(0, MOBILE_WEB_SOURCE_CONTROL_MUTATION_LIMIT)
}
function mutationEntrySnapshot(
entry: MobileWebSourceControlStatusEntry
): MobileWebSourceControlMutationEntry {
return {
relativePath: entry.relativePath,
...(entry.oldRelativePath ? { oldRelativePath: entry.oldRelativePath } : {}),
status: entry.status,
area: entry.area,
...(entry.conflictStatus ? { conflictStatus: entry.conflictStatus } : {})
}
}
function mutationError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
export type MobileWebSourceControlMutations = ReturnType<typeof useMobileWebSourceControlMutations>
@@ -1,247 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT,
MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT,
type MobileWebSourceControlBranchCompareResult,
type MobileWebSourceControlBranchesResult,
type MobileWebSourceControlCommitCompareResult,
type MobileWebSourceControlHistoryItem,
type MobileWebSourceControlHistoryResult
} from '../../shared/mobile-web/source-control-history-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { useMobileWebSourceControlSync } from './use-mobile-web-source-control-sync'
export type MobileWebRepositorySelection =
| { kind: 'branch'; id: string; label: string }
| { kind: 'commit'; id: string; label: string }
export type MobileWebRepositoryComparison =
| MobileWebSourceControlBranchCompareResult
| MobileWebSourceControlCommitCompareResult
type RepositoryState = {
client: MobileWebBridgeClient
workspaceId: string
loading: boolean
branches: MobileWebSourceControlBranchesResult | null
history: MobileWebSourceControlHistoryResult | null
error: MobileWebBridgeClientError | null
selection: MobileWebRepositorySelection | null
comparison: MobileWebRepositoryComparison | null
comparisonLoading: boolean
comparisonError: MobileWebBridgeClientError | null
}
export function useMobileWebSourceControlRepository(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
onRepositoryChanged: () => void
}) {
const { client, workspaceId, connected, onRepositoryChanged } = args
const comparisonController = useRef<AbortController | null>(null)
const [refreshCount, setRefreshCount] = useState(0)
const [historyLimit, setHistoryLimit] = useState(MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT)
const [state, setState] = useState<RepositoryState>(() =>
emptyRepositoryState(client, workspaceId)
)
const retry = useCallback(() => setRefreshCount((value) => value + 1), [])
const sync = useMobileWebSourceControlSync({
client,
workspaceId,
connected,
onRepositoryChanged: useCallback(() => {
retry()
onRepositoryChanged()
}, [onRepositoryChanged, retry])
})
useEffect(() => {
if (!connected) {
comparisonController.current?.abort()
comparisonController.current = null
setState((current) =>
matchesRepository(current, client, workspaceId)
? { ...current, loading: false, comparisonLoading: false }
: current
)
return
}
const controller = new AbortController()
setState((current) => ({
...emptyRepositoryState(client, workspaceId),
loading: true,
branches: matchesRepository(current, client, workspaceId) ? current.branches : null,
history: matchesRepository(current, client, workspaceId) ? current.history : null
}))
void Promise.all([
client.sourceControlBranches({ workspaceId }, { signal: controller.signal }),
client.sourceControlHistory(
{ workspaceId, limit: historyLimit },
{ signal: controller.signal }
)
]).then(
([branches, history]) => {
if (!controller.signal.aborted) {
setState({
...emptyRepositoryState(client, workspaceId),
branches,
history
})
}
},
(error: unknown) => {
if (!controller.signal.aborted) {
setState((current) => ({
...emptyRepositoryState(client, workspaceId),
branches: matchesRepository(current, client, workspaceId) ? current.branches : null,
history: matchesRepository(current, client, workspaceId) ? current.history : null,
error: bridgeClientError(error)
}))
}
}
)
return () => controller.abort()
}, [client, connected, historyLimit, refreshCount, workspaceId])
useEffect(
() => () => {
comparisonController.current?.abort()
comparisonController.current = null
},
[client, workspaceId]
)
const requestComparison = useCallback(
(selection: MobileWebRepositorySelection) => {
if (!connected) {
return
}
comparisonController.current?.abort()
const controller = new AbortController()
comparisonController.current = controller
setState((current) => ({
...current,
selection,
comparison: null,
comparisonLoading: true,
comparisonError: null
}))
const request =
selection.kind === 'branch'
? client.sourceControlBranchCompare(
{ workspaceId, baseRef: selection.id, offset: 0, limit: 128 },
{ signal: controller.signal }
)
: client.sourceControlCommitCompare(
{ workspaceId, commitId: selection.id },
{ signal: controller.signal }
)
void request.then(
(comparison) => {
if (!controller.signal.aborted) {
setState((current) =>
acceptsComparison(current, client, workspaceId, selection)
? { ...current, comparison, comparisonLoading: false, comparisonError: null }
: current
)
}
},
(error: unknown) => {
if (!controller.signal.aborted) {
setState((current) =>
acceptsComparison(current, client, workspaceId, selection)
? {
...current,
comparison: null,
comparisonLoading: false,
comparisonError: bridgeClientError(error)
}
: current
)
}
}
)
},
[client, connected, workspaceId]
)
const matches = matchesRepository(state, client, workspaceId)
const visible = matches ? state : emptyRepositoryState(client, workspaceId)
return {
...visible,
sync,
loading: matches ? state.loading : connected,
retry,
compareBranch: useCallback(
(branch: string) => requestComparison({ kind: 'branch', id: branch, label: branch }),
[requestComparison]
),
compareCommit: useCallback(
(commit: MobileWebSourceControlHistoryItem) =>
requestComparison({ kind: 'commit', id: commit.id, label: commit.subject }),
[requestComparison]
),
retryComparison: useCallback(() => {
if (matches && state.selection) {
requestComparison(state.selection)
}
}, [matches, requestComparison, state.selection]),
loadMoreHistory: useCallback(
() =>
setHistoryLimit((limit) =>
Math.min(
MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT,
limit + MOBILE_WEB_SOURCE_CONTROL_HISTORY_DEFAULT_LIMIT
)
),
[]
),
canLoadMoreHistory:
visible.history?.hasMore === true &&
visible.history.limit < MOBILE_WEB_SOURCE_CONTROL_HISTORY_MAX_LIMIT
}
}
function emptyRepositoryState(client: MobileWebBridgeClient, workspaceId: string): RepositoryState {
return {
client,
workspaceId,
loading: false,
branches: null,
history: null,
error: null,
selection: null,
comparison: null,
comparisonLoading: false,
comparisonError: null
}
}
function matchesRepository(
state: RepositoryState,
client: MobileWebBridgeClient,
workspaceId: string
): boolean {
return state.client === client && state.workspaceId === workspaceId
}
function acceptsComparison(
state: RepositoryState,
client: MobileWebBridgeClient,
workspaceId: string,
selection: MobileWebRepositorySelection
): boolean {
return (
matchesRepository(state, client, workspaceId) &&
state.selection?.kind === selection.kind &&
state.selection.id === selection.id
)
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,168 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import {
MOBILE_WEB_SOURCE_CONTROL_STATUS_LIMIT,
type MobileWebSourceControlStatusResult
} from '../../shared/mobile-web/source-control-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
type StatusState = {
client: MobileWebBridgeClient
workspaceId: string
loading: boolean
result: MobileWebSourceControlStatusResult | null
error: MobileWebBridgeClientError | null
}
const MOBILE_WEB_SOURCE_CONTROL_POLL_MS = 10_000
export function useMobileWebSourceControlStatus(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
}) {
const { client, workspaceId, connected } = args
const [refreshCount, setRefreshCount] = useState(0)
const [liveRefresh, setLiveRefresh] = useState<
'offline' | 'connecting' | 'active' | 'unavailable'
>(connected ? 'connecting' : 'offline')
const [state, setState] = useState<StatusState>({
client,
workspaceId,
loading: false,
result: null,
error: null
})
useEffect(() => {
if (!connected) {
setState((current) => ({ ...current, loading: false }))
return
}
const controller = new AbortController()
setState((current) => ({
client,
workspaceId,
loading: true,
result: matchesStatus(current, client, workspaceId) ? current.result : null,
error: null
}))
void client
.sourceControlStatus(
{ workspaceId, limit: MOBILE_WEB_SOURCE_CONTROL_STATUS_LIMIT },
{ signal: controller.signal }
)
.then((result) => {
if (controller.signal.aborted) {
return
}
if (result.workspaceId !== workspaceId) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
setState({ client, workspaceId, loading: false, result, error: null })
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setState((current) => ({
client,
workspaceId,
loading: false,
result: matchesStatus(current, client, workspaceId) ? current.result : null,
error: bridgeClientError(error)
}))
}
})
return () => controller.abort()
}, [client, connected, refreshCount, workspaceId])
useEffect(() => {
if (!connected) {
setLiveRefresh('offline')
return
}
let active = true
let refreshQueued = false
setLiveRefresh('connecting')
const queueRefresh = (): void => {
if (refreshQueued) {
return
}
refreshQueued = true
queueMicrotask(() => {
refreshQueued = false
if (active) {
setRefreshCount((value) => value + 1)
}
})
}
const subscription = client.sourceControlSubscribe(
{ workspaceId },
(event) => {
if (!active) {
return
}
if (event.reason === 'unavailable') {
setLiveRefresh('unavailable')
return
}
queueRefresh()
},
() => {
if (active) {
setLiveRefresh('unavailable')
}
}
)
void subscription.ready
.then(() => {
if (active) {
setLiveRefresh('active')
}
})
.catch(() => undefined)
return () => {
active = false
subscription.unsubscribe()
}
}, [client, connected, workspaceId])
useEffect(() => {
if (!connected) {
return
}
const refreshWhenVisible = (): void => {
if (typeof document === 'undefined' || document.visibilityState !== 'hidden') {
setRefreshCount((value) => value + 1)
}
}
const timer = setInterval(refreshWhenVisible, MOBILE_WEB_SOURCE_CONTROL_POLL_MS)
document.addEventListener('visibilitychange', refreshWhenVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', refreshWhenVisible)
}
}, [connected])
const matches = matchesStatus(state, client, workspaceId)
return {
loading: matches ? state.loading : connected,
result: matches ? state.result : null,
error: matches ? state.error : null,
liveRefresh,
retry: useCallback(() => setRefreshCount((value) => value + 1), [])
}
}
function matchesStatus(
state: StatusState,
client: MobileWebBridgeClient,
workspaceId: string
): boolean {
return state.client === client && state.workspaceId === workspaceId
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
@@ -1,114 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebSourceControlSyncResult } from '../../shared/mobile-web/source-control-sync-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { useMobileWebSourceControlSync } from './use-mobile-web-source-control-sync'
afterEach(cleanup)
describe('useMobileWebSourceControlSync', () => {
it('cancels and ignores an action when the bridge client is replaced', async () => {
await verifyReplacementCancellation('client')
})
it('cancels and ignores an action when the workspace is replaced', async () => {
await verifyReplacementCancellation('workspace')
})
})
async function verifyReplacementCancellation(replacement: 'client' | 'workspace') {
const action = deferred<MobileWebSourceControlSyncResult>()
const first = syncClient(action.promise)
const second = replacement === 'client' ? syncClient() : first
const onRepositoryChanged = vi.fn()
const hook = renderHook(
({ client, workspaceId }) =>
useMobileWebSourceControlSync({
client,
workspaceId,
connected: true,
onRepositoryChanged
}),
{
initialProps: {
client: first as unknown as MobileWebBridgeClient,
workspaceId: 'workspace-1'
}
}
)
await waitFor(() => expect(hook.result.current.repository?.workspaceId).toBe('workspace-1'))
let request = Promise.resolve(false)
act(() => {
request = hook.result.current.fetch()
})
const signal = first.sourceControlFetch.mock.calls[0]?.[1]?.signal as AbortSignal
expect(signal.aborted).toBe(false)
hook.rerender({
client: second as unknown as MobileWebBridgeClient,
workspaceId: replacement === 'workspace' ? 'workspace-2' : 'workspace-1'
})
await waitFor(() => expect(signal.aborted).toBe(true))
action.resolve(actionResult('workspace-1'))
await expect(request).resolves.toBe(false)
expect(onRepositoryChanged).not.toHaveBeenCalled()
await waitFor(() =>
expect(hook.result.current.repository?.workspaceId).toBe(
replacement === 'workspace' ? 'workspace-2' : 'workspace-1'
)
)
}
function syncClient(fetchResult?: Promise<MobileWebSourceControlSyncResult>) {
return {
sourceControlUpstream: vi
.fn()
.mockImplementation(({ workspaceId }) => Promise.resolve(repositoryState(workspaceId))),
sourceControlFetch: vi
.fn()
.mockImplementation(
({ workspaceId }) => fetchResult ?? Promise.resolve(actionResult(workspaceId))
)
}
}
function repositoryState(workspaceId: string) {
return {
workspaceId,
head: 'a'.repeat(40),
branch: 'main',
conflictOperation: 'unknown' as const,
baseRef: 'origin/main',
upstream: {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0,
hasConfiguredPushTarget: false,
behindCommitsArePatchEquivalent: false
}
}
}
function actionResult(workspaceId: string): MobileWebSourceControlSyncResult {
return {
workspaceId,
operation: 'fetch',
previousHead: 'a'.repeat(40),
previousBranch: 'main',
repository: repositoryState(workspaceId),
completed: true
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((complete) => {
resolve = complete
})
return { promise, resolve }
}
@@ -1,311 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type {
MobileWebSourceControlRepositoryState,
MobileWebSourceControlSyncOperation
} from '../../shared/mobile-web/source-control-sync-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
export type MobileWebSourceControlPendingAction =
| { kind: 'checkout'; branch: string }
| { kind: 'pull'; strategy: 'fast-forward' | 'merge' }
| { kind: 'push'; mode: 'push' | 'publish' }
| { kind: 'rebase'; baseRef: string }
| { kind: 'abort'; conflictOperation: 'merge' | 'rebase' }
type SyncAction = MobileWebSourceControlPendingAction | { kind: 'fetch' }
type SyncState = {
client: MobileWebBridgeClient
workspaceId: string
loading: boolean
repository: MobileWebSourceControlRepositoryState | null
busy: MobileWebSourceControlSyncOperation | null
pending: MobileWebSourceControlPendingAction | null
error: MobileWebBridgeClientError | null
}
export function useMobileWebSourceControlSync(args: {
client: MobileWebBridgeClient
workspaceId: string
connected: boolean
onRepositoryChanged: () => void
}) {
const { client, workspaceId, connected, onRepositoryChanged } = args
const actionController = useRef<AbortController | null>(null)
const [refreshCount, setRefreshCount] = useState(0)
const [state, setState] = useState<SyncState>(() => emptyState(client, workspaceId))
useEffect(() => {
if (!connected) {
actionController.current?.abort()
actionController.current = null
setState((current) =>
matchesSync(current, client, workspaceId)
? { ...current, loading: false, busy: null, pending: null }
: current
)
return
}
const controller = new AbortController()
setState((current) => ({
...emptyState(client, workspaceId),
loading: true,
repository: matchesSync(current, client, workspaceId) ? current.repository : null,
pending: matchesSync(current, client, workspaceId) ? current.pending : null
}))
void client.sourceControlUpstream({ workspaceId }, { signal: controller.signal }).then(
(repository) => {
if (!controller.signal.aborted) {
setState((current) => ({
...emptyState(client, workspaceId),
repository,
pending: matchesSync(current, client, workspaceId) ? current.pending : null
}))
}
},
(error: unknown) => {
if (!controller.signal.aborted) {
setState((current) => ({
...emptyState(client, workspaceId),
repository: matchesSync(current, client, workspaceId) ? current.repository : null,
error: bridgeError(error)
}))
}
}
)
return () => controller.abort()
}, [client, connected, refreshCount, workspaceId])
useEffect(
() => () => {
actionController.current?.abort()
actionController.current = null
},
[client, workspaceId]
)
const matches = matchesSync(state, client, workspaceId)
const visible = matches ? state : emptyState(client, workspaceId)
const run = useCallback(
async (action: SyncAction): Promise<boolean> => {
const repository = matches ? state.repository : null
if (!connected || !repository || actionController.current) {
return false
}
const controller = new AbortController()
actionController.current = controller
const operation = operationForAction(action)
setState((current) =>
matchesSync(current, client, workspaceId)
? { ...current, busy: operation, pending: null, error: null }
: current
)
try {
const identity = {
workspaceId,
expectedHead: repository.head,
expectedBranch: repository.branch
}
const expectedUpstream = repository.upstream
const result =
action.kind === 'fetch'
? await client.sourceControlFetch(identity, { signal: controller.signal })
: action.kind === 'checkout'
? await client.sourceControlCheckout(
{
...identity,
branch: action.branch,
confirmation: 'checkout-confirmed'
},
{ signal: controller.signal }
)
: action.kind === 'pull'
? await client.sourceControlPull(
{
...identity,
expectedUpstream,
strategy: action.strategy,
confirmation: 'pull-confirmed'
},
{ signal: controller.signal }
)
: action.kind === 'push'
? await client.sourceControlPush(
{
...identity,
expectedUpstream,
mode: action.mode,
confirmation: 'push-confirmed'
},
{ signal: controller.signal }
)
: action.kind === 'rebase'
? await client.sourceControlRebase(
{
...identity,
expectedUpstream,
baseRef: action.baseRef,
confirmation: 'rebase-confirmed'
},
{ signal: controller.signal }
)
: await client.sourceControlAbort(
{
...identity,
conflictOperation: action.conflictOperation,
confirmation: 'abort-confirmed'
},
{ signal: controller.signal }
)
if (controller.signal.aborted) {
return false
}
setState((current) =>
matchesSync(current, client, workspaceId)
? {
...current,
repository: result.repository ?? current.repository,
busy: null,
pending: null,
error: null
}
: current
)
setRefreshCount((value) => value + 1)
onRepositoryChanged()
return true
} catch (error) {
if (controller.signal.aborted) {
return false
}
const nextError = bridgeError(error)
setState((current) =>
matchesSync(current, client, workspaceId)
? { ...current, busy: null, pending: null, error: nextError }
: current
)
if (nextError.code === 'conflict') {
setRefreshCount((value) => value + 1)
onRepositoryChanged()
}
return false
} finally {
if (actionController.current === controller) {
actionController.current = null
}
}
},
[client, connected, matches, onRepositoryChanged, state.repository, workspaceId]
)
return {
...visible,
loading: matches ? state.loading : connected,
retry: useCallback(() => setRefreshCount((value) => value + 1), []),
fetch: useCallback(() => run({ kind: 'fetch' }), [run]),
requestCheckout: useCallback(
(branch: string) =>
requestPending(setState, client, workspaceId, { kind: 'checkout', branch }),
[client, workspaceId]
),
requestPull: useCallback(() => {
const upstream = visible.repository?.upstream
if (upstream?.hasUpstream && upstream.behind > 0) {
requestPending(setState, client, workspaceId, {
kind: 'pull',
strategy: upstream.ahead === 0 ? 'fast-forward' : 'merge'
})
}
}, [client, visible.repository, workspaceId]),
requestPush: useCallback(() => {
const upstream = visible.repository?.upstream
if (!upstream) {
return
}
const mode = upstream.hasUpstream || upstream.hasConfiguredPushTarget ? 'push' : 'publish'
requestPending(setState, client, workspaceId, { kind: 'push', mode })
}, [client, visible.repository, workspaceId]),
requestRebase: useCallback(() => {
const baseRef = visible.repository?.baseRef
if (baseRef) {
requestPending(setState, client, workspaceId, { kind: 'rebase', baseRef })
}
}, [client, visible.repository, workspaceId]),
requestAbort: useCallback(() => {
const operation = visible.repository?.conflictOperation
if (operation === 'merge' || operation === 'rebase') {
requestPending(setState, client, workspaceId, {
kind: 'abort',
conflictOperation: operation
})
}
}, [client, visible.repository, workspaceId]),
confirmPending: useCallback(
() => (visible.pending ? run(visible.pending) : Promise.resolve(false)),
[run, visible.pending]
),
cancelPending: useCallback(
() =>
setState((current) =>
matchesSync(current, client, workspaceId) && !current.busy
? { ...current, pending: null }
: current
),
[client, workspaceId]
),
clearError: useCallback(
() =>
setState((current) =>
matchesSync(current, client, workspaceId) ? { ...current, error: null } : current
),
[client, workspaceId]
)
}
}
function requestPending(
setState: React.Dispatch<React.SetStateAction<SyncState>>,
client: MobileWebBridgeClient,
workspaceId: string,
pending: MobileWebSourceControlPendingAction
): void {
setState((current) =>
matchesSync(current, client, workspaceId) && !current.busy
? { ...current, pending, error: null }
: current
)
}
function operationForAction(action: SyncAction): MobileWebSourceControlSyncOperation {
return action.kind === 'checkout' ? 'branch' : action.kind
}
function emptyState(client: MobileWebBridgeClient, workspaceId: string): SyncState {
return {
client,
workspaceId,
loading: false,
repository: null,
busy: null,
pending: null,
error: null
}
}
function matchesSync(
state: SyncState,
client: MobileWebBridgeClient,
workspaceId: string
): boolean {
return state.client === client && state.workspaceId === workspaceId
}
function bridgeError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
export type MobileWebSourceControlSync = ReturnType<typeof useMobileWebSourceControlSync>
@@ -1,209 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { useMobileWebTerminalPathPreview } from './use-mobile-web-terminal-path-preview'
const WORKSPACE_ID = 'workspace-1'
const TAB_ID = 'tab-1'
const TOKEN = 'T'.repeat(43)
afterEach(cleanup)
describe('useMobileWebTerminalPathPreview', () => {
it('loads an external artifact through its opaque token and releases it on close', async () => {
const harness = clientHarness()
harness.fileResolveTerminalPath.mockResolvedValue(terminalArtifactTarget())
harness.fileReadTerminalArtifactChunk.mockResolvedValue({
workspaceId: WORKSPACE_ID,
tabId: TAB_ID,
token: TOKEN,
offset: 0,
bytes: new TextEncoder().encode('hello'),
bytesRead: 5,
eof: true
})
const hook = renderPreviewHook(harness.client)
act(() => {
hook.result.current.openPath({
pathText: '/private/results/report.txt',
line: 3,
column: 2
})
})
await waitFor(() => expect(hook.result.current.preview.status).toBe('ready'))
expect(harness.fileReadTerminalArtifactChunk).toHaveBeenCalledWith(
{
workspaceId: WORKSPACE_ID,
tabId: TAB_ID,
token: TOKEN,
offset: 0,
length: 128 * 1024
},
{ signal: expect.any(AbortSignal) }
)
expect(hook.result.current.preview).toMatchObject({
status: 'ready',
target: {
kind: 'terminal-artifact',
displayName: 'report.txt',
token: TOKEN
},
document: {
relativePath: 'report.txt',
content: 'hello',
eof: true,
revision: null
}
})
expect(JSON.stringify(hook.result.current.preview)).not.toContain('/private')
act(() => hook.result.current.closePreview())
await waitFor(() =>
expect(harness.fileReleaseTerminalArtifact).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
tabId: TAB_ID,
token: TOKEN
})
)
expect(hook.result.current.preview).toEqual({ status: 'idle' })
})
it('loads a worktree path through the ordinary bounded file reader without a token', async () => {
const harness = clientHarness()
harness.fileResolveTerminalPath.mockResolvedValue({
kind: 'worktree-file',
workspaceId: WORKSPACE_ID,
relativePath: 'src/app.ts',
displayName: 'app.ts',
previewKind: 'text',
line: null,
column: null
})
harness.fileReadChunk.mockResolvedValue({
workspaceId: WORKSPACE_ID,
relativePath: 'src/app.ts',
offset: 0,
bytes: new TextEncoder().encode('export {}'),
bytesRead: 9,
eof: true
})
const hook = renderPreviewHook(harness.client)
act(() => {
hook.result.current.openPath({
pathText: 'src/app.ts',
line: null,
column: null
})
})
await waitFor(() => expect(hook.result.current.preview.status).toBe('ready'))
expect(harness.fileReadChunk).toHaveBeenCalledWith(
{
workspaceId: WORKSPACE_ID,
relativePath: 'src/app.ts',
offset: 0,
length: 128 * 1024
},
{ signal: expect.any(AbortSignal) }
)
expect(harness.fileReadTerminalArtifactChunk).not.toHaveBeenCalled()
expect(harness.fileReleaseTerminalArtifact).not.toHaveBeenCalled()
})
it('aborts and releases an in-flight artifact when its tab scope changes', async () => {
const harness = clientHarness()
harness.fileResolveTerminalPath.mockResolvedValue(terminalArtifactTarget())
let resolveRead: ((value: ReturnType<typeof artifactChunk>) => void) | undefined
harness.fileReadTerminalArtifactChunk.mockReturnValue(
new Promise((resolve) => {
resolveRead = resolve
})
)
const hook = renderHook(
({ tabId }) =>
useMobileWebTerminalPathPreview({
client: harness.client,
workspaceId: WORKSPACE_ID,
tabId,
connected: true
}),
{ initialProps: { tabId: TAB_ID } }
)
act(() => {
hook.result.current.openPath({
pathText: '/private/results/report.txt',
line: null,
column: null
})
})
await waitFor(() => expect(hook.result.current.preview.status).toBe('loading'))
const signal = harness.fileReadTerminalArtifactChunk.mock.calls[0]?.[1]?.signal
hook.rerender({ tabId: 'tab-2' })
expect(signal?.aborted).toBe(true)
await waitFor(() => expect(harness.fileReleaseTerminalArtifact).toHaveBeenCalledOnce())
act(() => resolveRead?.(artifactChunk()))
await waitFor(() => expect(hook.result.current.preview).toEqual({ status: 'idle' }))
})
})
function renderPreviewHook(client: MobileWebBridgeClient) {
return renderHook(() =>
useMobileWebTerminalPathPreview({
client,
workspaceId: WORKSPACE_ID,
tabId: TAB_ID,
connected: true
})
)
}
function terminalArtifactTarget() {
return {
kind: 'terminal-artifact' as const,
workspaceId: WORKSPACE_ID,
token: TOKEN,
displayName: 'report.txt',
previewKind: 'text' as const,
line: 3,
column: 2
}
}
function artifactChunk() {
return {
workspaceId: WORKSPACE_ID,
tabId: TAB_ID,
token: TOKEN,
offset: 0,
bytes: new TextEncoder().encode('hello'),
bytesRead: 5,
eof: true
}
}
function clientHarness() {
const fileResolveTerminalPath = vi.fn()
const fileReadTerminalArtifactChunk = vi.fn()
const fileReleaseTerminalArtifact = vi.fn().mockResolvedValue(null)
const fileReadChunk = vi.fn()
const client = {
fileResolveTerminalPath,
fileReadTerminalArtifactChunk,
fileReleaseTerminalArtifact,
fileReadChunk
} as unknown as MobileWebBridgeClient
return {
client,
fileResolveTerminalPath,
fileReadTerminalArtifactChunk,
fileReleaseTerminalArtifact,
fileReadChunk
}
}
@@ -1,241 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
MOBILE_WEB_TERMINAL_ARTIFACT_RASTER_MAX_BYTES,
MOBILE_WEB_TERMINAL_ARTIFACT_TEXT_MAX_BYTES,
type MobileWebTerminalPathResolveResult
} from '../../shared/mobile-web/terminal-artifact-contract'
import type { TerminalFileLinkTarget } from '../../shared/terminal-file-link-matcher'
import type { MobileWebFileChunkResult } from '../../shared/mobile-web/bridge-operation-contract'
import type { MobileWebBridgeClient } from './mobile-web-bridge-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import {
appendMobileWebFileChunk,
mobileWebFileNextChunkLength,
type MobileWebFileDocument
} from './mobile-web-file-document'
export type MobileWebTerminalPathPreviewState =
| { status: 'idle' }
| { status: 'resolving' }
| {
status: 'loading'
target: MobileWebTerminalPathResolveResult
document: MobileWebFileDocument | null
}
| { status: 'ready'; target: MobileWebTerminalPathResolveResult; document: MobileWebFileDocument }
| {
status: 'error'
target: MobileWebTerminalPathResolveResult | null
error: MobileWebBridgeClientError
}
export function useMobileWebTerminalPathPreview(args: {
client: MobileWebBridgeClient
workspaceId: string
tabId: string
connected: boolean
}): {
preview: MobileWebTerminalPathPreviewState
openPath: (target: TerminalFileLinkTarget) => void
closePreview: () => void
} {
const { client, workspaceId, tabId, connected } = args
const [preview, setPreview] = useState<MobileWebTerminalPathPreviewState>({ status: 'idle' })
const scope = useRef(0)
const controller = useRef<AbortController | null>(null)
const activeTarget = useRef<MobileWebTerminalPathResolveResult | null>(null)
const retire = useCallback(
(updateState: boolean) => {
scope.current += 1
controller.current?.abort()
controller.current = null
const target = activeTarget.current
activeTarget.current = null
if (target?.kind === 'terminal-artifact') {
void releaseTerminalArtifact(client, target, tabId)
}
if (updateState) {
setPreview({ status: 'idle' })
}
},
[client, tabId]
)
useEffect(() => {
retire(true)
return () => retire(false)
}, [client, retire, workspaceId])
useEffect(() => {
if (!connected) {
retire(true)
}
}, [connected, retire])
const openPath = useCallback(
(pathTarget: TerminalFileLinkTarget) => {
if (!connected) {
return
}
retire(false)
const requestScope = ++scope.current
const requestController = new AbortController()
controller.current = requestController
setPreview({ status: 'resolving' })
void client
.fileResolveTerminalPath(
{
workspaceId,
tabId,
pathText: pathTarget.pathText,
line: pathTarget.line,
column: pathTarget.column
},
{ signal: requestController.signal }
)
.then(async (target) => {
if (scope.current !== requestScope) {
await releaseTerminalArtifact(client, target, tabId)
return
}
activeTarget.current = target
setPreview({ status: 'loading', target, document: null })
const document = await readTerminalPathDocument({
client,
target,
tabId,
signal: requestController.signal,
onProgress: (nextDocument) => {
if (scope.current === requestScope) {
setPreview({ status: 'loading', target, document: nextDocument })
}
}
})
if (scope.current === requestScope) {
setPreview({ status: 'ready', target, document })
}
})
.catch((error: unknown) => {
if (scope.current !== requestScope) {
return
}
const target = activeTarget.current
activeTarget.current = null
if (target?.kind === 'terminal-artifact') {
void releaseTerminalArtifact(client, target, tabId)
}
setPreview({
status: 'error',
target,
error: bridgeClientError(error)
})
})
.finally(() => {
if (scope.current === requestScope) {
controller.current = null
}
})
},
[client, connected, retire, tabId, workspaceId]
)
return { preview, openPath, closePreview: () => retire(true) }
}
async function readTerminalPathDocument(args: {
client: MobileWebBridgeClient
target: MobileWebTerminalPathResolveResult
tabId: string
signal: AbortSignal
onProgress: (document: MobileWebFileDocument) => void
}): Promise<MobileWebFileDocument> {
const maximumBytes =
args.target.previewKind === 'raster'
? MOBILE_WEB_TERMINAL_ARTIFACT_RASTER_MAX_BYTES
: MOBILE_WEB_TERMINAL_ARTIFACT_TEXT_MAX_BYTES
const relativePath =
args.target.kind === 'worktree-file' ? args.target.relativePath : args.target.displayName
let document: MobileWebFileDocument | null = null
while (!document?.eof && !document?.limitReached) {
const offset = document?.bytes.byteLength ?? 0
const length = mobileWebFileNextChunkLength(document, maximumBytes)
if (length === 0) {
break
}
const chunk =
args.target.kind === 'worktree-file'
? await args.client.fileReadChunk(
{
workspaceId: args.target.workspaceId,
relativePath,
offset,
length
},
{ signal: args.signal }
)
: terminalArtifactFileChunk(
relativePath,
await args.client.fileReadTerminalArtifactChunk(
{
workspaceId: args.target.workspaceId,
tabId: args.tabId,
token: args.target.token,
offset,
length
},
{ signal: args.signal }
)
)
const assembled = appendMobileWebFileChunk(document, chunk, maximumBytes)
document =
args.target.previewKind === 'raster'
? { ...assembled, content: '', kind: 'binary', revision: null }
: { ...assembled, revision: null }
args.onProgress(document)
if (args.target.previewKind === 'text' && document.kind === 'binary') {
break
}
}
if (!document) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return document
}
function terminalArtifactFileChunk(
relativePath: string,
chunk: Awaited<ReturnType<MobileWebBridgeClient['fileReadTerminalArtifactChunk']>>
): MobileWebFileChunkResult {
return {
workspaceId: chunk.workspaceId,
relativePath,
offset: chunk.offset,
bytes: chunk.bytes,
bytesRead: chunk.bytesRead,
eof: chunk.eof
}
}
async function releaseTerminalArtifact(
client: MobileWebBridgeClient,
target: MobileWebTerminalPathResolveResult,
tabId: string
): Promise<void> {
if (target.kind !== 'terminal-artifact') {
return
}
await client
.fileReleaseTerminalArtifact({
workspaceId: target.workspaceId,
tabId,
token: target.token
})
.catch(() => undefined)
}
function bridgeClientError(error: unknown): MobileWebBridgeClientError {
return error instanceof MobileWebBridgeClientError
? error
: new MobileWebBridgeClientError('internal', false)
}
-40
View File
@@ -1,40 +0,0 @@
import { resolve } from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import { createMobileWebContentAddressedPlugin } from './config/build-plugins/mobile-web-content-addressed'
import { createMobileWebImportBoundaryPlugin } from './config/build-plugins/mobile-web-import-boundary'
import { createMobileWebStyleBoundaryPlugin } from './config/build-plugins/mobile-web-style-boundary'
export default defineConfig({
root: resolve('src/mobile-web'),
base: './',
plugins: [
createMobileWebImportBoundaryPlugin(),
createMobileWebStyleBoundaryPlugin(),
react(),
tailwindcss(),
createMobileWebContentAddressedPlugin()
],
resolve: {
alias: {
'@mobile-web': resolve('src/mobile-web/src'),
'@renderer': resolve('src/renderer/src'),
'@': resolve('src/renderer/src')
}
},
build: {
outDir: resolve('out/mobile-web'),
emptyOutDir: true,
sourcemap: false,
assetsInlineLimit: 0,
rollupOptions: {
input: resolve('src/mobile-web/index.html'),
output: {
inlineDynamicImports: true,
entryFileNames: 'assets/application.js',
assetFileNames: 'assets/[name][extname]'
}
}
}
})