diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 75b9a1f1..3ff3fde7 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -386,6 +386,12 @@ interface PulseCommit { - Converts `hasRemote: false` into a local-only commit path - Keeps the normal push path unchanged for vaults that do have a remote +`useAutoGit` is the checkpoint-time companion to both hooks: +- Consumes installation-local AutoGit settings (`autogit_enabled`, idle threshold, inactive threshold) +- Tracks the last meaningful editor activity plus app focus/visibility transitions +- Triggers `useCommitFlow.runAutomaticCheckpoint()` only when the vault is git-backed, pending changes exist, and no unsaved edits remain +- Shares the same deterministic automatic commit message generator with the bottom-bar Commit button, so timer-driven checkpoints and manual quick commits produce the same `Updated N note(s)` / `Updated N file(s)` messages + ### Frontend Integration - **Modified file badges**: Orange dots in sidebar @@ -580,6 +586,9 @@ App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads ```typescript interface Settings { auto_pull_interval_minutes: number | null + autogit_enabled: boolean | null + autogit_idle_threshold_seconds: number | null + autogit_inactive_threshold_seconds: number | null telemetry_consent: boolean | null crash_reporting_enabled: boolean | null analytics_enabled: boolean | null @@ -589,7 +598,7 @@ interface Settings { } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. +Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 06e2063f..7dbe0464 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -553,6 +553,8 @@ flowchart TD `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. +`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior. + #### Sync States | State | Indicator | Color | Trigger | @@ -705,6 +707,8 @@ if (isTauri()) { The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits. +Browser smoke tests can also override `window.__mockHandlers` before the app boots. The AutoGit smoke bridge uses that path directly for seeded saves so the mocked git dirty-state stays synchronized even when the optional browser vault API is serving note content. + ## State Management No Redux or global context. State lives in the root `App.tsx` and custom hooks: @@ -722,9 +726,11 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useTheme` | Editor theme CSS vars | Editor typography theme | | `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation | | `useAutoSync` | Sync interval, pull/push state | Git auto-sync | +| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints | +| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration | | `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 | +| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | | `useVaultConfig` | Per-vault UI preferences | Vault-specific config | | `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index fdd402bc..142d0fd9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -8,6 +8,9 @@ const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { pub auto_pull_interval_minutes: Option, + pub autogit_enabled: Option, + pub autogit_idle_threshold_seconds: Option, + pub autogit_inactive_threshold_seconds: Option, pub telemetry_consent: Option, pub crash_reporting_enabled: Option, pub analytics_enabled: Option, @@ -23,6 +26,10 @@ fn normalize_optional_string(value: Option) -> Option { .filter(|candidate| !candidate.is_empty()) } +fn normalize_optional_positive_u32(value: Option) -> Option { + value.filter(|candidate| *candidate > 0) +} + pub fn normalize_release_channel(value: Option<&str>) -> Option { match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { Some(channel) if channel == "alpha" => Some(channel), @@ -48,6 +55,13 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option { fn normalize_settings(settings: Settings) -> Settings { Settings { auto_pull_interval_minutes: settings.auto_pull_interval_minutes, + autogit_enabled: settings.autogit_enabled, + autogit_idle_threshold_seconds: normalize_optional_positive_u32( + settings.autogit_idle_threshold_seconds, + ), + autogit_inactive_threshold_seconds: normalize_optional_positive_u32( + settings.autogit_inactive_threshold_seconds, + ), telemetry_consent: settings.telemetry_consent, crash_reporting_enabled: settings.crash_reporting_enabled, analytics_enabled: settings.analytics_enabled, @@ -151,6 +165,9 @@ mod tests { use super::*; type SettingsSnapshot<'a> = ( + Option, + Option, + Option, Option, Option, Option, @@ -164,6 +181,9 @@ mod tests { fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> { ( settings.auto_pull_interval_minutes, + settings.autogit_enabled, + settings.autogit_idle_threshold_seconds, + settings.autogit_inactive_threshold_seconds, settings.telemetry_consent, settings.crash_reporting_enabled, settings.analytics_enabled, @@ -177,7 +197,7 @@ mod tests { fn assert_empty_settings(settings: &Settings) { assert_eq!( settings_snapshot(settings), - (None, None, None, None, None, None, None, None) + (None, None, None, None, None, None, None, None, None, None, None) ); } @@ -211,6 +231,9 @@ mod tests { fn test_settings_json_roundtrip() { let settings = Settings { auto_pull_interval_minutes: Some(10), + autogit_enabled: Some(true), + autogit_idle_threshold_seconds: Some(90), + autogit_inactive_threshold_seconds: Some(30), telemetry_consent: Some(true), crash_reporting_enabled: Some(true), analytics_enabled: Some(false), @@ -236,12 +259,18 @@ mod tests { fn test_save_and_load_preserves_values() { let loaded = save_and_reload(Settings { auto_pull_interval_minutes: Some(10), + autogit_enabled: Some(true), + autogit_idle_threshold_seconds: Some(90), + autogit_inactive_threshold_seconds: Some(30), release_channel: Some("alpha".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), ..Default::default() }); assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); + assert_eq!(loaded.autogit_enabled, Some(true)); + assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90)); + assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30)); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false)); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); @@ -269,6 +298,17 @@ mod tests { assert!(loaded.release_channel.is_none()); } + #[test] + fn test_non_positive_autogit_thresholds_are_filtered() { + let loaded = save_and_reload(Settings { + autogit_idle_threshold_seconds: Some(0), + autogit_inactive_threshold_seconds: Some(0), + ..Default::default() + }); + assert!(loaded.autogit_idle_threshold_seconds.is_none()); + assert!(loaded.autogit_inactive_threshold_seconds.is_none()); + } + #[test] fn test_non_alpha_release_channels_normalize_to_stable() { let loaded = save_and_reload(Settings { @@ -339,6 +379,9 @@ mod tests { assert_eq!( settings_snapshot(&loaded), ( + None, + None, + None, None, Some(true), Some(true), diff --git a/src/App.tsx b/src/App.tsx index d9f4a6fa..05b2e09e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { useMcpStatus } from './hooks/useMcpStatus' import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus' +import { useAutoGit } from './hooks/useAutoGit' import { useVaultLoader } from './hooks/useVaultLoader' import { useAiAgentPreferences } from './hooks/useAiAgentPreferences' import { useSettings } from './hooks/useSettings' @@ -616,6 +617,84 @@ function App() { vaultPath: resolvedPath, }) const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles]) + const isGitVault = !vault.modifiedFilesError + const modifiedFilesSignature = useMemo( + () => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'), + [vault.modifiedFiles], + ) + const autoGit = useAutoGit({ + enabled: settings.autogit_enabled === true, + idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90, + inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30, + isGitVault, + hasPendingChanges: vault.modifiedFiles.length > 0 + || ((autoSync.remoteStatus?.hasRemote ?? false) && (autoSync.remoteStatus?.ahead ?? 0) > 0), + hasUnsavedChanges: vault.unsavedPaths.size > 0, + onCheckpoint: () => commitFlow.runAutomaticCheckpoint(), + }) + const recordAutoGitActivity = autoGit.recordActivity + const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint + const handleAppContentChange = appSave.handleContentChange + const handleAppSave = appSave.handleSave + const loadModifiedFiles = vault.loadModifiedFiles + + useEffect(() => { + if (modifiedFilesSignature.length === 0) return + recordAutoGitActivity() + }, [modifiedFilesSignature, recordAutoGitActivity]) + + const handleQuickCommitPush = useCallback(() => { + void runAutomaticCheckpoint({ savePendingBeforeCommit: true }) + }, [runAutomaticCheckpoint]) + + const handleTrackedContentChange = useCallback((path: string, content: string) => { + recordAutoGitActivity() + handleAppContentChange(path, content) + }, [handleAppContentChange, recordAutoGitActivity]) + + const handleTrackedSave = useCallback(async (...args: Parameters) => { + const result = await handleAppSave(...args) + recordAutoGitActivity() + return result + }, [handleAppSave, recordAutoGitActivity]) + + const seedAutoGitSavedChange = useCallback(async () => { + if (isTauri()) { + throw new Error('seedAutoGitSavedChange is only available in browser smoke tests') + } + + const activePath = notes.activeTabPath + const activeTab = activePath + ? notes.tabs.find((tab) => tab.entry.path === activePath) + : null + + if (!activePath || !activeTab) { + throw new Error('No active note is available for the AutoGit test bridge') + } + + const saveNoteContent = window.__mockHandlers?.save_note_content + if (typeof saveNoteContent === 'function') { + await Promise.resolve(saveNoteContent({ path: activePath, content: activeTab.content })) + } else { + await mockInvoke('save_note_content', { path: activePath, content: activeTab.content }) + } + + await loadModifiedFiles() + recordAutoGitActivity() + }, [loadModifiedFiles, notes.activeTabPath, notes.tabs, recordAutoGitActivity]) + + useEffect(() => { + window.__laputaTest = { + ...window.__laputaTest, + seedAutoGitSavedChange, + } + + return () => { + if (window.__laputaTest?.seedAutoGitSavedChange === seedAutoGitSavedChange) { + delete window.__laputaTest.seedAutoGitSavedChange + } + } + }, [seedAutoGitSavedChange]) const entryActions = useEntryActions({ entries: vault.entries, updateEntry: vault.updateEntry, @@ -993,8 +1072,8 @@ function App() { onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote} onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote} onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote} - onContentChange={appSave.handleContentChange} - onSave={appSave.handleSave} + onContentChange={handleTrackedContentChange} + onSave={handleTrackedSave} onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename} rawToggleRef={rawToggleRef} diffToggleRef={diffToggleRef} @@ -1014,7 +1093,7 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleQuickCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} /> setToastMessage(null)} /> @@ -1048,7 +1127,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={conflictFlow.handleCloseConflictResolver} /> - + {deleteActions.confirmDelete && ( diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 648f58b4..9ae97990 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + 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( diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index f58410c7..4e100abe 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -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 & { +type SettingsPanelInnerProps = Omit & { 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({ 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 (
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} + +function SyncSettingsSection({ + pullInterval, + setPullInterval, +}: Pick) { + return ( + <> + + ) +} - +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 ( + <> + + + + + + + + + ) +} + +function TitleSettingsSection({ + initialH1AutoRename, + setInitialH1AutoRename, +}: Pick) { + return ( + <> + + ) +} - +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) { + return ( + <> 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" />
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
+ + ) +} - - +function ReleaseChannelSection({ + releaseChannel, + setReleaseChannel, +}: Pick) { + return ( + <> + + ) +} - - - - - - +function PrivacySettingsSection({ + crashReporting, + setCrashReporting, + analytics, + setAnalytics, +}: Pick) { + return ( + <> - + ) } @@ -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 ( +
+ + onValueChange(sanitizePositiveInteger(Number(event.target.value), value))} + data-testid={testId} + className="w-full bg-transparent" + /> +
+ ) +} + 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 ( ) } diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index ed8909d8..fa9579b7 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -369,6 +369,15 @@ describe('StatusBar', () => { expect(onCommitPush).toHaveBeenCalledOnce() }) + it('activates the Commit button with the keyboard', () => { + const onCommitPush = vi.fn() + render() + 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( void) { return (event: ReactKeyboardEvent) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() onActivate?.() } } @@ -508,7 +509,9 @@ export function CommitButton({ | { event.currentTarget.style.background = 'var(--hover)' }} diff --git a/src/hooks/useAutoGit.test.ts b/src/hooks/useAutoGit.test.ts new file mode 100644 index 00000000..917be623 --- /dev/null +++ b/src/hooks/useAutoGit.test.ts @@ -0,0 +1,113 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAutoGit } from './useAutoGit' + +describe('useAutoGit', () => { + let hasFocus = true + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockImplementation(() => hasFocus) + hasFocus = true + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('triggers an idle checkpoint after the configured threshold', async () => { + const onCheckpoint = vi.fn().mockResolvedValue(true) + renderHook(() => useAutoGit({ + enabled: true, + idleThresholdSeconds: 3, + inactiveThresholdSeconds: 2, + isGitVault: true, + hasPendingChanges: true, + hasUnsavedChanges: false, + onCheckpoint, + })) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_999) + }) + expect(onCheckpoint).not.toHaveBeenCalled() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(onCheckpoint).toHaveBeenCalledWith('idle') + }) + + it('waits for the app to become inactive before triggering the inactive checkpoint', async () => { + const onCheckpoint = vi.fn().mockResolvedValue(true) + renderHook(() => useAutoGit({ + enabled: true, + idleThresholdSeconds: 10, + inactiveThresholdSeconds: 2, + isGitVault: true, + hasPendingChanges: true, + hasUnsavedChanges: false, + onCheckpoint, + })) + + hasFocus = false + await act(async () => { + window.dispatchEvent(new Event('blur')) + await vi.advanceTimersByTimeAsync(2_000) + }) + + expect(onCheckpoint).toHaveBeenCalledWith('inactive') + }) + + it('does not trigger while the editor still has unsaved changes', async () => { + const onCheckpoint = vi.fn().mockResolvedValue(true) + renderHook(() => useAutoGit({ + enabled: true, + idleThresholdSeconds: 1, + inactiveThresholdSeconds: 1, + isGitVault: true, + hasPendingChanges: true, + hasUnsavedChanges: true, + onCheckpoint, + })) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + + expect(onCheckpoint).not.toHaveBeenCalled() + }) + + it('only triggers once per activity burst until activity is recorded again', async () => { + const onCheckpoint = vi.fn().mockResolvedValue(true) + const { result } = renderHook(() => useAutoGit({ + enabled: true, + idleThresholdSeconds: 1, + inactiveThresholdSeconds: 1, + isGitVault: true, + hasPendingChanges: true, + hasUnsavedChanges: false, + onCheckpoint, + })) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(onCheckpoint).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_000) + }) + expect(onCheckpoint).toHaveBeenCalledTimes(1) + + act(() => { + result.current.recordActivity() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(onCheckpoint).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/hooks/useAutoGit.ts b/src/hooks/useAutoGit.ts new file mode 100644 index 00000000..6faeb947 --- /dev/null +++ b/src/hooks/useAutoGit.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useEffectEvent, useRef } from 'react' + +export type AutoGitTrigger = 'idle' | 'inactive' + +interface UseAutoGitOptions { + enabled: boolean + idleThresholdSeconds: number + inactiveThresholdSeconds: number + isGitVault: boolean + hasPendingChanges: boolean + hasUnsavedChanges: boolean + onCheckpoint: (trigger: AutoGitTrigger) => Promise +} + +interface TriggerState { + idle: number | null + inactive: number | null +} + +interface AutoGitState { + recordActivity: () => void +} + +interface CheckpointEligibility { + enabled: boolean + isGitVault: boolean + hasPendingChanges: boolean + hasUnsavedChanges: boolean +} + +function isDocumentActive(): boolean { + return document.visibilityState === 'visible' && document.hasFocus() +} + +function resetTriggerState(target: TriggerState): void { + target.idle = null + target.inactive = null +} + +function thresholdMsForTrigger( + trigger: AutoGitTrigger, + idleThresholdSeconds: number, + inactiveThresholdSeconds: number, +): number { + return (trigger === 'idle' ? idleThresholdSeconds : inactiveThresholdSeconds) * 1000 +} + +function isCheckpointEligible({ + enabled, + isGitVault, + hasPendingChanges, + hasUnsavedChanges, +}: CheckpointEligibility): boolean { + return enabled && isGitVault && hasPendingChanges && !hasUnsavedChanges +} + +function markTriggerAsHandled( + target: TriggerState, + trigger: AutoGitTrigger, + activityAt: number, +): void { + target[trigger] = activityAt +} + +function shouldTriggerCheckpoint({ + eligibility, + trigger, + lastTriggeredAt, + lastActivityAt, + idleThresholdSeconds, + inactiveThresholdSeconds, +}: { + eligibility: CheckpointEligibility + trigger: AutoGitTrigger + lastTriggeredAt: number | null + lastActivityAt: number + idleThresholdSeconds: number + inactiveThresholdSeconds: number +}): boolean { + if (!isCheckpointEligible(eligibility)) return false + if (lastTriggeredAt === lastActivityAt) return false + + const thresholdMs = thresholdMsForTrigger( + trigger, + idleThresholdSeconds, + inactiveThresholdSeconds, + ) + return Date.now() - lastActivityAt >= thresholdMs +} + +export function useAutoGit({ + enabled, + idleThresholdSeconds, + inactiveThresholdSeconds, + isGitVault, + hasPendingChanges, + hasUnsavedChanges, + onCheckpoint, +}: UseAutoGitOptions): AutoGitState { + const lastActivityAtRef = useRef(0) + const lastTriggeredRef = useRef({ idle: null, inactive: null }) + const appActiveRef = useRef(true) + + const recordActivity = useCallback(() => { + lastActivityAtRef.current = Date.now() + resetTriggerState(lastTriggeredRef.current) + }, []) + + const maybeTriggerCheckpoint = useEffectEvent((trigger: AutoGitTrigger) => { + const lastActivityAt = lastActivityAtRef.current + const eligibility = { + enabled, + isGitVault, + hasPendingChanges, + hasUnsavedChanges, + } + if (!shouldTriggerCheckpoint({ + eligibility, + trigger, + lastTriggeredAt: lastTriggeredRef.current[trigger], + lastActivityAt, + idleThresholdSeconds, + inactiveThresholdSeconds, + })) return + + void onCheckpoint(trigger).then((didRun) => { + if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt) + }).catch(() => {}) + }) + + const updateAppActivity = useEffectEvent((active: boolean) => { + if (appActiveRef.current === active) return + appActiveRef.current = active + + if (active) { + lastTriggeredRef.current.inactive = null + } else { + lastTriggeredRef.current.idle = null + } + + maybeTriggerCheckpoint(active ? 'idle' : 'inactive') + }) + + useEffect(() => { + lastActivityAtRef.current = Date.now() + appActiveRef.current = isDocumentActive() + + const handleFocus = () => { updateAppActivity(true) } + const handleBlur = () => { updateAppActivity(false) } + const handleVisibilityChange = () => { updateAppActivity(isDocumentActive()) } + + window.addEventListener('focus', handleFocus) + window.addEventListener('blur', handleBlur) + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.removeEventListener('focus', handleFocus) + window.removeEventListener('blur', handleBlur) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + + useEffect(() => { + if (!enabled) return + + const id = window.setInterval(() => { + maybeTriggerCheckpoint(appActiveRef.current ? 'idle' : 'inactive') + }, 1000) + + return () => window.clearInterval(id) + }, [enabled]) + + return { recordActivity } +} diff --git a/src/hooks/useCommitFlow.test.ts b/src/hooks/useCommitFlow.test.ts index 76792aa8..e6193548 100644 --- a/src/hooks/useCommitFlow.test.ts +++ b/src/hooks/useCommitFlow.test.ts @@ -36,6 +36,7 @@ describe('useCommitFlow', () => { 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' }) + if (command === 'get_modified_files') return Promise.resolve([{ path: '/vault/a.md', relativePath: 'a.md', status: 'modified' }]) throw new Error(`Unexpected command: ${command}`) }) }) @@ -83,6 +84,57 @@ describe('useCommitFlow', () => { expect(result.current.showCommitDialog).toBe(false) }) + it('runAutomaticCheckpoint saves pending first and uses the deterministic automatic message', async () => { + const { result } = renderCommitFlow() + + await act(async () => { + await result.current.runAutomaticCheckpoint({ savePendingBeforeCommit: true }) + }) + + expect(savePending).toHaveBeenCalledTimes(1) + expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' }) + expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_commit', { vaultPath: '/vault', message: 'Updated 1 note' }) + expect(mockInvokeFn).toHaveBeenNthCalledWith(3, 'git_push', { vaultPath: '/vault' }) + expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed') + }) + + it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => { + resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true }) + mockInvokeFn.mockImplementation((command: string) => { + if (command === 'get_modified_files') return Promise.resolve([]) + if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' }) + throw new Error(`Unexpected command: ${command}`) + }) + + const { result } = renderCommitFlow() + + await act(async () => { + await result.current.runAutomaticCheckpoint() + }) + + expect(mockInvokeFn).toHaveBeenCalledTimes(2) + expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' }) + expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' }) + expect(setToastMessage).toHaveBeenCalledWith('Pushed committed changes') + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + + it('runAutomaticCheckpoint reports when there is nothing to commit or push', async () => { + mockInvokeFn.mockImplementation((command: string) => { + if (command === 'get_modified_files') return Promise.resolve([]) + throw new Error(`Unexpected command: ${command}`) + }) + + const { result } = renderCommitFlow() + + await act(async () => { + await result.current.runAutomaticCheckpoint() + }) + + expect(setToastMessage).toHaveBeenCalledWith('Nothing to commit or push') + expect(mockTrackEvent).not.toHaveBeenCalled() + }) + 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) => { diff --git a/src/hooks/useCommitFlow.ts b/src/hooks/useCommitFlow.ts index 39c3d501..3709413b 100644 --- a/src/hooks/useCommitFlow.ts +++ b/src/hooks/useCommitFlow.ts @@ -1,8 +1,9 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState, type MutableRefObject } from 'react' import { invoke } from '@tauri-apps/api/core' -import type { GitPushResult, GitRemoteStatus } from '../types' +import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types' import { trackEvent } from '../lib/telemetry' import { isTauri, mockInvoke } from '../mock-tauri' +import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage' export type CommitMode = 'push' | 'local' @@ -12,6 +13,11 @@ interface LocalCommitResult { } type CommitResult = GitPushResult | LocalCommitResult +type CheckpointAction = 'commit' | 'push_only' + +interface AutomaticCheckpointOptions { + savePendingBeforeCommit?: boolean +} interface CommitFlowConfig { savePending: () => Promise @@ -22,11 +28,37 @@ interface CommitFlowConfig { vaultPath: string } +interface VaultPathArgs { + vaultPath: string +} + +interface CommitArgs extends VaultPathArgs { + message: string +} + +interface CommitExecutionArgs extends CommitArgs { + commitMode: CommitMode +} + +interface AutomaticCheckpointContext extends VaultPathArgs { + remoteStatus: GitRemoteStatus | null +} + +interface AutomaticCheckpointCommand extends AutomaticCheckpointContext { + action: CheckpointAction + message?: string +} + +interface ExecutedCheckpoint { + action: CheckpointAction + result: CommitResult +} + function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode { return remoteStatus?.hasRemote === false ? 'local' : 'push' } -async function commitLocally(vaultPath: string, message: string): Promise { +async function commitLocally({ vaultPath, message }: CommitArgs): Promise { if (!isTauri()) { await mockInvoke('git_commit', { vaultPath, message }) return @@ -35,7 +67,7 @@ async function commitLocally(vaultPath: string, message: string): Promise await invoke('git_commit', { vaultPath, message }) } -async function pushCommittedChanges(vaultPath: string): Promise { +async function pushCommittedChanges({ vaultPath }: VaultPathArgs): Promise { if (!isTauri()) { return mockInvoke('git_push', { vaultPath }) } @@ -43,13 +75,25 @@ async function pushCommittedChanges(vaultPath: string): Promise { return invoke('git_push', { vaultPath }) } -async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise { - await commitLocally(vaultPath, message) +async function readModifiedFiles({ vaultPath }: VaultPathArgs): Promise { + if (!isTauri()) { + return mockInvoke('get_modified_files', { vaultPath }) + } + + return invoke('get_modified_files', { vaultPath }) +} + +async function executeCommitAction({ + vaultPath, + message, + commitMode, +}: CommitExecutionArgs): Promise { + await commitLocally({ vaultPath, message }) if (commitMode === 'local') { return { status: 'local_only', message: 'Committed locally (no remote configured)' } } - return pushCommittedChanges(vaultPath) + return pushCommittedChanges({ vaultPath }) } function commitToastMessage(result: CommitResult): string { @@ -67,6 +111,182 @@ function formatCommitError(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean { + return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0 +} + +function nothingToCommitToast(remoteStatus: GitRemoteStatus | null): string { + return remoteStatus?.hasRemote === false ? 'Nothing to commit' : 'Nothing to commit or push' +} + +function checkpointToastMessage(result: CommitResult, action: CheckpointAction): string { + if (action === 'push_only') { + if (result.status === 'ok') return 'Pushed committed changes' + if (result.status === 'rejected') return 'Push rejected — remote has new commits. Pull first.' + return result.message + } + + return commitToastMessage(result) +} + +function createAutomaticCheckpointCommand({ + remoteStatus, + vaultPath, + message, +}: AutomaticCheckpointContext & { message: string }): AutomaticCheckpointCommand | null { + if (message.length > 0) { + return { action: 'commit', remoteStatus, vaultPath, message } + } + + if (shouldRetryPush(remoteStatus)) { + return { action: 'push_only', remoteStatus, vaultPath } + } + + return null +} + +async function executeAutomaticCheckpoint( + command: AutomaticCheckpointCommand, +): Promise { + if (command.action === 'push_only') { + return { + action: 'push_only', + result: await pushCommittedChanges({ vaultPath: command.vaultPath }), + } + } + + const result = await executeCommitAction({ + vaultPath: command.vaultPath, + message: command.message ?? '', + commitMode: commitModeFromRemoteStatus(command.remoteStatus), + }) + trackEvent('commit_made') + return { action: 'commit', result } +} + +async function runCheckpointRefresh({ + loadModifiedFiles, + resolveRemoteStatus, +}: Pick): Promise { + await loadModifiedFiles() + await resolveRemoteStatus() +} + +async function finalizeCheckpoint({ + result, + toastMessage, + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, +}: Pick & { + result: CommitResult + toastMessage: string +}): Promise { + setToastMessage(toastMessage) + if (isPushRejected(result)) { + onPushRejected?.() + } + + await runCheckpointRefresh({ loadModifiedFiles, resolveRemoteStatus }) +} + +function useAutomaticCheckpointAction({ + checkpointInFlightRef, + savePending, + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, + vaultPath, +}: CommitFlowConfig & { + checkpointInFlightRef: MutableRefObject +}) { + return useCallback(async ({ + savePendingBeforeCommit = false, + }: AutomaticCheckpointOptions = {}): Promise => { + if (checkpointInFlightRef.current) return false + checkpointInFlightRef.current = true + + try { + if (savePendingBeforeCommit) { + await savePending() + } + + const remoteStatus = await resolveRemoteStatus() + const modifiedFiles = await readModifiedFiles({ vaultPath }) + const message = generateAutomaticCommitMessage(modifiedFiles) + const command = createAutomaticCheckpointCommand({ remoteStatus, vaultPath, message }) + if (!command) { + setToastMessage(nothingToCommitToast(remoteStatus)) + return false + } + + const { action, result } = await executeAutomaticCheckpoint(command) + await finalizeCheckpoint({ + result, + toastMessage: checkpointToastMessage(result, action), + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, + }) + return true + } catch (err) { + console.error('Commit failed:', err) + setToastMessage(`Commit failed: ${formatCommitError(err)}`) + return false + } finally { + checkpointInFlightRef.current = false + } + }, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath]) +} + +function useManualCommitPushAction({ + checkpointInFlightRef, + savePending, + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, + vaultPath, + setShowCommitDialog, +}: CommitFlowConfig & { + checkpointInFlightRef: MutableRefObject + setShowCommitDialog: (open: boolean) => void +}) { + return useCallback(async (message: string) => { + setShowCommitDialog(false) + if (checkpointInFlightRef.current) return + checkpointInFlightRef.current = true + + try { + await savePending() + const remoteStatus = await resolveRemoteStatus() + const result = await executeCommitAction({ + vaultPath, + message, + commitMode: commitModeFromRemoteStatus(remoteStatus), + }) + + trackEvent('commit_made') + await finalizeCheckpoint({ + result, + toastMessage: commitToastMessage(result), + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, + }) + } catch (err) { + console.error('Commit failed:', err) + setToastMessage(`Commit failed: ${formatCommitError(err)}`) + } finally { + checkpointInFlightRef.current = false + } + }, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setShowCommitDialog, setToastMessage, vaultPath]) +} + /** Manages the commit dialog state and the save→commit→push/local flow. */ export function useCommitFlow({ savePending, @@ -78,6 +298,7 @@ export function useCommitFlow({ }: CommitFlowConfig) { const [showCommitDialog, setShowCommitDialog] = useState(false) const [commitMode, setCommitMode] = useState('push') + const checkpointInFlightRef = useRef(false) const openCommitDialog = useCallback(async () => { await savePending() @@ -87,29 +308,28 @@ export function useCommitFlow({ setShowCommitDialog(true) }, [loadModifiedFiles, resolveRemoteStatus, savePending]) - const handleCommitPush = useCallback(async (message: string) => { - setShowCommitDialog(false) - try { - await savePending() - const remoteStatus = await resolveRemoteStatus() - const nextCommitMode = commitModeFromRemoteStatus(remoteStatus) - const result = await executeCommitAction(vaultPath, message, nextCommitMode) + const runAutomaticCheckpoint = useAutomaticCheckpointAction({ + checkpointInFlightRef, + savePending, + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, + vaultPath, + }) - trackEvent('commit_made') - setToastMessage(commitToastMessage(result)) - if (isPushRejected(result)) { - onPushRejected?.() - } - - await loadModifiedFiles() - await resolveRemoteStatus() - } catch (err) { - console.error('Commit failed:', err) - setToastMessage(`Commit failed: ${formatCommitError(err)}`) - } - }, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath]) + const handleCommitPush = useManualCommitPushAction({ + checkpointInFlightRef, + savePending, + loadModifiedFiles, + resolveRemoteStatus, + setToastMessage, + onPushRejected, + vaultPath, + setShowCommitDialog, + }) const closeCommitDialog = useCallback(() => setShowCommitDialog(false), []) - return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog } + return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog, runAutomaticCheckpoint } } diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 61487521..f5c66d2e 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -5,6 +5,9 @@ import { useSettings } from './useSettings' const defaultSettings: 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, @@ -15,6 +18,9 @@ const defaultSettings: Settings = { const savedSettings: Settings = { auto_pull_interval_minutes: 15, + autogit_enabled: true, + autogit_idle_threshold_seconds: 90, + autogit_inactive_threshold_seconds: 30, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, @@ -94,6 +100,9 @@ describe('useSettings', () => { const newSettings: Settings = { auto_pull_interval_minutes: null, + autogit_enabled: false, + autogit_idle_threshold_seconds: 120, + autogit_inactive_threshold_seconds: 45, telemetry_consent: null, crash_reporting_enabled: null, analytics_enabled: null, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 2b8f7e13..3202f7b2 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -11,6 +11,9 @@ function tauriCall(command: string, tauriArgs: Record, mockA const EMPTY_SETTINGS: 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, diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index fdf2e6ef..4383f89c 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -92,6 +92,9 @@ const mockSavedSinceCommit = new Set() let mockSettings: Settings = { auto_pull_interval_minutes: 5, + autogit_enabled: false, + autogit_idle_threshold_seconds: 90, + autogit_inactive_threshold_seconds: 30, telemetry_consent: false, crash_reporting_enabled: null, analytics_enabled: null, @@ -314,6 +317,9 @@ export const mockHandlers: Record any> = { const s = args.settings mockSettings = { auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5, + autogit_enabled: s.autogit_enabled ?? false, + autogit_idle_threshold_seconds: s.autogit_idle_threshold_seconds ?? 90, + autogit_inactive_threshold_seconds: s.autogit_inactive_threshold_seconds ?? 30, telemetry_consent: s.telemetry_consent, crash_reporting_enabled: s.crash_reporting_enabled, analytics_enabled: s.analytics_enabled, diff --git a/src/types.ts b/src/types.ts index 03eb9639..2a03ee08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,9 @@ export interface ModifiedFile { export interface Settings { auto_pull_interval_minutes: number | null + autogit_enabled?: boolean | null + autogit_idle_threshold_seconds?: number | null + autogit_inactive_threshold_seconds?: number | null telemetry_consent: boolean | null crash_reporting_enabled: boolean | null analytics_enabled: boolean | null diff --git a/src/types/laputaTestBridge.ts b/src/types/laputaTestBridge.ts index 6ca0c436..f9815c85 100644 --- a/src/types/laputaTestBridge.ts +++ b/src/types/laputaTestBridge.ts @@ -10,6 +10,7 @@ export interface LaputaTestBridge { triggerMenuCommand?: (id: string) => Promise triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void seedBlockNoteTable?: (columnWidths?: Array) => Promise | void + seedAutoGitSavedChange?: () => Promise | void } declare global { diff --git a/src/utils/automaticCommitMessage.test.ts b/src/utils/automaticCommitMessage.test.ts new file mode 100644 index 00000000..c12c24b8 --- /dev/null +++ b/src/utils/automaticCommitMessage.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { generateAutomaticCommitMessage } from './automaticCommitMessage' +import type { ModifiedFile } from '../types' + +function file(relativePath: string): ModifiedFile { + return { + path: `/vault/${relativePath}`, + relativePath, + status: 'modified', + } +} + +describe('generateAutomaticCommitMessage', () => { + it('returns an empty string when there is nothing to commit', () => { + expect(generateAutomaticCommitMessage([])).toBe('') + }) + + it('counts markdown files as notes', () => { + expect(generateAutomaticCommitMessage([file('note-a.md')])).toBe('Updated 1 note') + expect(generateAutomaticCommitMessage([file('note-a.md'), file('note-b.md')])).toBe('Updated 2 notes') + }) + + it('falls back to files when any modified path is not markdown', () => { + expect(generateAutomaticCommitMessage([file('note-a.md'), file('attachments/image.png')])).toBe('Updated 2 files') + }) +}) diff --git a/src/utils/automaticCommitMessage.ts b/src/utils/automaticCommitMessage.ts new file mode 100644 index 00000000..b76ed469 --- /dev/null +++ b/src/utils/automaticCommitMessage.ts @@ -0,0 +1,17 @@ +import type { ModifiedFile } from '../types' + +function pluralize(count: number, singular: string, plural: string): string { + return count === 1 ? singular : plural +} + +function allFilesAreNotes(files: ModifiedFile[]): boolean { + return files.every((file) => file.relativePath.toLowerCase().endsWith('.md')) +} + +export function generateAutomaticCommitMessage(files: ModifiedFile[]): string { + if (files.length === 0) return '' + const noun = allFilesAreNotes(files) + ? pluralize(files.length, 'note', 'notes') + : pluralize(files.length, 'file', 'files') + return `Updated ${files.length} ${noun}` +} diff --git a/tests/smoke/autogit-checkpoints.spec.ts b/tests/smoke/autogit-checkpoints.spec.ts new file mode 100644 index 00000000..1918877a --- /dev/null +++ b/tests/smoke/autogit-checkpoints.spec.ts @@ -0,0 +1,196 @@ +import { expect, test, type Page } from '@playwright/test' +import { executeCommand, openCommandPalette } from './helpers' +import { seedAutoGitSavedChange } from './testBridge' + +type MockHandler = (args?: Record) => unknown + +function installAutoGitMocks() { + type BrowserWindow = Window & typeof globalThis & { + __getDirtyPathCount?: () => number + __gitCommitMessages?: string[] + __gitPushCalls?: number + __mockHandlers?: Record + __setMockAppActive?: (active: boolean) => void + } + + const browserWindow = window as BrowserWindow + const dirtyPaths = new Set() + let appActive = true + let ahead = 0 + + const createModifiedFiles = () => [...dirtyPaths].map((path) => ({ + path, + relativePath: path.split('/').pop() ?? path, + status: 'modified', + })) + + const isPatched = (handlers?: Record | null) => + !handlers || (handlers as Record).__autogitPatched === true + + const patchSaveNoteContent = (handlers: Record) => { + const originalSaveNoteContent = handlers.save_note_content + handlers.save_note_content = (args?: Record) => { + const path = typeof args?.path === 'string' ? args.path : null + if (path) dirtyPaths.add(path) + return originalSaveNoteContent?.(args) + } + } + + const patchGitHandlers = (handlers: Record) => { + handlers.get_modified_files = () => createModifiedFiles() + + handlers.git_commit = (args?: Record) => { + const message = typeof args?.message === 'string' ? args.message : '' + browserWindow.__gitCommitMessages?.push(message) + dirtyPaths.clear() + ahead = 1 + return `[main abc1234] ${message}` + } + + handlers.git_push = () => { + browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1 + ahead = 0 + return { status: 'ok', message: 'Pushed to remote' } + } + + handlers.git_remote_status = () => ({ + branch: 'main', + ahead, + behind: 0, + hasRemote: true, + }) + } + + const markPatched = (handlers: Record) => { + Object.defineProperty(handlers, '__autogitPatched', { + configurable: true, + enumerable: false, + value: true, + }) + } + + const patchHandlers = (handlers?: Record | null) => { + if (isPatched(handlers)) { + return handlers ?? null + } + + patchSaveNoteContent(handlers) + patchGitHandlers(handlers) + markPatched(handlers) + return handlers + } + + document.hasFocus = () => appActive + browserWindow.__setMockAppActive = (active: boolean) => { + appActive = active + } + browserWindow.__getDirtyPathCount = () => dirtyPaths.size + browserWindow.__gitCommitMessages = [] + browserWindow.__gitPushCalls = 0 + + let ref = patchHandlers(browserWindow.__mockHandlers) ?? null + Object.defineProperty(browserWindow, '__mockHandlers', { + configurable: true, + get() { + return patchHandlers(ref) ?? ref + }, + set(value) { + ref = patchHandlers(value as Record | undefined) ?? null + }, + }) +} + +async function openFirstNote(page: Page) { + const noteList = page.locator('[data-testid="note-list-container"]') + await noteList.waitFor({ timeout: 5_000 }) + await noteList.locator('.cursor-pointer').first().click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +async function openAutoGitSettings(page: Page) { + await openCommandPalette(page) + await executeCommand(page, 'Open Settings') + + const settingsPanel = page.getByTestId('settings-panel') + await expect(settingsPanel).toBeVisible({ timeout: 5_000 }) + + await page.getByRole('switch', { name: 'AutoGit' }).click() + await page.getByTestId('settings-autogit-idle-threshold').fill('2') + await page.getByTestId('settings-autogit-inactive-threshold').fill('2') + await page.getByTestId('settings-save').click() + await expect(settingsPanel).not.toBeVisible({ timeout: 5_000 }) +} + +async function expectDirtyPathCount(page: Page, expectedCount: number) { + await expect.poll(async () => + page.evaluate(() => (window as Window & { __getDirtyPathCount?: () => number }).__getDirtyPathCount?.() ?? 0), + ).toBe(expectedCount) +} + +async function expectCommitMessageCount(page: Page, expectedCount: number) { + await expect.poll(async () => + page.evaluate(() => (window as Window & { __gitCommitMessages?: string[] }).__gitCommitMessages?.length ?? 0), + ).toBe(expectedCount) +} + +async function expectCommitMessage(page: Page, index: number, expectedMessage: string) { + await expect.poll(async () => + page.evaluate( + ({ targetIndex }) => (window as Window & { __gitCommitMessages?: string[] }).__gitCommitMessages?.[targetIndex] ?? '', + { targetIndex: index }, + ), + ).toBe(expectedMessage) +} + +async function expectPushCount(page: Page, expectedCount: number) { + await expect.poll(async () => + page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0), + ).toBe(expectedCount) +} + +async function expectCheckpoint(page: Page, count: number) { + await expectCommitMessageCount(page, count) + await expectCommitMessage(page, count - 1, 'Updated 1 note') + await expectPushCount(page, count) +} + +async function seedSavedChange(page: Page) { + await seedAutoGitSavedChange(page) + await expectDirtyPathCount(page, 1) +} + +async function setMockAppActive(page: Page, active: boolean) { + await page.evaluate((nextActive) => { + ;(window as Window & { __setMockAppActive?: (active: boolean) => void }).__setMockAppActive?.(nextActive) + window.dispatchEvent(new Event(nextActive ? 'focus' : 'blur')) + }, active) +} + +async function triggerQuickCommit(page: Page) { + const commitButton = page.getByTestId('status-commit-push') + await commitButton.focus() + await page.keyboard.press('Enter') +} + +test('@smoke AutoGit checkpoints on idle and inactive, and the bottom bar reuses the same message', async ({ page }) => { + await page.addInitScript(installAutoGitMocks) + await page.goto('/') + await page.waitForLoadState('networkidle') + + await openAutoGitSettings(page) + await openFirstNote(page) + + await seedSavedChange(page) + await expectCheckpoint(page, 1) + + await seedSavedChange(page) + await setMockAppActive(page, false) + await expectCheckpoint(page, 2) + + await setMockAppActive(page, true) + await seedSavedChange(page) + await triggerQuickCommit(page) + await expectCheckpoint(page, 3) + + await expect(page.locator('.fixed.bottom-8')).toContainText('Committed and pushed', { timeout: 5_000 }) +}) diff --git a/tests/smoke/testBridge.ts b/tests/smoke/testBridge.ts index 2ee3ec2f..1cf0f9f8 100644 --- a/tests/smoke/testBridge.ts +++ b/tests/smoke/testBridge.ts @@ -5,37 +5,54 @@ import type { AppCommandShortcutEventOptions, } from '../../src/hooks/appCommandCatalog' -export async function triggerMenuCommand(page: Page, id: string): Promise { - await page.evaluate(async (commandId) => { - const deadline = Date.now() + 5_000 +async function waitForDispatchBrowserMenuCommand(page: Page): Promise { + await page.waitForFunction( + () => typeof window.__laputaTest?.dispatchBrowserMenuCommand === 'function', + undefined, + { timeout: 5_000 }, + ) +} - while (Date.now() < deadline) { - const bridge = window.__laputaTest - const dispatchBrowserMenuCommand = bridge?.dispatchBrowserMenuCommand - const triggerMenuCommand = bridge?.triggerMenuCommand +function shouldFallbackToDispatch(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('dispatchBrowserMenuCommand') +} - if (typeof dispatchBrowserMenuCommand === 'function') { - if (typeof triggerMenuCommand === 'function') { - try { - await triggerMenuCommand(commandId) - return - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (!message.includes('dispatchBrowserMenuCommand')) { - throw error - } - } - } +async function tryTriggerMenuCommand(commandId: string): Promise { + const triggerMenuCommand = window.__laputaTest?.triggerMenuCommand + if (typeof triggerMenuCommand !== 'function') { + return false + } - dispatchBrowserMenuCommand(commandId) - return - } - - await new Promise((resolve) => window.setTimeout(resolve, 50)) + try { + await triggerMenuCommand(commandId) + return true + } catch (error) { + if (!shouldFallbackToDispatch(error)) { + throw error } + return false + } +} +async function dispatchMenuCommandInPage(commandId: string): Promise { + const dispatchBrowserMenuCommand = window.__laputaTest?.dispatchBrowserMenuCommand + + if (typeof dispatchBrowserMenuCommand !== 'function') { throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') - }, id) + } + + const didTrigger = await tryTriggerMenuCommand(commandId) + if (didTrigger) { + return + } + + dispatchBrowserMenuCommand(commandId) +} + +export async function triggerMenuCommand(page: Page, id: string): Promise { + await waitForDispatchBrowserMenuCommand(page) + await page.evaluate(dispatchMenuCommandInPage, id) } export async function seedBlockNoteTable( @@ -51,6 +68,16 @@ export async function seedBlockNoteTable( }, columnWidths) } +export async function seedAutoGitSavedChange(page: Page): Promise { + await page.evaluate(async () => { + const bridge = window.__laputaTest?.seedAutoGitSavedChange + if (typeof bridge !== 'function') { + throw new Error('Tolaria test bridge is missing seedAutoGitSavedChange') + } + await bridge() + }) +} + export async function dispatchShortcutEvent( page: Page, init: AppCommandShortcutEventInit,