feat: add AutoGit idle and inactive checkpoints

This commit is contained in:
lucaronin
2026-04-16 23:38:30 +02:00
parent 2b85f4e45f
commit 323ef1913b
21 changed files with 1366 additions and 88 deletions

View File

@@ -5,6 +5,9 @@ import type { Settings } from '../types'
const emptySettings: Settings = {
auto_pull_interval_minutes: null,
autogit_enabled: null,
autogit_idle_threshold_seconds: null,
autogit_inactive_threshold_seconds: null,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
@@ -57,6 +60,9 @@ describe('SettingsPanel', () => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
auto_pull_interval_minutes: 5,
autogit_enabled: false,
autogit_idle_threshold_seconds: 90,
autogit_inactive_threshold_seconds: 30,
release_channel: null,
}))
expect(onClose).toHaveBeenCalled()
@@ -116,6 +122,49 @@ describe('SettingsPanel', () => {
expect(screen.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' })).toHaveAttribute('aria-checked', 'true')
})
it('defaults AutoGit to off with recommended thresholds', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByRole('switch', { name: 'AutoGit' })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByTestId('settings-autogit-idle-threshold')).toHaveValue(90)
expect(screen.getByTestId('settings-autogit-inactive-threshold')).toHaveValue(30)
})
it('saves AutoGit preferences when toggled and edited', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByRole('switch', { name: 'AutoGit' }))
fireEvent.change(screen.getByTestId('settings-autogit-idle-threshold'), { target: { value: '120' } })
fireEvent.change(screen.getByTestId('settings-autogit-inactive-threshold'), { target: { value: '45' } })
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
autogit_enabled: true,
autogit_idle_threshold_seconds: 120,
autogit_inactive_threshold_seconds: 45,
}))
})
it('disables AutoGit controls when the current vault is not git-enabled', () => {
render(
<SettingsPanel
open={true}
settings={emptySettings}
isGitVault={false}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByRole('switch', { name: 'AutoGit' })).toBeDisabled()
expect(screen.getByTestId('settings-autogit-idle-threshold')).toBeDisabled()
expect(screen.getByTestId('settings-autogit-inactive-threshold')).toBeDisabled()
})
it('saves the initial H1 auto-rename preference when toggled off', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />

View File

@@ -20,6 +20,7 @@ import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel }
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
import { Checkbox, type CheckedState } from './ui/checkbox'
import { Input } from './ui/input'
import {
Select,
SelectContent,
@@ -34,6 +35,7 @@ interface SettingsPanelProps {
settings: Settings
aiAgentsStatus?: AiAgentsStatus
onSave: (settings: Settings) => void
isGitVault?: boolean
explicitOrganizationEnabled?: boolean
onSaveExplicitOrganization?: (enabled: boolean) => void
onClose: () => void
@@ -41,6 +43,9 @@ interface SettingsPanelProps {
interface SettingsDraft {
pullInterval: number
autoGitEnabled: boolean
autoGitIdleThresholdSeconds: number
autoGitInactiveThresholdSeconds: number
defaultAiAgent: AiAgentId
releaseChannel: ReleaseChannel
initialH1AutoRename: boolean
@@ -52,6 +57,13 @@ interface SettingsDraft {
interface SettingsBodyProps {
pullInterval: number
setPullInterval: (value: number) => void
isGitVault: boolean
autoGitEnabled: boolean
setAutoGitEnabled: (value: boolean) => void
autoGitIdleThresholdSeconds: number
setAutoGitIdleThresholdSeconds: (value: number) => void
autoGitInactiveThresholdSeconds: number
setAutoGitInactiveThresholdSeconds: (value: number) => void
aiAgentsStatus: AiAgentsStatus
defaultAiAgent: AiAgentId
setDefaultAiAgent: (value: AiAgentId) => void
@@ -68,6 +80,8 @@ interface SettingsBodyProps {
}
const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const
const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90
const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30
function isSaveShortcut(event: ReactKeyboardEvent): boolean {
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
@@ -79,6 +93,15 @@ function createSettingsDraft(
): SettingsDraft {
return {
pullInterval: settings.auto_pull_interval_minutes ?? 5,
autoGitEnabled: settings.autogit_enabled ?? false,
autoGitIdleThresholdSeconds: sanitizePositiveInteger(
settings.autogit_idle_threshold_seconds,
DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS,
),
autoGitInactiveThresholdSeconds: sanitizePositiveInteger(
settings.autogit_inactive_threshold_seconds,
DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS,
),
defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent),
releaseChannel: normalizeReleaseChannel(settings.release_channel),
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
@@ -104,6 +127,9 @@ function resolveAnonymousId(settings: Settings, draft: SettingsDraft): string |
function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Settings {
return {
auto_pull_interval_minutes: draft.pullInterval,
autogit_enabled: draft.autoGitEnabled,
autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds,
autogit_inactive_threshold_seconds: draft.autoGitInactiveThresholdSeconds,
telemetry_consent: resolveTelemetryConsent(settings, draft),
crash_reporting_enabled: draft.crashReporting,
analytics_enabled: draft.analytics,
@@ -123,11 +149,17 @@ function isChecked(checked: CheckedState): boolean {
return checked === true
}
function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number {
if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback
return Math.round(value)
}
export function SettingsPanel({
open,
settings,
aiAgentsStatus = createMissingAiAgentsStatus(),
onSave,
isGitVault = true,
explicitOrganizationEnabled = true,
onSaveExplicitOrganization,
onClose,
@@ -139,6 +171,7 @@ export function SettingsPanel({
settings={settings}
aiAgentsStatus={aiAgentsStatus}
onSave={onSave}
isGitVault={isGitVault}
explicitOrganizationEnabled={explicitOrganizationEnabled}
onSaveExplicitOrganization={onSaveExplicitOrganization}
onClose={onClose}
@@ -146,8 +179,9 @@ export function SettingsPanel({
)
}
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus'> & {
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus' | 'isGitVault'> & {
aiAgentsStatus: AiAgentsStatus
isGitVault: boolean
explicitOrganizationEnabled: boolean
}
@@ -155,6 +189,7 @@ function SettingsPanelInner({
settings,
aiAgentsStatus,
onSave,
isGitVault,
explicitOrganizationEnabled,
onSaveExplicitOrganization,
onClose,
@@ -224,6 +259,13 @@ function SettingsPanelInner({
<SettingsBody
pullInterval={draft.pullInterval}
setPullInterval={(value) => updateDraft('pullInterval', value)}
isGitVault={isGitVault}
autoGitEnabled={draft.autoGitEnabled}
setAutoGitEnabled={(value) => updateDraft('autoGitEnabled', value)}
autoGitIdleThresholdSeconds={draft.autoGitIdleThresholdSeconds}
setAutoGitIdleThresholdSeconds={(value) => updateDraft('autoGitIdleThresholdSeconds', value)}
autoGitInactiveThresholdSeconds={draft.autoGitInactiveThresholdSeconds}
setAutoGitInactiveThresholdSeconds={(value) => updateDraft('autoGitInactiveThresholdSeconds', value)}
aiAgentsStatus={aiAgentsStatus}
defaultAiAgent={draft.defaultAiAgent}
setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)}
@@ -267,6 +309,13 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
function SettingsBody({
pullInterval,
setPullInterval,
isGitVault,
autoGitEnabled,
setAutoGitEnabled,
autoGitIdleThresholdSeconds,
setAutoGitIdleThresholdSeconds,
autoGitInactiveThresholdSeconds,
setAutoGitInactiveThresholdSeconds,
aiAgentsStatus,
defaultAiAgent,
setDefaultAiAgent,
@@ -283,6 +332,70 @@ function SettingsBody({
}: SettingsBodyProps) {
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto' }}>
<SyncSettingsSection
pullInterval={pullInterval}
setPullInterval={setPullInterval}
/>
<Divider />
<AutoGitSettingsSection
isGitVault={isGitVault}
autoGitEnabled={autoGitEnabled}
setAutoGitEnabled={setAutoGitEnabled}
autoGitIdleThresholdSeconds={autoGitIdleThresholdSeconds}
setAutoGitIdleThresholdSeconds={setAutoGitIdleThresholdSeconds}
autoGitInactiveThresholdSeconds={autoGitInactiveThresholdSeconds}
setAutoGitInactiveThresholdSeconds={setAutoGitInactiveThresholdSeconds}
/>
<Divider />
<TitleSettingsSection
initialH1AutoRename={initialH1AutoRename}
setInitialH1AutoRename={setInitialH1AutoRename}
/>
<Divider />
<AiAgentSettingsSection
aiAgentsStatus={aiAgentsStatus}
defaultAiAgent={defaultAiAgent}
setDefaultAiAgent={setDefaultAiAgent}
/>
<Divider />
<ReleaseChannelSection
releaseChannel={releaseChannel}
setReleaseChannel={setReleaseChannel}
/>
<Divider />
<OrganizationWorkflowSection
checked={explicitOrganization}
onChange={setExplicitOrganization}
/>
<Divider />
<PrivacySettingsSection
crashReporting={crashReporting}
setCrashReporting={setCrashReporting}
analytics={analytics}
setAnalytics={setAnalytics}
/>
</div>
)
}
function SyncSettingsSection({
pullInterval,
setPullInterval,
}: Pick<SettingsBodyProps, 'pullInterval' | 'setPullInterval'>) {
return (
<>
<SectionHeading
title="Sync"
description="Automatically pull vault changes from Git in the background."
@@ -299,9 +412,75 @@ function SettingsBody({
testId="settings-pull-interval"
autoFocus={true}
/>
</>
)
}
<Divider />
function autoGitSectionDescription(isGitVault: boolean): string {
return isGitVault
? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.'
: 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.'
}
function AutoGitSettingsSection({
isGitVault,
autoGitEnabled,
setAutoGitEnabled,
autoGitIdleThresholdSeconds,
setAutoGitIdleThresholdSeconds,
autoGitInactiveThresholdSeconds,
setAutoGitInactiveThresholdSeconds,
}: Pick<
SettingsBodyProps,
| 'isGitVault'
| 'autoGitEnabled'
| 'setAutoGitEnabled'
| 'autoGitIdleThresholdSeconds'
| 'setAutoGitIdleThresholdSeconds'
| 'autoGitInactiveThresholdSeconds'
| 'setAutoGitInactiveThresholdSeconds'
>) {
return (
<>
<SectionHeading
title="AutoGit"
description={autoGitSectionDescription(isGitVault)}
/>
<SettingsSwitchRow
label="AutoGit"
description="When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive."
checked={autoGitEnabled}
onChange={setAutoGitEnabled}
disabled={!isGitVault}
testId="settings-autogit-enabled"
/>
<LabeledNumberInput
label="Idle threshold (seconds)"
value={autoGitIdleThresholdSeconds}
onValueChange={setAutoGitIdleThresholdSeconds}
testId="settings-autogit-idle-threshold"
disabled={!isGitVault}
/>
<LabeledNumberInput
label="Inactive-app grace period (seconds)"
value={autoGitInactiveThresholdSeconds}
onValueChange={setAutoGitInactiveThresholdSeconds}
testId="settings-autogit-inactive-threshold"
disabled={!isGitVault}
/>
</>
)
}
function TitleSettingsSection({
initialH1AutoRename,
setInitialH1AutoRename,
}: Pick<SettingsBodyProps, 'initialH1AutoRename' | 'setInitialH1AutoRename'>) {
return (
<>
<SectionHeading
title="Titles & Filenames"
description="Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title."
@@ -314,9 +493,30 @@ function SettingsBody({
onChange={setInitialH1AutoRename}
testId="settings-initial-h1-auto-rename"
/>
</>
)
}
<Divider />
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ value: string; label: string }> {
return AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]
const suffix = status.status === 'installed'
? ` (installed${status.version ? ` ${status.version}` : ''})`
: ' (missing)'
return {
value: definition.id,
label: `${definition.label}${suffix}`,
}
})
}
function AiAgentSettingsSection({
aiAgentsStatus,
defaultAiAgent,
setDefaultAiAgent,
}: Pick<SettingsBodyProps, 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
return (
<>
<SectionHeading
title="AI Agents"
description="Choose which CLI AI agent Tolaria uses in the AI panel and command palette."
@@ -326,25 +526,23 @@ function SettingsBody({
label="Default AI agent"
value={defaultAiAgent}
onValueChange={(value) => setDefaultAiAgent(value as AiAgentId)}
options={AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]
const suffix = status.status === 'installed'
? ` (installed${status.version ? ` ${status.version}` : ''})`
: ' (missing)'
return {
value: definition.id,
label: `${definition.label}${suffix}`,
}
})}
options={buildDefaultAiAgentOptions(aiAgentsStatus)}
testId="settings-default-ai-agent"
/>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
</div>
</>
)
}
<Divider />
function ReleaseChannelSection({
releaseChannel,
setReleaseChannel,
}: Pick<SettingsBodyProps, 'releaseChannel' | 'setReleaseChannel'>) {
return (
<>
<SectionHeading
title="Release Channel"
description="Controls which update feed Tolaria polls. Stable only receives manually promoted releases. Alpha follows every push to main."
@@ -360,16 +558,18 @@ function SettingsBody({
]}
testId="settings-release-channel"
/>
</>
)
}
<Divider />
<OrganizationWorkflowSection
checked={explicitOrganization}
onChange={setExplicitOrganization}
/>
<Divider />
function PrivacySettingsSection({
crashReporting,
setCrashReporting,
analytics,
setAnalytics,
}: Pick<SettingsBodyProps, 'crashReporting' | 'setCrashReporting' | 'analytics' | 'setAnalytics'>) {
return (
<>
<SectionHeading
title="Privacy & Telemetry"
description="Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent."
@@ -389,7 +589,7 @@ function SettingsBody({
onChange={setAnalytics}
testId="settings-analytics"
/>
</div>
</>
)
}
@@ -462,6 +662,37 @@ function LabeledSelect({
)
}
function LabeledNumberInput({
label,
value,
onValueChange,
testId,
disabled = false,
}: {
label: string
value: number
onValueChange: (value: number) => void
testId: string
disabled?: boolean
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }} htmlFor={testId}>{label}</label>
<Input
id={testId}
type="number"
min={1}
step={1}
value={value}
disabled={disabled}
onChange={(event) => onValueChange(sanitizePositiveInteger(Number(event.target.value), value))}
data-testid={testId}
className="w-full bg-transparent"
/>
</div>
)
}
function OrganizationWorkflowSection({
checked,
onChange,
@@ -492,25 +723,27 @@ function SettingsSwitchRow({
description,
checked,
onChange,
disabled = false,
testId,
}: {
label: string
description: string
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
testId?: string
}) {
return (
<label
className="flex items-start justify-between gap-3"
style={{ cursor: 'pointer' }}
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1 }}
data-testid={testId}
>
<div className="space-y-1">
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{label}</div>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{description}</div>
</div>
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} />
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} disabled={disabled} />
</label>
)
}

View File

@@ -369,6 +369,15 @@ describe('StatusBar', () => {
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('activates the Commit button with the keyboard', () => {
const onCommitPush = vi.fn()
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={onCommitPush} />)
const commitButton = screen.getByTestId('status-commit-push')
commitButton.focus()
fireEvent.keyDown(commitButton, { key: 'Enter' })
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
render(
<StatusBar

View File

@@ -82,7 +82,8 @@ function createHoverHandlers(interactive: boolean): HoverHandlers {
function createEnterKeyHandler(onActivate?: () => void) {
return (event: ReactKeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Enter') {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate?.()
}
}
@@ -508,7 +509,9 @@ export function CommitButton({
<span style={SEP_STYLE}>|</span>
<span
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={createEnterKeyHandler(onClick)}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={commitButtonTitle(remoteStatus)}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}