From a13e36a504e70889ee05971bb345e044db9b80e2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 12 Apr 2026 19:38:25 +0200 Subject: [PATCH] feat: support local-only git commits without remotes --- docs/ABSTRACTIONS.md | 13 +++ docs/ARCHITECTURE.md | 12 ++- src/App.tsx | 20 +++- src/components/CommitDialog.test.tsx | 26 +++-- src/components/CommitDialog.tsx | 70 +++++++++--- src/components/StatusBar.test.tsx | 28 +++++ src/components/status-bar/StatusBarBadges.tsx | 45 +++++++- .../status-bar/StatusBarSections.tsx | 4 +- src/components/ui/textarea.tsx | 22 ++++ src/hooks/useCommitFlow.test.ts | 77 +++++++++++-- src/hooks/useCommitFlow.ts | 102 +++++++++++++++--- src/hooks/useGitRemoteStatus.test.ts | 49 +++++++++ src/hooks/useGitRemoteStatus.ts | 52 +++++++++ tests/smoke/git-no-remote-commit-only.spec.ts | 59 ++++++++++ 14 files changed, 524 insertions(+), 55 deletions(-) create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/hooks/useGitRemoteStatus.test.ts create mode 100644 src/hooks/useGitRemoteStatus.ts create mode 100644 tests/smoke/git-no-remote-commit-only.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index b23629fc..095152f9 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 762e7e7b..b2e65530 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | diff --git a/src/App.tsx b/src/App.tsx index 80d7f926..06c8f3e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]) @@ -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() { - + { 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() - expect(getCommitButton()).toBeDisabled() + expect(getActionButton()).toBeDisabled() }) it('enables Commit button when message is typed', () => { render() 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() 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() 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() - expect(getCommitButton()).not.toBeDisabled() + expect(getActionButton()).not.toBeDisabled() }) it('submits suggestedMessage on Cmd+Enter without user edits', () => { @@ -101,7 +100,16 @@ describe('CommitDialog', () => { render() 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() + + 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() + }) }) diff --git a/src/components/CommitDialog.tsx b/src/components/CommitDialog.tsx index 4a07297a..499f0f2c 100644 --- a/src/components/CommitDialog.tsx +++ b/src/components/CommitDialog.tsx @@ -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(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,
- Commit & Push + {copy.title} - {modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed + {changedFilesLabel(modifiedCount)}
- - Review changed files and enter a commit message before committing and pushing. - + {copy.description}
-