fix(lint): enable anti-slop/no-pass-through-type-alias

`anti-slop/no-pass-through-type-alias` rejects a type alias whose entire
right-hand side is a bare reference to another named type, including the
generic form where every type parameter is forwarded positionally and
unchanged (`type A<T> = B<T>`). Those aliases add a second name for one
type: readers have to resolve the indirection, "go to definition" lands on
a rename rather than the shape, and the two names drift apart in review.

Flipped the rule from "off" to "error" in `config/oxlint-anti-slop.json`
and fixed all 195 violations reported across `src`, `config`, `tests`, and
`mobile`.

Fix approach, in order of preference per site:

- Delete the alias and use the target type directly at every reference,
  updating imports. This covers the large majority of the 195.
- Where the alias name was the better or more widely used name, rename the
  target declaration to the alias name instead of renaming call sites (for
  example `GitUncommittedEntry` -> `GitStatusEntry` in
  `src/shared/git-status-types.ts`).
- Where a pass-through sat in front of a type that was itself only used
  through that alias, collapse the pair into a single declaration that
  keeps the real shape (intersection, `Pick`/`Omit`, or union) under one
  name.

No alias was converted into an equivalent `interface X extends Y` to dodge
the rule, and no new pass-through was introduced.

No suppressions were added. The vendored anti-slop plugin source under
`config/oxlint-plugins/anti-slop/` is excluded from the audit by the
`--ignore-pattern` flag in `audit:anti-slop`, and stays byte-identical to
upstream.

Verified:

- `npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern
  'config/oxlint-plugins/anti-slop/**' src config tests mobile` exits 0 (195 -> 0; baseline counted on a scratch worktree of
  `nwparker/2x-lint` with the rule flipped on).
- `node config/scripts/run-typecheck-projects-in-parallel.mjs` exits 0.
- `cd mobile && pnpm typecheck` exits 0 (the parallel script covers only the
  three desktop tsconfigs).
- Root vitest over the changed files and their sibling tests: 210 files,
  4072 passed, 1 skipped.
- Mobile vitest over the changed files and their sibling tests: 36 files,
  445 passed.
- `npx oxlint` with the repo's default config over every changed file
  (root and mobile) exits 0.
