diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index 64842f98..08c7917a 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -65,6 +65,7 @@ import type { MobileVaultRuntime } from './mobileVaultRuntime' import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader' import type { MobileNotePropertyPatch } from './mobileNoteProperties' import { useMobileGitSyncFlow } from './useMobileGitSyncFlow' +import { createUnavailableMobileGitTransport } from './mobileGitTransport' export function MobileApp() { const { width } = useWindowDimensions() @@ -72,6 +73,7 @@ export function MobileApp() { const showsProperties = width >= 1000 const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), []) const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), []) + const gitTransport = useMemo(() => createUnavailableMobileGitTransport(), []) const gitHubOAuthClientIdState = useMemo(() => currentMobileGitHubOAuthClientIdState(), []) const vaultMetadataStorage = useMemo(() => createNativeMobileVaultMetadataStorage(), []) const [activeVaultMetadata, setActiveVaultMetadata] = useState(defaultMobileVaultMetadata) @@ -86,6 +88,7 @@ export function MobileApp() { const gitSyncFlow = useMobileGitSyncFlow({ createGitHubOAuthSession: createNativeMobileGitHubOAuthSessionFromEnvironment, credentialStorage: gitCredentialStorage, + gitTransport, vault: activeVaultMetadata, }) const autosaveQueue = useMemo( @@ -173,7 +176,7 @@ export function MobileApp() { onCancelCreateNote={createFlow.cancel} onChangeCreateNoteTitle={createFlow.setTitle} onOpenCreateNote={createFlow.open} - onGitSyncAction={gitSyncFlow.authenticate} + onGitSyncAction={gitSyncFlow.runPrimaryAction} onRetryRuntimeLoad={runtimeLoader.retry} onSubmitCreateNote={createFlow.submit} onSelectNote={selectNote} @@ -213,7 +216,7 @@ export function MobileApp() { onCancelCreateNote={createFlow.cancel} onChangeCreateNoteTitle={createFlow.setTitle} onOpenCreateNote={createFlow.open} - onGitSyncAction={gitSyncFlow.authenticate} + onGitSyncAction={gitSyncFlow.runPrimaryAction} onRetryRuntimeLoad={runtimeLoader.retry} onSubmitCreateNote={createFlow.submit} onSelectNote={selectNote} diff --git a/apps/mobile/src/mobileGitPrimaryAction.test.ts b/apps/mobile/src/mobileGitPrimaryAction.test.ts new file mode 100644 index 00000000..f61bcfed --- /dev/null +++ b/apps/mobile/src/mobileGitPrimaryAction.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction' +import type { MobileGitRemote } from './mobileGitRemote' + +describe('mobile git primary action', () => { + it('routes missing credentials to authentication', () => { + expect(mobileGitPrimaryActionForPlan({ + authStrategy: 'githubOAuth', + host: 'github.com', + primaryAction: 'authenticate', + state: 'authRequired', + })).toEqual({ state: 'authenticate' }) + }) + + it('routes ready pull and push states to transport execution', () => { + expect(mobileGitPrimaryActionForPlan({ + canPull: true, + canPush: false, + primaryAction: 'pull', + remote: remote(), + state: 'ready', + })).toMatchObject({ operation: 'pull', state: 'transport' }) + + expect(mobileGitPrimaryActionForPlan({ + canPull: true, + canPush: true, + primaryAction: 'push', + remote: remote(), + state: 'ready', + })).toMatchObject({ operation: 'push', state: 'transport' }) + }) + + it('retries clone failures through authentication and sync failures through transport', () => { + expect(mobileGitPrimaryActionForPlan({ + message: 'GitHub authentication failed.', + operation: 'clone', + primaryAction: 'retry', + remote: remote(), + state: 'failed', + })).toEqual({ state: 'authenticate' }) + + expect(mobileGitPrimaryActionForPlan({ + message: 'Pull failed.', + operation: 'pull', + primaryAction: 'retry', + remote: remote(), + state: 'failed', + })).toMatchObject({ operation: 'pull', state: 'transport' }) + }) + + it('ignores local-only and already syncing plans', () => { + expect(mobileGitPrimaryActionForPlan({ primaryAction: null, state: 'localOnly' })).toEqual({ state: 'ignored' }) + expect(mobileGitPrimaryActionForPlan({ + operation: 'pull', + primaryAction: null, + remote: remote(), + state: 'syncing', + })).toEqual({ state: 'ignored' }) + }) +}) + +function remote(): MobileGitRemote { + return { + authStrategy: 'githubOAuth', + host: 'github.com', + owner: 'refactoringhq', + repository: 'tolaria', + url: 'https://github.com/refactoringhq/tolaria.git', + } +} diff --git a/apps/mobile/src/mobileGitPrimaryAction.ts b/apps/mobile/src/mobileGitPrimaryAction.ts new file mode 100644 index 00000000..78050111 --- /dev/null +++ b/apps/mobile/src/mobileGitPrimaryAction.ts @@ -0,0 +1,52 @@ +import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan' +import type { MobileGitRemote } from './mobileGitRemote' + +export type MobileGitTransportOperation = Exclude + +export type MobileGitPrimaryAction = + | { + state: 'authenticate' + } + | { + operation: MobileGitTransportOperation + remote: MobileGitRemote + state: 'transport' + } + | { + state: 'ignored' + } + +export function mobileGitPrimaryActionForPlan(plan: MobileGitSyncPlan): MobileGitPrimaryAction { + if (plan.state === 'authRequired') { + return { state: 'authenticate' } + } + + if (plan.state === 'ready') { + return transportAction({ + operation: plan.primaryAction, + remote: plan.remote, + }) + } + + if (plan.state === 'failed') { + return plan.operation === 'clone' + ? { state: 'authenticate' } + : transportAction({ operation: plan.operation, remote: plan.remote }) + } + + return { state: 'ignored' } +} + +function transportAction({ + operation, + remote, +}: { + operation: MobileGitTransportOperation + remote: MobileGitRemote +}): MobileGitPrimaryAction { + return { + operation, + remote, + state: 'transport', + } +} diff --git a/apps/mobile/src/mobileGitSyncFlowAction.test.ts b/apps/mobile/src/mobileGitSyncFlowAction.test.ts new file mode 100644 index 00000000..6f7c1c0e --- /dev/null +++ b/apps/mobile/src/mobileGitSyncFlowAction.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mobileGitSyncFlowAction' +import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan' +import type { MobileGitRemote } from './mobileGitRemote' + +describe('mobile git sync flow action', () => { + it('ignores actions while another operation is active', () => { + const events: string[] = [] + + runMobileGitSyncFlowAction({ + ...baseActionInput(events), + activeOperation: 'pull', + }) + + expect(events).toEqual([]) + }) + + it('runs ready pull and records transport failures', async () => { + const events: string[] = [] + const failures: Array = [] + + runMobileGitSyncFlowAction({ + ...baseActionInput(events), + gitSyncPlan: readyPlan(), + gitTransport: { + pull: async () => ({ message: 'Pull failed.', state: 'failed' }), + push: async () => ({ state: 'completed' }), + }, + setFailure: (failure) => failures.push(failure), + }) + await Promise.resolve() + + expect(events).toEqual(['active:pull']) + expect(failures).toEqual([null, { message: 'Pull failed.', operation: 'pull' }]) + }) + + it('routes auth-required plans to the authentication operation', () => { + const events: string[] = [] + + runMobileGitSyncFlowAction({ + ...baseActionInput(events), + gitSyncPlan: { + authStrategy: 'githubOAuth', + host: 'github.com', + primaryAction: 'authenticate', + state: 'authRequired', + }, + }) + + expect(events).toEqual(['active:clone']) + }) +}) + +function baseActionInput(events: string[]) { + return { + activeOperation: null, + credentialStorage: { + loadState: async () => ({ state: 'missing' as const }), + remove: async () => {}, + saveRecord: async () => {}, + }, + createGitHubOAuthSession: () => ({ + authorize: async () => ({ state: 'cancelled' as const }), + }), + gitSyncPlan: { primaryAction: null, state: 'localOnly' } satisfies MobileGitSyncPlan, + gitTransport: { + pull: async () => ({ state: 'completed' as const }), + push: async () => ({ state: 'completed' as const }), + }, + refreshCredentials: () => events.push('refresh'), + setActiveOperation: (operation: MobileGitOperation | null) => { + if (operation) { + events.push(`active:${operation}`) + } + }, + setFailure: () => {}, + vault: { id: 'personal', name: 'Personal Journal' }, + } +} + +function readyPlan(): MobileGitSyncPlan { + return { + canPull: true, + canPush: false, + primaryAction: 'pull', + remote: remote(), + state: 'ready', + } +} + +function remote(): MobileGitRemote { + return { + authStrategy: 'githubOAuth', + host: 'github.com', + owner: 'refactoringhq', + repository: 'tolaria', + url: 'https://github.com/refactoringhq/tolaria.git', + } +} diff --git a/apps/mobile/src/mobileGitSyncFlowAction.ts b/apps/mobile/src/mobileGitSyncFlowAction.ts new file mode 100644 index 00000000..63945429 --- /dev/null +++ b/apps/mobile/src/mobileGitSyncFlowAction.ts @@ -0,0 +1,134 @@ +import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication' +import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow' +import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction' +import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage' +import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan' +import { + runMobileGitTransportOperation, + type MobileGitTransport, +} from './mobileGitTransport' +import type { MobileVaultMetadata } from './mobileVaultMetadata' + +export type MobileGitSyncFlowFailure = { + message: string + operation: MobileGitOperation +} + +export function runMobileGitSyncFlowAction({ + activeOperation, + credentialStorage, + createGitHubOAuthSession, + gitSyncPlan, + gitTransport, + refreshCredentials, + setActiveOperation, + setFailure, + vault, +}: { + activeOperation: MobileGitOperation | null + credentialStorage: MobileGitCredentialStorage + createGitHubOAuthSession: () => MobileGitHubOAuthSession + gitSyncPlan: MobileGitSyncPlan + gitTransport: MobileGitTransport + refreshCredentials: () => void + setActiveOperation: (operation: MobileGitOperation | null) => void + setFailure: (failure: MobileGitSyncFlowFailure | null) => void + vault: MobileVaultMetadata +}) { + if (activeOperation) { + return + } + + const action = mobileGitPrimaryActionForPlan(gitSyncPlan) + if (action.state === 'authenticate') { + runAuthenticationAction({ + credentialStorage, + createGitHubOAuthSession, + gitSyncPlan, + refreshCredentials, + setActiveOperation, + setFailure, + }) + return + } + + if (action.state === 'transport') { + runTransportAction({ + gitTransport, + operation: action.operation, + remote: action.remote, + setActiveOperation, + setFailure, + vault, + }) + } +} + +function runAuthenticationAction({ + credentialStorage, + createGitHubOAuthSession, + gitSyncPlan, + refreshCredentials, + setActiveOperation, + setFailure, +}: { + credentialStorage: MobileGitCredentialStorage + createGitHubOAuthSession: () => MobileGitHubOAuthSession + gitSyncPlan: MobileGitSyncPlan + refreshCredentials: () => void + setActiveOperation: (operation: MobileGitOperation | null) => void + setFailure: (failure: MobileGitSyncFlowFailure | null) => void +}) { + setFailure(null) + setActiveOperation('clone') + void authenticateMobileGitSyncPlan({ + credentialStorage, + createGitHubOAuthSession, + now: () => new Date().toISOString(), + plan: gitSyncPlan, + }) + .then((result) => { + if (result.state === 'connected') { + refreshCredentials() + return + } + + if (result.state === 'failed') { + setFailure({ message: result.message, operation: 'clone' }) + } + }) + .catch(() => setFailure({ message: 'GitHub authentication failed.', operation: 'clone' })) + .finally(() => setActiveOperation(null)) +} + +function runTransportAction({ + gitTransport, + operation, + remote, + setActiveOperation, + setFailure, + vault, +}: { + gitTransport: MobileGitTransport + operation: 'pull' | 'push' + remote: Parameters[0]['remote'] + setActiveOperation: (operation: MobileGitOperation | null) => void + setFailure: (failure: MobileGitSyncFlowFailure | null) => void + vault: MobileVaultMetadata +}) { + setFailure(null) + setActiveOperation(operation) + void runMobileGitTransportOperation({ + operation, + remote, + transport: gitTransport, + vault, + }) + .then((result) => { + if (result.state === 'failed') { + setFailure({ message: result.message, operation }) + } + }) + .catch(() => setFailure({ message: 'Mobile Git sync failed.', operation })) + .finally(() => setActiveOperation(null)) +} diff --git a/apps/mobile/src/mobileGitSyncPlan.ts b/apps/mobile/src/mobileGitSyncPlan.ts index ced99a21..59ed3f3c 100644 --- a/apps/mobile/src/mobileGitSyncPlan.ts +++ b/apps/mobile/src/mobileGitSyncPlan.ts @@ -26,7 +26,7 @@ export type MobileGitSyncPlan = state: 'ready' canPull: true canPush: boolean - primaryAction: MobileGitOperation + primaryAction: 'pull' | 'push' remote: MobileGitRemote } | { diff --git a/apps/mobile/src/mobileGitTransport.test.ts b/apps/mobile/src/mobileGitTransport.test.ts new file mode 100644 index 00000000..fdca0c02 --- /dev/null +++ b/apps/mobile/src/mobileGitTransport.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + createUnavailableMobileGitTransport, + runMobileGitTransportOperation, + type MobileGitTransport, +} from './mobileGitTransport' +import type { MobileGitRemote } from './mobileGitRemote' + +describe('mobile git transport', () => { + it('runs the requested transport operation', async () => { + const calls: string[] = [] + const transport: MobileGitTransport = { + pull: async () => { + calls.push('pull') + return { state: 'completed' } + }, + push: async () => { + calls.push('push') + return { state: 'completed' } + }, + } + + await runMobileGitTransportOperation({ + operation: 'push', + remote: remote(), + transport, + vault: { id: 'personal', name: 'Personal Journal' }, + }) + + expect(calls).toEqual(['push']) + }) + + it('fails explicitly until a native git implementation is wired', async () => { + await expect(createUnavailableMobileGitTransport().pull({ + remote: remote(), + vault: { id: 'personal', name: 'Personal Journal' }, + })).resolves.toEqual({ + message: 'Mobile Git transport is not available yet.', + state: 'failed', + }) + }) +}) + +function remote(): MobileGitRemote { + return { + authStrategy: 'githubOAuth', + host: 'github.com', + owner: 'refactoringhq', + repository: 'tolaria', + url: 'https://github.com/refactoringhq/tolaria.git', + } +} diff --git a/apps/mobile/src/mobileGitTransport.ts b/apps/mobile/src/mobileGitTransport.ts new file mode 100644 index 00000000..f6aaedcd --- /dev/null +++ b/apps/mobile/src/mobileGitTransport.ts @@ -0,0 +1,50 @@ +import type { MobileGitRemote } from './mobileGitRemote' +import type { MobileVaultMetadata } from './mobileVaultMetadata' +import type { MobileGitTransportOperation } from './mobileGitPrimaryAction' + +export type MobileGitTransportRequest = { + remote: MobileGitRemote + vault: MobileVaultMetadata +} + +export type MobileGitTransportResult = + | { + state: 'completed' + } + | { + message: string + state: 'failed' + } + +export type MobileGitTransport = { + pull: (request: MobileGitTransportRequest) => Promise + push: (request: MobileGitTransportRequest) => Promise +} + +export function createUnavailableMobileGitTransport(): MobileGitTransport { + return { + pull: () => unavailableTransportResult(), + push: () => unavailableTransportResult(), + } +} + +export function runMobileGitTransportOperation({ + operation, + remote, + transport, + vault, +}: { + operation: MobileGitTransportOperation + remote: MobileGitRemote + transport: MobileGitTransport + vault: MobileVaultMetadata +}) { + return transport[operation]({ remote, vault }) +} + +function unavailableTransportResult(): Promise { + return Promise.resolve({ + message: 'Mobile Git transport is not available yet.', + state: 'failed', + }) +} diff --git a/apps/mobile/src/useMobileGitSyncFlow.ts b/apps/mobile/src/useMobileGitSyncFlow.ts index be47ab53..872b6d93 100644 --- a/apps/mobile/src/useMobileGitSyncFlow.ts +++ b/apps/mobile/src/useMobileGitSyncFlow.ts @@ -1,24 +1,27 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage' import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault' -import type { MobileGitCredentialState } from './mobileGitSyncPlan' +import type { MobileGitCredentialState, MobileGitOperation } from './mobileGitSyncPlan' import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan' -import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication' +import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mobileGitSyncFlowAction' +import type { MobileGitTransport } from './mobileGitTransport' import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow' import type { MobileVaultMetadata } from './mobileVaultMetadata' export function useMobileGitSyncFlow({ createGitHubOAuthSession, credentialStorage, + gitTransport, vault, }: { createGitHubOAuthSession: () => MobileGitHubOAuthSession credentialStorage: MobileGitCredentialStorage + gitTransport: MobileGitTransport vault: MobileVaultMetadata }) { - const [authFailureMessage, setAuthFailureMessage] = useState(null) + const [failure, setFailure] = useState(null) const [credentials, setCredentials] = useState({ state: 'missing' }) - const [isAuthenticating, setIsAuthenticating] = useState(false) + const [activeOperation, setActiveOperation] = useState(null) const refreshCredentials = useCallback(() => { void loadMobileGitCredentialStateForVault({ credentialStorage, vault }) .then(setCredentials) @@ -27,43 +30,30 @@ export function useMobileGitSyncFlow({ const gitSyncPlan = useMemo( () => createMobileGitSyncPlanForVault({ credentials, - ...(authFailureMessage ? { failure: { message: authFailureMessage, operation: 'clone' } } : {}), - ...(isAuthenticating ? { operation: 'clone' } : {}), + ...(failure ? { failure } : {}), + ...(activeOperation ? { operation: activeOperation } : {}), vault, }), - [authFailureMessage, credentials, isAuthenticating, vault], + [activeOperation, credentials, failure, vault], ) - const authenticate = useCallback(() => { - if (isAuthenticating) { - return - } - - setAuthFailureMessage(null) - setIsAuthenticating(true) - void authenticateMobileGitSyncPlan({ + const runPrimaryAction = useCallback(() => { + runMobileGitSyncFlowAction({ + activeOperation, credentialStorage, createGitHubOAuthSession, - now: () => new Date().toISOString(), - plan: gitSyncPlan, + gitSyncPlan, + gitTransport, + refreshCredentials, + setActiveOperation, + setFailure, + vault, }) - .then((result) => { - if (result.state === 'connected') { - refreshCredentials() - return - } - - if (result.state === 'failed') { - setAuthFailureMessage(result.message) - } - }) - .catch(() => setAuthFailureMessage('GitHub authentication failed.')) - .finally(() => setIsAuthenticating(false)) - }, [createGitHubOAuthSession, credentialStorage, gitSyncPlan, isAuthenticating, refreshCredentials]) + }, [activeOperation, createGitHubOAuthSession, credentialStorage, gitSyncPlan, gitTransport, refreshCredentials, vault]) useEffect(refreshCredentials, [refreshCredentials]) return { - authenticate, gitSyncPlan, + runPrimaryAction, } } diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index deebc7ef..126437cf 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -97,13 +97,14 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Exposed the missing `EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID` requirement inside the mobile remote setup prompt and split the client-id detector away from native AuthSession imports. - Separated the mobile Git remote setup prompt styles from note-creation prompt styles so vault-management UI can evolve without coupling to compose UI names. - Added a sidebar vault-management card that shows the active app-local vault, Git sync state, and an explicit Git remote action on iPad and compact sidebar surfaces. +- Added the first mobile Git transport execution boundary behind the existing sync/auth plan, including explicit pull/push routing and a visible unavailable-transport failure until the native Git implementation lands. ## Next Action Continue Phase 4 with editor durability: 1. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA. -2. Start the mobile Git transport execution boundary behind the existing sync/auth plan. +2. Start the native Git engine spike behind `MobileGitTransport`, preferably Rust/libgit2 unless Expo native-module constraints block it. 3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode. ## Verification Log @@ -366,6 +367,10 @@ Continue Phase 4 with editor durability: - `pnpm --filter @tolaria/mobile typecheck` passed after adding the vault-management sidebar card. - CodeScene after adding the vault-management sidebar card: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileVaultManagementCard.tsx`, `apps/mobile/src/mobileVaultManagementSummary.ts`, `apps/mobile/src/mobileVaultManagementSummary.test.ts`, and `apps/mobile/src/styles/vaultManagementStyles.ts` scored `10`; `apps/mobile/src/styles.ts` returned no scorable code and no findings. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the vault-management sidebar card; Metro recovered from a cache deserialize warning by doing a full crawl. +- `pnpm --filter @tolaria/mobile test -- src/mobileGitPrimaryAction.test.ts src/mobileGitTransport.test.ts src/mobileGitSyncFlowAction.test.ts src/mobileGitSyncRuntimePlan.test.ts src/mobileGitSyncStatus.test.ts` passed after adding the mobile Git transport execution boundary: 43 files / 143 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after adding the mobile Git transport execution boundary. +- CodeScene after adding the mobile Git transport execution boundary: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitSyncPlan.ts`, `apps/mobile/src/mobileGitPrimaryAction.ts`, `apps/mobile/src/mobileGitPrimaryAction.test.ts`, `apps/mobile/src/mobileGitTransport.ts`, `apps/mobile/src/mobileGitTransport.test.ts`, `apps/mobile/src/mobileGitSyncFlowAction.ts`, and `apps/mobile/src/mobileGitSyncFlowAction.test.ts` scored `10`. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after adding the mobile Git transport execution boundary. ## Risks / Watch Items