mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
refactor(mobile): remove duplicate web presentation
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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)',
|
||||
'',
|
||||
'<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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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=='
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user