feat: surface actionable push error messages in frontend

Update commitWithPush to parse GitPushResult from backend and show
specific messages (rejected, auth, network) instead of generic
"push failed". Tests verify rejected + network error scenarios.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-07 00:31:46 +01:00
parent c14927df8f
commit 90ebc2e939
4 changed files with 56 additions and 12 deletions

View File

@@ -34,7 +34,7 @@ function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve('pushed')
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
return Promise.resolve(null)
}
@@ -382,6 +382,49 @@ describe('useVaultLoader', () => {
expect(response).toBe('Committed and pushed')
})
it('returns actionable message when push is rejected', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('Pull first')
expect(response).not.toBe('Committed and pushed')
})
it('returns network error message on network failure', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('network error')
})
})
describe('loadModifiedFiles', () => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, startTransition } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
@@ -18,16 +18,12 @@ async function loadVaultData(vaultPath: string) {
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('git_push', {})
return 'Committed and pushed'
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
}
await invoke<string>('git_commit', { vaultPath, message })
try {
await invoke<string>('git_push', { vaultPath })
return 'Committed and pushed'
} catch {
return 'Committed (push failed)'
}
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
}
function useNewNoteTracker() {

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -172,7 +172,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
get_build_number: () => 'bDEV',
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
const limit = args.limit ?? 30
const ts = Math.floor(Date.now() / 1000)

View File

@@ -78,6 +78,11 @@ export interface GitPullResult {
conflictFiles: string[]
}
export interface GitPushResult {
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
message: string
}
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
export interface DeviceFlowStart {