feat: support local-only git commits without remotes

This commit is contained in:
lucaronin
2026-04-12 19:38:25 +02:00
parent 73a63085c7
commit a13e36a504
14 changed files with 524 additions and 55 deletions

View File

@@ -336,6 +336,13 @@ interface ModifiedFile {
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
}
interface GitRemoteStatus {
branch: string
ahead: number
behind: number
hasRemote: boolean
}
interface PulseCommit {
hash: string
shortHash: string
@@ -372,12 +379,18 @@ interface PulseCommit {
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
- Converts `hasRemote: false` into a local-only commit path
- Keeps the normal push path unchanged for vaults that do have a remote
### Frontend Integration
- **Modified file badges**: Orange dots in sidebar
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
- **Pulse view**: Activity feed when Pulse filter is selected
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button

View File

@@ -522,8 +522,13 @@ flowchart TD
PC -->|Fast-forward| RV["reload vault"]
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
GC --> GP["invoke('git_push')"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
RS --> RCHK["invoke('git_remote_status')"]
RCHK --> RMODE{Remote configured?}
RMODE -->|No| GC["invoke('git_commit', message)"]
GC --> LOCAL["Local commit only\nNo remote chip + local toast"]
RMODE -->|Yes| GC2["invoke('git_commit', message)"]
GC2 --> GP["invoke('git_push')"]
GP --> PR{Push result?}
PR -->|ok| RM["Reload modified files"]
PR -->|rejected| DIV["syncStatus = pull_required"]
@@ -536,6 +541,8 @@ flowchart TD
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
#### Sync States
| State | Indicator | Color | Trigger |
@@ -696,6 +703,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useTheme` | Editor theme CSS vars | Editor typography theme |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |

View File

@@ -25,6 +25,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
@@ -185,6 +186,7 @@ function App() {
},
onToast: (msg) => setToastMessage(msg),
})
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
@@ -418,7 +420,14 @@ function App() {
}
}, [vault, notes, setToastMessage])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const commitFlow = useCommitFlow({
savePending: appSave.savePending,
loadModifiedFiles: vault.loadModifiedFiles,
resolveRemoteStatus: gitRemoteStatus.refreshRemoteStatus,
setToastMessage,
onPushRejected: autoSync.handlePushRejected,
vaultPath: resolvedPath,
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const entryActions = useEntryActions({
@@ -752,7 +761,14 @@ function App() {
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<CommitDialog
open={commitFlow.showCommitDialog}
modifiedCount={vault.modifiedFiles.length}
commitMode={commitFlow.commitMode}
suggestedMessage={suggestedCommitMessage}
onCommit={commitFlow.handleCommitPush}
onClose={commitFlow.closeCommitDialog}
/>
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}

View File

@@ -10,9 +10,8 @@ describe('CommitDialog', () => {
vi.clearAllMocks()
})
function getCommitButton() {
// "Commit & Push" appears in both dialog title and button — use role to disambiguate
return screen.getByRole('button', { name: 'Commit & Push' })
function getActionButton(name = 'Commit & Push') {
return screen.getByRole('button', { name })
}
it('shows file count badge', () => {
@@ -27,21 +26,21 @@ describe('CommitDialog', () => {
it('disables Commit button when message is empty', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).toBeDisabled()
expect(getActionButton()).toBeDisabled()
})
it('enables Commit button when message is typed', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: bug fix' } })
expect(getCommitButton()).not.toBeDisabled()
expect(getActionButton()).not.toBeDisabled()
})
it('calls onCommit with trimmed message on button click', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: ' fix: bug fix ' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).toHaveBeenCalledWith('fix: bug fix')
})
@@ -70,7 +69,7 @@ describe('CommitDialog', () => {
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: ' ' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).not.toHaveBeenCalled()
})
@@ -87,7 +86,7 @@ describe('CommitDialog', () => {
it('enables Commit button when suggestedMessage is provided', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
expect(getCommitButton()).not.toBeDisabled()
expect(getActionButton()).not.toBeDisabled()
})
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
@@ -101,7 +100,16 @@ describe('CommitDialog', () => {
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
const textarea = screen.getByPlaceholderText('Commit message...')
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
fireEvent.click(getCommitButton())
fireEvent.click(getActionButton())
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
})
it('switches to local-only copy when commitMode is local', () => {
render(<CommitDialog open={true} modifiedCount={2} commitMode="local" onCommit={onCommit} onClose={onClose} />)
expect(screen.getByRole('heading', { name: 'Commit' })).toBeInTheDocument()
expect(screen.getByText('This vault has no git remote configured. Tolaria will create a local commit only.')).toBeInTheDocument()
expect(screen.getByText('Cmd+Enter to commit locally')).toBeInTheDocument()
expect(getActionButton('Commit')).toBeDisabled()
})
})

View File

@@ -2,18 +2,66 @@ import { useState, useEffect, useRef } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import type { CommitMode } from '../hooks/useCommitFlow'
type CommitDialogCopy = {
title: string
description: string
actionLabel: string
shortcutHint: string
}
function getDialogCopy(commitMode: CommitMode): CommitDialogCopy {
if (commitMode === 'local') {
return {
title: 'Commit',
description: 'This vault has no git remote configured. Tolaria will create a local commit only.',
actionLabel: 'Commit',
shortcutHint: 'Cmd+Enter to commit locally',
}
}
return {
title: 'Commit & Push',
description: 'Review changed files and enter a commit message before committing and pushing.',
actionLabel: 'Commit & Push',
shortcutHint: 'Cmd+Enter to commit',
}
}
function changedFilesLabel(modifiedCount: number): string {
return `${modifiedCount} file${modifiedCount !== 1 ? 's' : ''} changed`
}
function isSubmitShortcut(event: React.KeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
}
function isCloseShortcut(event: React.KeyboardEvent): boolean {
return event.key === 'Escape'
}
interface CommitDialogProps {
open: boolean
modifiedCount: number
commitMode?: CommitMode
suggestedMessage?: string
onCommit: (message: string) => void
onClose: () => void
}
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
export function CommitDialog({
open,
modifiedCount,
commitMode = 'push',
suggestedMessage,
onCommit,
onClose,
}: CommitDialogProps) {
const [message, setMessage] = useState('')
const inputRef = useRef<HTMLTextAreaElement>(null)
const copy = getDialogCopy(commitMode)
useEffect(() => {
if (open) {
@@ -29,10 +77,10 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if (isSubmitShortcut(e)) {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
} else if (isCloseShortcut(e)) {
onClose()
}
}
@@ -42,18 +90,16 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Commit & Push</DialogTitle>
<DialogTitle>{copy.title}</DialogTitle>
<Badge variant="secondary" className="text-xs">
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
{changedFilesLabel(modifiedCount)}
</Badge>
</div>
<DialogDescription className="sr-only">
Review changed files and enter a commit message before committing and pushing.
</DialogDescription>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<textarea
<Textarea
ref={inputRef}
className="w-full resize-y rounded-lg border border-input bg-[var(--bg-input)] px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-ring"
className="min-h-[84px] resize-y bg-[var(--bg-input)] py-2.5 text-[13px]"
placeholder="Commit message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
@@ -61,13 +107,13 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
rows={3}
/>
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
<span className="text-[11px] text-muted-foreground">{copy.shortcutHint}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!message.trim()}>
Commit & Push
{copy.actionLabel}
</Button>
</div>
</DialogFooter>

View File

@@ -339,6 +339,19 @@ describe('StatusBar', () => {
expect(screen.getByTestId('status-offline')).toHaveTextContent('Offline')
})
it('shows a no-remote chip when the active git vault has no remote', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-no-remote')).toHaveTextContent('No remote')
})
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(
@@ -394,6 +407,21 @@ describe('StatusBar', () => {
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
render(
<StatusBar
noteCount={100}
modifiedCount={5}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCommitPush={vi.fn()}
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
/>
)
expect(screen.getByTestId('status-commit-push')).toHaveAttribute('title', 'Commit locally (no remote configured)')
})
it('shows Commit button even when no modified files', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()

View File

@@ -122,6 +122,16 @@ function hasRemote(remoteStatus: GitRemoteStatus | null): boolean {
return remoteStatus?.hasRemote ?? false
}
function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): boolean {
return remoteStatus?.hasRemote === false
}
function commitButtonTitle(remoteStatus: GitRemoteStatus | null | undefined): string {
return isRemoteMissing(remoteStatus)
? 'Commit locally (no remote configured)'
: 'Commit & Push'
}
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
if (status === 'installed' || status === 'checking') return null
const clickable = status === 'not_installed' && Boolean(onInstall)
@@ -331,6 +341,31 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
)
}
export function NoRemoteBadge({ remoteStatus }: { remoteStatus?: GitRemoteStatus | null }) {
if (!isRemoteMissing(remoteStatus)) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
style={{
...ICON_STYLE,
color: 'var(--muted-foreground)',
background: 'var(--hover)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
}}
title="This git vault has no remote configured. Commits stay local until you add one."
data-testid="status-no-remote"
>
<GitBranch size={12} />
No remote
</span>
</>
)
}
export function SyncBadge({
status,
lastSyncTime,
@@ -459,7 +494,13 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
)
}
export function CommitButton({ onClick }: { onClick?: () => void }) {
export function CommitButton({
onClick,
remoteStatus,
}: {
onClick?: () => void
remoteStatus?: GitRemoteStatus | null
}) {
if (!onClick) return null
return (
@@ -469,7 +510,7 @@ export function CommitButton({ onClick }: { onClick?: () => void }) {
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Commit & Push"
title={commitButtonTitle(remoteStatus)}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"

View File

@@ -11,6 +11,7 @@ import {
ConflictBadge,
ChangesBadge,
McpBadge,
NoRemoteBadge,
OfflineBadge,
PulseBadge,
SyncBadge,
@@ -111,8 +112,9 @@ export function StatusBarPrimarySection({
{buildNumber ?? 'b?'}
</span>
<OfflineBadge isOffline={isOffline} />
<NoRemoteBadge remoteStatus={remoteStatus} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} />
<CommitButton onClick={onCommitPush} remoteStatus={remoteStatus} />
<SyncBadge
status={syncStatus}
lastSyncTime={lastSyncTime}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex min-h-20 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
),
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -2,28 +2,58 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCommitFlow } from './useCommitFlow'
const mockInvokeFn = vi.fn()
const mockTrackEvent = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../lib/telemetry', () => ({
trackEvent: (event: string, properties?: Record<string, unknown>) => mockTrackEvent(event, properties),
}))
describe('useCommitFlow', () => {
let savePending: vi.Mock
let loadModifiedFiles: vi.Mock
let commitAndPush: vi.Mock
let resolveRemoteStatus: vi.Mock
let setToastMessage: vi.Mock
let onPushRejected: vi.Mock
beforeEach(() => {
savePending = vi.fn().mockResolvedValue(undefined)
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
resolveRemoteStatus = vi.fn().mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
setToastMessage = vi.fn()
onPushRejected = vi.fn()
mockTrackEvent.mockReset()
mockInvokeFn.mockReset()
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit')
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
throw new Error(`Unexpected command: ${command}`)
})
})
function renderCommitFlow() {
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
return renderHook(() => useCommitFlow({
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath: '/vault',
}))
}
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
it('openCommitDialog saves pending, refreshes files, and sets local mode when no remote exists', async () => {
resolveRemoteStatus.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
const { result } = renderCommitFlow()
expect(result.current.showCommitDialog).toBe(false)
await act(async () => {
await result.current.openCommitDialog()
@@ -31,10 +61,12 @@ describe('useCommitFlow', () => {
expect(savePending).toHaveBeenCalledTimes(1)
expect(loadModifiedFiles).toHaveBeenCalledTimes(1)
expect(resolveRemoteStatus).toHaveBeenCalledTimes(1)
expect(result.current.showCommitDialog).toBe(true)
expect(result.current.commitMode).toBe('local')
})
it('handleCommitPush saves pending, commits, shows toast, and refreshes files', async () => {
it('handleCommitPush commits and pushes when a remote is configured', async () => {
const { result } = renderCommitFlow()
await act(async () => {
@@ -42,14 +74,39 @@ describe('useCommitFlow', () => {
})
expect(savePending).toHaveBeenCalled()
expect(commitAndPush).toHaveBeenCalledWith('test message')
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'git_commit', { vaultPath: '/vault', message: 'test message' })
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
expect(loadModifiedFiles).toHaveBeenCalled()
expect(resolveRemoteStatus).toHaveBeenCalledTimes(2)
expect(mockTrackEvent).toHaveBeenCalledWith('commit_made', undefined)
expect(result.current.showCommitDialog).toBe(false)
})
it('handleCommitPush commits locally and skips push when no remote is configured', async () => {
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
await act(async () => {
await result.current.handleCommitPush('test message')
})
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'test message' })
expect(setToastMessage).toHaveBeenCalledWith('Committed locally (no remote configured)')
expect(onPushRejected).not.toHaveBeenCalled()
})
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
if (command === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected' })
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
await act(async () => {
@@ -61,7 +118,7 @@ describe('useCommitFlow', () => {
})
it('handleCommitPush shows error toast on failure', async () => {
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
mockInvokeFn.mockImplementation(() => Promise.reject(new Error('push failed')))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderCommitFlow()
@@ -69,7 +126,7 @@ describe('useCommitFlow', () => {
await result.current.handleCommitPush('test')
})
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Commit failed'))
expect(setToastMessage).toHaveBeenCalledWith('Commit failed: push failed')
consoleSpy.mockRestore()
})

View File

@@ -1,47 +1,115 @@
import { useCallback, useState } from 'react'
import type { GitPushResult } from '../types'
import { invoke } from '@tauri-apps/api/core'
import type { GitPushResult, GitRemoteStatus } from '../types'
import { trackEvent } from '../lib/telemetry'
import { isTauri, mockInvoke } from '../mock-tauri'
export type CommitMode = 'push' | 'local'
interface LocalCommitResult {
status: 'local_only'
message: string
}
type CommitResult = GitPushResult | LocalCommitResult
interface CommitFlowConfig {
savePending: () => Promise<void | boolean>
loadModifiedFiles: () => Promise<void>
commitAndPush: (message: string) => Promise<GitPushResult>
resolveRemoteStatus: () => Promise<GitRemoteStatus | null>
setToastMessage: (msg: string | null) => void
onPushRejected?: () => void
vaultPath: string
}
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
return remoteStatus?.hasRemote === false ? 'local' : 'push'
}
async function commitLocally(vaultPath: string, message: string): Promise<void> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { vaultPath, message })
return
}
await invoke<string>('git_commit', { vaultPath, message })
}
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
if (!isTauri()) {
return mockInvoke<GitPushResult>('git_push', { vaultPath })
}
return invoke<GitPushResult>('git_push', { vaultPath })
}
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
await commitLocally(vaultPath, message)
if (commitMode === 'local') {
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
}
return pushCommittedChanges(vaultPath)
}
function commitToastMessage(result: CommitResult): string {
if (result.status === 'ok') return 'Committed and pushed'
if (result.status === 'local_only') return result.message
if (result.status === 'rejected') return 'Committed, but push rejected — remote has new commits. Pull first.'
return result.message
}
function isPushRejected(result: CommitResult): boolean {
return result.status === 'rejected'
}
function formatCommitError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Manages the commit dialog state and the save→commit→push/local flow. */
export function useCommitFlow({
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath,
}: CommitFlowConfig) {
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [commitMode, setCommitMode] = useState<CommitMode>('push')
const openCommitDialog = useCallback(async () => {
await savePending()
await loadModifiedFiles()
const remoteStatus = await resolveRemoteStatus()
setCommitMode(commitModeFromRemoteStatus(remoteStatus))
setShowCommitDialog(true)
}, [savePending, loadModifiedFiles])
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
const handleCommitPush = useCallback(async (message: string) => {
setShowCommitDialog(false)
try {
await savePending()
const result = await commitAndPush(message)
if (result.status === 'ok') {
trackEvent('commit_made')
setToastMessage('Committed and pushed')
} else if (result.status === 'rejected') {
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
const remoteStatus = await resolveRemoteStatus()
const nextCommitMode = commitModeFromRemoteStatus(remoteStatus)
const result = await executeCommitAction(vaultPath, message, nextCommitMode)
trackEvent('commit_made')
setToastMessage(commitToastMessage(result))
if (isPushRejected(result)) {
onPushRejected?.()
} else {
setToastMessage(result.message)
}
loadModifiedFiles()
await loadModifiedFiles()
await resolveRemoteStatus()
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${err}`)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
}
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
}, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
return { showCommitDialog, openCommitDialog, handleCommitPush, closeCommitDialog }
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog }
}

