diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3f88cf1e051..02c8732e9c4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -90,8 +90,7 @@ import { RecoverableRenderErrorBoundary } from './components/error-boundaries/Re import { ConfirmationDialogProvider } from './components/confirmation-dialog' import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog' import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' -import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' -import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' +import { WorkspaceGitAndFileWatchGate } from './components/right-sidebar/WorkspaceGitAndFileWatchGate' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { @@ -742,21 +741,6 @@ function App(): React.JSX.Element { // than being called inline here so its high-churn store subscriptions // (agentStatusByPaneKey ticks at PTY event frequency) // do not re-render the App tree on every agent status update. - // Why: git conflict-operation state also drives the worktree cards. Polling - // cannot live under RightSidebar because App unmounts that subtree when the - // sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind - // until some unrelated view remount happens to refresh them. - // Why: visible-window polling runs immediately on mount. Wait until the - // workspace session has hydrated so git status work cannot compete with the - // first window becoming usable. - useGitStatusPolling({ enabled: workspaceSessionReady }) - // Why: the editor must hear external filesystem changes regardless of - // which right-sidebar panel is visible (Explorer unmounts when the user - // switches to Source Control or Checks). Wiring this at App level mirrors - // VSCode's workbench-scoped `TextFileEditorModelManager`, which reloads - // clean models from a single always-on file-change subscription instead - // of tying reloads to the Explorer UI lifecycle. - useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() @@ -2255,6 +2239,9 @@ function App(): React.JSX.Element { + {/* Why: keep Git status and editor file watching workspace-scoped + without letting their sidebar subscriptions re-render App. */} + {/* Why: leaf-mounted retention sync keeps agent-status retention subscriptions from re-rendering the App tree. */} diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 119ac5f77e0..82f22a03f5e 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -154,8 +154,17 @@ describe('renderer startup runtime routing', () => { it('defers background polling until the workspace session is ready', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const syncGateSource = readFileSync( + join( + process.cwd(), + 'src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.tsx' + ), + 'utf8' + ) - expect(source).toContain('useGitStatusPolling({ enabled: workspaceSessionReady })') + expect(source).toContain('') + expect(syncGateSource).toContain('useGitStatusPolling({ enabled })') + expect(syncGateSource).toContain('useEditorExternalWatch()') expect(source).toContain('') }) diff --git a/src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.test.tsx b/src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.test.tsx new file mode 100644 index 00000000000..d2a591420da --- /dev/null +++ b/src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import { act, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { WorkspaceGitAndFileWatchGate } from './WorkspaceGitAndFileWatchGate' + +const hookCalls = vi.hoisted(() => ({ git: vi.fn(), fileWatch: vi.fn() })) + +vi.mock('./useGitStatusPolling', async () => { + const { useAppStore: useStore } = await import('@/store') + return { + useGitStatusPolling(options: { enabled?: boolean }): void { + useStore((state) => state.rightSidebarPeek) + hookCalls.git(options) + } + } +}) + +vi.mock('@/hooks/useEditorExternalWatch', async () => { + const { useAppStore: useStore } = await import('@/store') + return { + useEditorExternalWatch(): void { + useStore((state) => state.rightSidebarPeek) + hookCalls.fileWatch() + } + } +}) + +const initialAppState = useAppStore.getInitialState() + +afterEach(() => { + useAppStore.setState(initialAppState, true) + vi.clearAllMocks() +}) + +describe('WorkspaceGitAndFileWatchGate', () => { + it('contains sidebar-driven hook renders without re-rendering its parent', () => { + let parentRenderCount = 0 + function Parent(): React.JSX.Element { + parentRenderCount += 1 + return + } + + render() + expect(parentRenderCount).toBe(1) + expect(hookCalls.git).toHaveBeenCalledTimes(1) + expect(hookCalls.fileWatch).toHaveBeenCalledTimes(1) + + act(() => useAppStore.setState({ rightSidebarPeek: true })) + + expect(hookCalls.git).toHaveBeenCalledTimes(2) + expect(hookCalls.fileWatch).toHaveBeenCalledTimes(2) + expect(parentRenderCount).toBe(1) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.tsx b/src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.tsx new file mode 100644 index 00000000000..baafdec1dd2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/WorkspaceGitAndFileWatchGate.tsx @@ -0,0 +1,14 @@ +import { useEditorExternalWatch } from '@/hooks/useEditorExternalWatch' +import { useGitStatusPolling } from './useGitStatusPolling' + +// Why: sidebar visibility drives both hooks; isolating their subscriptions in +// a leaf keeps transient edge peeks from re-rendering the entire App tree. +export function WorkspaceGitAndFileWatchGate({ enabled }: { enabled: boolean }): null { + // Why: conflict badges need Git status even with the sidebar closed; defer + // the first scan until session hydration keeps it off the startup path. + useGitStatusPolling({ enabled }) + // Why: open editors must hear file changes regardless of which sidebar tab + // is mounted, so watcher ownership stays at the workspace level. + useEditorExternalWatch() + return null +} diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx index 27e11d1d4fc..d171105ece4 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.test.tsx @@ -220,22 +220,25 @@ describe('useRightSidebarEdgePeekDismiss', () => { expect(setPeek).not.toHaveBeenCalled() }) - it('keeps the peek while the pointer uses portaled sidebar content', () => { - const setPeek = vi.fn() - const portalContent = document.createElement('div') - portalContent.dataset.slot = 'dropdown-menu-content' - document.body.appendChild(portalContent) - renderDismissHook({ setPeek }) + it.each(['dropdown-menu-content', 'dialog-content'])( + 'keeps the peek while the pointer uses portaled %s', + (portalSlot) => { + const setPeek = vi.fn() + const portalContent = document.createElement('div') + portalContent.dataset.slot = portalSlot + document.body.appendChild(portalContent) + renderDismissHook({ setPeek }) - fireEvent.mouseMove(portalContent, { clientX: 400 }) - vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS) - expect(setPeek).not.toHaveBeenCalled() + fireEvent.mouseMove(portalContent, { clientX: 400 }) + vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS) + expect(setPeek).not.toHaveBeenCalled() - fireEvent.mouseMove(window, { clientX: 400 }) - vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS) - expect(setPeek).toHaveBeenCalledWith(false) - portalContent.remove() - }) + fireEvent.mouseMove(window, { clientX: 400 }) + vi.advanceTimersByTime(PEEK_CLOSE_DELAY_MS) + expect(setPeek).toHaveBeenCalledWith(false) + portalContent.remove() + } + ) it('does not dismiss when the boundary was measured mid entrance animation', () => { const setPeek = vi.fn() diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx index 0e8a4548b21..47922b11e75 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-edge-peek.tsx @@ -13,6 +13,7 @@ const PEEK_TITLEBAR_ZONE_PX = 36 const PEEK_INTERACTIVE_PORTAL_SELECTOR = [ '[data-slot="context-menu-content"]', '[data-slot="context-menu-sub-content"]', + '[data-slot="dialog-content"]', '[data-slot="dropdown-menu-content"]', '[data-slot="dropdown-menu-sub-content"]', '[data-slot="hover-card-content"]', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c99325eb944..6ea8faf9ab7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10266,7 +10266,10 @@ "peek": { "tip": { "title": "Hover the right edge to peek", - "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance." + "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.", + "titleLong": "Hover over the right edge of the screen to quickly show this sidebar.", + "settingsPrefix": "Turn this off in", + "settingsLink": "Settings" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 43a431a16d2..71cf86565be 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10266,7 +10266,10 @@ "peek": { "tip": { "title": "Hover the right edge to peek", - "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance." + "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.", + "titleLong": "Hover over the right edge of the screen to quickly show this sidebar.", + "settingsPrefix": "Turn this off in", + "settingsLink": "Settings" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33d0e3c76ed..f5003dc38fb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10266,7 +10266,10 @@ "peek": { "tip": { "title": "Hover the right edge to peek", - "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance." + "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.", + "titleLong": "Hover over the right edge of the screen to quickly show this sidebar.", + "settingsPrefix": "Turn this off in", + "settingsLink": "Settings" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c225ba0bafd..ba97df3c2bb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10266,7 +10266,10 @@ "peek": { "tip": { "title": "Hover the right edge to peek", - "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance." + "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.", + "titleLong": "Hover over the right edge of the screen to quickly show this sidebar.", + "settingsPrefix": "Turn this off in", + "settingsLink": "Settings" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 73bc58bb762..9ba92bd320c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10266,7 +10266,10 @@ "peek": { "tip": { "title": "Hover the right edge to peek", - "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance." + "description": "The right sidebar slides over the editor without reflowing it. Click the toggle or use the shortcut to pin it open. Change this anytime in Settings › Appearance.", + "titleLong": "Hover over the right edge of the screen to quickly show this sidebar.", + "settingsPrefix": "Turn this off in", + "settingsLink": "Settings" } } },