fix: restore editor focus after active refresh
This commit is contained in:
@@ -531,7 +531,7 @@ interface PulseCommit {
|
||||
- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it
|
||||
- Pushes the active repository set during divergence recovery
|
||||
- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, then restores editor focus if the editor owned focus before the remount
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks aggregate remote status (ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
@@ -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 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.
|
||||
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; if the active editor owned focus before that remount, the app requests editor focus again after the fresh tab is mounted. 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
|
||||
|
||||
@@ -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 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.
|
||||
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. When that clean active-editor remount starts from a focused editor, the main app dispatches a post-replacement editor focus request so typing can continue in the refreshed tab. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
|
||||
|
||||
#### Progressive Vault Loading
|
||||
|
||||
@@ -649,7 +649,8 @@ flowchart TD
|
||||
RV --> TAB{"clean active tab?"}
|
||||
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
|
||||
TAB -->|No| DONE["idle"]
|
||||
RT --> DONE
|
||||
RT --> RF["restore editor focus\nwhen previously focused"]
|
||||
RF --> DONE
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
|
||||
|
||||
@@ -129,6 +129,7 @@ import {
|
||||
activeVaultModifiedFiles,
|
||||
aiWorkspaceWindowContextForPath,
|
||||
canCustomizeColumnsForSelection,
|
||||
isActiveElementInsideEditorSurface,
|
||||
mergeModifiedFiles,
|
||||
runNativeTextHistoryCommand,
|
||||
shouldPreferOnboardingVaultPath,
|
||||
@@ -524,6 +525,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
} = notes
|
||||
const noteActiveTabPath = notes.activeTabPath
|
||||
const noteActiveTabPathRef = notes.activeTabPathRef
|
||||
const refocusActiveEditor = useCallback((path: string) => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path } }))
|
||||
}, [])
|
||||
useNoteWindowLifecycle({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
handleSelectNote,
|
||||
@@ -546,6 +550,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
reloadVault: vault.reloadVault,
|
||||
reloadViews: vault.reloadViews,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
shouldRefocusActiveEditor: isActiveElementInsideEditorSurface,
|
||||
updatedFiles,
|
||||
vaultPath: updateVaultPath,
|
||||
})
|
||||
@@ -555,6 +561,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
handleReplaceActiveTab,
|
||||
noteActiveTabPath,
|
||||
noteActiveTabPathRef,
|
||||
refocusActiveEditor,
|
||||
refreshGitModifiedFiles,
|
||||
resolvedPath,
|
||||
vault.reloadFolders,
|
||||
@@ -636,7 +643,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
|
||||
reloadViews: vault.reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
shouldRefocusActiveEditor: isActiveElementInsideEditorSurface,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
getActiveTabPath: () => notes.activeTabPathRef.current,
|
||||
|
||||
@@ -42,6 +42,8 @@ describe('useVaultBridge', () => {
|
||||
activeTabPath: string | null = null,
|
||||
overrides: Partial<{
|
||||
hasUnsavedChanges: typeof hasUnsavedChanges
|
||||
refocusActiveEditor: (path: string) => void
|
||||
shouldRefocusActiveEditor: () => boolean
|
||||
}> = {},
|
||||
) {
|
||||
const entriesByPath = new Map(entries.map(e => [e.path, e]))
|
||||
@@ -55,6 +57,8 @@ describe('useVaultBridge', () => {
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
hasUnsavedChanges: overrides.hasUnsavedChanges ?? hasUnsavedChanges,
|
||||
shouldRefocusActiveEditor: overrides.shouldRefocusActiveEditor,
|
||||
refocusActiveEditor: overrides.refocusActiveEditor,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
}),
|
||||
@@ -131,6 +135,22 @@ describe('useVaultBridge', () => {
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('refocuses the editor after agent changes refresh the focused active tab', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
const shouldRefocusActiveEditor = vi.fn(() => true)
|
||||
const refocusActiveEditor = vi.fn()
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([], '/vault/active.md', {
|
||||
refocusActiveEditor,
|
||||
shouldRefocusActiveEditor,
|
||||
})
|
||||
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(shouldRefocusActiveEditor).toHaveBeenCalledOnce()
|
||||
expect(refocusActiveEditor).toHaveBeenCalledWith('/vault/active.md')
|
||||
})
|
||||
|
||||
it('handleAgentFileModified keeps the active tab mounted for other notes', async () => {
|
||||
const active = makeEntry('/vault/other.md', 'Other')
|
||||
reloadVault.mockResolvedValue([active])
|
||||
|
||||
@@ -10,12 +10,30 @@ interface VaultBridgeDeps {
|
||||
reloadViews: () => Promise<unknown> | unknown
|
||||
closeAllTabs: () => void
|
||||
replaceActiveTab: (entry: VaultEntry) => Promise<void>
|
||||
refocusActiveEditor?: (path: string) => void
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
shouldRefocusActiveEditor?: () => boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
activeTabPath: string | null
|
||||
getActiveTabPath?: () => string | null
|
||||
}
|
||||
|
||||
type RefreshAgentChangesOptions = Pick<
|
||||
VaultBridgeDeps,
|
||||
| 'activeTabPath'
|
||||
| 'closeAllTabs'
|
||||
| 'getActiveTabPath'
|
||||
| 'hasUnsavedChanges'
|
||||
| 'refocusActiveEditor'
|
||||
| 'reloadFolders'
|
||||
| 'reloadVault'
|
||||
| 'reloadViews'
|
||||
| 'replaceActiveTab'
|
||||
| 'resolvedPath'
|
||||
| 'shouldRefocusActiveEditor'
|
||||
> & { updatedFiles: string[] }
|
||||
type RefreshAgentChangesDeps = Omit<RefreshAgentChangesOptions, 'updatedFiles'>
|
||||
|
||||
function findEntry(entriesByPath: Map<string, VaultEntry>, resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
}
|
||||
@@ -24,6 +42,58 @@ function findInFresh(entries: VaultEntry[], resolvedPath: string, path: string):
|
||||
return entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
function refreshAgentChangedFiles(options: RefreshAgentChangesOptions) {
|
||||
const { resolvedPath, updatedFiles, ...refreshOptions } = options
|
||||
return refreshPulledVaultState({
|
||||
...refreshOptions,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
}
|
||||
|
||||
function useRefreshAgentChanges({
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
refocusActiveEditor,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
resolvedPath,
|
||||
shouldRefocusActiveEditor,
|
||||
}: RefreshAgentChangesDeps) {
|
||||
return useCallback((updatedFiles: string[]) => (
|
||||
refreshAgentChangedFiles({
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
shouldRefocusActiveEditor,
|
||||
updatedFiles,
|
||||
resolvedPath,
|
||||
})
|
||||
), [
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
resolvedPath,
|
||||
shouldRefocusActiveEditor,
|
||||
])
|
||||
}
|
||||
|
||||
export function useVaultBridge({
|
||||
entriesByPath,
|
||||
resolvedPath,
|
||||
@@ -32,7 +102,9 @@ export function useVaultBridge({
|
||||
reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
hasUnsavedChanges,
|
||||
shouldRefocusActiveEditor,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
getActiveTabPath,
|
||||
@@ -44,20 +116,7 @@ export function useVaultBridge({
|
||||
})
|
||||
}, [reloadVault, onSelectNote, resolvedPath])
|
||||
|
||||
const refreshAgentChanges = useCallback((updatedFiles: string[]) => (
|
||||
refreshPulledVaultState({
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
), [
|
||||
const refreshAgentChanges = useRefreshAgentChanges({
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
getActiveTabPath,
|
||||
@@ -66,8 +125,10 @@ export function useVaultBridge({
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
resolvedPath,
|
||||
])
|
||||
shouldRefocusActiveEditor,
|
||||
})
|
||||
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = findEntry(entriesByPath, resolvedPath, path)
|
||||
|
||||
@@ -100,6 +100,22 @@ describe('refreshPulledVaultState', () => {
|
||||
expect(options.replaceActiveTab).toHaveBeenCalledWith(makeEntry('/vault/active.md', 'Active'))
|
||||
})
|
||||
|
||||
it('refocuses the editor after refreshing a focused clean active tab', async () => {
|
||||
const shouldRefocusActiveEditor = vi.fn(() => true)
|
||||
const refocusActiveEditor = vi.fn()
|
||||
const options = makeOptions({
|
||||
shouldRefocusActiveEditor,
|
||||
refocusActiveEditor,
|
||||
})
|
||||
|
||||
await refreshPulledVaultState(options)
|
||||
|
||||
expect(shouldRefocusActiveEditor).toHaveBeenCalledOnce()
|
||||
expect(options.closeAllTabs).toHaveBeenCalledOnce()
|
||||
expect(options.replaceActiveTab).toHaveBeenCalledWith(makeEntry('/vault/active.md', 'Active'))
|
||||
expect(refocusActiveEditor).toHaveBeenCalledWith('/vault/active.md')
|
||||
})
|
||||
|
||||
it('keeps the active tab mounted when the active note was not changed', async () => {
|
||||
const options = makeOptions({ updatedFiles: ['other.md'] })
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ interface PulledVaultRefreshOptions {
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
reloadViews: () => Promise<unknown> | unknown
|
||||
replaceActiveTab: (entry: VaultEntry) => Promise<void>
|
||||
refocusActiveEditor?: (path: string) => void
|
||||
shouldRefocusActiveEditor?: () => boolean
|
||||
updatedFiles: string[]
|
||||
vaultPath: string
|
||||
}
|
||||
@@ -88,19 +90,25 @@ function shouldReplaceActiveEntry(options: {
|
||||
async function applyActiveEntryReplacement(options: {
|
||||
closeAllTabs: PulledVaultRefreshOptions['closeAllTabs']
|
||||
replaceActiveTab: PulledVaultRefreshOptions['replaceActiveTab']
|
||||
refocusActiveEditor?: PulledVaultRefreshOptions['refocusActiveEditor']
|
||||
replacementEntry: VaultEntry | null
|
||||
shouldRefocusActiveEditor?: PulledVaultRefreshOptions['shouldRefocusActiveEditor']
|
||||
shouldReplace: boolean | null
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
replacementEntry,
|
||||
shouldRefocusActiveEditor,
|
||||
shouldReplace,
|
||||
} = options
|
||||
if (!replacementEntry || !shouldReplace) return false
|
||||
|
||||
const shouldRefocus = shouldRefocusActiveEditor?.() === true
|
||||
closeAllTabs()
|
||||
await replaceActiveTab(replacementEntry)
|
||||
if (shouldRefocus) refocusActiveEditor?.(replacementEntry.path)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -114,6 +122,8 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
shouldRefocusActiveEditor,
|
||||
updatedFiles,
|
||||
vaultPath,
|
||||
} = options
|
||||
@@ -148,7 +158,9 @@ export async function refreshPulledVaultState(options: PulledVaultRefreshOptions
|
||||
const handledReplacement = await applyActiveEntryReplacement({
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
refocusActiveEditor,
|
||||
replacementEntry,
|
||||
shouldRefocusActiveEditor,
|
||||
shouldReplace,
|
||||
})
|
||||
if (handledReplacement) return entries
|
||||
|
||||
Reference in New Issue
Block a user