View File

@@ -0,0 +1,49 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { useGitRemoteStatus } from './useGitRemoteStatus'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
}))
describe('useGitRemoteStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads remote status on mount', async () => {
mockInvokeFn.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
await waitFor(() => {
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_remote_status', { vaultPath: '/vault' })
})
it('refreshRemoteStatus updates the current remote state', async () => {
mockInvokeFn
.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
.mockResolvedValueOnce({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
const { result } = renderHook(() => useGitRemoteStatus('/vault'))
await waitFor(() => {
expect(result.current.remoteStatus?.hasRemote).toBe(true)
})
await act(async () => {
await result.current.refreshRemoteStatus()
})
expect(result.current.remoteStatus).toEqual({ branch: 'main', ahead: 1, behind: 0, hasRemote: false })
})
})

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitRemoteStatus } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
export interface GitRemoteState {
remoteStatus: GitRemoteStatus | null
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
}
async function readRemoteStatus(vaultPath: string): Promise<GitRemoteStatus> {
return tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
}
export function useGitRemoteStatus(vaultPath: string): GitRemoteState {
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
const refreshRemoteStatus = useCallback(async () => {
try {
const status = await readRemoteStatus(vaultPath)
setRemoteStatus(status)
return status
} catch {
setRemoteStatus(null)
return null
}
}, [vaultPath])
useEffect(() => {
let cancelled = false
async function loadRemoteStatus() {
try {
const status = await readRemoteStatus(vaultPath)
if (!cancelled) setRemoteStatus(status)
} catch {
if (!cancelled) setRemoteStatus(null)
}
}
void loadRemoteStatus()
return () => {
cancelled = true
}
}, [vaultPath])
return { remoteStatus, refreshRemoteStatus }
}

View File

@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
test('commit flow stays local when the active vault has no remote @smoke', async ({ page }) => {
await page.addInitScript(() => {
type Handler = (args?: Record<string, unknown>) => unknown
type BrowserWindow = Window & typeof globalThis & {
__gitPushCalls?: number
__mockHandlers?: Record<string, Handler>
__mockHandlersRef?: Record<string, Handler> | null
}
const browserWindow = window as BrowserWindow
const applyOverrides = (handlers?: Record<string, Handler> | null) => {
if (!handlers) return handlers ?? null
handlers.git_remote_status = () => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
handlers.git_push = () => {
browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1
return { status: 'ok', message: 'Pushed to remote' }
}
return handlers
}
browserWindow.__gitPushCalls = 0
let ref = applyOverrides(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
set(value) {
ref = applyOverrides(value as Record<string, Handler> | undefined) ?? null
},
get() {
return applyOverrides(ref) ?? ref
},
})
})
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('status-no-remote')).toContainText('No remote')
await openCommandPalette(page)
await executeCommand(page, 'Commit & Push')
await expect(page.getByRole('heading', { name: 'Commit' })).toBeVisible()
await expect(page.getByText(/local commit only/i)).toBeVisible()
await page.locator('textarea[placeholder="Commit message..."]').fill('test local commit')
await page.getByRole('button', { name: 'Commit' }).click()
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed locally', { timeout: 5000 })
await expect.poll(async () =>
page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0),
).toBe(0)
})