feat: add h1 auto-rename setting

This commit is contained in:
lucaronin
2026-04-16 12:18:11 +02:00
parent 711d49b3bb
commit c0d16e4b4d
10 changed files with 291 additions and 27 deletions

View File

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

View File

@@ -15,6 +15,53 @@ interface SettingsCommandsConfig {
onRepairVault?: () => void
}
function buildPrimarySettingsCommands({
onOpenSettings,
onOpenFeedback,
onCheckForUpdates,
}: Pick<SettingsCommandsConfig, 'onOpenSettings' | 'onOpenFeedback' | 'onCheckForUpdates'>): 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<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): 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<SettingsCommandsConfig, 'mcpStatus' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'>): 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 }),
]
}

View File

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

View File

@@ -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<boolean>
initialH1AutoRenameEnabled: boolean
}) {
const pendingUntitledRenameRef = useRef<PendingUntitledRename | null>(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<TabState[]>
@@ -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<VaultEntry> & { path: string }, newContent: string) => void) => Promise<void>
replaceEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { 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,