diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 97289881..fdd402bc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -13,6 +13,7 @@ pub struct Settings { pub analytics_enabled: Option, pub anonymous_id: Option, pub release_channel: Option, + pub initial_h1_auto_rename_enabled: Option, pub default_ai_agent: Option, } @@ -52,6 +53,7 @@ fn normalize_settings(settings: Settings) -> Settings { analytics_enabled: settings.analytics_enabled, anonymous_id: normalize_optional_string(settings.anonymous_id), release_channel: normalize_release_channel(settings.release_channel.as_deref()), + initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled, default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()), } } @@ -155,6 +157,7 @@ mod tests { Option, Option<&'a str>, Option<&'a str>, + Option, Option<&'a str>, ); @@ -166,6 +169,7 @@ mod tests { settings.analytics_enabled, settings.anonymous_id.as_deref(), settings.release_channel.as_deref(), + settings.initial_h1_auto_rename_enabled, settings.default_ai_agent.as_deref(), ) } @@ -173,7 +177,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) ); } @@ -212,6 +216,7 @@ mod tests { analytics_enabled: Some(false), anonymous_id: Some("abc-123-uuid".to_string()), release_channel: Some("alpha".to_string()), + initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), }; let json = serde_json::to_string(&settings).unwrap(); @@ -232,11 +237,13 @@ mod tests { let loaded = save_and_reload(Settings { auto_pull_interval_minutes: Some(10), 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.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")); } @@ -339,6 +346,7 @@ mod tests { Some("test-uuid-v4"), None, None, + None, ) ); } diff --git a/src/App.tsx b/src/App.tsx index f28bf961..0a8f38c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -480,6 +480,7 @@ function App() { tabs: notes.tabs, activeTabPath: notes.activeTabPath, handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, + initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, }) const aiActivity = useAiActivity({ diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 464baab7..648f58b4 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -109,6 +109,26 @@ describe('SettingsPanel', () => { expect(screen.getByRole('switch', { name: 'Organize notes explicitly' })).toHaveAttribute('aria-checked', 'true') }) + it('defaults the initial H1 auto-rename switch to on', () => { + render( + + ) + expect(screen.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' })).toHaveAttribute('aria-checked', 'true') + }) + + it('saves the initial H1 auto-rename preference when toggled off', () => { + render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' })) + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + initial_h1_auto_rename_enabled: false, + })) + }) + it('saves the organization workflow preference when toggled off', () => { const onSaveExplicitOrganization = vi.fn() render( diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 9de944b0..f58410c7 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -43,6 +43,7 @@ interface SettingsDraft { pullInterval: number defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel + initialH1AutoRename: boolean crashReporting: boolean analytics: boolean explicitOrganization: boolean @@ -56,6 +57,8 @@ interface SettingsBodyProps { setDefaultAiAgent: (value: AiAgentId) => void releaseChannel: ReleaseChannel setReleaseChannel: (value: ReleaseChannel) => void + initialH1AutoRename: boolean + setInitialH1AutoRename: (value: boolean) => void explicitOrganization: boolean setExplicitOrganization: (value: boolean) => void crashReporting: boolean @@ -78,6 +81,7 @@ function createSettingsDraft( pullInterval: settings.auto_pull_interval_minutes ?? 5, defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), + initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true, crashReporting: settings.crash_reporting_enabled ?? false, analytics: settings.analytics_enabled ?? false, explicitOrganization: explicitOrganizationEnabled, @@ -105,6 +109,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti analytics_enabled: draft.analytics, anonymous_id: resolveAnonymousId(settings, draft), release_channel: serializeReleaseChannel(draft.releaseChannel), + initial_h1_auto_rename_enabled: draft.initialH1AutoRename, default_ai_agent: draft.defaultAiAgent, } } @@ -224,6 +229,8 @@ function SettingsPanelInner({ setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)} releaseChannel={draft.releaseChannel} setReleaseChannel={(value) => updateDraft('releaseChannel', value)} + initialH1AutoRename={draft.initialH1AutoRename} + setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)} explicitOrganization={draft.explicitOrganization} setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)} crashReporting={draft.crashReporting} @@ -265,6 +272,8 @@ function SettingsBody({ setDefaultAiAgent, releaseChannel, setReleaseChannel, + initialH1AutoRename, + setInitialH1AutoRename, explicitOrganization, setExplicitOrganization, crashReporting, @@ -293,6 +302,21 @@ function SettingsBody({ + + + + + + - + ) } +function SettingsSwitchRow({ + label, + description, + checked, + onChange, + testId, +}: { + label: string + description: string + checked: boolean + onChange: (value: boolean) => void + testId?: string +}) { + return ( + + ) +} + function TelemetryToggle({ label, description, diff --git a/src/hooks/commands/settingsCommands.test.ts b/src/hooks/commands/settingsCommands.test.ts new file mode 100644 index 00000000..14fb116d --- /dev/null +++ b/src/hooks/commands/settingsCommands.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildSettingsCommands } from './settingsCommands' + +describe('buildSettingsCommands', () => { + it('adds a discoverable H1 auto-rename settings command', () => { + const onOpenSettings = vi.fn() + + const commands = buildSettingsCommands({ onOpenSettings }) + const command = commands.find((item) => item.id === 'open-h1-auto-rename-setting') + + expect(command).toMatchObject({ + label: 'Open H1 Auto-Rename Setting', + enabled: true, + group: 'Settings', + }) + + command?.execute() + expect(onOpenSettings).toHaveBeenCalledTimes(1) + }) + + it('keeps the general settings command available', () => { + const onOpenSettings = vi.fn() + + const commands = buildSettingsCommands({ onOpenSettings }) + + expect(commands.find((item) => item.id === 'open-settings')).toMatchObject({ + label: 'Open Settings', + shortcut: '⌘,', + enabled: true, + }) + }) +}) diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts index c8870397..86aee39d 100644 --- a/src/hooks/commands/settingsCommands.ts +++ b/src/hooks/commands/settingsCommands.ts @@ -15,6 +15,53 @@ interface SettingsCommandsConfig { onRepairVault?: () => void } +function buildPrimarySettingsCommands({ + onOpenSettings, + onOpenFeedback, + onCheckForUpdates, +}: Pick): CommandAction[] { + return [ + { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, + { + id: 'open-h1-auto-rename-setting', + label: 'Open H1 Auto-Rename Setting', + group: 'Settings', + keywords: ['h1', 'title', 'filename', 'rename', 'auto', 'untitled', 'sync', 'preference'], + enabled: true, + execute: onOpenSettings, + }, + { id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() }, + { id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() }, + ] +} + +function buildVaultSettingsCommands({ + vaultCount, + isGettingStartedHidden, + onOpenVault, + onRemoveActiveVault, + onRestoreGettingStarted, +}: Pick): CommandAction[] { + return [ + { id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() }, + { id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() }, + { id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() }, + ] +} + +function buildMaintenanceCommands({ + mcpStatus, + onInstallMcp, + onReloadVault, + onRepairVault, +}: Pick): CommandAction[] { + return [ + { id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() }, + { id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() }, + { id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() }, + ] +} + export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] { const { mcpStatus, vaultCount, isGettingStartedHidden, @@ -23,14 +70,14 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc } = config return [ - { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, - { id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() }, - { id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() }, - { id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() }, - { id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() }, - { id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() }, - { id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() }, - { id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() }, - { id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() }, + ...buildPrimarySettingsCommands({ onOpenSettings, onOpenFeedback, onCheckForUpdates }), + ...buildVaultSettingsCommands({ + vaultCount, + isGettingStartedHidden, + onOpenVault, + onRemoveActiveVault, + onRestoreGettingStarted, + }), + ...buildMaintenanceCommands({ mcpStatus, onInstallMcp, onReloadVault, onRepairVault }), ] } diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index 8c4801a1..1e747e47 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -46,6 +46,7 @@ describe('useAppSave', () => { handleRenameFilename: vi.fn().mockResolvedValue(undefined), replaceEntry: vi.fn(), resolvedPath: '/vault', + initialH1AutoRenameEnabled: true, } beforeEach(() => { @@ -57,6 +58,7 @@ describe('useAppSave', () => { deps.activeTabPath = null deps.handleRenameNote.mockResolvedValue(undefined) deps.handleRenameFilename.mockResolvedValue(undefined) + deps.initialH1AutoRenameEnabled = true }) afterEach(() => { @@ -216,6 +218,35 @@ describe('useAppSave', () => { ) }) + it('does not auto-rename untitled notes when the H1 auto-rename preference is disabled', async () => { + vi.useFakeTimers() + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(invoke).mockImplementation(async (command: string) => { + if (command === 'save_note_content') return undefined + if (command === 'auto_rename_untitled') { + throw new Error('auto_rename_untitled should not run when disabled') + } + return undefined + }) + + const entry = makeEntry('/vault/untitled-note-123.md', 'Untitled Note 123', 'untitled-note-123.md') + const tabs = [{ entry, content: '# Fresh Title\n\nBody' }] + const { result } = renderSave({ + tabs, + activeTabPath: entry.path, + unsavedPaths: new Set([entry.path]), + initialH1AutoRenameEnabled: false, + }) + + await act(async () => { + result.current.handleContentChange(entry.path, '# Fresh Title\n\nBody') + await vi.advanceTimersByTimeAsync(3_000) + }) + + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('auto_rename_untitled', expect.anything()) + expect(deps.replaceEntry).not.toHaveBeenCalled() + }) + it('switches the active tab to the renamed path after untitled H1 auto-rename', async () => { const { result, newPath, getTabs } = setupUntitledRenameHarness() diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index c8d226e8..c3db7c47 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -97,8 +97,17 @@ function isUntitledRenameCandidate(path: string): boolean { return stem.startsWith('untitled-') && /\d+$/.test(stem) } -function shouldScheduleUntitledRename({ path, content }: { path: string; content: string }): boolean { +function shouldScheduleUntitledRename({ + path, + content, + initialH1AutoRenameEnabled, +}: { + path: string + content: string + initialH1AutoRenameEnabled: boolean +}): boolean { return isTauri() + && initialH1AutoRenameEnabled && isUntitledRenameCandidate(path) && extractH1TitleFromContent(content) !== null } @@ -318,8 +327,10 @@ function useUntitledRenameExecutor({ function useUntitledRenameScheduler({ executeUntitledRename, + initialH1AutoRenameEnabled, }: { executeUntitledRename: (path: string) => Promise + initialH1AutoRenameEnabled: boolean }) { const pendingUntitledRenameRef = useRef(null) @@ -334,7 +345,7 @@ function useUntitledRenameScheduler({ }, [executeUntitledRename]) const scheduleUntitledRename = useCallback((path: string, content: string) => { - if (!shouldScheduleUntitledRename({ path, content })) { + if (!shouldScheduleUntitledRename({ path, content, initialH1AutoRenameEnabled })) { cancelPendingUntitledRename(path) return } @@ -346,7 +357,7 @@ function useUntitledRenameScheduler({ void executeUntitledRename(pendingPath) }, }) - }, [cancelPendingUntitledRename, executeUntitledRename]) + }, [cancelPendingUntitledRename, executeUntitledRename, initialH1AutoRenameEnabled]) return { pendingUntitledRenameRef, @@ -364,6 +375,7 @@ function useUntitledRenameCoordinator({ handleSwitchTab, replaceEntry, loadModifiedFiles, + initialH1AutoRenameEnabled, }: { resolvedPath: string tabsRef: MutableRefObject @@ -372,6 +384,7 @@ function useUntitledRenameCoordinator({ handleSwitchTab: AppSaveDeps['handleSwitchTab'] replaceEntry: AppSaveDeps['replaceEntry'] loadModifiedFiles: AppSaveDeps['loadModifiedFiles'] + initialH1AutoRenameEnabled: boolean }) { const { renamedPathsRef, @@ -396,7 +409,7 @@ function useUntitledRenameCoordinator({ cancelPendingUntitledRename, flushPendingUntitledRename, scheduleUntitledRename, - } = useUntitledRenameScheduler({ executeUntitledRename }) + } = useUntitledRenameScheduler({ executeUntitledRename, initialH1AutoRenameEnabled }) return { pendingUntitledRenameRef, @@ -424,6 +437,7 @@ interface AppSaveDeps { handleRenameFilename: (path: string, newFilenameStem: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void) => Promise replaceEntry: (oldPath: string, newEntry: Partial & { path: string }, newContent: string) => void resolvedPath: string + initialH1AutoRenameEnabled: boolean } function useAppSaveStateRefs({ @@ -754,6 +768,7 @@ export function useAppSave({ updateEntry, setTabs, handleSwitchTab, setToastMessage, loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths, tabs, activeTabPath, handleRenameNote, handleRenameFilename: handleRenameFilenameRaw, replaceEntry, resolvedPath, + initialH1AutoRenameEnabled, }: AppSaveDeps) { const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) const { tabsRef, activeTabPathRef, unsavedPathsRef } = useAppSaveStateRefs({ tabs, activeTabPath, unsavedPaths }) @@ -768,6 +783,7 @@ export function useAppSave({ handleSwitchTab, replaceEntry, loadModifiedFiles, + initialH1AutoRenameEnabled, }) const { handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorPersistence({ updateEntry, diff --git a/src/types.ts b/src/types.ts index 47440fb1..03eb9639 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,7 @@ export interface Settings { analytics_enabled: boolean | null anonymous_id: string | null release_channel: string | null + initial_h1_auto_rename_enabled?: boolean | null default_ai_agent?: AiAgentId | null } diff --git a/tests/smoke/h1-auto-rename-setting.spec.ts b/tests/smoke/h1-auto-rename-setting.spec.ts new file mode 100644 index 00000000..94f0ec40 --- /dev/null +++ b/tests/smoke/h1-auto-rename-setting.spec.ts @@ -0,0 +1,62 @@ +import fs from 'fs' +import path from 'path' +import { test, expect, type Page } from '@playwright/test' +import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { openCommandPalette, executeCommand } from './helpers' +import { triggerMenuCommand } from './testBridge' + +let tempVaultDir: string + +async function createUntitledNote(page: Page): Promise { + await page.locator('body').click() + await triggerMenuCommand(page, 'file-new-note') + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(90_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVaultTauri(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('@smoke disabling H1 auto-rename keeps untitled filenames until manual sync', async ({ page }) => { + await openCommandPalette(page) + await executeCommand(page, 'H1 Auto-Rename') + + const settingsPanel = page.getByTestId('settings-panel') + await expect(settingsPanel).toBeVisible({ timeout: 5_000 }) + + const autoRenameSwitch = page.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' }) + await expect(autoRenameSwitch).toHaveAttribute('aria-checked', 'true') + await autoRenameSwitch.focus() + await page.keyboard.press('Space') + await expect(autoRenameSwitch).toHaveAttribute('aria-checked', 'false') + await page.keyboard.press('Meta+Enter') + await expect(settingsPanel).not.toBeVisible({ timeout: 5_000 }) + + await createUntitledNote(page) + + const filenameTrigger = page.getByTestId('breadcrumb-filename-trigger') + const untitledStem = (await filenameTrigger.textContent())?.trim() ?? '' + expect(untitledStem).toMatch(/^untitled-note-\d+(?:-\d+)?$/i) + + await page.keyboard.type('Manual Sync Title', { delay: 20 }) + await page.keyboard.press('Enter') + await page.waitForTimeout(2_700) + + await expect(filenameTrigger).toContainText(untitledStem) + await expect(page.getByTestId('breadcrumb-sync-button')).toBeVisible() + await expect.poll(() => fs.existsSync(path.join(tempVaultDir, `${untitledStem}.md`))).toBe(true) + + const syncButton = page.getByTestId('breadcrumb-sync-button') + await syncButton.focus() + await page.keyboard.press('Enter') + + await expect(filenameTrigger).toContainText('manual-sync-title') + await expect.poll(() => fs.existsSync(path.join(tempVaultDir, `${untitledStem}.md`))).toBe(false) + await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'manual-sync-title.md'))).toBe(true) +})