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

@@ -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

View File

@@ -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 |

View File

@@ -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<u32>,
pub autogit_enabled: Option<bool>,
pub autogit_idle_threshold_seconds: Option<u32>,
pub autogit_inactive_threshold_seconds: Option<u32>,
pub telemetry_consent: Option<bool>,
pub crash_reporting_enabled: Option<bool>,
pub analytics_enabled: Option<bool>,
@@ -23,6 +26,10 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
.filter(|candidate| !candidate.is_empty())
}
fn normalize_optional_positive_u32(value: Option<u32>) -> Option<u32> {
value.filter(|candidate| *candidate > 0)
}
pub fn normalize_release_channel(value: Option<&str>) -> Option<String> {
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<String> {
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<u32>,
Option<bool>,
Option<u32>,
Option<u32>,
Option<bool>,
Option<bool>,
@@ -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),

View File

@@ -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<typeof handleAppSave>) => {
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() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1048,7 +1127,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (

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)' }}

View File

@@ -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)
})
})

174
src/hooks/useAutoGit.ts Normal file
View File

@@ -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<boolean>
}
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<TriggerState>({ 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 }
}

View File

@@ -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) => {

View File

@@ -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<void | boolean>
@@ -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<void> {
async function commitLocally({ vaultPath, message }: CommitArgs): Promise<void> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { vaultPath, message })
return
@@ -35,7 +67,7 @@ async function commitLocally(vaultPath: string, message: string): Promise<void>
await invoke<string>('git_commit', { vaultPath, message })
}
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
async function pushCommittedChanges({ vaultPath }: VaultPathArgs): Promise<GitPushResult> {
if (!isTauri()) {
return mockInvoke<GitPushResult>('git_push', { vaultPath })
}
@@ -43,13 +75,25 @@ async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
return invoke<GitPushResult>('git_push', { vaultPath })
}
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
await commitLocally(vaultPath, message)
async function readModifiedFiles({ vaultPath }: VaultPathArgs): Promise<ModifiedFile[]> {
if (!isTauri()) {
return mockInvoke<ModifiedFile[]>('get_modified_files', { vaultPath })
}
return invoke<ModifiedFile[]>('get_modified_files', { vaultPath })
}
async function executeCommitAction({
vaultPath,
message,
commitMode,
}: CommitExecutionArgs): Promise<CommitResult> {
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<ExecutedCheckpoint> {
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<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus'>): Promise<void> {
await loadModifiedFiles()
await resolveRemoteStatus()
}
async function finalizeCheckpoint({
result,
toastMessage,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus' | 'setToastMessage' | 'onPushRejected'> & {
result: CommitResult
toastMessage: string
}): Promise<void> {
setToastMessage(toastMessage)
if (isPushRejected(result)) {
onPushRejected?.()
}
await runCheckpointRefresh({ loadModifiedFiles, resolveRemoteStatus })
}
function useAutomaticCheckpointAction({
checkpointInFlightRef,
savePending,
loadModifiedFiles,
resolveRemoteStatus,
setToastMessage,
onPushRejected,
vaultPath,
}: CommitFlowConfig & {
checkpointInFlightRef: MutableRefObject<boolean>
}) {
return useCallback(async ({
savePendingBeforeCommit = false,
}: AutomaticCheckpointOptions = {}): Promise<boolean> => {
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<boolean>
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<CommitMode>('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 }
}

View File

@@ -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,

View File

@@ -11,6 +11,9 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, 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,

View File

@@ -92,6 +92,9 @@ const mockSavedSinceCommit = new Set<string>()
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<string, (args: any) => 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,

View File

@@ -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

View File

@@ -10,6 +10,7 @@ export interface LaputaTestBridge {
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
seedAutoGitSavedChange?: () => Promise<void> | void
}
declare global {

View File

@@ -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')
})
})

View File

@@ -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}`
}

View File

@@ -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<string, unknown>) => unknown
function installAutoGitMocks() {
type BrowserWindow = Window & typeof globalThis & {
__getDirtyPathCount?: () => number
__gitCommitMessages?: string[]
__gitPushCalls?: number
__mockHandlers?: Record<string, MockHandler>
__setMockAppActive?: (active: boolean) => void
}
const browserWindow = window as BrowserWindow
const dirtyPaths = new Set<string>()
let appActive = true
let ahead = 0
const createModifiedFiles = () => [...dirtyPaths].map((path) => ({
path,
relativePath: path.split('/').pop() ?? path,
status: 'modified',
}))
const isPatched = (handlers?: Record<string, MockHandler> | null) =>
!handlers || (handlers as Record<string, unknown>).__autogitPatched === true
const patchSaveNoteContent = (handlers: Record<string, MockHandler>) => {
const originalSaveNoteContent = handlers.save_note_content
handlers.save_note_content = (args?: Record<string, unknown>) => {
const path = typeof args?.path === 'string' ? args.path : null
if (path) dirtyPaths.add(path)
return originalSaveNoteContent?.(args)
}
}
const patchGitHandlers = (handlers: Record<string, MockHandler>) => {
handlers.get_modified_files = () => createModifiedFiles()
handlers.git_commit = (args?: Record<string, unknown>) => {
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<string, MockHandler>) => {
Object.defineProperty(handlers, '__autogitPatched', {
configurable: true,
enumerable: false,
value: true,
})
}
const patchHandlers = (handlers?: Record<string, MockHandler> | 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<string, MockHandler> | 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 })
})

View File

@@ -5,37 +5,54 @@ import type {
AppCommandShortcutEventOptions,
} from '../../src/hooks/appCommandCatalog'
export async function triggerMenuCommand(page: Page, id: string): Promise<void> {
await page.evaluate(async (commandId) => {
const deadline = Date.now() + 5_000
async function waitForDispatchBrowserMenuCommand(page: Page): Promise<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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,