fix: refresh clean active notes after external edits

This commit is contained in:
lucaronin
2026-05-30 23:04:02 +02:00
parent 4f52f3e803
commit b84d6579da
9 changed files with 59 additions and 71 deletions

View File

@@ -540,7 +540,7 @@ interface PulseCommit {
### External Vault Refresh
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note only when the changed-path list includes that note, and closes the active tab if the file disappeared. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note when the changed-path list includes that note, and closes the active tab if the file disappeared. Editor focus does not block the clean active note from converging to disk when its own file changed externally. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
`useGitRepositories` is the commit-time companion to `useAutoSync`:
- Owns repository picker validation plus `get_modified_files` and `git_remote_status` loading for active Git repositories

View File

@@ -98,7 +98,7 @@ flowchart LR
#### External Change Detection
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and clean active-editor content refresh under the ADR-0135 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
#### Progressive Vault Loading

View File

@@ -0,0 +1,44 @@
---
type: ADR
id: "0135"
title: "Clean active notes refresh immediately after external edits"
status: active
date: 2026-05-30
supersedes: "0111"
---
## Context
ADR-0111 made external vault refreshes path-aware and preserved focused editor mounts so unrelated watcher events would not disrupt cursor state. That avoided needless remount churn, but it also meant a clean active note edited by Codex or another external process could remain visibly stale while the editor stayed focused. Because no later safe-remount trigger was guaranteed, users could see the old content until a full app restart.
Tolaria's filesystem-first model requires clean in-memory editor state to converge to the file on disk during the current session. Unsaved local editor buffers still need protection, but editor focus alone is not enough reason to keep showing stale content when the changed-path batch identifies the active file.
## Decision
**External vault refreshes now remount a clean active note immediately when the external changed-path batch includes that note, regardless of editor focus.**
The shared `refreshPulledVaultState()` path applies these rules:
1. Reload vault entries, folders, and saved views together for every external change batch.
2. If there is no active note, stop after the shared reload.
3. If the active note changed during the async reload, stop rather than reopening stale context.
4. If the active note has unsaved local edits, keep the current editor buffer mounted.
5. If the active file disappeared, close the tab instead of leaving a stale editor behind.
6. If the changed-path batch includes the clean active file, close and reopen the active tab from disk even when focus is inside the rich or raw editor.
7. Unknown or unrelated change batches refresh vault-derived state without remounting the active editor.
Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches continue to converge through this single reconciliation helper instead of adding separate reload policies.
## Alternatives considered
- **Immediate clean active-note remount** (chosen): restores filesystem convergence for Codex and other external note edits while preserving unsaved local edits. Cons: a focused clean editor can lose cursor state when its own file changes externally.
- **Keep focused-editor preservation from ADR-0111**: avoids cursor disruption, but can leave the active editor stale indefinitely.
- **Defer active-note reload until blur**: reduces focus disruption, but adds another pending-refresh state machine and still allows the active editor to show stale disk content for an unbounded editing session.
## Consequences
- External edits to the currently open clean note become visible without restarting Tolaria.
- Unsaved local content remains authoritative and is not replaced by watcher, pull, or agent refreshes.
- The changed-path batch remains part of the external-refresh contract; callers should pass specific file paths whenever available.
- Unrelated watcher events still avoid active-editor remounts, so broad vault churn does not disturb the editor unless the active file itself changed.
- ADR-0111 is superseded by this stronger filesystem-convergence rule.

View File

@@ -164,7 +164,7 @@ proposed → active → superseded
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | superseded → [0135](0135-clean-active-note-refresh-after-external-edit.md) |
| [0112](0112-system-theme-mode.md) | System theme mode | active |
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
@@ -186,3 +186,4 @@ proposed → active → superseded
| [0132](0132-alpha-authenticode-soft-gate.md) | Alpha Authenticode soft gate | active |
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active |

View File

@@ -78,7 +78,7 @@ import type { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelecti
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { viewMatchesSelection } from './utils/viewIdentity'
import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams, type NoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
@@ -128,7 +128,6 @@ import {
activeVaultModifiedFiles,
aiWorkspaceWindowContextForPath,
canCustomizeColumnsForSelection,
isActiveElementInsideEditorSurface,
mergeModifiedFiles,
runNativeTextHistoryCommand,
shouldPreferOnboardingVaultPath,
@@ -533,7 +532,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
})
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
options: { vaultPath?: string } = {},
) => {
const updateVaultPath = options.vaultPath ?? resolvedPath
await refreshPulledVaultState({
@@ -541,9 +540,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
closeAllTabs,
getActiveTabPath: () => noteActiveTabPathRef.current,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
shouldKeepActiveEditorMounted: options.preserveFocusedEditor
? isActiveElementInsideEditorSurface
: undefined,
reloadFolders: vault.reloadFolders,
reloadVault: vault.reloadVault,
reloadViews: vault.reloadViews,
@@ -565,14 +561,11 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
vault.unsavedPaths,
])
const handlePulledVaultUpdate = useCallback(
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
...getPulledVaultUpdateOptions(),
vaultPath,
}),
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, { vaultPath }),
[handleVaultUpdate],
)
const handleFocusedVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, { preserveFocusedEditor: true }),
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
[handleVaultUpdate],
)
useEffect(() => {
@@ -642,7 +635,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
closeAllTabs,
replaceActiveTab: handleReplaceActiveTab,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
shouldKeepActiveEditorMounted: isActiveElementInsideEditorSurface,
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
getActiveTabPath: () => notes.activeTabPathRef.current,

View File

@@ -11,7 +11,6 @@ interface VaultBridgeDeps {
closeAllTabs: () => void
replaceActiveTab: (entry: VaultEntry) => Promise<void>
hasUnsavedChanges: (path: string) => boolean
shouldKeepActiveEditorMounted?: () => boolean
onSelectNote: (entry: VaultEntry) => void
activeTabPath: string | null
getActiveTabPath?: () => string | null
@@ -34,7 +33,6 @@ export function useVaultBridge({
closeAllTabs,
replaceActiveTab,
hasUnsavedChanges,
shouldKeepActiveEditorMounted,
onSelectNote,
activeTabPath,
getActiveTabPath,
@@ -52,7 +50,6 @@ export function useVaultBridge({
closeAllTabs,
getActiveTabPath,
hasUnsavedChanges,
shouldKeepActiveEditorMounted,
reloadFolders,
reloadVault,
reloadViews,
@@ -65,7 +62,6 @@ export function useVaultBridge({
closeAllTabs,
getActiveTabPath,
hasUnsavedChanges,
shouldKeepActiveEditorMounted,
reloadFolders,
reloadVault,
reloadViews,

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { VaultEntry } from '../types'
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './pulledVaultRefresh'
import { refreshPulledVaultState } from './pulledVaultRefresh'
function makeEntry(path: string, title = 'Test note'): VaultEntry {
return {
@@ -30,10 +30,6 @@ function makeOptions(overrides: Partial<Parameters<typeof refreshPulledVaultStat
}
describe('refreshPulledVaultState', () => {
it('marks pull-originated vault updates as focused-editor preserving', () => {
expect(getPulledVaultUpdateOptions()).toEqual({ preserveFocusedEditor: true })
})
it('reloads vault-derived data and refreshes the active note when pull updated it', async () => {
const options = makeOptions()
@@ -92,42 +88,23 @@ describe('refreshPulledVaultState', () => {
expect(options.closeAllTabs).not.toHaveBeenCalled()
})
it('keeps the active tab mounted while focused when an external watcher update changed that note', async () => {
const options = makeOptions({
shouldKeepActiveEditorMounted: vi.fn(() => true),
})
it('refreshes the clean active tab even when focused after an external watcher update changed that note', async () => {
const options = makeOptions()
await refreshPulledVaultState(options)
expect(options.shouldKeepActiveEditorMounted).toHaveBeenCalledOnce()
expect(options.reloadVault).toHaveBeenCalledOnce()
expect(options.reloadFolders).toHaveBeenCalledOnce()
expect(options.reloadViews).toHaveBeenCalledOnce()
expect(options.closeAllTabs).not.toHaveBeenCalled()
expect(options.replaceActiveTab).not.toHaveBeenCalled()
})
it('refreshes the active tab when focus is outside the editor', async () => {
const options = makeOptions({
shouldKeepActiveEditorMounted: vi.fn(() => false),
})
await refreshPulledVaultState(options)
expect(options.shouldKeepActiveEditorMounted).toHaveBeenCalledOnce()
expect(options.closeAllTabs).toHaveBeenCalledOnce()
expect(options.replaceActiveTab).toHaveBeenCalledWith(makeEntry('/vault/active.md', 'Active'))
})
it('keeps the active tab mounted while focused when the active note was not changed', async () => {
const options = makeOptions({
shouldKeepActiveEditorMounted: vi.fn(() => true),
updatedFiles: ['other.md'],
})
it('keeps the active tab mounted when the active note was not changed', async () => {
const options = makeOptions({ updatedFiles: ['other.md'] })
await refreshPulledVaultState(options)
expect(options.shouldKeepActiveEditorMounted).not.toHaveBeenCalled()
expect(options.replaceActiveTab).not.toHaveBeenCalled()
expect(options.closeAllTabs).not.toHaveBeenCalled()
})
@@ -137,13 +114,11 @@ describe('refreshPulledVaultState', () => {
const options = makeOptions({
activeTabPath: '/vault/active.md',
reloadVault: vi.fn().mockResolvedValue([movedEntry]),
shouldKeepActiveEditorMounted: vi.fn(() => true),
updatedFiles: ['active.md', 'projects/active.md'],
})
await refreshPulledVaultState(options)
expect(options.shouldKeepActiveEditorMounted).not.toHaveBeenCalled()
expect(options.closeAllTabs).toHaveBeenCalledOnce()
expect(options.replaceActiveTab).toHaveBeenCalledWith(movedEntry)
})

View File

@@ -6,7 +6,6 @@ interface PulledVaultRefreshOptions {
getActiveTabPath?: () => string | null
closeAllTabs: () => void
hasUnsavedChanges: (path: string) => boolean
shouldKeepActiveEditorMounted?: () => boolean
reloadFolders: () => Promise<unknown> | unknown
reloadVault: () => Promise<VaultEntry[]>
reloadViews: () => Promise<unknown> | unknown
@@ -86,43 +85,25 @@ function shouldReplaceActiveEntry(options: {
return didPullUpdateActiveNote({ updatedFiles, vaultPath, activeTabPath: activePath })
}
function shouldPreserveFocusedActiveEntry(options: {
movedEntry: VaultEntry | null
shouldKeepActiveEditorMounted?: () => boolean
}): boolean {
const { movedEntry, shouldKeepActiveEditorMounted } = options
if (movedEntry || !shouldKeepActiveEditorMounted) return false
return shouldKeepActiveEditorMounted()
}
async function applyActiveEntryReplacement(options: {
closeAllTabs: PulledVaultRefreshOptions['closeAllTabs']
movedEntry: VaultEntry | null
replaceActiveTab: PulledVaultRefreshOptions['replaceActiveTab']
replacementEntry: VaultEntry | null
shouldKeepActiveEditorMounted?: PulledVaultRefreshOptions['shouldKeepActiveEditorMounted']
shouldReplace: boolean | null
}): Promise<boolean> {
const {
closeAllTabs,
movedEntry,
replaceActiveTab,
replacementEntry,
shouldKeepActiveEditorMounted,
shouldReplace,
} = options
if (!replacementEntry || !shouldReplace) return false
if (shouldPreserveFocusedActiveEntry({ movedEntry, shouldKeepActiveEditorMounted })) return true
closeAllTabs()
await replaceActiveTab(replacementEntry)
return true
}
export function getPulledVaultUpdateOptions(): { preserveFocusedEditor: true } {
return { preserveFocusedEditor: true }
}
export async function refreshPulledVaultState(options: PulledVaultRefreshOptions): Promise<VaultEntry[]> {
const {
activeTabPath,
@@ -133,7 +114,6 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions
reloadVault,
reloadViews,
replaceActiveTab,
shouldKeepActiveEditorMounted,
updatedFiles,
vaultPath,
} = options
@@ -167,10 +147,8 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions
const handledReplacement = await applyActiveEntryReplacement({
closeAllTabs,
movedEntry,
replaceActiveTab,
replacementEntry,
shouldKeepActiveEditorMounted,
shouldReplace,
})
if (handledReplacement) return entries

View File

@@ -120,6 +120,8 @@ test.describe('Pull refreshes the open note immediately', () => {
await openNote(page, originalTitle)
await expect(page.locator('.bn-editor h1').first()).toHaveText(originalTitle, { timeout: 5_000 })
await placeCaretAtEndOfBlock(page, 1)
await expectEditorFocused(page)
fs.writeFileSync(notePath, `---
Is A: Note