- `npx oxfmt --write` run over the changed files in both workspaces.
This commit is contained in:
Neil
2026-09-14 17:14:11 -07:00
parent 8463348caf
commit 755c1562c3
607 changed files with 2511 additions and 2901 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
"anti-slop/no-known-value-widening": "off",
"anti-slop/no-module-mocking": "off",
"anti-slop/no-object-parameters": "off",
"anti-slop/no-pass-through-type-alias": "off",
"anti-slop/no-pass-through-type-alias": "error",
"anti-slop/no-reduce-accumulator-copy": "off",
"anti-slop/no-reflect-apply": "off",
"anti-slop/no-reflect-get": "off",
+3 -3
View File
@@ -1,6 +1,6 @@
import type { MobileGitStatusEntry } from '../src/source-control/mobile-git-status'
import type { GitStatusEntry } from '../../src/shared/git-status-types'
type FakeGitEntry = MobileGitStatusEntry & {
type FakeGitEntry = GitStatusEntry & {
stagedFromUntracked?: boolean
}
@@ -30,7 +30,7 @@ let fakeAhead = 1
let fakeBehind = 0
let fakeHasUpstream = true
function toGitStatusEntry(entry: FakeGitEntry): MobileGitStatusEntry {
function toGitStatusEntry(entry: FakeGitEntry): GitStatusEntry {
const { stagedFromUntracked: _stagedFromUntracked, ...statusEntry } = entry
return statusEntry
}
@@ -1,3 +1,4 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
import { Check, Download } from 'lucide-react-native'
@@ -11,8 +12,7 @@ import {
fetchDictationSetup,
isModelInFlight,
setDictationConfig,
type MobileSpeechModel,
type MobileSpeechSetup
type MobileSpeechModel
} from '../dictation/mobile-dictation-setup'
const POLL_INTERVAL_MS = 1500
@@ -35,7 +35,7 @@ function formatSize(bytes: number | null): string {
// Lets the user enable dictation and download a speech model on the paired
// desktop, from the phone. Polls while a download is in flight.
export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: Props) {
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [setup, setSetup] = useState<RuntimeSpeechSetupState | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
+3 -3
View File
@@ -1,3 +1,4 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native'
import { RotateCw } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
@@ -18,7 +19,6 @@ import { usePRBotAuthorOverrides } from '../session/use-pr-bot-author-overrides'
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import { PRSidebarHeader } from './pr-sidebar/PRSidebarHeader'
import { PRConflictingFilesSection } from './pr-sidebar/PRConflictingFilesSection'
import { PRActionsSection } from './pr-sidebar/PRActionsSection'
@@ -37,7 +37,7 @@ type Props = {
connState: ConnectionState
worktreeId: string
gitBranch: string | null
gitStatus: MobileGitStatusResult | null
gitStatus: GitStatusResult | null
headSha: string | null
bottomInset?: number
// Hub chrome already shows open-on-web; hide the in-body icon there.
@@ -154,7 +154,7 @@ function PrSidebarContent({
connState: ConnectionState
worktreeId: string
gitBranch: string | null
gitStatus: MobileGitStatusResult | null
gitStatus: GitStatusResult | null
actions: MobilePrActions
commentActions: MobilePrCommentActions
titleAction: MobilePrTitleAction
@@ -1,5 +1,5 @@
import { Pressable, Switch, Text, View } from 'react-native'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { SetupDecision } from '../../../src/shared/worktree/create-types'
import { colors } from '../theme/mobile-theme'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
import type { SetupRunPolicy } from './new-worktree-modal-types'
@@ -16,9 +16,9 @@ export function NewWorkspaceSetupScriptField({
command: string
source: string | null
runPolicy: SetupRunPolicy
decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
decision: Exclude<SetupDecision, 'inherit'> | null
runSetup: boolean
onDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
onDecisionChange: (decision: Exclude<SetupDecision, 'inherit'>) => void
onRunSetupChange: (run: boolean) => void
}) {
return (
@@ -1,6 +1,6 @@
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { SetupDecision } from '../../../src/shared/worktree/create-types'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors } from '../theme/mobile-theme'
@@ -37,7 +37,7 @@ export function NewWorktreeFormSheet(props: {
setupCommand: string | null
setupSource: string | null
setupRunPolicy: SetupRunPolicy
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
setupDecisionChoice: Exclude<SetupDecision, 'inherit'> | null
runSetup: boolean
error: string
creating: boolean
@@ -52,7 +52,7 @@ export function NewWorktreeFormSheet(props: {
onOpenAgent: () => void
onShowAdvancedChange: (show: boolean) => void
onNoteChange: (note: string) => void
onSetupDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
onSetupDecisionChange: (decision: Exclude<SetupDecision, 'inherit'>) => void
onRunSetupChange: (run: boolean) => void
onCreate: () => void
}) {
+3 -6
View File
@@ -1,14 +1,11 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
import { Check, Download, Trash2 } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
isModelInFlight,
type MobileSpeechModel,
type MobileSpeechSetup
} from '../dictation/mobile-dictation-setup'
import { isModelInFlight, type MobileSpeechModel } from '../dictation/mobile-dictation-setup'
type Props = {
setup: MobileSpeechSetup
setup: RuntimeSpeechSetupState
// Disabled mirrors desktop: the model list greys out when dictation is off.
disabled: boolean
busyAction: { modelId: string; type: 'download' | 'select' | 'delete' } | null
@@ -1,9 +1,9 @@
import type { GitStatusResult } from '../../../../src/shared/git-status-types'
import { StyleSheet, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { colors } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
@@ -13,7 +13,7 @@ type Props = {
worktreeId: string
branch: string | null
headSha: string | null
gitStatus: MobileGitStatusResult | null
gitStatus: GitStatusResult | null
isGithubRepo?: boolean
branchContextLoaded?: boolean
controller: MobilePrSidebarController
@@ -1,10 +1,10 @@
import type { GitStatusResult } from '../../../../src/shared/git-status-types'
import { useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { GitPullRequestArrow, Link2, RefreshCw } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import type { ConnectionState } from '../../transport/types'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import {
getMobileCommitFailureStagedEntries,
type MobileCommitFailureRecovery
@@ -26,7 +26,7 @@ type Props = {
client: RpcClient | null
worktreeId: string
gitBranch: string | null
gitStatus: MobileGitStatusResult | null
gitStatus: GitStatusResult | null
connState: ConnectionState
// Refetches the sidebar after create or an explicit empty-state refresh.
onCreated: () => void
@@ -13,7 +13,7 @@ import {
} from '../tasks/setup-hook-trust'
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { SetupDecision } from '../../../src/shared/worktree/create-types'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
@@ -28,7 +28,7 @@ import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation
import { getSuggestedCreatureName } from './worktree-name-suggestion'
type CreateOptions = {
setupOverride?: Exclude<WorkspaceCreateSetupDecision, 'inherit'>
setupOverride?: Exclude<SetupDecision, 'inherit'>
approvedSetupContentHash?: string
}
@@ -51,7 +51,7 @@ export function useNewWorkspaceCreateSubmit(args: {
setupCommand: string | null
setupTrust: SetupHookTrust | null
setupRunPolicy: SetupRunPolicy
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
setupDecisionChoice: Exclude<SetupDecision, 'inherit'> | null
runSetup: boolean
trustedOrcaHooks: PersistedTrustedOrcaHooks
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
@@ -118,7 +118,7 @@ export function useNewWorkspaceCreateSubmit(args: {
undefined,
args.retiredWorktreeNames
)
let setupDecision: WorkspaceCreateSetupDecision = 'inherit'
let setupDecision: SetupDecision = 'inherit'
if (args.setupCommand) {
if (options.setupOverride) {
setupDecision = options.setupOverride
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { normalizeSetupHookTrust } from '../tasks/setup-hook-trust'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { SetupDecision } from '../../../src/shared/worktree/create-types'
import type {
MobileWorkspaceRepo,
RepoHooksResponse,
@@ -17,8 +17,8 @@ export function useNewWorkspaceSetupScript(args: {
setupSource: string | null
setupTrust: SetupHookDetails['trust']
setupRunPolicy: SetupHookDetails['runPolicy']
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
setSetupDecisionChoice: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
setupDecisionChoice: Exclude<SetupDecision, 'inherit'> | null
setSetupDecisionChoice: (decision: Exclude<SetupDecision, 'inherit'>) => void
runSetup: boolean
setRunSetup: (run: boolean) => void
showAdvanced: boolean
@@ -27,7 +27,7 @@ export function useNewWorkspaceSetupScript(args: {
const { client, selectedRepo } = args
const [details, setDetails] = useState<SetupHookDetails | null>(null)
const [setupDecisionChoice, setSetupDecisionChoice] = useState<Exclude<
WorkspaceCreateSetupDecision,
SetupDecision,
'inherit'
> | null>(null)
const [runSetup, setRunSetup] = useState(true)
@@ -1,3 +1,4 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
@@ -10,8 +11,7 @@ import {
isDictationSetupRequiredError,
isModelInFlight,
setDictationConfig,
type MobileSpeechModel,
type MobileSpeechSetup
type MobileSpeechModel
} from './mobile-dictation-setup'
function ok(result: unknown): RpcSuccess {
@@ -62,14 +62,14 @@ describe('isDictationSetupRequiredError', () => {
describe('rpc wrappers', () => {
it('fetches setup', async () => {
const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
const setup: RuntimeSpeechSetupState = { enabled: false, selectedModelId: '', models: [] }
const client = clientWith([ok(setup)])
await expect(fetchDictationSetup(client)).resolves.toEqual(setup)
expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null })
})
it('retries the idempotent setup read once after logical-client cutover', async () => {
const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
const setup: RuntimeSpeechSetupState = { enabled: false, selectedModelId: '', models: [] }
const sendRequest = vi
.fn()
.mockRejectedValueOnce(new LogicalClientCutoverError())
@@ -95,14 +95,14 @@ describe('rpc wrappers', () => {
})
it('deletes a model and returns refreshed setup', async () => {
const setup: MobileSpeechSetup = { enabled: true, selectedModelId: '', models: [] }
const setup: RuntimeSpeechSetupState = { enabled: true, selectedModelId: '', models: [] }
const client = clientWith([ok(setup)])
await expect(deleteDictationModel(client, 'm1')).resolves.toEqual(setup)
expect(client.calls[0]).toEqual({ method: 'speech.models.delete', params: { modelId: 'm1' } })
})
it('sets config', async () => {
const setup: MobileSpeechSetup = { enabled: true, selectedModelId: 'm1', models: [] }
const setup: RuntimeSpeechSetupState = { enabled: true, selectedModelId: 'm1', models: [] }
const client = clientWith([ok(setup)])
await expect(setDictationConfig(client, { enabled: true, modelId: 'm1' })).resolves.toEqual(
setup
@@ -3,7 +3,6 @@ import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
export type MobileSpeechSetup = RuntimeSpeechSetupState
export type MobileSpeechModel = RuntimeSpeechSetupState['models'][number]
// Dictation-setup errors startMobileDictation throws when the desktop isn't
@@ -31,7 +30,7 @@ export function isDictationSetupRequiredError(message: string): boolean {
export async function fetchDictationSetup(
client: Pick<RpcClient, 'sendRequest'>
): Promise<MobileSpeechSetup> {
): Promise<RuntimeSpeechSetupState> {
const response = await fetchDictationSetupResponse(client)
if (!response.ok) {
if (isLegacyDesktopSpeechSetupError(response.error)) {
@@ -39,7 +38,7 @@ export async function fetchDictationSetup(
}
throw new Error(response.error?.message || 'Failed to load dictation models')
}
return (response as RpcSuccess).result as MobileSpeechSetup
return (response as RpcSuccess).result as RuntimeSpeechSetupState
}
async function fetchDictationSetupResponse(client: Pick<RpcClient, 'sendRequest'>) {
@@ -68,23 +67,23 @@ export async function downloadDictationModel(
export async function deleteDictationModel(
client: Pick<RpcClient, 'sendRequest'>,
modelId: string
): Promise<MobileSpeechSetup> {
): Promise<RuntimeSpeechSetupState> {
const response = await client.sendRequest('speech.models.delete', { modelId })
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to delete model')
}
return (response as RpcSuccess).result as MobileSpeechSetup
return (response as RpcSuccess).result as RuntimeSpeechSetupState
}
export async function setDictationConfig(
client: Pick<RpcClient, 'sendRequest'>,
params: { enabled?: boolean; modelId?: string; dictationMode?: 'toggle' | 'hold' }
): Promise<MobileSpeechSetup> {
): Promise<RuntimeSpeechSetupState> {
const response = await client.sendRequest('speech.dictation.setup', params)
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to update dictation settings')
}
return (response as RpcSuccess).result as MobileSpeechSetup
return (response as RpcSuccess).result as RuntimeSpeechSetupState
}
// A model is mid-download (or extracting) and the sheet should keep polling.
@@ -93,7 +92,7 @@ export function isModelInFlight(model: MobileSpeechModel): boolean {
}
// Whether dictation can be used right now: enabled + a selected model that's ready.
export function isDictationReady(setup: MobileSpeechSetup): boolean {
export function isDictationReady(setup: RuntimeSpeechSetupState): boolean {
if (!setup.enabled || !setup.selectedModelId) {
return false
}
@@ -46,7 +46,6 @@ export type MobileFilePreviewRequest = {
}
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
type TerminalArtifactSource = MobileTerminalArtifactPreviewSource
type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & {
baseContent?: string
}
@@ -112,7 +111,7 @@ export async function loadMobileFilePreview(
export async function saveMobileTerminalArtifactPreview(
client: MobileFilePreviewClient,
source: TerminalArtifactSource,
source: MobileTerminalArtifactPreviewSource,
content: string,
options: TerminalArtifactSaveOptions = {}
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
@@ -175,11 +174,11 @@ export async function saveMobileTerminalArtifactPreview(
async function verifyTerminalArtifactBaseContent(
client: MobileFilePreviewClient,
source: TerminalArtifactSource,
source: MobileTerminalArtifactPreviewSource,
baseContent: string,
options: TerminalArtifactRetryOptions
): Promise<
| { status: 'ok'; source: TerminalArtifactSource; refreshed: boolean }
| { status: 'ok'; source: MobileTerminalArtifactPreviewSource; refreshed: boolean }
| { status: 'error'; error: MobileFilePreviewResult }
> {
let readSource = source
@@ -233,7 +232,7 @@ async function verifyTerminalArtifactBaseContent(
function writeTerminalArtifactPreview(
client: MobileFilePreviewClient,
source: TerminalArtifactSource,
source: MobileTerminalArtifactPreviewSource,
content: string
): Promise<RpcResponse> {
return client.sendRequest('files.writeTerminalArtifact', {
+1 -3
View File
@@ -4,9 +4,7 @@ import {
type ResponsiveLayoutMetrics
} from './responsive-layout-metrics'
export type ResponsiveLayout = ResponsiveLayoutMetrics
export function useResponsiveLayout(): ResponsiveLayout {
export function useResponsiveLayout(): ResponsiveLayoutMetrics {
const { width, height } = useWindowDimensions()
return getResponsiveLayoutMetrics(width, height)
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { useCallback } from 'react'
import { StyleSheet, View } from 'react-native'
import { TerminalWebView } from '../terminal/TerminalWebView'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalWebViewHandle
@@ -12,7 +12,7 @@ type TerminalPaneViewProps = {
handle: string
active: boolean
keyboardLift: number
terminalTheme?: MobileTerminalTheme
terminalTheme?: RuntimeMobileTerminalTheme
textScale: number
onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
onWebReady: (handle: string) => void
@@ -13,10 +13,8 @@ import {
readMobileReviewGitDiffResult,
readMobileReviewWorktreeMetadata
} from './mobile-diff-review-rpc'
import {
canOpenMobileBranchCompareDiff,
type MobileGitBranchCompareResult
} from '../source-control/mobile-branch-compare'
import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import { resolveMobileBranchCompareBaseRef } from '../source-control/mobile-branch-base-ref'
import { isMobileGitUnavailable } from '../source-control/mobile-git-status'
import type { RpcClient } from '../transport/rpc-client'
@@ -25,7 +23,7 @@ import type { ReviewDiffState, ReviewScreenState } from './mobile-diff-review-sc
import { reviewDescriptorFromItem } from './mobile-diff-review-screen-model'
type BranchCompareLoadResult = {
result: MobileGitBranchCompareResult | null
result: GitBranchCompareResult | null
error?: string
}
@@ -33,7 +31,7 @@ type DiffLoadInput = {
client: RpcClient
worktreeId: string
item: MobileDiffReviewQueueItem
branchCompare: MobileGitBranchCompareResult | null
branchCompare: GitBranchCompareResult | null
}
export async function loadMobileDiffReviewBranchCompare(
@@ -163,7 +161,7 @@ async function loadBranchFileDiff(
client: RpcClient,
worktreeId: string,
item: MobileDiffReviewQueueItem,
branchCompare: MobileGitBranchCompareResult | null
branchCompare: GitBranchCompareResult | null
) {
const summary = branchCompare?.summary
if (!summary || !summary.headOid || !summary.mergeBase) {
@@ -1,11 +1,11 @@
import type { GitStagingArea } from '../../../src/shared/git-status-types'
import type { DiffReviewScope } from '../../../src/shared/diff-comment-types'
import type { MobileGitStagingArea } from '../source-control/mobile-git-status'
import {
createMobileDiffReviewFileKey,
type MobileDiffReviewQueueItem
} from './mobile-diff-review-queue'
export type MobileDiffReviewTargetArea = MobileGitStagingArea | 'branch'
export type MobileDiffReviewTargetArea = GitStagingArea | 'branch'
export type MobileDiffReviewInitialTarget = {
filePath: string
@@ -1,7 +1,7 @@
import type { GitStatusEntry } from '../../../src/shared/git-status-types'
import { describe, expect, it } from 'vitest'
import type { DiffComment, MobileDiffReviewState } from '../../../src/shared/diff-comment-types'
import type { MobileGitBranchChangeEntry } from '../source-control/mobile-branch-compare'
import type { MobileGitStatusEntry } from '../source-control/mobile-git-status'
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
import {
buildMobileDiffReviewQueue,
createMobileDiffReviewFileKey,
@@ -12,7 +12,7 @@ import {
const emptyReviewState: MobileDiffReviewState = { version: 1, files: {} }
function statusEntry(overrides: Partial<MobileGitStatusEntry>): MobileGitStatusEntry {
function statusEntry(overrides: Partial<GitStatusEntry>): GitStatusEntry {
return {
path: 'src/app.ts',
status: 'modified',
@@ -21,7 +21,7 @@ function statusEntry(overrides: Partial<MobileGitStatusEntry>): MobileGitStatusE
}
}
function branchEntry(overrides: Partial<MobileGitBranchChangeEntry>): MobileGitBranchChangeEntry {
function branchEntry(overrides: Partial<GitBranchChangeEntry>): GitBranchChangeEntry {
return {
path: 'src/branch.ts',
status: 'modified',
+17 -15
View File
@@ -1,15 +1,17 @@
import type {
GitFileStatus,
GitStagingArea,
GitStatusEntry
} from '../../../src/shared/git-status-types'
import type {
DiffComment,
DiffReviewScope,
MobileDiffReviewState
} from '../../../src/shared/diff-comment-types'
import type { MobileGitBranchChangeEntry } from '../source-control/mobile-branch-compare'
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
import {
isMobileGitDiscardableEntry,
isMobileGitStageableEntry,
type MobileGitFileStatus,
type MobileGitStagingArea,
type MobileGitStatusEntry
isMobileGitStageableEntry
} from '../source-control/mobile-git-status'
import {
buildMobileDiffIdentity,
@@ -28,10 +30,10 @@ export type MobileDiffReviewQueueFilter =
export type MobileDiffReviewQueueItem = {
key: string
scope: DiffReviewScope
area: MobileGitStagingArea | 'branch'
area: GitStagingArea | 'branch'
filePath: string
oldPath?: string
status: MobileGitFileStatus
status: GitFileStatus
title: string
subtitle: string
added?: number
@@ -51,8 +53,8 @@ export type MobileDiffReviewQueueItem = {
export type BuildMobileDiffReviewQueueInput = {
worktreeId: string
statusEntries: readonly MobileGitStatusEntry[]
branchEntries: readonly MobileGitBranchChangeEntry[]
statusEntries: readonly GitStatusEntry[]
branchEntries: readonly GitBranchChangeEntry[]
branchHeadOid?: string | null
branchMergeBase?: string | null
comments: readonly DiffComment[]
@@ -65,20 +67,20 @@ const SCOPE_SORT_ORDER: Record<DiffReviewScope, number> = {
branch: 2
}
function scopeForStatusArea(area: MobileGitStagingArea): DiffReviewScope {
function scopeForStatusArea(area: GitStagingArea): DiffReviewScope {
return area === 'staged' ? 'staged' : 'unstaged'
}
export function createMobileDiffReviewFileKey(
scope: DiffReviewScope,
area: MobileGitStagingArea | 'branch',
area: GitStagingArea | 'branch',
filePath: string,
oldPath?: string
): string {
return [scope, area, oldPath ?? '', filePath].join('\0')
}
function statusEntryIdentity(entry: MobileGitStatusEntry, scope: DiffReviewScope): string {
function statusEntryIdentity(entry: GitStatusEntry, scope: DiffReviewScope): string {
return buildMobileDiffIdentity([
scope,
entry.area,
@@ -92,7 +94,7 @@ function statusEntryIdentity(entry: MobileGitStatusEntry, scope: DiffReviewScope
}
function branchEntryIdentity(
entry: MobileGitBranchChangeEntry,
entry: GitBranchChangeEntry,
branchHeadOid: string | null | undefined,
branchMergeBase: string | null | undefined
): string {
@@ -163,7 +165,7 @@ function queueNoteCounts(
}
function statusEntryToQueueItem(
entry: MobileGitStatusEntry,
entry: GitStatusEntry,
comments: readonly DiffComment[],
reviewState: MobileDiffReviewState
): MobileDiffReviewQueueItem {
@@ -199,7 +201,7 @@ function statusEntryToQueueItem(
}
function branchEntryToQueueItem(
entry: MobileGitBranchChangeEntry,
entry: GitBranchChangeEntry,
input: BuildMobileDiffReviewQueueInput
): MobileDiffReviewQueueItem {
const scope: DiffReviewScope = 'branch'
+20 -22
View File
@@ -1,15 +1,13 @@
import type {
MobileGitBranchChangeEntry,
MobileGitBranchCompareResult,
MobileGitBranchCompareSummary
} from '../source-control/mobile-branch-compare'
import type {
MobileGitFileStatus,
MobileGitStagingArea,
MobileGitStatusEntry,
MobileGitStatusResult,
MobileGitUpstreamStatus
} from '../source-control/mobile-git-status'
GitFileStatus,
GitStagingArea,
GitStatusEntry,
GitStatusResult,
GitUpstreamStatus
} from '../../../src/shared/git-status-types'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
import type { GitBranchCompareSummary } from '../../../src/shared/git-diff-compare-types'
export type MobileReviewGitDiffResult =
| {
@@ -47,7 +45,7 @@ function readBoolean(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function readFileStatus(value: unknown): MobileGitFileStatus | null {
function readFileStatus(value: unknown): GitFileStatus | null {
return value === 'modified' ||
value === 'added' ||
value === 'deleted' ||
@@ -58,17 +56,17 @@ function readFileStatus(value: unknown): MobileGitFileStatus | null {
: null
}
function readStagingArea(value: unknown): MobileGitStagingArea | null {
function readStagingArea(value: unknown): GitStagingArea | null {
return value === 'staged' || value === 'unstaged' || value === 'untracked' ? value : null
}
function readConflictOperation(value: unknown): MobileGitStatusResult['conflictOperation'] {
function readConflictOperation(value: unknown): GitStatusResult['conflictOperation'] {
return value === 'merge' || value === 'rebase' || value === 'cherry-pick' || value === 'unknown'
? value
: 'unknown'
}
function readUpstreamStatus(value: unknown): MobileGitUpstreamStatus | undefined {
function readUpstreamStatus(value: unknown): GitUpstreamStatus | undefined {
if (!isRecord(value)) {
return undefined
}
@@ -88,7 +86,7 @@ function readUpstreamStatus(value: unknown): MobileGitUpstreamStatus | undefined
}
}
function readStatusEntry(value: unknown): MobileGitStatusEntry | null {
function readStatusEntry(value: unknown): GitStatusEntry | null {
if (!isRecord(value)) {
return null
}
@@ -117,12 +115,12 @@ function readStatusEntry(value: unknown): MobileGitStatusEntry | null {
}
}
export function readMobileGitStatusResult(value: unknown): MobileGitStatusResult | null {
export function readMobileGitStatusResult(value: unknown): GitStatusResult | null {
if (!isRecord(value) || !Array.isArray(value.entries)) {
return null
}
return {
entries: value.entries.flatMap((entry): MobileGitStatusEntry[] => {
entries: value.entries.flatMap((entry): GitStatusEntry[] => {
const parsed = readStatusEntry(entry)
return parsed ? [parsed] : []
}),
@@ -133,7 +131,7 @@ export function readMobileGitStatusResult(value: unknown): MobileGitStatusResult
}
}
function readBranchStatus(value: unknown): MobileGitBranchCompareSummary['status'] {
function readBranchStatus(value: unknown): GitBranchCompareSummary['status'] {
return value === 'ready' ||
value === 'invalid-base' ||
value === 'unborn-head' ||
@@ -144,7 +142,7 @@ function readBranchStatus(value: unknown): MobileGitBranchCompareSummary['status
: 'error'
}
function readBranchEntry(value: unknown): MobileGitBranchChangeEntry | null {
function readBranchEntry(value: unknown): GitBranchChangeEntry | null {
if (!isRecord(value)) {
return null
}
@@ -162,7 +160,7 @@ function readBranchEntry(value: unknown): MobileGitBranchChangeEntry | null {
}
}
export function readMobileBranchCompareResult(value: unknown): MobileGitBranchCompareResult | null {
export function readMobileBranchCompareResult(value: unknown): GitBranchCompareResult | null {
if (!isRecord(value) || !isRecord(value.summary) || !Array.isArray(value.entries)) {
return null
}
@@ -184,7 +182,7 @@ export function readMobileBranchCompareResult(value: unknown): MobileGitBranchCo
status: readBranchStatus(value.summary.status),
errorMessage: readString(value.summary.errorMessage)
},
entries: value.entries.flatMap((entry): MobileGitBranchChangeEntry[] => {
entries: value.entries.flatMap((entry): GitBranchChangeEntry[] => {
const parsed = readBranchEntry(entry)
return parsed ? [parsed] : []
})
@@ -1,6 +1,6 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import type { DiffComment, MobileDiffReviewState } from '../../../src/shared/diff-comment-types'
import type { MobileGitBranchCompareResult } from '../source-control/mobile-branch-compare'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import type { MobileDiffLine } from './mobile-diff-lines'
import type { MobileDiffHunk } from './mobile-diff-hunks'
import type {
@@ -15,8 +15,8 @@ export type ReviewScreenState =
| { kind: 'loading' }
| {
kind: 'ready'
status: MobileGitStatusResult
branchCompare: MobileGitBranchCompareResult | null
status: GitStatusResult
branchCompare: GitBranchCompareResult | null
branchError?: string
comments: DiffComment[]
reviewState: MobileDiffReviewState
@@ -3,12 +3,9 @@ import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { MobileBrowserTab } from '../browser/MobileBrowserPane'
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { MobileDiffLine } from './mobile-diff-lines'
import type { MobileHighlightedDiffLine, MobileSyntaxSegment } from './mobile-file-syntax'
import type { TerminalRecord } from './mobile-terminal-records'
export type Terminal = TerminalRecord
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session'
@@ -28,7 +25,7 @@ export type MobileSessionTab =
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
terminalTheme?: RuntimeMobileTerminalTheme
isActive: boolean
}
| {
@@ -1,10 +1,10 @@
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
export type TerminalRecord = {
handle: string
title: string
terminalTheme?: MobileTerminalTheme
terminalTheme?: RuntimeMobileTerminalTheme
isActive: boolean
/** From `terminal.list`; parked and proven-absent leaves report false. */
connected?: boolean
@@ -24,7 +24,7 @@ export type MobileTerminalSessionTab = {
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
terminalTheme?: RuntimeMobileTerminalTheme
isActive: boolean
}
@@ -72,8 +72,8 @@ type MobileSessionTabLike =
}
export function mobileTerminalThemesEqual(
left: MobileTerminalTheme | null | undefined,
right: MobileTerminalTheme | null | undefined
left: RuntimeMobileTerminalTheme | null | undefined,
right: RuntimeMobileTerminalTheme | null | undefined
): boolean {
if (left === right) {
return true
@@ -1,13 +1,13 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { describe, expect, it, vi } from 'vitest'
import type { MobileGitBranchCompareResult } from '../source-control/mobile-branch-compare'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import {
deriveMobilePrBranchContext,
loadMobilePrBranchContext,
loadMobilePrRepoContext
} from './use-mobile-pr-branch-context'
function status(overrides: Partial<MobileGitStatusResult>): MobileGitStatusResult {
function status(overrides: Partial<GitStatusResult>): GitStatusResult {
return {
entries: [],
conflictOperation: 'unknown',
@@ -17,7 +17,7 @@ function status(overrides: Partial<MobileGitStatusResult>): MobileGitStatusResul
}
}
function branchCompare(headOid: string | null): MobileGitBranchCompareResult {
function branchCompare(headOid: string | null): GitBranchCompareResult {
return {
summary: {
baseRef: 'main',
@@ -1,8 +1,8 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { useEffect, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import type { MobileGitBranchCompareResult } from '../source-control/mobile-branch-compare'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import { resolveMobileBranchCompareBaseRef } from '../source-control/mobile-branch-base-ref'
import { fetchGithubRepoSlug } from './github-pr-rpc'
import { readMobileBranchCompareResult, readMobileGitStatusResult } from './mobile-diff-review-rpc'
@@ -10,7 +10,7 @@ import { readMobileBranchCompareResult, readMobileGitStatusResult } from './mobi
export type MobilePrBranchContext = {
branch: string | null
headSha: string | null
status: MobileGitStatusResult | null
status: GitStatusResult | null
isGithubRepo: boolean
repoLoaded: boolean
loaded: boolean
@@ -21,9 +21,9 @@ export type MobilePrBranchContext = {
// `status.head ?? branchCompare.summary.headOid ?? null` — a status-only read would lose
// the SHA when `status.head` is absent and diverge from the review surface's check status.
export function deriveMobilePrBranchContext(
status: MobileGitStatusResult | null,
branchCompare: MobileGitBranchCompareResult | null
): { branch: string | null; headSha: string | null; status: MobileGitStatusResult | null } {
status: GitStatusResult | null,
branchCompare: GitBranchCompareResult | null
): { branch: string | null; headSha: string | null; status: GitStatusResult | null } {
return {
branch: status?.branch ?? null,
headSha: status?.head ?? branchCompare?.summary.headOid ?? null,
@@ -172,7 +172,7 @@ export async function loadMobilePrBranchIdentity(
async function readGitStatus(
client: RpcClient,
worktreeId: string
): Promise<MobileGitStatusResult | null> {
): Promise<GitStatusResult | null> {
const response = await client.sendRequest('git.status', { worktree: `id:${worktreeId}` })
return response.ok ? readMobileGitStatusResult(response.result) : null
}
@@ -180,7 +180,7 @@ async function readGitStatus(
async function readBranchCompare(
client: RpcClient,
worktreeId: string
): Promise<MobileGitBranchCompareResult | null> {
): Promise<GitBranchCompareResult | null> {
// branchCompare requires a baseRef; without one (or on error) the headOid fallback is
// simply unavailable and headSha relies on status.head.
const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
@@ -1,4 +1,5 @@
import type { MobileSessionTab, Terminal } from './mobile-session-route-types'
import type { MobileSessionTab } from './mobile-session-route-types'
import type { TerminalRecord } from './mobile-terminal-records'
import type { MobileSessionContentCreateActionsModel } from './use-mobile-session-content-create-actions'
export function useMobileSessionCloseActions(scope: MobileSessionContentCreateActionsModel) {
@@ -60,7 +61,7 @@ export function useMobileSessionCloseActions(scope: MobileSessionContentCreateAc
}
}
async function handleCloseTerminal(target: Terminal) {
async function handleCloseTerminal(target: TerminalRecord) {
if (!client) {
return
}
@@ -20,16 +20,16 @@ import type {
MarkdownDocState,
MobileDisplayMode,
MobileNewTabAgentLoadState,
MobileSessionTab,
Terminal
MobileSessionTab
} from './mobile-session-route-types'
import type { TerminalRecord } from './mobile-terminal-records'
import { useMobileSessionTabActionTargets } from './use-mobile-session-tab-action-targets'
import type { MobileSessionFoundationModel } from './use-mobile-session-foundation'
export function useMobileSessionScreenState(scope: MobileSessionFoundationModel) {
const { worktreeId, hostId, initialCreateWarning } = scope
const [terminals, setTerminals] = useState<Terminal[]>([])
const terminalsRef = useRef<Terminal[]>([])
const [terminals, setTerminals] = useState<TerminalRecord[]>([])
const terminalsRef = useRef<TerminalRecord[]>([])
const [sessionTabs, setSessionTabs] = useState<MobileSessionTab[]>([])
const sessionTabsRef = useRef<MobileSessionTab[]>([])
// Why: track the last applied (epoch, version) so a late older snapshot can't overwrite a newer one and resurrect closed tabs (session-tab-snapshot-gate).
@@ -97,7 +97,7 @@ export function useMobileSessionScreenState(scope: MobileSessionFoundationModel)
{ type: 'markdown' }
> | null>(null)
const [leaveDrafts, setLeaveDrafts] = useState<DirtyMarkdownDraft[] | null>(null)
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
const [renameTarget, setRenameTarget] = useState<TerminalRecord | null>(null)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
@@ -5,7 +5,8 @@ import {
type MutableRefObject,
type SetStateAction
} from 'react'
import type { MobileSessionTab, Terminal } from './mobile-session-route-types'
import type { MobileSessionTab } from './mobile-session-route-types'
import type { TerminalRecord } from './mobile-terminal-records'
type MarkdownTab = Extract<MobileSessionTab, { type: 'markdown' }>
type FileTab = Extract<MobileSessionTab, { type: 'file' }>
@@ -14,7 +15,7 @@ type AgentSessionTab = Extract<MobileSessionTab, { type: 'agent-session' }>
type SetActionTarget<T> = Dispatch<SetStateAction<T | null>>
export function useMobileSessionTabActionTargets() {
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
const [actionTarget, setActionTarget] = useState<TerminalRecord | null>(null)
const [markdownActionTarget, setMarkdownActionTarget] = useState<MarkdownTab | null>(null)
const [fileActionTarget, setFileActionTarget] = useState<FileTab | null>(null)
const [browserActionTarget, setBrowserActionTarget] = useState<BrowserTab | null>(null)
@@ -38,7 +39,7 @@ export function useMobileSessionTabActionTargets() {
export function useMobileSessionTabActionSheetOpener(args: {
activeHandleRef: MutableRefObject<string | null>
setActionTarget: SetActionTarget<Terminal>
setActionTarget: SetActionTarget<TerminalRecord>
setMarkdownActionTarget: SetActionTarget<MarkdownTab>
setFileActionTarget: SetActionTarget<FileTab>
setBrowserActionTarget: SetActionTarget<BrowserTab>
@@ -8,7 +8,8 @@ import { buildTerminalSendParams } from '../terminal/terminal-send-request'
import { terminalRecordsEqual } from './mobile-terminal-records'
import type { MobileNewTabAgentOption } from './mobile-new-tab-agent-options'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { Terminal, TerminalCreateResult } from './mobile-session-route-types'
import type { TerminalCreateResult } from './mobile-session-route-types'
import type { TerminalRecord } from './mobile-terminal-records'
import type { MobileSessionAttachmentsModel } from './use-mobile-session-attachments'
import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle'
import { createMobileStructuredAgentSession } from './mobile-structured-agent-session-launch'
@@ -145,7 +146,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach
setActiveHandle(createdHandle)
setTerminals((prev) => {
const existing = prev.find((terminal) => terminal.handle === createdHandle)
const createdTerminal: Terminal = {
const createdTerminal: TerminalRecord = {
handle: createdHandle,
title: created.title || existing?.title || 'Terminal',
terminalTheme: created.terminalTheme ?? existing?.terminalTheme,
@@ -19,7 +19,8 @@ import {
TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
} from './mobile-session-route-helpers'
import type { Terminal, TerminalGestureInputQueue } from './mobile-session-route-types'
import type { TerminalGestureInputQueue } from './mobile-session-route-types'
import type { TerminalRecord } from './mobile-terminal-records'
import type { MobileSessionFileActionsModel } from './use-mobile-session-file-actions'
export function useMobileSessionTerminalInput(scope: MobileSessionFileActionsModel) {
@@ -228,7 +229,7 @@ export function useMobileSessionTerminalInput(scope: MobileSessionFileActionsMod
})
}, [])
async function handleClearTerminal(target: Terminal) {
async function handleClearTerminal(target: TerminalRecord) {
if (!client) {
return
}
@@ -6,7 +6,7 @@ import {
pruneTerminalKeyboardMetrics,
resolveRetainedTerminalHandles
} from './mobile-terminal-prune-decision'
import type { Terminal } from './mobile-session-route-types'
import type { TerminalRecord } from './mobile-terminal-records'
import type { MobileSessionTerminalStreamDisplayModel } from './use-mobile-session-terminal-stream-display'
import { MobileTerminalInventoryRequest } from './mobile-terminal-inventory-request'
import type { MobileTerminalInventoryRefreshOptions } from './use-mobile-terminal-inventory-recovery'
@@ -61,7 +61,7 @@ export function useMobileSessionTerminalList(scope: MobileSessionTerminalStreamD
if (!isCurrent() || !response.ok) {
return false
}
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
const result = (response as RpcSuccess).result as { terminals: TerminalRecord[] }
if (result.terminals.length === 0 && !allowsEmpty()) {
return true
}
@@ -1,12 +1,12 @@
import type { MobileSpeechSetup } from '../dictation/mobile-dictation-setup'
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
export interface VoiceSettingsOperations {
load(): Promise<MobileSpeechSetup>
load(): Promise<RuntimeSpeechSetupState>
configure(params: {
enabled?: boolean
modelId?: string
dictationMode?: 'toggle' | 'hold'
}): Promise<MobileSpeechSetup>
}): Promise<RuntimeSpeechSetupState>
download(modelId: string): Promise<void>
delete(modelId: string): Promise<MobileSpeechSetup>
delete(modelId: string): Promise<RuntimeSpeechSetupState>
}
@@ -1,3 +1,4 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import { useCallback, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, ScrollView, Switch, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -8,11 +9,7 @@ import { colors, spacing } from '../theme/mobile-theme'
import { BottomDrawer } from '../components/BottomDrawer'
import { VoiceModelList } from '../components/VoiceModelList'
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
import {
isModelInFlight,
type MobileSpeechModel,
type MobileSpeechSetup
} from '../dictation/mobile-dictation-setup'
import { isModelInFlight, type MobileSpeechModel } from '../dictation/mobile-dictation-setup'
const POLL_INTERVAL_MS = 1500
@@ -33,7 +30,7 @@ export default function VoiceSettingsScreen({
onBack: () => void
}): React.JSX.Element {
const insets = useSafeAreaInsets()
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [setup, setSetup] = useState<RuntimeSpeechSetupState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
@@ -1,17 +1,11 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import { describe, expect, it } from 'vitest'
import {
buildMobileBranchCompareSection,
canOpenMobileBranchCompareDiff,
formatMobileBranchCompareSummary,
type MobileGitBranchCompareResult
formatMobileBranchCompareSummary
} from './mobile-branch-compare'
describe('mobile branch compare helpers', () => {
it('keeps the mobile branch compare type in lockstep with the runtime contract', () => {
expectTypeOf<MobileGitBranchCompareResult>().toEqualTypeOf<GitBranchCompareResult>()
})
it('sorts committed branch entries by path', () => {
const section = buildMobileBranchCompareSection([
{ path: 'zeta.ts', status: 'modified' },
@@ -1,21 +1,15 @@
import type {
GitBranchChangeEntry,
GitBranchCompareResult,
GitBranchCompareSummary
} from '../../../src/shared/git-diff-compare-types'
export type MobileGitBranchChangeEntry = GitBranchChangeEntry
export type MobileGitBranchCompareSummary = GitBranchCompareSummary
export type MobileGitBranchCompareResult = GitBranchCompareResult
export type MobileBranchCompareSection<TEntry extends GitBranchChangeEntry = GitBranchChangeEntry> =
{
title: 'Committed on Branch'
data: TEntry[]
}
export type MobileBranchCompareSection<
TEntry extends MobileGitBranchChangeEntry = MobileGitBranchChangeEntry
> = {
title: 'Committed on Branch'
data: TEntry[]
}
export function buildMobileBranchCompareSection<TEntry extends MobileGitBranchChangeEntry>(
export function buildMobileBranchCompareSection<TEntry extends GitBranchChangeEntry>(
entries: readonly TEntry[]
): MobileBranchCompareSection<TEntry> | null {
if (entries.length === 0) {
@@ -32,9 +26,7 @@ export function buildMobileBranchCompareSection<TEntry extends MobileGitBranchCh
}
}
export function formatMobileBranchCompareSummary(
summary: MobileGitBranchCompareSummary
): string | null {
export function formatMobileBranchCompareSummary(summary: GitBranchCompareSummary): string | null {
if (summary.status !== 'ready') {
return summary.errorMessage ?? null
}
@@ -46,6 +38,6 @@ export function formatMobileBranchCompareSummary(
return parts.join(' - ')
}
export function canOpenMobileBranchCompareDiff(summary: MobileGitBranchCompareSummary): boolean {
export function canOpenMobileBranchCompareDiff(summary: GitBranchCompareSummary): boolean {
return summary.status === 'ready' && Boolean(summary.headOid && summary.mergeBase)
}
@@ -1,6 +1,6 @@
import type { MobileGitBranchChangeEntry } from './mobile-branch-compare'
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
export function formatMobileBranchEntryMeta(entry: MobileGitBranchChangeEntry): string | null {
export function formatMobileBranchEntryMeta(entry: GitBranchChangeEntry): string | null {
const stats =
entry.added !== undefined || entry.removed !== undefined
? `+${entry.added ?? 0} -${entry.removed ?? 0}`
@@ -1,4 +1,4 @@
import type { MobileGitStatusEntry } from './mobile-git-status'
import type { GitStatusEntry } from '../../../src/shared/git-status-types'
export {
COMMIT_FAILURE_SUMMARY_SCAN_CODE_UNITS,
@@ -10,14 +10,14 @@ export {
export type MobileCommitFailureRecovery = {
error: string
commitMessage: string
stagedEntries: Pick<MobileGitStatusEntry, 'path' | 'status' | 'area'>[]
stagedEntries: Pick<GitStatusEntry, 'path' | 'status' | 'area'>[]
}
export type RecordMobileCommitFailure = (failure: MobileCommitFailureRecovery | null) => void
export function getMobileCommitFailureStagedEntries(
entries: readonly MobileGitStatusEntry[] | undefined
): Pick<MobileGitStatusEntry, 'path' | 'status' | 'area'>[] {
entries: readonly GitStatusEntry[] | undefined
): Pick<GitStatusEntry, 'path' | 'status' | 'area'>[] {
return (entries ?? [])
.filter((entry) => entry.area === 'staged')
.map((entry) => ({ path: entry.path, status: entry.status, area: entry.area }))
@@ -1,3 +1,4 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
import {
@@ -5,7 +6,6 @@ import {
rpcUncheckedPayloadReader
} from '../transport/rpc-reader-payload'
import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc'
import type { MobileGitStatusResult } from './mobile-git-status'
// Source-control reads. Every one of these replies used to be re-typed with a cast at the call
// site; the reader below is now the only place that says what the payload is.
@@ -32,7 +32,7 @@ export const gitStatusHostPayloadRead = bindDeferredRpcOperation(
const gitStatusProjectionReader: RpcCompatibleReader<
unknown,
'normalized-status',
MobileGitStatusResult | null
GitStatusResult | null
> = (raw) => ({
compatible: true,
variant: 'normalized-status',
@@ -1,5 +1,5 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { describe, expect, it } from 'vitest'
import type { GitStatusEntry } from '../../../src/shared/git-status-types'
import {
buildMobileSourceControlSections,
canOpenMobileGitStatusEntry,
@@ -10,22 +10,16 @@ import {
isMobileGitDiscardableEntry,
isMobileGitStageableEntry,
isMobileGitTransientRefreshError,
isMobileGitUnavailable,
type MobileGitStatusEntry,
type MobileGitStatusResult
isMobileGitUnavailable
} from './mobile-git-status'
const entries: MobileGitStatusEntry[] = [
const entries: GitStatusEntry[] = [
{ path: 'b.ts', status: 'modified', area: 'staged' },
{ path: 'a.ts', status: 'modified', area: 'unstaged' },
{ path: 'new.ts', status: 'untracked', area: 'untracked' }
]
describe('mobile source control status helpers', () => {
it('keeps the mobile RPC status type in lockstep with the shared git contract', () => {
expectTypeOf<MobileGitStatusResult>().toEqualTypeOf<GitStatusResult>()
})
it('builds sections in the mobile source control order', () => {
const sections = buildMobileSourceControlSections(entries)
@@ -44,7 +38,7 @@ describe('mobile source control status helpers', () => {
})
it('keeps unresolved conflicts out of stage actions', () => {
const conflictedEntries: MobileGitStatusEntry[] = [
const conflictedEntries: GitStatusEntry[] = [
{ path: 'ready.ts', status: 'modified', area: 'unstaged' },
{
path: 'conflicted.ts',
+18 -27
View File
@@ -1,34 +1,25 @@
import type {
GitFileStatus,
GitStagingArea,
GitStatusEntry,
GitStatusResult,
GitUpstreamStatus
GitStatusEntry
} from '../../../src/shared/git-status-types'
import type { RpcResponse } from '../transport/types'
export type MobileGitFileStatus = GitFileStatus
export type MobileGitStagingArea = GitStagingArea
export type MobileGitStatusEntry = GitStatusEntry
export type MobileGitUpstreamStatus = GitUpstreamStatus
export type MobileGitStatusResult = GitStatusResult
export type MobileSourceControlSection<TEntry extends GitStatusEntry = GitStatusEntry> = {
area: GitStagingArea
title: string
data: TEntry[]
}
export type MobileSourceControlSection<TEntry extends MobileGitStatusEntry = MobileGitStatusEntry> =
{
area: MobileGitStagingArea
title: string
data: TEntry[]
}
const AREA_ORDER: GitStagingArea[] = ['unstaged', 'untracked', 'staged']
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
const AREA_TITLES: Record<MobileGitStagingArea, string> = {
const AREA_TITLES: Record<GitStagingArea, string> = {
unstaged: 'Changes',
untracked: 'Untracked Files',
staged: 'Staged Changes'
}
export const MOBILE_GIT_STATUS_LABELS: Record<MobileGitFileStatus, string> = {
export const MOBILE_GIT_STATUS_LABELS: Record<GitFileStatus, string> = {
modified: 'M',
added: 'A',
deleted: 'D',
@@ -37,7 +28,7 @@ export const MOBILE_GIT_STATUS_LABELS: Record<MobileGitFileStatus, string> = {
copied: 'C'
}
function getConflictSortRank(entry: MobileGitStatusEntry): number {
function getConflictSortRank(entry: GitStatusEntry): number {
if (entry.conflictStatus === 'unresolved') {
return 0
}
@@ -47,7 +38,7 @@ function getConflictSortRank(entry: MobileGitStatusEntry): number {
return 2
}
export function buildMobileSourceControlSections<TEntry extends MobileGitStatusEntry>(
export function buildMobileSourceControlSections<TEntry extends GitStatusEntry>(
entries: readonly TEntry[]
): MobileSourceControlSection<TEntry>[] {
const sections = AREA_ORDER.map((area) => ({
@@ -67,36 +58,36 @@ export function buildMobileSourceControlSections<TEntry extends MobileGitStatusE
return sections
}
export function countStagedEntries(entries: readonly MobileGitStatusEntry[]): number {
export function countStagedEntries(entries: readonly GitStatusEntry[]): number {
return entries.filter((entry) => entry.area === 'staged').length
}
export function countUnstagedEntries(entries: readonly MobileGitStatusEntry[]): number {
export function countUnstagedEntries(entries: readonly GitStatusEntry[]): number {
return entries.filter((entry) => entry.area === 'unstaged' || entry.area === 'untracked').length
}
export function getStageablePaths(entries: readonly MobileGitStatusEntry[]): string[] {
export function getStageablePaths(entries: readonly GitStatusEntry[]): string[] {
return entries.filter(isMobileGitStageableEntry).map((entry) => entry.path)
}
export function getUnstageablePaths(entries: readonly MobileGitStatusEntry[]): string[] {
export function getUnstageablePaths(entries: readonly GitStatusEntry[]): string[] {
return entries.filter((entry) => entry.area === 'staged').map((entry) => entry.path)
}
export function isMobileGitStageableEntry(entry: MobileGitStatusEntry): boolean {
export function isMobileGitStageableEntry(entry: GitStatusEntry): boolean {
return (
(entry.area === 'unstaged' || entry.area === 'untracked') &&
entry.conflictStatus !== 'unresolved'
)
}
export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolean {
export function isMobileGitDiscardableEntry(entry: GitStatusEntry): boolean {
return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally'
}
// Why: unresolved conflicts are not a stable file to open. Deletions are —
// git.diff still returns the pre-delete side (text or image via modifiedDeleted).
export function canOpenMobileGitStatusEntry(entry: MobileGitStatusEntry): boolean {
export function canOpenMobileGitStatusEntry(entry: GitStatusEntry): boolean {
return entry.conflictStatus !== 'unresolved'
}
@@ -1,11 +1,11 @@
import type { MobileGitStatusResult } from './mobile-git-status'
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import {
createMobilePr,
getMobilePrCreateBlockMessage,
getMobilePrCreateSuccessWarning,
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
shouldPushBeforeMobilePrCreate
} from './mobile-pr-create'
import type { MobileHostedReviewPrefill } from './mobile-hosted-review-service'
import {
prepareMobileHostedReviewCreateIntent,
type MobileHostedReviewCreateIntentProgress
@@ -15,7 +15,7 @@ import type { MobileSourceControlRpcSender } from './mobile-source-control-rpc-s
type RunInput = {
branch: string
title: string
status: MobileGitStatusResult | null
status: GitStatusResult | null
commitMessage?: string
onProgress?: (progress: MobileHostedReviewCreateIntentProgress) => void
}
@@ -25,15 +25,15 @@ export type MobileHostedReviewCreateIntentRunOutcome =
ok: true
url: string
warning?: string
prefill: MobilePrPrefill
status: MobileGitStatusResult | null
prefill: MobileHostedReviewPrefill
status: GitStatusResult | null
committed: boolean
}
| {
ok: false
error: string
committed?: boolean
status?: MobileGitStatusResult | null
status?: GitStatusResult | null
commitMessage?: string
}
@@ -1,7 +1,9 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { requestMobileCommitMessage } from './mobile-commit-message-ai'
import { getStageablePaths, type MobileGitStatusResult } from './mobile-git-status'
import { getStageablePaths } from './mobile-git-status'
import { getMobilePrEligibilityReadiness } from './mobile-open-pr-prefill'
import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create'
import { resolveMobilePrPrefill } from './mobile-pr-create'
import type { MobileHostedReviewPrefill } from './mobile-hosted-review-service'
import {
commitMobileHostedReviewStagedChanges,
mobileHostedReviewBranchStillMatches,
@@ -24,15 +26,15 @@ type MobileHostedReviewCreateIntentFailure = {
ok: false
error: string
committed?: boolean
status?: MobileGitStatusResult | null
status?: GitStatusResult | null
commitMessage?: string
}
export type MobileHostedReviewCreateIntentOutcome =
| {
ok: true
prefill: MobilePrPrefill
status: MobileGitStatusResult | null
prefill: MobileHostedReviewPrefill
status: GitStatusResult | null
committed: boolean
}
| MobileHostedReviewCreateIntentFailure
@@ -40,7 +42,7 @@ export type MobileHostedReviewCreateIntentOutcome =
type PrepareInput = {
branch: string
title: string
status: MobileGitStatusResult | null
status: GitStatusResult | null
commitMessage?: string
onProgress?: (progress: MobileHostedReviewCreateIntentProgress) => void
}
@@ -66,7 +68,7 @@ export function mobileHostedReviewCreateIntentProgressMessage(
}
}
function hasUnresolvedConflicts(status: MobileGitStatusResult | null): boolean {
function hasUnresolvedConflicts(status: GitStatusResult | null): boolean {
return status?.entries.some((entry) => entry.conflictStatus === 'unresolved') === true
}
@@ -75,8 +77,8 @@ async function resolvePrefillFromStatus(
worktreeId: string,
branch: string,
title: string,
status: MobileGitStatusResult | null
): Promise<MobilePrPrefill> {
status: GitStatusResult | null
): Promise<MobileHostedReviewPrefill> {
return resolveMobilePrPrefill(client, worktreeId, {
branch,
title,
@@ -88,9 +90,9 @@ async function ensureLocalChangesCommitted(
client: MobileSourceControlRpcSender,
worktreeId: string,
input: PrepareInput,
currentStatus: MobileGitStatusResult | null
currentStatus: GitStatusResult | null
): Promise<
| { ok: true; status: MobileGitStatusResult | null; committed: boolean }
| { ok: true; status: GitStatusResult | null; committed: boolean }
| MobileHostedReviewCreateIntentFailure
> {
if ((currentStatus?.entries.length ?? 0) === 0) {
@@ -1,3 +1,4 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import type { RpcSendParams } from '../transport/rpc-params-contract'
import {
hostReplyErrorTextOrFallback,
@@ -6,11 +7,10 @@ import {
import type { RpcResponse } from '../transport/types'
import { gitBulkStageRun, gitCommitRun, gitPushRun } from './mobile-git-mutation-operations'
import { gitStatusProjectionRead } from './mobile-git-read-operations'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { MobileSourceControlRpcSender } from './mobile-source-control-rpc-sender'
export type MobileHostedReviewStatusReadResult =
| { ok: true; status: MobileGitStatusResult | null }
| { ok: true; status: GitStatusResult | null }
| { ok: false; error: string }
export type MobileHostedReviewMutationResult = { ok: true } | { ok: false; error: string }
@@ -32,7 +32,7 @@ export async function readMobileHostedReviewGitStatus(
export function mobileHostedReviewBranchStillMatches(
inputBranch: string,
status: MobileGitStatusResult | null
status: GitStatusResult | null
): boolean {
const branch = status?.branch
return Boolean(branch && (branch === inputBranch || branch === `refs/heads/${inputBranch}`))
@@ -1,18 +1,18 @@
import type { MobileGitStatusResult } from './mobile-git-status'
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import type { MobileHostedReviewCreateIntentProgress } from './mobile-hosted-review-create-intent'
import type { MobilePrPrefill } from './mobile-pr-create'
import type { MobileHostedReviewPrefill } from './mobile-hosted-review-service'
import { pushMobileHostedReviewBranch } from './mobile-hosted-review-git-preparation'
import type { MobileSourceControlRpcSender } from './mobile-source-control-rpc-sender'
type RemotePrerequisiteInput = {
status: MobileGitStatusResult | null
status: GitStatusResult | null
onProgress?: (progress: MobileHostedReviewCreateIntentProgress) => void
}
export async function applyMobileHostedReviewRemotePrerequisite(
client: MobileSourceControlRpcSender,
worktreeId: string,
prefill: MobilePrPrefill,
prefill: MobileHostedReviewPrefill,
input: RemotePrerequisiteInput
): Promise<{ ok: true; ran: boolean } | { ok: false; error: string }> {
const worktree = `id:${worktreeId}`
@@ -1,8 +1,8 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { describe, expect, it, vi } from 'vitest'
import { getMobilePrEligibilityReadiness, readFreshGitStatus } from './mobile-open-pr-prefill'
import type { MobileGitStatusResult } from './mobile-git-status'
const fallback = { branch: 'old', entries: [] } as unknown as MobileGitStatusResult
const fallback = { branch: 'old', entries: [] } as unknown as GitStatusResult
describe('readFreshGitStatus', () => {
it('returns the freshly-read status when parseable', async () => {
@@ -43,7 +43,7 @@ describe('getMobilePrEligibilityReadiness', () => {
const status = {
entries: [{ path: 'a.ts' }],
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 1 }
} as unknown as MobileGitStatusResult
} as unknown as GitStatusResult
expect(getMobilePrEligibilityReadiness(status)).toEqual({
hasUncommittedChanges: true,
@@ -1,12 +1,12 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc'
import type { MobileGitStatusResult } from './mobile-git-status'
// Refresh after a push when possible so readiness reflects the new upstream state.
export async function readFreshGitStatus(
worktreeId: string,
fallback: MobileGitStatusResult | null,
fallback: GitStatusResult | null,
sendGitRequest: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<MobileGitStatusResult | null> {
): Promise<GitStatusResult | null> {
try {
const fresh = await sendGitRequest<unknown>('git.status', { worktree: `id:${worktreeId}` })
return readMobileGitStatusResult(fresh) ?? fallback
@@ -15,7 +15,7 @@ export async function readFreshGitStatus(
}
}
export function getMobilePrEligibilityReadiness(status: MobileGitStatusResult | null): {
export function getMobilePrEligibilityReadiness(status: GitStatusResult | null): {
hasUncommittedChanges?: boolean
hasUpstream?: boolean
ahead?: number
@@ -1,7 +1,8 @@
import type { GitStatusEntry } from '../../../src/shared/git-status-types'
import { describe, expect, it, vi } from 'vitest'
import { buildMobileDiffReviewQueue } from '../session/mobile-diff-review-queue'
import { buildMobileBranchCompareSection } from './mobile-branch-compare'
import { buildMobileSourceControlSections, type MobileGitStatusEntry } from './mobile-git-status'
import { buildMobileSourceControlSections } from './mobile-git-status'
const paths = [
'file10.ts',
@@ -37,7 +38,7 @@ const reviewInput = {
reviewState: { version: 1 as const, files: {} }
}
const comparePath = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true })
const conflictRank = (entry: MobileGitStatusEntry) =>
const conflictRank = (entry: GitStatusEntry) =>
entry.conflictStatus === 'unresolved' ? 0 : entry.conflictStatus === 'resolved_locally' ? 1 : 2
describe('mobile path sort collation', () => {
@@ -8,9 +8,9 @@ import {
getMobilePrCreateBlockMessage,
mobileRepoSelectorFromWorktreeId,
resolveMobilePrPrefill,
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
shouldPushBeforeMobilePrCreate
} from './mobile-pr-create'
import type { MobileHostedReviewPrefill } from './mobile-hosted-review-service'
function ok(result: unknown): RpcSuccess {
return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
@@ -236,7 +236,8 @@ describe('mobile create form gating parity', () => {
title: 'Add feature',
body: '',
canCreate: false,
blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason']
blockedReason:
'future_desktop_reason' as unknown as MobileHostedReviewPrefill['blockedReason']
})
).toBe('This branch is not ready for a pull request yet.')
})
+3 -10
View File
@@ -5,17 +5,10 @@ import {
mobileRepoSelectorFromWorktreeId,
resolveMobileHostedReviewPrefill,
shouldPushBeforeMobileHostedReviewCreate,
type MobileHostedReviewCreateInput,
type MobileHostedReviewCreateOutcome,
type MobileHostedReviewEligibilityInput,
type MobileHostedReviewPrefill
} from './mobile-hosted-review-service'
export type MobilePrEligibilityInput = MobileHostedReviewEligibilityInput
export type MobilePrPrefill = MobileHostedReviewPrefill
export type MobilePrCreateInput = MobileHostedReviewCreateInput
export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome
export {
buildMobileHostedReviewCreateParams as buildMobilePrCreateParams,
createMobileHostedReview as createMobilePr,
@@ -25,8 +18,8 @@ export {
}
export function getMobilePrCreateSuccessWarning(
outcome: Extract<MobilePrCreateOutcome, { ok: true }>,
provider: MobilePrPrefill['provider']
outcome: Extract<MobileHostedReviewCreateOutcome, { ok: true }>,
provider: MobileHostedReviewPrefill['provider']
): string | undefined {
const copy = hostedReviewCopy(provider)
if (outcome.existing) {
@@ -40,7 +33,7 @@ export function getMobilePrCreateSuccessWarning(
return undefined
}
export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string | null {
export function getMobilePrCreateBlockMessage(prefill: MobileHostedReviewPrefill): string | null {
const copy = hostedReviewCopy(prefill.provider)
if (prefill.canCreate !== false || shouldPushBeforeMobileHostedReviewCreate(prefill)) {
// Fail closed: only an accepted no-review lookup (`not_found`) may open
@@ -1,6 +1,6 @@
import type { MobileGitStagingArea } from './mobile-git-status'
import type { GitStagingArea } from '../../../src/shared/git-status-types'
export type MobileReviewRouteArea = MobileGitStagingArea | 'branch'
export type MobileReviewRouteArea = GitStagingArea | 'branch'
export type MobileReviewRouteTarget = {
hostId: string
@@ -1,5 +1,5 @@
import type { GitUpstreamStatus } from '../../../src/shared/git-status-types'
import { describe, expect, it, vi } from 'vitest'
import type { MobileGitUpstreamStatus } from './mobile-git-status'
import {
buildMobileSourceControlActions,
type MobileSourceControlActionArgs
@@ -30,7 +30,7 @@ function args(
return {
commitMessage: 'msg',
stagedCount: 1,
upstream: { hasUpstream: true, ahead: 0, behind: 0 } as MobileGitUpstreamStatus,
upstream: { hasUpstream: true, ahead: 0, behind: 0 } as GitUpstreamStatus,
upstreamKnown: true,
busyAction: null,
openingPath: null,
@@ -67,14 +67,14 @@ describe('buildMobileSourceControlActions', () => {
it('disables fast-forward when ahead of upstream (would lose local commits)', () => {
const actions = buildMobileSourceControlActions(
args({ upstream: { hasUpstream: true, ahead: 2, behind: 3 } as MobileGitUpstreamStatus })
args({ upstream: { hasUpstream: true, ahead: 2, behind: 3 } as GitUpstreamStatus })
)
expect(action(actions, 'Fast-forward')?.disabled).toBe(true)
})
it('enables fast-forward when behind and not ahead', () => {
const actions = buildMobileSourceControlActions(
args({ upstream: { hasUpstream: true, ahead: 0, behind: 3 } as MobileGitUpstreamStatus })
args({ upstream: { hasUpstream: true, ahead: 0, behind: 3 } as GitUpstreamStatus })
)
expect(action(actions, 'Fast-forward')?.disabled).toBe(false)
})
@@ -1,4 +1,4 @@
import type { MobileGitUpstreamStatus } from './mobile-git-status'
import type { GitUpstreamStatus } from '../../../src/shared/git-status-types'
// Icon identifier resolved to a lucide component by the screen. Kept as a string
// here so this module stays free of the native lucide import and unit-testable.
@@ -27,7 +27,7 @@ export type MobileSourceControlAction = {
export type MobileSourceControlActionArgs = {
commitMessage: string
stagedCount: number
upstream: MobileGitUpstreamStatus | null
upstream: GitUpstreamStatus | null
upstreamKnown: boolean
busyAction: string | null
openingPath: string | null
@@ -1,10 +1,10 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { describe, expect, it, vi } from 'vitest'
import {
buildMobileSourceControlPrimaryAction,
type MobileSourceControlPrimaryActionArgs,
type MobileSourceControlPrimaryActionHandlers
} from './mobile-source-control-primary-action'
import type { MobileGitStatusResult } from './mobile-git-status'
function handlers(): MobileSourceControlPrimaryActionHandlers {
return {
@@ -15,7 +15,7 @@ function handlers(): MobileSourceControlPrimaryActionHandlers {
}
}
function status(overrides: Partial<MobileGitStatusResult> = {}): MobileGitStatusResult {
function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
return {
entries: [],
conflictOperation: 'unknown',
@@ -1,16 +1,14 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import {
resolveSourceControlCommitAreaPrimaryActionDecision,
type SourceControlCommitAreaPrimaryActionDecision,
type SourceControlRemoteOpKind
} from '../../../src/shared/source-control-primary-action-decision'
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
type GitStep = { method: string; params?: Record<string, unknown> }
type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind']
type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision
type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind
export type MobileSourceControlPrimaryAction = {
kind: MobileSourceControlPrimaryActionKind
@@ -31,7 +29,7 @@ export type MobileSourceControlPrimaryActionHandlers = {
}
export type MobileSourceControlPrimaryActionArgs = {
status: MobileGitStatusResult | null
status: GitStatusResult | null
hasUnresolvedConflicts: boolean
stageablePaths: readonly string[]
stagedCount: number
@@ -40,7 +38,7 @@ export type MobileSourceControlPrimaryActionArgs = {
busyAction: string | null
openingPath: string | null
openingBranchPath: string | null
branchCompareResult: MobileGitBranchCompareResult | null
branchCompareResult: GitBranchCompareResult | null
handlers: MobileSourceControlPrimaryActionHandlers
}
@@ -88,9 +86,7 @@ function isMobileRemoteOperationActive(busyAction: string | null): boolean {
return getInFlightRemoteOpKind(busyAction) !== null
}
function getInFlightRemoteOpKind(
busyAction: string | null
): MobileSourceControlRemoteOpKind | null {
function getInFlightRemoteOpKind(busyAction: string | null): SourceControlRemoteOpKind | null {
switch (busyAction) {
case 'push':
case 'commit-push':
@@ -127,7 +123,9 @@ function getMobileBranchCommitsAhead(
return upstream?.hasUpstream ? upstream.ahead : undefined
}
function getMobilePrimaryActionLabel(decision: MobileSourceControlPrimaryActionDecision): string {
function getMobilePrimaryActionLabel(
decision: SourceControlCommitAreaPrimaryActionDecision
): string {
if (decision.requiresForceWithLease) {
return 'Force Push'
}
@@ -147,7 +145,9 @@ function getMobilePrimaryActionLabel(decision: MobileSourceControlPrimaryActionD
}
}
function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDecision): string {
function getMobilePrimaryActionHint(
decision: SourceControlCommitAreaPrimaryActionDecision
): string {
switch (decision.titleIntent) {
case 'commit_in_progress':
return 'Commit in progress.'
@@ -194,7 +194,7 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe
}
function isLoadingDecision(
decision: MobileSourceControlPrimaryActionDecision,
decision: SourceControlCommitAreaPrimaryActionDecision,
busyAction: string | null
): boolean {
switch (decision.kind) {
@@ -219,7 +219,7 @@ function isLoadingDecision(
}
async function runMobilePrimaryAction(
decision: MobileSourceControlPrimaryActionDecision,
decision: SourceControlCommitAreaPrimaryActionDecision,
handlers: MobileSourceControlPrimaryActionHandlers
): Promise<void> {
switch (decision.kind) {
@@ -1,3 +1,8 @@
import type {
GitFileStatus,
GitStatusEntry,
GitStatusResult
} from '../../../src/shared/git-status-types'
import {
ArrowDown,
ArrowDownUp,
@@ -14,23 +19,18 @@ import { colors } from '../theme/mobile-theme'
import type { MobileSourceControlActionIcon } from './mobile-source-control-actions'
import type { MobileDiffLine } from '../session/mobile-diff-lines'
import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
import type {
MobileGitBranchChangeEntry,
MobileGitBranchCompareResult,
MobileGitBranchCompareSummary
} from './mobile-branch-compare'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
import type { GitBranchCompareSummary } from '../../../src/shared/git-diff-compare-types'
import {
canOpenMobileGitStatusEntry,
isMobileGitDiscardableEntry,
isMobileGitStageableEntry,
type MobileGitFileStatus,
type MobileGitStatusEntry,
type MobileGitStatusResult
isMobileGitStageableEntry
} from './mobile-git-status'
export type ScreenState =
| { kind: 'loading' }
| { kind: 'ready'; status: MobileGitStatusResult }
| { kind: 'ready'; status: GitStatusResult }
| { kind: 'unavailable'; message: string }
| { kind: 'error'; message: string }
@@ -49,7 +49,7 @@ export type StatusLoadInFlight = {
export type GitRequestError = Error & { code?: string }
export type GitCommitResult = { success: boolean; error?: string }
export type MobileGitStatusEntryView = MobileGitStatusEntry & {
export type MobileGitStatusEntryView = GitStatusEntry & {
canDiscard: boolean
canOpen: boolean
canStage: boolean
@@ -61,7 +61,7 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & {
// Decorate raw status entries with the row-level capability/action-id fields the
// file list needs. Opener guards must use the same canOpen rule.
export function buildMobileGitStatusEntryViews(
entries: readonly MobileGitStatusEntry[]
entries: readonly GitStatusEntry[]
): MobileGitStatusEntryView[] {
return entries.map((entry) => ({
...entry,
@@ -77,23 +77,23 @@ export function buildMobileGitStatusEntryViews(
export type MobileBranchCompareState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; result: MobileGitBranchCompareResult }
| { kind: 'ready'; result: GitBranchCompareResult }
| { kind: 'error'; message: string }
export type MobileBranchEntryView = MobileGitBranchChangeEntry & {
export type MobileBranchEntryView = GitBranchChangeEntry & {
canOpen: boolean
}
export type MobileBranchDiffPreviewState =
| { kind: 'loading'; entry: MobileGitBranchChangeEntry }
| { kind: 'loading'; entry: GitBranchChangeEntry }
| {
kind: 'ready'
entry: MobileGitBranchChangeEntry
summary: MobileGitBranchCompareSummary
entry: GitBranchChangeEntry
summary: GitBranchCompareSummary
lines: MobileHighlightedDiffLine<MobileDiffLine>[]
truncated: boolean
}
| { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string }
| { kind: 'error'; entry: GitBranchChangeEntry; message: string }
export type GitDiffTextResult = {
kind: 'text'
@@ -134,7 +134,7 @@ export function formatBranchLabel(branch: string | undefined, head: string | und
return branch || head?.slice(0, 7) || 'No branch'
}
export function statusColor(status: MobileGitFileStatus): string {
export function statusColor(status: GitFileStatus): string {
switch (status) {
case 'added':
case 'copied':
@@ -1,7 +1,7 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { useCallback, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError } from '../platform/haptics'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { LoadStatusOptions } from './mobile-source-control-screen-state'
import {
getMobileCommitFailureStagedEntries,
@@ -24,7 +24,7 @@ type LoadStatus = (options?: LoadStatusOptions) => Promise<boolean>
type Params = {
client: RpcClient | null
worktreeId: string
status: MobileGitStatusResult | null
status: GitStatusResult | null
branchLabel: string
commitMessage: string
stagedEntries: MobileCommitFailureRecovery['stagedEntries']
@@ -1,11 +1,8 @@
import type { GitStatusResult, GitUpstreamStatus } from '../../../src/shared/git-status-types'
import { useCallback } from 'react'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import {
isMobileGitUnavailable,
type MobileGitStatusResult,
type MobileGitUpstreamStatus
} from './mobile-git-status'
import { isMobileGitUnavailable } from './mobile-git-status'
import type { GitCommitResult, GitRequestError } from './mobile-source-control-screen-state'
type Params = {
@@ -49,16 +46,16 @@ export function useMobileGitRequests({ client, connState, worktreeId }: Params)
[sendGitRequest]
)
const readUpstreamStatusForSync = useCallback(async (): Promise<MobileGitUpstreamStatus> => {
const readUpstreamStatusForSync = useCallback(async (): Promise<GitUpstreamStatus> => {
try {
return await sendGitRequest<MobileGitUpstreamStatus>('git.upstreamStatus')
return await sendGitRequest<GitUpstreamStatus>('git.upstreamStatus')
} catch (err) {
const code = err instanceof Error ? (err as GitRequestError).code : undefined
const message = err instanceof Error ? err.message : String(err)
if (!isMobileGitUnavailable(code, message)) {
throw err
}
const status = await sendGitRequest<MobileGitStatusResult>('git.status')
const status = await sendGitRequest<GitStatusResult>('git.status')
if (!status.upstreamStatus) {
throw new Error('Branch status unavailable')
}
@@ -1,14 +1,14 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { useMemo } from 'react'
import { buildMobileCreatePrAction } from './mobile-create-pr-action'
import { useMobileHostedReviewEligibility } from './use-mobile-hosted-review-eligibility'
import type { MobileGitStatusResult } from './mobile-git-status'
type Params = {
client: Parameters<typeof useMobileHostedReviewEligibility>[0]['client']
connState: Parameters<typeof useMobileHostedReviewEligibility>[0]['connState']
hostId: string
worktreeId: string
status: MobileGitStatusResult | null
status: GitStatusResult | null
hasUncommittedChanges: boolean
busyAction: string | null
createPr: (pushFirst: boolean) => void
@@ -1,3 +1,4 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { View } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
@@ -8,10 +9,9 @@ import { gitBranchCompareRead, gitStatusHostPayloadRead } from './mobile-git-rea
import {
isMobileGitTransientRefreshError,
isMobileGitUnavailableReply,
readMobileGitRefusal,
type MobileGitStatusResult
readMobileGitRefusal
} from './mobile-git-status'
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
import type { GitBranchCompareResult } from '../../../src/shared/git-diff-compare-types'
import {
SELECTOR_RETRY_COUNT,
SELECTOR_RETRY_DELAY_MS,
@@ -147,7 +147,7 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
setBranchCompareState({
kind: 'ready',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
result: compared as MobileGitBranchCompareResult
result: compared as GitBranchCompareResult
})
return true
} catch (err) {
@@ -215,7 +215,7 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
const refusal = readMobileGitRefusal(reply)
if (!refusal) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
const result = gitStatusHostPayloadRead.interpret(reply) as MobileGitStatusResult
const result = gitStatusHostPayloadRead.interpret(reply) as GitStatusResult
setScreenState({ kind: 'ready', status: result })
void loadBranchCompare({ preserveReadyOnFailure: true })
if (options?.clearActionErrorOnSuccess !== false) {
@@ -1,3 +1,4 @@
import type { GitStatusEntry } from '../../../src/shared/git-status-types'
import { useCallback, useRef, useState, type MutableRefObject } from 'react'
import { useRouter } from 'expo-router'
import type { RpcClient } from '../transport/rpc-client'
@@ -9,16 +10,10 @@ import {
highlightMobileDiffLines,
resolveMobileSyntaxLanguage
} from '../session/mobile-file-syntax'
import {
canOpenMobileBranchCompareDiff,
type MobileGitBranchChangeEntry
} from './mobile-branch-compare'
import { canOpenMobileBranchCompareDiff } from './mobile-branch-compare'
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
import { gitBranchDiffRead } from './mobile-git-read-operations'
import {
canOpenMobileGitStatusEntry,
isMobileGitUnavailableReply,
type MobileGitStatusEntry
} from './mobile-git-status'
import { canOpenMobileGitStatusEntry, isMobileGitUnavailableReply } from './mobile-git-status'
import { sourceFileDiffOpenRun, sourceFileOpenRun } from './mobile-source-file-open-operations'
import { buildMobileReviewFileRoute } from './mobile-review-route'
import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff'
@@ -77,7 +72,7 @@ export function useMobileSourceControlOpeners(params: Params) {
const openingBranchPathRef = useRef<string | null>(null)
const openFile = useCallback(
async (entry: MobileGitStatusEntry) => {
async (entry: GitStatusEntry) => {
// Deletions are openable (pre-delete text/image via git.diff); only block
// unresolved conflicts, matching canOpenMobileGitStatusEntry / row UI.
if (!canOpenMobileGitStatusEntry(entry)) {
@@ -202,7 +197,7 @@ export function useMobileSourceControlOpeners(params: Params) {
)
const openBranchDiff = useCallback(
async (entry: MobileGitBranchChangeEntry) => {
async (entry: GitBranchChangeEntry) => {
if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) {
return
}
@@ -1,3 +1,4 @@
import type { GitStatusResult } from '../../../src/shared/git-status-types'
import { useCallback, type MutableRefObject } from 'react'
import { useRouter } from 'expo-router'
import type { RpcClient } from '../transport/rpc-client'
@@ -7,7 +8,6 @@ import { useMobileSourceControlCommitRunners } from './use-mobile-source-control
import { useMobileSourceControlActionSheetRunners } from './use-mobile-source-control-action-sheet-runners'
import { useMobileCreatePrRunner } from './use-mobile-create-pr-runner'
import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { LoadStatusOptions } from './mobile-source-control-screen-state'
import type {
MobileCommitFailureRecovery,
@@ -21,7 +21,7 @@ type Params = {
client: RpcClient | null
hostId: string
worktreeId: string
status: MobileGitStatusResult | null
status: GitStatusResult | null
branchLabel: string
commitMessage: string
stagedEntries: MobileCommitFailureRecovery['stagedEntries']
@@ -1,3 +1,4 @@
import type { GitStatusEntry } from '../../../src/shared/git-status-types'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useHostClient, useForceReconnect } from '../transport/client-context'
@@ -21,8 +22,7 @@ import {
countStagedEntries,
countUnstagedEntries,
getStageablePaths,
getUnstageablePaths,
type MobileGitStatusEntry
getUnstageablePaths
} from './mobile-git-status'
import { getMobileCommitFailureStagedEntries } from './mobile-commit-failure-recovery'
import { useMobileSourceControlCommitFailure } from './use-mobile-source-control-commit-failure'
@@ -32,8 +32,6 @@ import {
type MobileBranchEntryView
} from './mobile-source-control-screen-state'
type MobileGitLocalBranches = RuntimeGitLocalBranches
export type MobileSourceControlStateParams = {
hostId: string
worktreeId: string
@@ -67,10 +65,10 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const [commitMessage, setCommitMessage] = useState('')
const [generatingMessage, setGeneratingMessage] = useState(false)
const [showBranchPicker, setShowBranchPicker] = useState(false)
const [localBranches, setLocalBranches] = useState<MobileGitLocalBranches | null>(null)
const [localBranches, setLocalBranches] = useState<RuntimeGitLocalBranches | null>(null)
const [createdPrUrl, setCreatedPrUrl] = useState<string | null>(null)
const [createdPrWarning, setCreatedPrWarning] = useState<string | null>(null)
const [discardTarget, setDiscardTarget] = useState<MobileGitStatusEntry | null>(null)
const [discardTarget, setDiscardTarget] = useState<GitStatusEntry | null>(null)
const [showActionSheet, setShowActionSheet] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const keyboardLift = useMobileSourceControlKeyboardLift()
+3 -6
View File
@@ -2,11 +2,8 @@ import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { RpcClient } from '../transport/rpc-client'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import {
agentLaunchCreateFields,
type WorkspaceCreateParams,
type WorkspaceCreateSetupDecision
} from './workspace-create-params'
import { agentLaunchCreateFields, type WorkspaceCreateParams } from './workspace-create-params'
import type { SetupDecision } from '../../../src/shared/worktree/create-types'
// The blank/named create path, extracted from NewWorktreeModal so the modal keeps
// only the UI-coupled setup-trust flow. Assembles worktree.create params and
@@ -17,7 +14,7 @@ export async function createBlankWorkspace(args: {
baseName: string
createdWithAgentId: TuiAgent | undefined
comment: string | undefined
setupDecision: WorkspaceCreateSetupDecision
setupDecision: SetupDecision
/** True when `baseName` is a generated creature name rather than one the user typed; only then
* may the host retire it. */
nameWasGenerated: boolean
@@ -16,7 +16,7 @@ import type {
MobileLinkedWorkItem,
SmartNameSelection
} from './mobile-composer-source-types'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
import type { GitPushTarget } from '../../../src/shared/worktree/types'
export function buildGitHubLinkedWorkItem(item: {
type: 'issue' | 'pr'
@@ -85,7 +85,7 @@ export function resolveComposerCreateSelection(args: {
base: {
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
pushTarget?: GitPushTarget
branchNameOverride?: string
}
branch: { refName: string; localBranchName: string } | null
+1 -3
View File
@@ -6,10 +6,8 @@ export type GitHubCheckLike = {
conclusion?: string | null
}
export type GitHubCheckSummary = ProviderCheckSummary
// Why: reuse the desktop classifier verbatim — a second copy is what let mobile call `skipped`
// unresolved while desktop called the same PR green.
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary {
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): ProviderCheckSummary {
return summarizeProviderChecks(checks)
}
+4 -5
View File
@@ -1,14 +1,13 @@
import type { GitHubProjectIdentity } from '../../../src/shared/github/project-identity'
export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType']
export type GitHubProjectRef = GitHubProjectIdentity
export type GitHubProjectSettings = {
pinned: GitHubProjectRef[]
recent: Array<GitHubProjectRef & { lastOpenedAt: string }>
pinned: GitHubProjectIdentity[]
recent: Array<GitHubProjectIdentity & { lastOpenedAt: string }>
lastViewByProject: Record<string, { viewId: string }>
activeProject: GitHubProjectRef | null
activeProject: GitHubProjectIdentity | null
}
export type GitHubProjectSummary = GitHubProjectRef & {
export type GitHubProjectSummary = GitHubProjectIdentity & {
id: string
title: string
url: string
@@ -3,14 +3,14 @@ import type {
WorkspaceSourceLinkedItem,
WorkspaceSourceSelection
} from '../../../src/shared/new-workspace/workspace-source'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
import type { GitPushTarget } from '../../../src/shared/worktree/types'
export type { SmartNameMode }
export type ComposerBaseState = {
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
pushTarget?: GitPushTarget
branchNameOverride?: string
}
@@ -48,7 +48,7 @@ export type MobileComposerCreateSelection =
item: MobileLinkedWorkItem
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
pushTarget?: GitPushTarget
branchNameOverride?: string
}
| {
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LinearIssue } from './mobile-tasks-provider-detail-types'
import type { LinearMobileIssue } from './mobile-tasks-provider-detail-types'
import {
sortLinearIssues,
groupLinearIssues,
@@ -9,7 +9,7 @@ import {
vi.mock('./mobile-tasks-dependencies', () => import('../theme/mobile-theme'))
afterEach(() => vi.restoreAllMocks())
const issues: LinearIssue[] = Array.from({ length: 60 }, (_, i) => ({
const issues: LinearMobileIssue[] = Array.from({ length: 60 }, (_, i) => ({
id: `${i}`,
identifier: ['ENG-10', 'ENG-2', 'Ä-1', 'Å-1', 'é-2', 'e\u0301-2', 'İ-3'][i % 7],
title: 'Task',
+6 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LinearIssue } from './mobile-tasks-provider-detail-types'
import type { LinearMobileIssue } from './mobile-tasks-provider-detail-types'
import type { LinearOrderBy } from './mobile-tasks-view-state-types'
import { groupLinearIssues, sortLinearIssues } from './mobile-tasks-reviewer-linear'
import { taskTime } from './mobile-tasks-item-mapping'
@@ -8,7 +8,7 @@ import { getLinearPriorityRank } from './mobile-tasks-hosted-review'
vi.mock('./mobile-tasks-dependencies', () => import('../theme/mobile-theme'))
afterEach(() => vi.restoreAllMocks())
const issues: LinearIssue[] = Array.from({ length: 60 }, (_, i) => ({
const issues: LinearMobileIssue[] = Array.from({ length: 60 }, (_, i) => ({
id: `${i}`,
identifier: ['ENG-10', 'ENG-2', 'Ä-1', 'Å-1', 'é-2', 'e\u0301-2', 'İ-3'][i % 7],
title: 'Task',
@@ -21,7 +21,10 @@ const issues: LinearIssue[] = Array.from({ length: 60 }, (_, i) => ({
state: { name: i % 2 ? 'Todo' : 'Done', type: 'started', color: '' },
team: { id: `${i % 3}`, name: `Team ${i % 3}`, key: 'ENG' }
}))
function originalSort(input: readonly LinearIssue[], mode: LinearOrderBy): LinearIssue[] {
function originalSort(
input: readonly LinearMobileIssue[],
mode: LinearOrderBy
): LinearMobileIssue[] {
return [...input].sort((a, b) => {
if (mode === 'updated') {
return taskTime(b.updatedAt) - taskTime(a.updatedAt)
@@ -1,12 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import type { HostStackNavigationState } from '../navigation/host-stack-navigation'
import {
coordinateMobileTasksNavigation,
mobileTasksHostRoute,
navigateToMobileTasks,
type MobileTasksNavigationState
navigateToMobileTasks
} from './mobile-task-navigation'
function navigationHarness(initialState: MobileTasksNavigationState) {
function navigationHarness(initialState: HostStackNavigationState) {
let stateListener = () => {}
let state = initialState
const unsubscribeState = vi.fn()
@@ -20,7 +20,7 @@ function navigationHarness(initialState: MobileTasksNavigationState) {
}
return {
navigation,
setState(nextState: MobileTasksNavigationState) {
setState(nextState: HostStackNavigationState) {
state = nextState
stateListener()
},
+8 -16
View File
@@ -4,7 +4,6 @@ import {
navigateToHostStackRoute,
type HostStackHostRoute,
type HostStackNavigationController,
type HostStackNavigationState,
type HostStackRootNavigation,
type HostStackRouteTarget,
type HostStackRouter,
@@ -12,14 +11,7 @@ import {
} from '../navigation/host-stack-navigation'
import type { TaskProvider } from './mobile-task-providers'
export type MobileTasksHostRoute = HostStackHostRoute
export type MobileTasksNavigationState = HostStackNavigationState
export type MobileTasksRootNavigation = HostStackRootNavigation
export type MobileTasksRouter = HostStackRouter
export type MobileTasksNavigationController = HostStackNavigationController
export type PendingMobileTasksNavigation = PendingHostStackNavigation
export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute {
export function mobileTasksHostRoute(hostId: string): HostStackHostRoute {
return hostStackHostRoute(hostId)
}
@@ -34,11 +26,11 @@ export function mobileTasksRouteTarget(
}
export function navigateToMobileTasks(
navigation: MobileTasksRootNavigation,
router: MobileTasksRouter,
navigation: HostStackRootNavigation,
router: HostStackRouter,
hostId: string,
provider?: TaskProvider
): MobileTasksNavigationController {
): HostStackNavigationController {
return navigateToHostStackRoute(
navigation,
router,
@@ -48,12 +40,12 @@ export function navigateToMobileTasks(
}
export function coordinateMobileTasksNavigation(
current: PendingMobileTasksNavigation | null,
navigation: MobileTasksRootNavigation,
router: MobileTasksRouter,
current: PendingHostStackNavigation | null,
navigation: HostStackRootNavigation,
router: HostStackRouter,
hostId: string,
provider?: TaskProvider
): PendingMobileTasksNavigation {
): PendingHostStackNavigation {
return coordinateHostStackNavigation(
current,
navigation,
@@ -89,11 +89,11 @@ export { parseGitHubProjectInput as parseProjectInput } from './github-project-r
export type {
GitHubProjectOwnerType,
GitHubProjectPartialFailure,
GitHubProjectRef,
GitHubProjectSettings,
GitHubProjectSummary,
GitHubProjectViewSummary
} from './github-project-reference'
export type { GitHubProjectIdentity } from '../../../src/shared/github/project-identity'
export {
extractGitHubIssueSourceFallback,
extractGitHubIssueSourceError
@@ -15,7 +15,7 @@ import type {
GitHubWorkItem,
GitLabTodo,
GitLabWorkItem,
LinearIssue,
LinearMobileIssue,
RepoSummary
} from './mobile-tasks-provider-detail-types'
@@ -288,7 +288,7 @@ export async function mapWithConcurrency<T, R>(
return results
}
export function createLinearTask(issue: LinearIssue): TaskItem {
export function createLinearTask(issue: LinearMobileIssue): TaskItem {
return {
key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`,
provider: 'linear',
+3 -3
View File
@@ -22,7 +22,7 @@ import type {
TaskSort
} from './mobile-tasks-view-state-types'
import type { ActionableTaskItem } from './mobile-tasks-project-workspace-types'
import type { DetailComment, LinearIssue } from './mobile-tasks-provider-detail-types'
import type { DetailComment, LinearMobileIssue } from './mobile-tasks-provider-detail-types'
export const PROVIDER_OPTIONS: PickerOption<TaskProvider>[] = [
{
@@ -180,12 +180,12 @@ export type LinearIssueSection = {
key: string
label: string
color: string
issues: LinearIssue[]
issues: LinearMobileIssue[]
}
export type LinearListEntry =
| { type: 'section'; section: LinearIssueSection }
| { type: 'issue'; issue: LinearIssue }
| { type: 'issue'; issue: LinearMobileIssue }
export const PROJECT_VIEW_DEFAULT_SORT = '__view_default__'
@@ -2,7 +2,7 @@ import type {
GitHubWorkItem,
GitLabWorkItem,
GitLabTodo,
LinearIssue,
LinearMobileIssue,
SetupDecision
} from './mobile-tasks-provider-detail-types'
import type { WorkspaceAgentChoice, SparsePreset } from './mobile-tasks-dependencies'
@@ -43,7 +43,7 @@ export type TaskItem =
subtitle: string
status: string
updatedAt: string
source: LinearIssue
source: LinearMobileIssue
}
export type ActionableTaskItem = Exclude<TaskItem, { provider: 'gitlabTodo' }>
@@ -151,7 +151,7 @@ export type LinearIssueChild = {
url: string
}
export type LinearIssue = LinearMobileIssue
export type { LinearMobileIssue }
export type LinearState = {
id: string
@@ -11,7 +11,7 @@ import type {
GitHubAssignableUser,
GitHubPRReviewSummary,
GitHubRepoSources,
LinearIssue,
LinearMobileIssue,
LinearTeam
} from './mobile-tasks-provider-detail-types'
@@ -84,9 +84,9 @@ export function issueSourceSlug(source: GitHubOwnerRepo | null | undefined): str
}
export function sortLinearIssues(
issues: readonly LinearIssue[],
issues: readonly LinearMobileIssue[],
orderBy: LinearOrderBy
): LinearIssue[] {
): LinearMobileIssue[] {
if (issues.length < 2) {
return [...issues]
}
@@ -104,7 +104,7 @@ export function sortLinearIssues(
}
export function getLinearIssueGroup(
issue: LinearIssue,
issue: LinearMobileIssue,
groupBy: LinearGroupBy
): {
key: string
@@ -135,7 +135,7 @@ export function getLinearIssueGroup(
}
export function groupLinearIssues(
issues: LinearIssue[],
issues: LinearMobileIssue[],
groupBy: LinearGroupBy,
orderBy: LinearOrderBy
): LinearIssueSection[] {
@@ -144,14 +144,14 @@ export function groupLinearIssues(
/** The caller must sort issues by its selected order before grouping. */
export function groupSortedLinearIssues(
issues: readonly LinearIssue[],
issues: readonly LinearMobileIssue[],
groupBy: LinearGroupBy
): LinearIssueSection[] {
return groupOrderedLinearIssues([...issues], groupBy)
}
function groupOrderedLinearIssues(
sorted: LinearIssue[],
sorted: LinearMobileIssue[],
groupBy: LinearGroupBy
): LinearIssueSection[] {
if (groupBy === 'none') {
@@ -159,7 +159,7 @@ function groupOrderedLinearIssues(
}
const sections = new Map<
string,
{ key: string; label: string; color: string; issues: LinearIssue[] }
{ key: string; label: string; color: string; issues: LinearMobileIssue[] }
>()
for (const issue of sorted) {
const group = getLinearIssueGroup(issue, groupBy)
@@ -174,7 +174,7 @@ function groupOrderedLinearIssues(
}
export function linearIssueSecondaryParts(
issue: LinearIssue,
issue: LinearMobileIssue,
displayProperties: ReadonlySet<LinearDisplayProperty>
): string[] {
const parts = [issue.identifier]
@@ -3,7 +3,7 @@ import type {
TuiAgent,
TaskProvider,
GitHubProjectSettings,
GitHubProjectRef
GitHubProjectIdentity
} from './mobile-tasks-dependencies'
import type { GitHubProjectSortDirection } from '../../../src/shared/github/project-types'
@@ -159,7 +159,7 @@ export type GitHubProjectRow = {
}
export type GitHubProjectTable = {
project: GitHubProjectRef & {
project: GitHubProjectIdentity & {
id: string
title: string
url: string
+5 -5
View File
@@ -10,9 +10,9 @@ import {
agentLaunchCreateFields,
buildTaskWorkspaceCreateParams,
type WorkspaceCreateParams,
type WorkspaceCreateSetupDecision,
type WorkspaceCreateTaskItem
} from './workspace-create-params'
import type { SetupDecision } from '../../../src/shared/worktree/create-types'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
@@ -26,7 +26,7 @@ export type CreateWorkspaceFromComposerArgs = {
client: RpcClient
selection: MobileComposerCreateSelection
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
setupDecision: SetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
nameIsAutoManaged?: boolean
@@ -89,7 +89,7 @@ async function createWorkItemWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'work-item' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
setupDecision: SetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
nameIsAutoManaged?: boolean
@@ -148,7 +148,7 @@ async function createBranchWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
setupDecision: SetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
nameIsAutoManaged?: boolean
@@ -235,7 +235,7 @@ async function createNewBranchWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'new-branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
setupDecision: SetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
nameIsAutoManaged?: boolean
@@ -10,7 +10,7 @@ import {
type GitHubDetailCheck,
type GitHubDetailFile,
type GitHubPRReviewSummary,
type LinearIssue,
type LinearMobileIssue,
type TaskItem,
createLinearTask,
isSuccess
@@ -207,7 +207,7 @@ export function useMobileTasksItemDetailLoading(model: ItemDetailMetadataEffects
if (!isSuccess(issueResponse)) {
throw new Error(issueResponse.error.message)
}
const issue = issueResponse.result as LinearIssue | null
const issue = issueResponse.result as LinearMobileIssue | null
const comments = isSuccess(commentsResponse)
? ((commentsResponse.result as DetailComment[]) ?? [])
: []
@@ -2,7 +2,7 @@ import type { GithubReplyMergeActionsModel } from './use-mobile-tasks-github-rep
import { useCallback } from './mobile-tasks-dependencies'
import {
type DetailComment,
type LinearIssue,
type LinearMobileIssue,
type LinearIssueChild,
type TaskItem,
createLinearTask,
@@ -87,7 +87,7 @@ export function useMobileTasksLinearItemActions(model: GithubReplyMergeActionsMo
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
const issue = response.result as LinearIssue | null
const issue = response.result as LinearMobileIssue | null
if (!issue) {
throw new Error('Sub-issue not found')
}
@@ -2,7 +2,7 @@ import type { TaskPaginationActionsModel } from './use-mobile-tasks-task-paginat
import {
type GitHubProjectOwnerType,
type GitHubProjectPartialFailure,
type GitHubProjectRef,
type GitHubProjectIdentity,
type GitHubProjectSettings,
type GitHubProjectSummary,
type GitHubProjectViewSummary,
@@ -69,7 +69,7 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions
}, [client, connState, tasksSupported])
const loadGitHubProjectViews = useCallback(
async (project: GitHubProjectRef): Promise<GitHubProjectViewSummary[]> => {
async (project: GitHubProjectIdentity): Promise<GitHubProjectViewSummary[]> => {
if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) {
return []
}
@@ -163,7 +163,7 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions
)
const commitGitHubProjectView = useCallback(
(project: GitHubProjectRef, viewId: string): void => {
(project: GitHubProjectIdentity, viewId: string): void => {
const projectKey = githubProjectKey(project)
const nextSettings: GitHubProjectSettings = {
...githubProjectSettings,
@@ -186,7 +186,10 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions
)
const selectGitHubProject = useCallback(
async (project: GitHubProjectRef, options: { viewNumber?: number } = {}): Promise<void> => {
async (
project: GitHubProjectIdentity,
options: { viewNumber?: number } = {}
): Promise<void> => {
if (!tasksSupported || !taskStateHydrated) {
return
}
@@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
LinearGroupBy,
LinearIssue,
LinearMobileIssue,
LinearIssueSection,
LinearOrderBy,
LinearViewMode,
@@ -35,11 +35,15 @@ vi.mock('./mobile-tasks-legacy-foundation', async () => {
return {
...options,
...linear,
groupLinearIssues: (issues: LinearIssue[], groupBy: LinearGroupBy, orderBy: LinearOrderBy) => {
groupLinearIssues: (
issues: LinearMobileIssue[],
groupBy: LinearGroupBy,
orderBy: LinearOrderBy
) => {
groupingInputSizes.push(issues.length)
return linear.groupLinearIssues(issues, groupBy, orderBy)
},
groupSortedLinearIssues: (issues: readonly LinearIssue[], groupBy: LinearGroupBy) => {
groupSortedLinearIssues: (issues: readonly LinearMobileIssue[], groupBy: LinearGroupBy) => {
groupingInputSizes.push(issues.length)
return linear.groupSortedLinearIssues(issues, groupBy)
}
@@ -70,7 +74,7 @@ const ORDERINGS: LinearOrderBy[] = ['priority', 'updated', 'identifier']
/** Deterministic issues: every field is a pure function of the index, so grouping,
* ordering and comparison counts repeat exactly across runs. */
function makeIssue(index: number): LinearIssue {
function makeIssue(index: number): LinearMobileIssue {
const state = STATES[(index * 3) % STATES.length]!
const team = TEAMS[(index * 5) % TEAMS.length]!
return {
@@ -190,7 +194,7 @@ function current(): Projection {
/** The pre-change board memo, kept verbatim as the parity and count oracle. */
function legacyProjection(input: ProbeInput): {
issuesForView: LinearIssue[]
issuesForView: LinearMobileIssue[]
listSections: LinearIssueSection[]
boardSections: LinearIssueSection[]
} {
@@ -2,7 +2,7 @@ import type { RouteAndItemStateModel } from './use-mobile-tasks-route-and-item-s
import {
type BaseRefSearchResult,
type GitHubProjectPartialFailure,
type GitHubProjectRef,
type GitHubProjectIdentity,
type GitHubProjectSettings,
type GitHubProjectSummary,
type GitHubProjectViewSummary,
@@ -120,7 +120,7 @@ export function useMobileTasksWorkspaceAndProjectState(model: RouteAndItemStateM
const [showGitHubProjectSortPicker, setShowGitHubProjectSortPicker] = useState(false)
const [showGitHubProjectFieldsPicker, setShowGitHubProjectFieldsPicker] = useState(false)
const [pendingGitHubProjectViewSelection, setPendingGitHubProjectViewSelection] =
useState<GitHubProjectRef | null>(null)
useState<GitHubProjectIdentity | null>(null)
const [projectRowItem, setProjectRowItem] = useState<GitHubProjectRow | null>(null)
const [projectRowDetail, setProjectRowDetail] = useState<DetailPayload | null>(null)
const [projectRowDetailLoading, setProjectRowDetailLoading] = useState(false)
+4 -8
View File
@@ -9,13 +9,9 @@ import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/worksp
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
export type WorkspaceCreateSetupDecision = SetupDecision
export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest
export type WorkspaceCreateGitPushTarget = GitPushTarget
export type WorkspaceCreateHostedStartPoint = {
baseBranch: string
pushTarget?: WorkspaceCreateGitPushTarget
pushTarget?: GitPushTarget
}
type WorkspaceCreateGitHubItem = {
@@ -78,15 +74,15 @@ export function agentLaunchCreateFields(agentId: TuiAgent | undefined): {
export function buildTaskWorkspaceCreateParams(args: {
item: WorkspaceCreateTaskItem
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
setupDecision: SetupDecision
agent?: WorkspaceAgentChoice
workspaceName?: string
note?: string
baseBranch?: string
compareBaseRef?: string
branchNameOverride?: string
pushTarget?: WorkspaceCreateGitPushTarget
sparseCheckout?: WorkspaceCreateSparseCheckout
pushTarget?: GitPushTarget
sparseCheckout?: CreateSparseCheckoutRequest
hostedStartPoint?: WorkspaceCreateHostedStartPoint
nameIsAutoManaged?: boolean
}): WorkspaceCreateParams {
+348 -348
View File
@@ -16,168 +16,16 @@ import { dispatchTerminalWebViewNotification } from './terminal-webview-notifica
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
type Props = TerminalWebViewProps
export type { TerminalWebViewHandle } from './terminal-webview-contract'
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{
style,
terminalTheme,
textScale = 1,
onWebReady,
onEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
},
ref
) {
const webViewRef = useRef<WebView>(null)
const isWebReadyRef = useRef(false)
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
const messageIdRef = useRef(0)
const pendingPingIdRef = useRef<number | null>(null)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
const measureResolveRef = useRef<
((result: { cols: number; rows: number } | null) => void) | null
>(null)
// Why: each init() call posts 'init' to the WebView and arms a fresh
// ready promise. WebView's init() rAF chain ends with a 'ready' notify
// that resolves it. measureFitDimensions awaits this so it doesn't
// race ahead of term.open() / renderService population.
const readyPromiseRef = useRef<Promise<void> | null>(null)
const readyResolveRef = useRef<(() => void) | null>(null)
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
const id = messageIdRef.current
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
return id
}, [])
const flushPendingMessages = useCallback(() => {
pendingMessages.flush(sendToWebView)
}, [pendingMessages, sendToWebView])
const postMessage = useCallback(
(msg: TerminalWebViewCommand) => {
if (!isWebReadyRef.current) {
pendingMessages.queue(msg)
return
}
sendToWebView(msg)
},
[pendingMessages, sendToWebView]
)
// Why: a busy PTY delivers ~200 stream frames/s; coalescing here collapses the
// per-frame bridge + WebKit IPC + paint cost that runs the phone hot (#9302).
const writeCoalescer = useMemo(
() => createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data })),
[postMessage]
)
useEffect(() => {
return () => {
writeCoalescer.clear()
}
}, [writeCoalescer])
const confirmWebReady = useCallback(
(notifyParent: boolean) => {
pendingPingIdRef.current = null
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
if (notifyParent) {
onWebReady?.()
}
// Why: reload clears queued commands, so readiness must always restore the
// native-selected theme even when its value did not change in React.
sendToWebView({ type: 'set-theme', terminalTheme })
flushPendingMessages()
},
[
clearEngineError,
clearWebReadyWatchdog,
flushPendingMessages,
export const TerminalWebView = forwardRef<TerminalWebViewHandle, TerminalWebViewProps>(
function TerminalWebView(
{
style,
terminalTheme,
textScale = 1,
onWebReady,
sendToWebView,
terminalTheme
]
)
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
} catch {
return
}
routeTerminalQueryReply(msg, onTerminalQueryReply)
if (msg.type === 'web-ready') {
confirmWebReady(true)
} else if (
msg.type === 'pong' &&
typeof msg.pingId === 'number' &&
msg.pingId === pendingPingIdRef.current
) {
confirmWebReady(false)
} else if (msg.type === 'ready') {
// Why: the WebView's init() rAF chain has run — term is open,
// renderService is populated, first paint has happened. Resolve
// any pending awaitReady() so a queued measure can now safely
// read cell dims.
const resolve = readyResolveRef.current
readyResolveRef.current = null
readyPromiseRef.current = null
resolve?.()
} else if (msg.type === 'measure-result') {
const resolve = measureResolveRef.current
measureResolveRef.current = null
if (resolve) {
const cols = typeof msg.cols === 'number' ? msg.cols : null
const rows = typeof msg.rows === 'number' ? msg.rows : null
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
}
} else {
dispatchTerminalWebViewNotification(msg, {
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
})
}
},
[
confirmWebReady,
reportEngineError,
onEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
@@ -190,207 +38,359 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onFileTap,
onOpenUrl,
onTextScaleChange
]
)
},
ref
) {
const webViewRef = useRef<WebView>(null)
const isWebReadyRef = useRef(false)
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
const messageIdRef = useRef(0)
const pendingPingIdRef = useRef<number | null>(null)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
const measureResolveRef = useRef<
((result: { cols: number; rows: number } | null) => void) | null
>(null)
// Why: each init() call posts 'init' to the WebView and arms a fresh
// ready promise. WebView's init() rAF chain ends with a 'ready' notify
// that resolves it. measureFitDimensions awaits this so it doesn't
// race ahead of term.open() / renderService population.
const readyPromiseRef = useRef<Promise<void> | null>(null)
const readyResolveRef = useRef<(() => void) | null>(null)
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
pendingPingIdRef.current = null
armWebReadyWatchdog()
// Why: messages queued for a previous WebView generation are stale after a reload;
// dropping them avoids replaying terminal chunks before the next init snapshot.
pendingMessages.clear()
writeCoalescer.clear()
}, [armWebReadyWatchdog, pendingMessages, writeCoalescer])
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
const id = messageIdRef.current
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
return id
}, [])
const handleReload = useCallback(() => {
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError])
const flushPendingMessages = useCallback(() => {
pendingMessages.flush(sendToWebView)
}, [pendingMessages, sendToWebView])
const handleContentProcessDidTerminate = useCallback(() => {
// Why: WKWebView content-process loss is recoverable; stale commands belong
// to the dead document and the replacement must prove readiness before replay.
isWebReadyRef.current = false
pendingPingIdRef.current = null
pendingMessages.clear()
writeCoalescer.clear()
clearEngineError()
armWebReadyWatchdog()
webViewRef.current?.reload()
}, [armWebReadyWatchdog, clearEngineError, pendingMessages, writeCoalescer])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
// Why: live-apply text-size changes to an already-mounted terminal (the pane
// stays alive while the user visits Settings), so no terminal reload is needed.
useEffect(() => {
postMessage({ type: 'set-font-scale', fontScale: textScale })
}, [postMessage, textScale])
useImperativeHandle(
ref,
() => ({
prepareForForegroundRecovery() {
if (Platform.OS !== 'ios') {
const postMessage = useCallback(
(msg: TerminalWebViewCommand) => {
if (!isWebReadyRef.current) {
pendingMessages.queue(msg)
return
}
// Why: direct ping is the only command allowed through while readiness is
// invalid; init/write commands queue until this exact document answers.
isWebReadyRef.current = false
armWebReadyWatchdog()
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
sendToWebView(msg)
},
write(data: string) {
writeCoalescer.write(data)
[pendingMessages, sendToWebView]
)
// Why: a busy PTY delivers ~200 stream frames/s; coalescing here collapses the
// per-frame bridge + WebKit IPC + paint cost that runs the phone hot (#9302).
const writeCoalescer = useMemo(
() => createTerminalWriteCoalescer((data) => postMessage({ type: 'write', data })),
[postMessage]
)
useEffect(() => {
return () => {
writeCoalescer.clear()
}
}, [writeCoalescer])
const confirmWebReady = useCallback(
(notifyParent: boolean) => {
pendingPingIdRef.current = null
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
if (notifyParent) {
onWebReady?.()
}
// Why: reload clears queued commands, so readiness must always restore the
// native-selected theme even when its value did not change in React.
sendToWebView({ type: 'set-theme', terminalTheme })
flushPendingMessages()
},
init(
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
// Resolve any prior in-flight ready first so awaiters from the
// previous generation don't sit on the 3s setTimeout fallback —
// each leaked timer + closure pinned an awaiting measure caller
// for the full 3s under rapid re-init (orientation change,
// multiple resubscribes), delaying cold-start fit chains.
const priorResolve = readyResolveRef.current
if (priorResolve) {
[
clearEngineError,
clearWebReadyWatchdog,
flushPendingMessages,
onWebReady,
sendToWebView,
terminalTheme
]
)
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(event.nativeEvent.data) as Record<string, unknown>
} catch {
return
}
routeTerminalQueryReply(msg, onTerminalQueryReply)
if (msg.type === 'web-ready') {
confirmWebReady(true)
} else if (
msg.type === 'pong' &&
typeof msg.pingId === 'number' &&
msg.pingId === pendingPingIdRef.current
) {
confirmWebReady(false)
} else if (msg.type === 'ready') {
// Why: the WebView's init() rAF chain has run — term is open,
// renderService is populated, first paint has happened. Resolve
// any pending awaitReady() so a queued measure can now safely
// read cell dims.
const resolve = readyResolveRef.current
readyResolveRef.current = null
readyPromiseRef.current = null
priorResolve()
}
readyPromiseRef.current = new Promise<void>((resolve) => {
readyResolveRef.current = resolve
})
// Why: pending chunks are pre-snapshot data; the init snapshot supersedes
// them, and writing them after init would corrupt the fresh buffer.
writeCoalescer.clear()
postMessage({
type: 'init',
cols,
rows,
initialData,
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
})
},
resize(cols: number, rows: number) {
// Why: resize/reflow must observe all prior writes or bytes reorder.
writeCoalescer.flushNow()
postMessage({ type: 'resize', cols, rows })
},
reflow(cols: number, rows: number) {
writeCoalescer.flushNow()
postMessage({ type: 'reflow', cols, rows })
},
clear() {
writeCoalescer.clear()
postMessage({ type: 'clear' })
},
measureFitDimensions(
containerHeight?: number
): Promise<{ cols: number; rows: number } | null> {
if (!isWebReadyRef.current) {
return Promise.resolve(null)
}
return new Promise((resolve) => {
measureResolveRef.current?.(null)
let timeout: ReturnType<typeof setTimeout> | null = null
const finish = (result: { cols: number; rows: number } | null) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (measureResolveRef.current === finish) {
measureResolveRef.current = null
}
resolve(result)
resolve?.()
} else if (msg.type === 'measure-result') {
const resolve = measureResolveRef.current
measureResolveRef.current = null
if (resolve) {
const cols = typeof msg.cols === 'number' ? msg.cols : null
const rows = typeof msg.rows === 'number' ? msg.rows : null
resolve(cols && rows && cols >= 20 && rows >= 8 ? { cols, rows } : null)
}
measureResolveRef.current = finish
sendToWebView({ type: 'measure', containerHeight })
// Why: if the WebView doesn't respond within 2s (e.g., xterm
// failed to load), resolve null so the caller can disable
// Fit to Phone rather than hanging indefinitely.
timeout = setTimeout(() => {
if (measureResolveRef.current === finish) {
finish(null)
}
}, 2000)
})
},
resetZoom() {
postMessage({ type: 'reset-zoom' })
},
cancelSelect() {
postMessage({ type: 'cancel-select' })
},
doSelectAll() {
postMessage({ type: 'do-select-all' })
},
async awaitReady(): Promise<void> {
// Why: returns the in-flight ready promise (set by init); resolves
// immediately if no init is pending. Capped at 3s so a stuck
// WebView doesn't hang the caller.
const p = readyPromiseRef.current
if (!p) {
return
} else {
dispatchTerminalWebViewNotification(msg, {
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
})
}
await new Promise<void>((resolve) => {
let settled = false
const timeout = setTimeout(() => {
settled = true
resolve()
}, 3000)
void p.finally(() => {
if (!settled) {
clearTimeout(timeout)
},
[
confirmWebReady,
reportEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
onTextScaleChange
]
)
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
pendingPingIdRef.current = null
armWebReadyWatchdog()
// Why: messages queued for a previous WebView generation are stale after a reload;
// dropping them avoids replaying terminal chunks before the next init snapshot.
pendingMessages.clear()
writeCoalescer.clear()
}, [armWebReadyWatchdog, pendingMessages, writeCoalescer])
const handleReload = useCallback(() => {
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError])
const handleContentProcessDidTerminate = useCallback(() => {
// Why: WKWebView content-process loss is recoverable; stale commands belong
// to the dead document and the replacement must prove readiness before replay.
isWebReadyRef.current = false
pendingPingIdRef.current = null
pendingMessages.clear()
writeCoalescer.clear()
clearEngineError()
armWebReadyWatchdog()
webViewRef.current?.reload()
}, [armWebReadyWatchdog, clearEngineError, pendingMessages, writeCoalescer])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
}, [postMessage, terminalThemeKey, terminalTheme])
// Why: live-apply text-size changes to an already-mounted terminal (the pane
// stays alive while the user visits Settings), so no terminal reload is needed.
useEffect(() => {
postMessage({ type: 'set-font-scale', fontScale: textScale })
}, [postMessage, textScale])
useImperativeHandle(
ref,
() => ({
prepareForForegroundRecovery() {
if (Platform.OS !== 'ios') {
return
}
// Why: direct ping is the only command allowed through while readiness is
// invalid; init/write commands queue until this exact document answers.
isWebReadyRef.current = false
armWebReadyWatchdog()
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
},
write(data: string) {
writeCoalescer.write(data)
},
init(
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
// Resolve any prior in-flight ready first so awaiters from the
// previous generation don't sit on the 3s setTimeout fallback —
// each leaked timer + closure pinned an awaiting measure caller
// for the full 3s under rapid re-init (orientation change,
// multiple resubscribes), delaying cold-start fit chains.
const priorResolve = readyResolveRef.current
if (priorResolve) {
readyResolveRef.current = null
readyPromiseRef.current = null
priorResolve()
}
readyPromiseRef.current = new Promise<void>((resolve) => {
readyResolveRef.current = resolve
})
// Why: pending chunks are pre-snapshot data; the init snapshot supersedes
// them, and writing them after init would corrupt the fresh buffer.
writeCoalescer.clear()
postMessage({
type: 'init',
cols,
rows,
initialData,
oscLinks,
terminalTheme,
fontScale: textScale,
preserveScroll
})
},
resize(cols: number, rows: number) {
// Why: resize/reflow must observe all prior writes or bytes reorder.
writeCoalescer.flushNow()
postMessage({ type: 'resize', cols, rows })
},
reflow(cols: number, rows: number) {
writeCoalescer.flushNow()
postMessage({ type: 'reflow', cols, rows })
},
clear() {
writeCoalescer.clear()
postMessage({ type: 'clear' })
},
measureFitDimensions(
containerHeight?: number
): Promise<{ cols: number; rows: number } | null> {
if (!isWebReadyRef.current) {
return Promise.resolve(null)
}
return new Promise((resolve) => {
measureResolveRef.current?.(null)
let timeout: ReturnType<typeof setTimeout> | null = null
const finish = (result: { cols: number; rows: number } | null) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (measureResolveRef.current === finish) {
measureResolveRef.current = null
}
resolve(result)
}
measureResolveRef.current = finish
sendToWebView({ type: 'measure', containerHeight })
// Why: if the WebView doesn't respond within 2s (e.g., xterm
// failed to load), resolve null so the caller can disable
// Fit to Phone rather than hanging indefinitely.
timeout = setTimeout(() => {
if (measureResolveRef.current === finish) {
finish(null)
}
}, 2000)
})
},
resetZoom() {
postMessage({ type: 'reset-zoom' })
},
cancelSelect() {
postMessage({ type: 'cancel-select' })
},
doSelectAll() {
postMessage({ type: 'do-select-all' })
},
async awaitReady(): Promise<void> {
// Why: returns the in-flight ready promise (set by init); resolves
// immediately if no init is pending. Capped at 3s so a stuck
// WebView doesn't hang the caller.
const p = readyPromiseRef.current
if (!p) {
return
}
await new Promise<void>((resolve) => {
let settled = false
const timeout = setTimeout(() => {
settled = true
resolve()
}
}, 3000)
void p.finally(() => {
if (!settled) {
clearTimeout(timeout)
settled = true
resolve()
}
})
})
})
}
}),
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale, writeCoalescer]
)
return (
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
})
}),
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale, writeCoalescer]
)
return (
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
}
)
+5 -5
View File
@@ -1,7 +1,7 @@
import type { TappedFilePath } from './terminal-path-tap'
import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location'
import { parsePathWithOptionalLineColumn } from './terminal-path-tap'
export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null {
export function resolveTerminalFileUrlTap(uri: string): ParsedFileLinkLocation | null {
let parsed: URL
try {
parsed = new URL(uri)
@@ -24,7 +24,7 @@ export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null {
)
}
export function resolveTerminalOscFileTap(uri: string): TappedFilePath | null {
export function resolveTerminalOscFileTap(uri: string): ParsedFileLinkLocation | null {
return resolveTerminalFileUrlTap(uri) ?? parseOscPathLikeTarget(uri)
}
@@ -57,7 +57,7 @@ function isLocalFileUriHostname(hostname: string): boolean {
)
}
function parseOscPathLikeTarget(value: string): TappedFilePath | null {
function parseOscPathLikeTarget(value: string): ParsedFileLinkLocation | null {
if (
!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(
value
@@ -81,7 +81,7 @@ function parseFileUrlLineHash(hash: string): { line: number; column: number | nu
return { line, column }
}
function parseFilePathTrailingLineTarget(filePath: string): TappedFilePath | null {
function parseFilePathTrailingLineTarget(filePath: string): ParsedFileLinkLocation | null {
const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath)
if (!match || !match[1] || match[1].endsWith('/') || match[1].endsWith('\\')) {
return null
+1 -3
View File
@@ -84,8 +84,6 @@ export type TerminalLiveInputDefaultResult = {
changed: boolean
}
export type TerminalLiveInputPruneResult = TerminalLiveInputDefaultResult
export function getTerminalLiveSpecialKeyBytes(key: string): string | null {
const shortcutKey = TERMINAL_LIVE_SPECIAL_KEY_IDS.get(key)
if (!shortcutKey) {
@@ -176,7 +174,7 @@ export function pruneTerminalLiveInputHandles(
enabledHandles: ReadonlySet<string>,
defaultedHandles: ReadonlySet<string>,
liveTerminalHandles: ReadonlySet<string>
): TerminalLiveInputPruneResult {
): TerminalLiveInputDefaultResult {
let nextEnabledHandles: Set<string> | null = null
let nextDefaultedHandles: Set<string> | null = null
+6 -5
View File
@@ -8,8 +8,6 @@ import {
type ParsedFileLinkLocation
} from '../../../src/shared/file-link-location'
export type TappedFilePath = ParsedFileLinkLocation
// Separator-anchored path tokens (absolute, relative, ~/, drive-letter, UNC) OR
// a bare filename with an extension (README.md, index.ts), optionally suffixed
// with :line or :line:col. Like desktop, we propose candidates and let the host
@@ -47,7 +45,7 @@ function trimBoundaryPunctuation(
}
}
export function parsePathWithOptionalLineColumn(value: string): TappedFilePath | null {
export function parsePathWithOptionalLineColumn(value: string): ParsedFileLinkLocation | null {
const parsed = parseFileLinkLocation(value)
if (!parsed) {
return null
@@ -137,7 +135,7 @@ function hasSpacedPathExtension(text: string): boolean {
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed)
}
function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
function matchSpacedFilePathAtColumn(lineText: string, col: number): ParsedFileLinkLocation | null {
SPACED_PATH_REGEX.lastIndex = 0
let match: RegExpExecArray | null
while ((match = SPACED_PATH_REGEX.exec(lineText)) !== null) {
@@ -165,7 +163,10 @@ function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFileP
// Returns the file-path span (after punctuation trim) that contains `col`, or
// null when the tap isn't on a path.
export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
export function matchFilePathAtColumn(
lineText: string,
col: number
): ParsedFileLinkLocation | null {
const spaced = matchSpacedFilePathAtColumn(lineText, col)
if (spaced) {
return spaced
@@ -42,8 +42,6 @@ function toNonNegativeInteger(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
@@ -65,7 +63,7 @@ export type TerminalSelectionEvents = {
export type TerminalWebViewProps = {
style?: StyleProp<ViewStyle>
terminalTheme?: MobileTerminalTheme
terminalTheme?: RuntimeMobileTerminalTheme
// Why: baseline zoom multiplier applied on top of fit-to-width scale; raw
// xterm fontSize alone cannot drive apparent size because fitting cancels it.
textScale?: number
@@ -1,6 +1,6 @@
import { createContext, Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
import type { TappedFilePath } from './terminal-path-tap'
import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import {
TERMINAL_HTTP_URL_MAX_LENGTH,
@@ -16,7 +16,7 @@ import { XTERM_HTML } from './terminal-webview-html'
type FileTapResolverCase = {
name: string
uri: string
expected: TappedFilePath | null
expected: ParsedFileLinkLocation | null
}
const FILE_URL_TAP_CASES: FileTapResolverCase[] = [
@@ -90,7 +90,7 @@ const OSC_FILE_TAP_CASES: FileTapResolverCase[] = [
}
]
type InjectedFileTapResolver = (uri: string) => TappedFilePath | null
type InjectedFileTapResolver = (uri: string) => ParsedFileLinkLocation | null
// Why: the WebView blob hand-translates terminal-file-url-tap.ts into plain JS
// with re-escaped regexes; executing it against the same cases as the TS module
+2 -4
View File
@@ -43,13 +43,11 @@ export {
useRefreshHostClient
} from './host-client-hooks'
type StoreEntry = HostClientStoreEntry
const Ctx = createContext<RpcClientContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries in a ref so state changes don't re-render the whole tree; propagation goes through per-host listener Sets.
const storeRef = useRef<Map<string, StoreEntry>>(new Map())
const storeRef = useRef<Map<string, HostClientStoreEntry>>(new Map())
const stateListenersRef = useRef<Map<string, Set<(state: ConnectionState) => void>>>(new Map())
const allHostsListenersRef = useRef<Set<() => void>>(new Set())
@@ -93,7 +91,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}, [])
const openEntry = useCallback(
(hostId: string, allowUnowned = false): Promise<StoreEntry | null> => {
(hostId: string, allowUnowned = false): Promise<HostClientStoreEntry | null> => {
const retryScheduler = retrySchedulerRef.current
if (!retryScheduler) {
throw new Error('host retry scheduler not initialized')
@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import {
mobileHostEditHostRoute,
mobileHostEditRouteTarget,
navigateToMobileHostEdit,
type MobileHostEditNavigationState
navigateToMobileHostEdit
} from './host-edit-navigation'
import type { HostStackNavigationState } from '../navigation/host-stack-navigation'
function navigationHarness(initialState: MobileHostEditNavigationState) {
function navigationHarness(initialState: HostStackNavigationState) {
let stateListener = () => {}
let state = initialState
const unsubscribeState = vi.fn()
@@ -20,7 +20,7 @@ function navigationHarness(initialState: MobileHostEditNavigationState) {
}
return {
navigation,
setState(nextState: MobileHostEditNavigationState) {
setState(nextState: HostStackNavigationState) {
state = nextState
stateListener()
},
@@ -30,7 +30,7 @@ function navigationHarness(initialState: MobileHostEditNavigationState) {
// Edit now waits for the nested host stack, not just the root `h` route, so every committed
// state below carries the stack the replacement targets.
function committedHostState(hostIdParam: string): MobileHostEditNavigationState {
function committedHostState(hostIdParam: string): HostStackNavigationState {
return {
index: 1,
routes: [
+4 -11
View File
@@ -3,19 +3,12 @@ import {
navigateToHostStackRoute,
type HostStackHostRoute,
type HostStackNavigationController,
type HostStackNavigationState,
type HostStackRootNavigation,
type HostStackRouteTarget,
type HostStackRouter
} from '../navigation/host-stack-navigation'
export type MobileHostEditHostRoute = HostStackHostRoute
export type MobileHostEditNavigationState = HostStackNavigationState
export type MobileHostEditRootNavigation = HostStackRootNavigation
export type MobileHostEditRouter = HostStackRouter
export type MobileHostEditNavigationController = HostStackNavigationController
export function mobileHostEditHostRoute(hostId: string): MobileHostEditHostRoute {
export function mobileHostEditHostRoute(hostId: string): HostStackHostRoute {
return hostStackHostRoute(hostId)
}
@@ -27,9 +20,9 @@ export function mobileHostEditRouteTarget(hostId: string): HostStackRouteTarget
}
export function navigateToMobileHostEdit(
navigation: MobileHostEditRootNavigation,
router: MobileHostEditRouter,
navigation: HostStackRootNavigation,
router: HostStackRouter,
hostId: string
): MobileHostEditNavigationController {
): HostStackNavigationController {
return navigateToHostStackRoute(navigation, router, hostId, mobileHostEditRouteTarget(hostId))
}
+1 -3
View File
@@ -25,8 +25,6 @@ type EmulatorKillResult = {
deviceUdid?: string
}
type EmulatorShutdownResult = EmulatorKillResult
type EmulatorGesturePoint = {
edge?: number
type: 'begin' | 'move' | 'end'
@@ -233,7 +231,7 @@ export const EMULATOR_HANDLERS: Record<string, CommandHandler> = {
worktree: target.worktree
})
printResult(res, json, (r: unknown) => {
const result = r as EmulatorShutdownResult
const result = r as EmulatorKillResult
return `Shut down ${result.deviceUdid || target.device || 'emulator'}`
})
},
@@ -18,9 +18,9 @@ import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
probeBranchUpstream,
renameCurrentBranch,
resolveUniqueBranchName,
type GitExec
resolveUniqueBranchName
} from '../git/branch-rename'
import type { GitCommandRunner } from '../../shared/git-effective-upstream'
import {
generateBranchNameFromContext,
resolveTextGenerationParams
@@ -180,7 +180,7 @@ async function runAutoRename(
if (repo.connectionId && !provider) {
return retry('ssh provider unavailable')
}
const exec: GitExec = provider
const exec: GitCommandRunner = provider
? (args) => provider.exec(args, worktreePath)
: (args) => gitExecFileAsync(args, { cwd: worktreePath })
@@ -19,15 +19,13 @@ export type ReadAiVaultFirstUserPromptArgs = {
codexHome?: string | null
}
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
/**
* Re-parse one session transcript under full first-prompt capture and return
* the untruncated first real user ask for copy/reuse.
*/
export async function readAiVaultFirstUserPrompt(
args: ReadAiVaultFirstUserPromptArgs
): Promise<ReadAiVaultFirstUserPromptResult> {
): Promise<AiVaultFirstUserPromptResult> {
const filePath = args.filePath.trim()
if (!filePath || !args.agent) {
return { prompt: null }
@@ -1,12 +1,15 @@
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
import type {
AiVaultFirstUserPromptResult,
AiVaultListResult,
AiVaultSubagentListResult
} from '../../shared/ai-vault-types'
import type {
AiVaultSessionTitleRequest,
AiVaultSessionTitlesResult
} from '../../shared/ai-vault-session-title'
import {
readAiVaultFirstUserPrompt,
type ReadAiVaultFirstUserPromptArgs,
type ReadAiVaultFirstUserPromptResult
type ReadAiVaultFirstUserPromptArgs
} from './session-first-user-prompt-read'
import {
clearAiVaultServiceRestartCircuit,
@@ -72,7 +75,7 @@ export function listAiVaultSubagentSessionsInBackground(
export function readAiVaultFirstUserPromptInBackground(
request: ReadAiVaultFirstUserPromptArgs
): Promise<ReadAiVaultFirstUserPromptResult> {
): Promise<AiVaultFirstUserPromptResult> {
return shouldUseAiVaultServiceProcess()
? readAiVaultFirstUserPromptInService(request)
: readAiVaultFirstUserPrompt(request)
@@ -21,14 +21,14 @@ afterEach(() => {
tempDirs = []
})
function createTempDb(): { db: Database.Database; path: string } {
function createTempDb(): { db: Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-bounds-'))
tempDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
function applySchema(db: Database.Database): void {
function applySchema(db: Database): void {
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY,
@@ -63,7 +63,7 @@ function applySchema(db: Database.Database): void {
`)
}
function insertSession(db: Database.Database, id: string, timeUpdated: number): void {
function insertSession(db: Database, id: string, timeUpdated: number): void {
db.prepare(
`INSERT INTO session (id, project_id, directory, title, time_created, time_updated, agent)
VALUES (?, 'proj', '/tmp/w', ?, ?, ?, 'build')`
@@ -71,7 +71,7 @@ function insertSession(db: Database.Database, id: string, timeUpdated: number):
}
function insertUserMessage(
db: Database.Database,
db: Database,
args: { id: string; sessionId: string; timeCreated: number; text: string }
): void {
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`).run(
@@ -92,7 +92,7 @@ function insertUserMessage(
}
function insertMessageWithPart(
db: Database.Database,
db: Database,
args: {
id: string
sessionId: string

Some files were not shown because too many files have changed in this diff Show More