feat: support local-only git commits without remotes
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user