diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index e931e26b..7b85b88e 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -34,6 +34,7 @@ import { } from './mobileEditorSaveState' import { applySavedMobileEditorDraft } from './mobileSavedDraftProjection' import { MobileEditorAdapter } from './MobileEditorAdapter' +import { MobileGitSyncStatusCard } from './MobileGitSyncStatusCard' import { createCompactNavigationState, transitionCompactNavigation, @@ -51,6 +52,8 @@ import { useMobileNoteDeleteFlow } from './useMobileNoteDeleteFlow' import { useMobileNotePropertiesFlow } from './useMobileNotePropertiesFlow' import { createNativeMobileAppStateStorage } from './mobileNativeAppStateStorage' import { createNativeMobileVaultMetadataStorage } from './mobileNativeVaultMetadataStorage' +import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan' +import type { MobileGitSyncPlan } from './mobileGitSyncPlan' import { defaultMobileVaultMetadata } from './mobileVaultMetadata' import type { MobileVaultRuntime } from './mobileVaultRuntime' import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader' @@ -71,6 +74,10 @@ export function MobileApp() { [availableNotes, compactNavigation.selectedNoteId], ) const selectedSaveState = saveStateByNoteId[selectedNote.id] ?? idleMobileEditorSaveState + const gitSyncPlan = useMemo( + () => createMobileGitSyncPlanForVault({ vault: activeVaultMetadata }), + [activeVaultMetadata], + ) const autosaveQueue = useMemo( () => createMobileAutosaveQueue({ @@ -140,6 +147,7 @@ export function MobileApp() { void }) { } function NoteListPanel({ + gitSyncPlan, notes, createNoteFailed, createNoteTitle, @@ -355,6 +368,7 @@ function NoteListPanel({ onSubmitCreateNote, selectedNoteId, }: { + gitSyncPlan: MobileGitSyncPlan notes: MobileNote[] createNoteFailed: boolean createNoteTitle: string @@ -378,6 +392,7 @@ function NoteListPanel({ } /> + {runtimeLoadFailed ? : null} + + + {status.label} + {status.detail} + + {status.actionLabel ? {status.actionLabel} : null} + + ) +} + +function gitSyncToneStyle(tone: MobileGitSyncStatusTone) { + switch (tone) { + case 'attention': + return styles.gitSyncStatus_attention + case 'neutral': + return styles.gitSyncStatus_neutral + case 'positive': + return styles.gitSyncStatus_positive + case 'warning': + return styles.gitSyncStatus_warning + } +} diff --git a/apps/mobile/src/mobileGitSyncRuntimePlan.test.ts b/apps/mobile/src/mobileGitSyncRuntimePlan.test.ts new file mode 100644 index 00000000..7af66b5a --- /dev/null +++ b/apps/mobile/src/mobileGitSyncRuntimePlan.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan' + +describe('createMobileGitSyncPlanForVault', () => { + it('keeps app-local vaults hidden from Git sync status', () => { + expect(createMobileGitSyncPlanForVault({ + vault: { id: 'personal', name: 'Personal Journal' }, + })).toEqual({ + primaryAction: null, + state: 'localOnly', + }) + }) + + it('requires credentials for remote-backed vaults', () => { + expect(createMobileGitSyncPlanForVault({ + vault: { + id: 'tolaria', + name: 'Tolaria', + remoteUrl: 'https://github.com/refactoringhq/tolaria.git', + }, + })).toMatchObject({ + authStrategy: 'githubOAuth', + host: 'github.com', + primaryAction: 'authenticate', + state: 'authRequired', + }) + }) + + it('plans ready sync when credentials are available', () => { + expect(createMobileGitSyncPlanForVault({ + credentials: { state: 'available' }, + vault: { + id: 'tolaria', + name: 'Tolaria', + remoteUrl: 'git@git.example.com:refactoringhq/tolaria.git', + }, + })).toMatchObject({ + canPull: true, + canPush: false, + primaryAction: 'pull', + state: 'ready', + }) + }) + + it('treats invalid remote metadata as local-only until vault management can repair it', () => { + expect(createMobileGitSyncPlanForVault({ + vault: { id: 'broken', name: 'Broken', remoteUrl: '/Users/luca/Laputa' }, + })).toEqual({ + primaryAction: null, + state: 'localOnly', + }) + }) +}) diff --git a/apps/mobile/src/mobileGitSyncRuntimePlan.ts b/apps/mobile/src/mobileGitSyncRuntimePlan.ts new file mode 100644 index 00000000..f4c6c2bf --- /dev/null +++ b/apps/mobile/src/mobileGitSyncRuntimePlan.ts @@ -0,0 +1,21 @@ +import { createMobileGitSyncPlan, type MobileGitCredentialState, type MobileGitSyncPlan } from './mobileGitSyncPlan' +import { createMobileVaultConfig } from './mobileVaultConfig' +import type { MobileVaultMetadata } from './mobileVaultMetadata' + +export function createMobileGitSyncPlanForVault({ + credentials = { state: 'missing' }, + vault, +}: { + credentials?: MobileGitCredentialState + vault: MobileVaultMetadata +}): MobileGitSyncPlan { + const result = createMobileVaultConfig(vault) + if (!result.ok) { + return { primaryAction: null, state: 'localOnly' } + } + + return createMobileGitSyncPlan({ + credentials, + sync: result.config.sync, + }) +} diff --git a/apps/mobile/src/mobileGitSyncStatus.test.ts b/apps/mobile/src/mobileGitSyncStatus.test.ts new file mode 100644 index 00000000..e90f2198 --- /dev/null +++ b/apps/mobile/src/mobileGitSyncStatus.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import type { MobileGitRemote } from './mobileGitRemote' +import { mobileGitSyncStatusView } from './mobileGitSyncStatus' + +describe('mobileGitSyncStatusView', () => { + it('hides local-only vaults from sync status chrome', () => { + expect(mobileGitSyncStatusView({ state: 'localOnly', primaryAction: null })).toBeNull() + }) + + it('summarizes authentication requirements by strategy', () => { + expect(mobileGitSyncStatusView({ + authStrategy: 'githubOAuth', + host: 'github.com', + primaryAction: 'authenticate', + state: 'authRequired', + })).toMatchObject({ + actionLabel: 'Connect', + detail: 'Connect GitHub for github.com.', + tone: 'attention', + }) + + expect(mobileGitSyncStatusView({ + authStrategy: 'sshKey', + host: 'git.example.com', + primaryAction: 'authenticate', + state: 'authRequired', + })).toMatchObject({ + detail: 'Add an SSH key for git.example.com.', + }) + }) + + it.each([ + { canPush: false, actionLabel: 'Pull', detail: 'Vault can pull from remote.', tone: 'positive' }, + { canPush: true, actionLabel: 'Push', detail: 'Local changes are ready to push.', tone: 'warning' }, + ] as const)('summarizes ready sync state with $actionLabel', ({ actionLabel, canPush, detail, tone }) => { + expect(mobileGitSyncStatusView({ + canPull: true, + canPush, + primaryAction: canPush ? 'push' : 'pull', + remote: remote(), + state: 'ready', + })).toMatchObject({ + actionLabel, + detail, + label: 'refactoringhq/tolaria', + tone, + }) + }) + + it('summarizes syncing and failed operations', () => { + expect(mobileGitSyncStatusView({ + operation: 'pull', + primaryAction: null, + remote: remote(), + state: 'syncing', + })).toMatchObject({ + actionLabel: null, + detail: 'Pull in progress.', + tone: 'neutral', + }) + + expect(mobileGitSyncStatusView({ + message: 'Authentication failed', + operation: 'push', + primaryAction: 'retry', + remote: remote(), + state: 'failed', + })).toMatchObject({ + actionLabel: 'Retry', + detail: 'Authentication failed', + label: 'Push 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/mobileGitSyncStatus.ts b/apps/mobile/src/mobileGitSyncStatus.ts new file mode 100644 index 00000000..4792e043 --- /dev/null +++ b/apps/mobile/src/mobileGitSyncStatus.ts @@ -0,0 +1,55 @@ +import type { MobileGitSyncPlan } from './mobileGitSyncPlan' + +export type MobileGitSyncStatusTone = 'attention' | 'neutral' | 'positive' | 'warning' + +export type MobileGitSyncStatusView = { + actionLabel: string | null + detail: string + label: string + tone: MobileGitSyncStatusTone +} + +export function mobileGitSyncStatusView(plan: MobileGitSyncPlan): MobileGitSyncStatusView | null { + switch (plan.state) { + case 'localOnly': + return null + case 'authRequired': + return { + actionLabel: 'Connect', + detail: authDetail(plan), + label: 'Git authentication required', + tone: 'attention', + } + case 'ready': + return { + actionLabel: actionLabel(plan.primaryAction), + detail: plan.canPush ? 'Local changes are ready to push.' : 'Vault can pull from remote.', + label: `${plan.remote.owner}/${plan.remote.repository}`, + tone: plan.canPush ? 'warning' : 'positive', + } + case 'syncing': + return { + actionLabel: null, + detail: `${actionLabel(plan.operation)} in progress.`, + label: `${plan.remote.owner}/${plan.remote.repository}`, + tone: 'neutral', + } + case 'failed': + return { + actionLabel: 'Retry', + detail: plan.message, + label: `${actionLabel(plan.operation)} failed`, + tone: 'warning', + } + } +} + +function authDetail(plan: Extract) { + return plan.authStrategy === 'githubOAuth' + ? `Connect GitHub for ${plan.host}.` + : `Add an SSH key for ${plan.host}.` +} + +function actionLabel(action: 'clone' | 'pull' | 'push') { + return action[0].toUpperCase() + action.slice(1) +} diff --git a/apps/mobile/src/styles.ts b/apps/mobile/src/styles.ts index d291a361..acb3d532 100644 --- a/apps/mobile/src/styles.ts +++ b/apps/mobile/src/styles.ts @@ -1,6 +1,7 @@ import { commonStyles } from './styles/commonStyles' import { editorSaveStateStyles } from './styles/editorSaveStateStyles' import { editorStyles } from './styles/editorStyles' +import { gitSyncStyles } from './styles/gitSyncStyles' import { noteCreateStyles } from './styles/noteCreateStyles' import { noteListStyles } from './styles/noteListStyles' import { propertiesStyles } from './styles/propertiesStyles' @@ -12,6 +13,7 @@ export const styles = { ...commonStyles, ...editorStyles, ...editorSaveStateStyles, + ...gitSyncStyles, ...noteCreateStyles, ...noteListStyles, ...propertiesStyles, diff --git a/apps/mobile/src/styles/gitSyncStyles.ts b/apps/mobile/src/styles/gitSyncStyles.ts new file mode 100644 index 00000000..364c0119 --- /dev/null +++ b/apps/mobile/src/styles/gitSyncStyles.ts @@ -0,0 +1,52 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const gitSyncStyles = StyleSheet.create({ + gitSyncStatus: { + minHeight: 54, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + borderRadius: 8, + borderWidth: StyleSheet.hairlineWidth, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + }, + gitSyncStatus_attention: { + borderColor: '#d99b57', + backgroundColor: '#fff4e6', + }, + gitSyncStatus_neutral: { + borderColor: colors.border, + backgroundColor: colors.canvas, + }, + gitSyncStatus_positive: { + borderColor: '#9bc49d', + backgroundColor: '#edf8ef', + }, + gitSyncStatus_warning: { + borderColor: '#d8b15f', + backgroundColor: '#fff7dd', + }, + gitSyncStatusAction: { + color: colors.primary, + fontSize: 13, + fontWeight: '800', + }, + gitSyncStatusCopy: { + flex: 1, + minWidth: 0, + }, + gitSyncStatusDetail: { + color: colors.textSoft, + fontSize: 12, + fontWeight: '600', + }, + gitSyncStatusLabel: { + color: colors.text, + fontSize: 13, + fontWeight: '800', + }, +}) diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 6ee77b90..11f08c2b 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 4 - Editor V1 -- Active slice: Core flow coverage and editor durability +- Active slice: Git credential/auth boundaries and editor durability - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -87,12 +87,13 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Added simple TenTap table serialization for rectangular tables, including entity decoding and escaped pipe characters, while continuing to block malformed table shapes. - Reworked the mobile properties panel into expandable Type/Status/Icon/Tags picker rows that show current values first and reveal chip choices on demand. - Added the first mobile Git sync plan model, covering local-only vaults, auth-required remotes, ready pull/push actions, active sync operations, and retryable failures. +- Wired the mobile Git sync plan into the note-list UI through a visible status card for remote-backed vaults while local-only vaults remain chrome-free. ## Next Action Continue Phase 4 with editor durability: -1. Wire the Git sync plan into the mobile runtime/status UI, still behind model-only operations until the native git adapter is selected. +1. Add credential storage/auth facades for GitHub OAuth and SSH key presence, still behind model-only Git operations until the native git adapter is selected. 2. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA. 3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode. @@ -322,6 +323,10 @@ Continue Phase 4 with editor durability: - `pnpm --filter @tolaria/mobile typecheck` passed after mobile Git sync planning. - CodeScene after mobile Git sync planning: `apps/mobile/src/mobileGitSyncPlan.ts` and `apps/mobile/src/mobileGitSyncPlan.test.ts` scored `10`. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after mobile Git sync planning. +- `pnpm --filter @tolaria/mobile test -- src/mobileGitSyncStatus.test.ts src/mobileGitSyncRuntimePlan.test.ts src/mobileGitSyncPlan.test.ts src/mobileVaultConfig.test.ts` passed after mobile Git sync status wiring: 31 files / 107 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after mobile Git sync status wiring. +- CodeScene after mobile Git sync status wiring: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileGitSyncStatusCard.tsx`, `apps/mobile/src/mobileGitSyncStatus.ts`, `apps/mobile/src/mobileGitSyncStatus.test.ts`, `apps/mobile/src/mobileGitSyncRuntimePlan.test.ts`, and `apps/mobile/src/styles/gitSyncStyles.ts` scored `10`; `apps/mobile/src/mobileGitSyncRuntimePlan.ts` and `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 mobile Git sync status wiring. ## Risks / Watch Items