feat: add AutoGit idle and inactive checkpoints
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
87
src/App.tsx
87
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<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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
113
src/hooks/useAutoGit.test.ts
Normal file
113
src/hooks/useAutoGit.test.ts
Normal 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
174
src/hooks/useAutoGit.ts
Normal 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 }
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
26
src/utils/automaticCommitMessage.test.ts
Normal file
26
src/utils/automaticCommitMessage.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
17
src/utils/automaticCommitMessage.ts
Normal file
17
src/utils/automaticCommitMessage.ts
Normal 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}`
|
||||
}
|
||||
196
tests/smoke/autogit-checkpoints.spec.ts
Normal file
196
tests/smoke/autogit-checkpoints.spec.ts
Normal 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 })
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user