diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index 9fd8dfe7fd8..50f8799618d 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -30,6 +30,7 @@ export const createBranchesRouter = () => { defaultBranch: string; checkedOutBranches: Record; worktreeBaseBranch: string | null; + currentBranch: string | null; }> => { assertRegisteredWorktree(input.worktreePath); @@ -83,6 +84,7 @@ export const createBranchesRouter = () => { defaultBranch, checkedOutBranches, worktreeBaseBranch: configuredBaseBranch ?? persistedBaseBranch, + currentBranch, }; }, ), diff --git a/apps/desktop/src/renderer/hooks/useDebouncedValue.ts b/apps/desktop/src/renderer/hooks/useDebouncedValue.ts new file mode 100644 index 00000000000..013f37128fa --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setDebouncedValue(value); + }, delayMs); + + return () => { + clearTimeout(timeoutId); + }; + }, [value, delayMs]); + + return debouncedValue; +} diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index 71b95fded62..833eb84c76d 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -20,37 +20,210 @@ export function setSkipNextHotkeysPersist(skip: boolean): void { interface TrpcStorageConfig { get: () => Promise; set: (input: unknown) => Promise; + writeDebounceMs?: number; } +const PENDING_SNAPSHOT_TTL_MS = 5 * 60 * 1000; +const LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS = 250; + function createTrpcStorageAdapter(config: TrpcStorageConfig): StateStorage { + const debounceMs = config.writeDebounceMs ?? 0; + let pendingValue: string | null = null; + let lastFlushedValue: string | null = null; + let flushTimer: ReturnType | null = null; + let isFlushing = false; + let pendingSnapshotValue: string | null = null; + let pendingSnapshotTimer: ReturnType | null = null; + + const getPendingSnapshotKey = (name: string) => `${name}:pending`; + const getPendingSnapshotUpdatedAtKey = (name: string) => + `${name}:pending:updatedAt`; + const pendingSnapshotDebounceMs = + debounceMs > 0 + ? Math.min(debounceMs, LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS) + : LOCAL_SNAPSHOT_WRITE_DEBOUNCE_MS; + + const clearPendingSnapshot = (name: string, expectedValue?: string): void => { + try { + const pendingKey = getPendingSnapshotKey(name); + if ( + expectedValue !== undefined && + localStorage.getItem(pendingKey) !== expectedValue + ) { + return; + } + localStorage.removeItem(pendingKey); + localStorage.removeItem(getPendingSnapshotUpdatedAtKey(name)); + } catch (error) { + console.error("[trpc-storage] Failed to clear pending snapshot:", error); + } + }; + + const schedulePendingSnapshotPersist = ( + name: string, + snapshot: string, + ): void => { + pendingSnapshotValue = snapshot; + + if (pendingSnapshotTimer) { + clearTimeout(pendingSnapshotTimer); + pendingSnapshotTimer = null; + } + + pendingSnapshotTimer = setTimeout(() => { + pendingSnapshotTimer = null; + const valueToPersist = pendingSnapshotValue; + pendingSnapshotValue = null; + if (!valueToPersist) return; + + try { + localStorage.setItem(getPendingSnapshotKey(name), valueToPersist); + localStorage.setItem( + getPendingSnapshotUpdatedAtKey(name), + String(Date.now()), + ); + } catch (error) { + console.error( + "[trpc-storage] Failed to cache pending snapshot in localStorage:", + error, + ); + } + }, pendingSnapshotDebounceMs); + }; + + const scheduleImmediateFlush = (name: string, snapshot: string): void => { + // Ensure pending snapshot eventually syncs to appState. + if (pendingValue === null) { + pendingValue = snapshot; + } + if (!isFlushing && flushTimer === null) { + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingWrite(name); + }, 0); + } + }; + + const flushPendingWrite = async (name: string): Promise => { + if (isFlushing || pendingValue === null) return; + const valueToFlush = pendingValue; + pendingValue = null; + + if (valueToFlush === lastFlushedValue) { + return; + } + + isFlushing = true; + try { + const parsed = JSON.parse(valueToFlush) as { + state: unknown; + version: number; + }; + // Persist version in localStorage, bare state via tRPC. + localStorage.setItem(`${name}:version`, String(parsed.version)); + await config.set(parsed.state); + lastFlushedValue = valueToFlush; + + // Cancel delayed snapshot write if this exact snapshot was already flushed. + if (pendingSnapshotValue === valueToFlush && pendingSnapshotTimer) { + clearTimeout(pendingSnapshotTimer); + pendingSnapshotTimer = null; + pendingSnapshotValue = null; + } + clearPendingSnapshot(name, valueToFlush); + } catch (error) { + console.error("[trpc-storage] Failed to set state:", error); + } finally { + isFlushing = false; + if (pendingValue !== null) { + if (debounceMs > 0) { + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingWrite(name); + }, debounceMs); + } else { + void flushPendingWrite(name); + } + } + } + }; + return { getItem: async (name: string): Promise => { try { const state = await config.get(); - if (!state) return null; - // Version is stored in localStorage as a sidecar since the - // tRPC backend validates bare state and rejects envelopes. const version = Number.parseInt( localStorage.getItem(`${name}:version`) ?? "0", 10, ); - return JSON.stringify({ state, version }); + const canonicalSnapshot = state + ? JSON.stringify({ state, version }) + : null; + + const pendingSnapshot = localStorage.getItem( + getPendingSnapshotKey(name), + ); + const pendingUpdatedAt = Number.parseInt( + localStorage.getItem(getPendingSnapshotUpdatedAtKey(name)) ?? "0", + 10, + ); + const pendingAgeMs = + Number.isFinite(pendingUpdatedAt) && pendingUpdatedAt > 0 + ? Date.now() - pendingUpdatedAt + : Number.POSITIVE_INFINITY; + const isPendingFresh = pendingAgeMs <= PENDING_SNAPSHOT_TTL_MS; + + if (pendingSnapshot) { + if (!canonicalSnapshot) { + if (isPendingFresh) { + scheduleImmediateFlush(name, pendingSnapshot); + return pendingSnapshot; + } + clearPendingSnapshot(name); + return null; + } + + if (pendingSnapshot === canonicalSnapshot) { + clearPendingSnapshot(name); + return canonicalSnapshot; + } + + // Only trust pending snapshots that are very recent; otherwise + // canonical appState remains the source of truth. + if (isPendingFresh) { + scheduleImmediateFlush(name, pendingSnapshot); + return pendingSnapshot; + } + + clearPendingSnapshot(name); + return canonicalSnapshot; + } + + return canonicalSnapshot; } catch (error) { console.error("[trpc-storage] Failed to get state:", error); return null; } }, setItem: async (name: string, value: string): Promise => { - try { - const parsed = JSON.parse(value) as { - state: unknown; - version: number; - }; - // Persist version in localStorage, bare state via tRPC. - localStorage.setItem(`${name}:version`, String(parsed.version)); - await config.set(parsed.state); - } catch (error) { - console.error("[trpc-storage] Failed to set state:", error); + if (value === pendingValue || value === lastFlushedValue) { + return; + } + + pendingValue = value; + schedulePendingSnapshotPersist(name, value); + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + + if (debounceMs > 0) { + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingWrite(name); + }, debounceMs); + } else { + void flushPendingWrite(name); } }, removeItem: async (_name: string): Promise => { @@ -68,6 +241,7 @@ export const trpcTabsStorage = createJSONStorage(() => get: () => electronTrpcClient.uiState.tabs.get.query(), // biome-ignore lint/suspicious/noExplicitAny: Zustand persist passes unknown, tRPC expects typed input set: (input) => electronTrpcClient.uiState.tabs.set.mutate(input as any), + writeDebounceMs: 300, }), ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 5cd02d5b59c..b1ce6fb4046 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -37,6 +37,8 @@ import { useIsWorkspaceInitializing, } from "renderer/stores/workspace-init"; +const EMPTY_HISTORY_STACK: string[] = []; + export const Route = createFileRoute( "/_authenticated/_dashboard/workspace/$workspaceId/", )({ @@ -117,9 +119,12 @@ function WorkspacePage() { const showInitView = isInitializing || hasFailed || hasIncompleteInit; const allTabs = useTabsStore((s) => s.tabs); - const activeTabIds = useTabsStore((s) => s.activeTabIds); - const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks); - const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const activeTabIdForWorkspace = useTabsStore( + (s) => s.activeTabIds[workspaceId] ?? null, + ); + const tabHistoryStack = useTabsStore( + (s) => s.tabHistoryStacks[workspaceId] ?? EMPTY_HISTORY_STACK, + ); const { addTab, splitPaneAuto, @@ -149,17 +154,19 @@ function WorkspacePage() { return resolveActiveTabIdForWorkspace({ workspaceId, tabs, - activeTabIds, - tabHistoryStacks, + activeTabIds: { [workspaceId]: activeTabIdForWorkspace }, + tabHistoryStacks: { [workspaceId]: tabHistoryStack }, }); - }, [workspaceId, tabs, activeTabIds, tabHistoryStacks]); + }, [workspaceId, tabs, activeTabIdForWorkspace, tabHistoryStack]); const activeTab = useMemo( () => (activeTabId ? tabs.find((t) => t.id === activeTabId) : null), [activeTabId, tabs], ); - const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; + const focusedPaneId = useTabsStore((s) => + activeTabId ? (s.focusedPaneIds[activeTabId] ?? null) : null, + ); const { presets } = usePresets(); diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts index 43d677eef87..6eee964eac2 100644 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useSearchDialogStore } from "renderer/stores/search-dialog-state"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -45,19 +46,22 @@ export function useKeywordSearch({ (state) => state.setFiltersOpen, ); const trimmedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(trimmedQuery, 150); + const isDebouncing = + trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; const { data: searchResults, isFetching } = electronTrpc.filesystem.searchKeyword.useQuery( { rootPath: worktreePath ?? "", - query: trimmedQuery, + query: debouncedQuery, includePattern, excludePattern, includeHidden: false, limit: SEARCH_LIMIT, }, { - enabled: open && Boolean(worktreePath) && trimmedQuery.length > 0, + enabled: open && Boolean(worktreePath) && debouncedQuery.length > 0, staleTime: 1000, placeholderData: (previous) => previous ?? [], }, @@ -126,6 +130,6 @@ export function useKeywordSearch({ toggle, selectMatch, searchResults: searchResults ?? [], - isFetching, + isFetching: isFetching || isDebouncing, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/ResizablePanel/ResizablePanel.tsx b/apps/desktop/src/renderer/screens/main/components/ResizablePanel/ResizablePanel.tsx index c8b8acac845..1867c358737 100644 --- a/apps/desktop/src/renderer/screens/main/components/ResizablePanel/ResizablePanel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/ResizablePanel/ResizablePanel.tsx @@ -43,6 +43,15 @@ export function ResizablePanel({ }: ResizablePanelProps) { const startXRef = useRef(0); const startWidthRef = useRef(0); + const pendingWidthRef = useRef(null); + const rafIdRef = useRef(null); + + const flushPendingWidth = useCallback(() => { + const pendingWidth = pendingWidthRef.current; + pendingWidthRef.current = null; + if (pendingWidth === null) return; + onWidthChange(pendingWidth); + }, [onWidthChange]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -66,16 +75,27 @@ export function ResizablePanel({ const finalWidth = clampWidth ? Math.max(minWidth, Math.min(maxWidth, newWidth)) : newWidth; - onWidthChange(finalWidth); + pendingWidthRef.current = finalWidth; + + if (rafIdRef.current !== null) return; + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null; + flushPendingWidth(); + }); }, - [isResizing, onWidthChange, minWidth, maxWidth, handleSide, clampWidth], + [isResizing, minWidth, maxWidth, handleSide, clampWidth, flushPendingWidth], ); const handleMouseUp = useCallback(() => { - if (isResizing) { - onResizingChange(false); + if (!isResizing) return; + + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; } - }, [isResizing, onResizingChange]); + flushPendingWidth(); + onResizingChange(false); + }, [isResizing, onResizingChange, flushPendingWidth]); useEffect(() => { if (isResizing) { @@ -90,6 +110,11 @@ export function ResizablePanel({ document.removeEventListener("mouseup", handleMouseUp); document.body.style.userSelect = ""; document.body.style.cursor = ""; + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = null; + } + pendingWidthRef.current = null; }; }, [isResizing, handleMouseMove, handleMouseUp]); diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index 48977f23c5b..4514fc7814e 100644 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -6,7 +6,8 @@ import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { useSidebarStore } from "renderer/stores"; export function SidebarControl() { - const { isSidebarOpen, toggleSidebar } = useSidebarStore(); + const isSidebarOpen = useSidebarStore((s) => s.isSidebarOpen); + const toggleSidebar = useSidebarStore((s) => s.toggleSidebar); return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index b299b2824b9..4e3e926b9fb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -2,8 +2,7 @@ import { useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { EnrichedPort } from "shared/types"; -/** Matches the port scanner's scan cycle in port-manager.ts */ -const PORTS_REFETCH_INTERVAL_MS = 2500; +const PORTS_FALLBACK_REFETCH_INTERVAL_MS = 10_000; export interface WorkspacePortGroup { workspaceId: string; @@ -18,7 +17,10 @@ export function usePortsData() { const { data: detectedPorts } = electronTrpc.ports.getAll.useQuery( undefined, - { refetchInterval: PORTS_REFETCH_INTERVAL_MS }, + { + // Keep a low-frequency safety net in case subscription events are missed. + refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, + }, ); electronTrpc.ports.subscribe.useSubscription(undefined, { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 7af465eaf60..76e398df63a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -93,8 +93,17 @@ export function WorkspaceListItem({ const [hasHovered, setHasHovered] = useState(false); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const rename = useWorkspaceRename(id, name, branch); - const tabs = useTabsStore((s) => s.tabs); - const panes = useTabsStore((s) => s.panes); + const workspaceStatus = useTabsStore((state) => { + function* paneStatuses() { + for (const tab of state.tabs) { + if (tab.workspaceId !== id) continue; + for (const paneId of extractPaneIdsFromLayout(tab.layout)) { + yield state.panes[paneId]?.status; + } + } + } + return getHighestPriorityStatus(paneStatuses()); + }); const clearWorkspaceAttentionStatus = useTabsStore( (s) => s.clearWorkspaceAttentionStatus, ); @@ -173,19 +182,6 @@ export function WorkspaceListItem({ return { additions, deletions }; }, [localChanges]); - const workspaceStatus = useMemo(() => { - const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - const paneIds = new Set( - workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), - ); - function* paneStatuses() { - for (const paneId of paneIds) { - yield panes[paneId]?.status; - } - } - return getHighestPriorityStatus(paneStatuses()); - }, [tabs, panes, id]); - const handleClick = () => { if (!rename.isRenaming) { clearWorkspaceAttentionStatus(id); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx index ad11d5d024e..4bdae7e68c7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/ChangesContent.tsx @@ -1,10 +1,17 @@ import { useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useGitChangesStatus } from "renderer/screens/main/hooks/useGitChangesStatus"; +import { + RightSidebarTab, + useSidebarStore, +} from "renderer/stores/sidebar-state"; import { InfiniteScrollView } from "./components/InfiniteScrollView"; export function ChangesContent() { const { workspaceId } = useParams({ strict: false }); + const isChangesSidebarVisible = useSidebarStore( + (s) => s.isSidebarOpen && s.rightSidebarTab === RightSidebarTab.Changes, + ); const { data: workspace } = electronTrpc.workspaces.get.useQuery( { id: workspaceId ?? "" }, { enabled: !!workspaceId }, @@ -13,8 +20,8 @@ export function ChangesContent() { const { status, isLoading, effectiveBaseBranch } = useGitChangesStatus({ worktreePath, - refetchInterval: 2500, - refetchOnWindowFocus: true, + refetchInterval: isChangesSidebarVisible ? undefined : 2500, + refetchOnWindowFocus: !isChangesSidebarVisible, }); if (!worktreePath) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/MentionPopover/MentionPopover.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/MentionPopover/MentionPopover.tsx index 56ada7e8c0c..10f0409f368 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/MentionPopover/MentionPopover.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/MentionPopover/MentionPopover.tsx @@ -27,6 +27,7 @@ import { useState, } from "react"; import { HiMiniAtSymbol } from "react-icons/hi2"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getFileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; const MAX_RESULTS = 20; @@ -86,23 +87,34 @@ export function MentionProvider({ setOpen(true); } }, [textInput.value]); + const immediateSearchQuery = searchQuery.trim(); + const debouncedSearchQuery = useDebouncedValue(immediateSearchQuery, 120); // File search via chatService (IPC to main process) - const { data: fileResults } = chatServiceTrpc.workspace.searchFiles.useQuery( - { - rootPath: cwd, - query: searchQuery, - includeHidden: false, - limit: MAX_RESULTS, - }, - { - enabled: open && searchQuery.length > 0 && !!cwd, - staleTime: 1000, - placeholderData: (previous) => previous ?? [], - }, - ); + const { data: fileResults, isFetching: isSearchFetching } = + chatServiceTrpc.workspace.searchFiles.useQuery( + { + rootPath: cwd, + query: debouncedSearchQuery, + includeHidden: false, + limit: MAX_RESULTS, + }, + { + enabled: + open && + immediateSearchQuery.length > 0 && + debouncedSearchQuery.length > 0 && + !!cwd, + staleTime: 1000, + placeholderData: (previous) => previous ?? [], + }, + ); - const files = fileResults ?? []; + const files = + open && immediateSearchQuery.length > 0 ? (fileResults ?? []) : []; + const isSearchPending = + immediateSearchQuery.length > 0 && + (immediateSearchQuery !== debouncedSearchQuery || isSearchFetching); const handleSelectFile = (relativePath: string) => { const current = textInput.value; @@ -139,7 +151,9 @@ export function MentionProvider({ {searchQuery.length === 0 ? "Type to search files..." - : "No results found."} + : isSearchPending + ? "Searching files..." + : "No results found."} )} {files.length > 0 && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index b69c919e606..2f41d7e1a61 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -32,9 +32,16 @@ interface ChangesViewProps { commitHash?: string, ) => void; isExpandedView?: boolean; + isActive?: boolean; } -export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { +const INACTIVE_BRANCH_REFETCH_INTERVAL_MS = 10_000; + +export function ChangesView({ + onFileOpen, + isExpandedView, + isActive = true, +}: ChangesViewProps) { const { workspaceId } = useParams({ strict: false }); const { data: workspace } = electronTrpc.workspaces.get.useQuery( { id: workspaceId ?? "" }, @@ -46,8 +53,12 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { const { status, isLoading, effectiveBaseBranch, branchData, refetch } = useGitChangesStatus({ worktreePath, - refetchInterval: 2500, - refetchOnWindowFocus: true, + refetchInterval: isActive ? 2500 : undefined, + refetchOnWindowFocus: isActive, + branchRefetchInterval: isActive + ? undefined + : INACTIVE_BRANCH_REFETCH_INTERVAL_MS, + branchRefetchOnWindowFocus: true, }); const { @@ -57,13 +68,13 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: workspaceId ?? "" }, { - enabled: !!workspaceId, - refetchInterval: 10000, + enabled: !!workspaceId && isActive, + refetchInterval: isActive ? 10000 : false, }, ); useBranchSyncInvalidation({ - gitBranch: status?.branch, + gitBranch: status?.branch ?? branchData?.currentBranch ?? undefined, workspaceBranch: workspace?.branch, workspaceId: workspaceId ?? "", }); @@ -249,10 +260,10 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { const expandedCommitHashes = useMemo( () => - expandedSections.committed + isActive && expandedSections.committed ? Array.from(expandedCommits) : ([] as string[]), - [expandedSections.committed, expandedCommits], + [isActive, expandedSections.committed, expandedCommits], ); const commitFilesQueries = electronTrpc.useQueries((t) => diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts index 002666bfdf9..6e79e8d162b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts @@ -1,3 +1,4 @@ +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { SEARCH_RESULT_LIMIT } from "../../constants"; @@ -19,19 +20,22 @@ export function useFileSearch({ limit = SEARCH_RESULT_LIMIT, }: UseFileSearchParams) { const trimmedQuery = searchTerm.trim(); + const debouncedQuery = useDebouncedValue(trimmedQuery, 150); + const isDebouncing = + trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; const { data: searchResults, isFetching } = electronTrpc.filesystem.searchFiles.useQuery( { rootPath: worktreePath ?? "", - query: trimmedQuery, + query: debouncedQuery, includePattern, excludePattern, includeHidden, limit, }, { - enabled: Boolean(worktreePath) && trimmedQuery.length > 0, + enabled: Boolean(worktreePath) && debouncedQuery.length > 0, staleTime: 1000, placeholderData: (previous) => previous ?? [], }, @@ -39,7 +43,7 @@ export function useFileSearch({ return { searchResults: searchResults ?? [], - isFetching, + isFetching: isFetching || isDebouncing, hasQuery: trimmedQuery.length > 0, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index fc5ca9924f8..4609056cd98 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -84,14 +84,12 @@ export function RightSidebar() { { enabled: !!workspaceId }, ); const worktreePath = workspace?.worktreePath; - const { - currentMode, - rightSidebarTab, - setRightSidebarTab, - toggleSidebar, - setMode, - sidebarWidth, - } = useSidebarStore(); + const currentMode = useSidebarStore((s) => s.currentMode); + const rightSidebarTab = useSidebarStore((s) => s.rightSidebarTab); + const setRightSidebarTab = useSidebarStore((s) => s.setRightSidebarTab); + const toggleSidebar = useSidebarStore((s) => s.toggleSidebar); + const setMode = useSidebarStore((s) => s.setMode); + const sidebarWidth = useSidebarStore((s) => s.sidebarWidth); const isExpanded = currentMode === SidebarMode.Changes; const compactTabs = sidebarWidth < 250; const showChangesTab = !!worktreePath; @@ -232,6 +230,7 @@ export function RightSidebar() { )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx index aee95e72410..047cf085626 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceLayout/WorkspaceLayout.tsx @@ -12,14 +12,12 @@ import { RightSidebar } from "../RightSidebar"; export function WorkspaceLayout() { useBrowserLifecycle(); - const { - isSidebarOpen, - sidebarWidth, - setSidebarWidth, - isResizing, - setIsResizing, - currentMode, - } = useSidebarStore(); + const isSidebarOpen = useSidebarStore((s) => s.isSidebarOpen); + const sidebarWidth = useSidebarStore((s) => s.sidebarWidth); + const setSidebarWidth = useSidebarStore((s) => s.setSidebarWidth); + const isResizing = useSidebarStore((s) => s.isResizing); + const setIsResizing = useSidebarStore((s) => s.setIsResizing); + const currentMode = useSidebarStore((s) => s.currentMode); const isExpanded = currentMode === SidebarMode.Changes; diff --git a/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts b/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts index 28aab607efa..bffb4ef1edd 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/useGitChangesStatus/useGitChangesStatus.ts @@ -7,6 +7,8 @@ interface UseGitChangesStatusOptions { refetchInterval?: number; refetchOnWindowFocus?: boolean; staleTime?: number; + branchRefetchInterval?: number; + branchRefetchOnWindowFocus?: boolean; } const LARGE_CHANGESET_THRESHOLD = 200; @@ -18,10 +20,16 @@ export function useGitChangesStatus({ refetchInterval, refetchOnWindowFocus, staleTime, + branchRefetchInterval, + branchRefetchOnWindowFocus, }: UseGitChangesStatusOptions) { const { data: branchData } = electronTrpc.changes.getBranches.useQuery( { worktreePath: worktreePath || "" }, - { enabled: enabled && !!worktreePath }, + { + enabled: enabled && !!worktreePath, + refetchInterval: branchRefetchInterval, + refetchOnWindowFocus: branchRefetchOnWindowFocus, + }, ); const effectiveBaseBranch = diff --git a/apps/desktop/src/renderer/stores/hotkeys/store.ts b/apps/desktop/src/renderer/stores/hotkeys/store.ts index b828f715134..0a6f010872d 100644 --- a/apps/desktop/src/renderer/stores/hotkeys/store.ts +++ b/apps/desktop/src/renderer/stores/hotkeys/store.ts @@ -325,7 +325,8 @@ export function useAppHotkey( id: HotkeyId, callback: (event: KeyboardEvent, handler: unknown) => void, options?: { enabled?: boolean; preventDefault?: boolean }, - deps: unknown[] = [], + // Deprecated: callback refs keep handlers fresh without listener re-registration. + _deps: unknown[] = [], ) { const keys = useHotkeyKeys(id); const enabled = Boolean(keys) && (options?.enabled ?? true); @@ -352,5 +353,5 @@ export function useAppHotkey( return () => { document.removeEventListener("keydown", onKeyDown); }; - }, [enabled, keys, preventDefault, ...deps]); + }, [enabled, keys, preventDefault]); } diff --git a/apps/desktop/src/renderer/stores/sidebar-state.ts b/apps/desktop/src/renderer/stores/sidebar-state.ts index b198741e3aa..0c887629bb1 100644 --- a/apps/desktop/src/renderer/stores/sidebar-state.ts +++ b/apps/desktop/src/renderer/stores/sidebar-state.ts @@ -87,6 +87,14 @@ export const useSidebarStore = create()( ); if (width > 0) { + const { sidebarWidth, lastOpenSidebarWidth, isSidebarOpen } = get(); + if ( + sidebarWidth === clampedWidth && + lastOpenSidebarWidth === clampedWidth && + isSidebarOpen + ) { + return; + } set({ sidebarWidth: clampedWidth, lastOpenSidebarWidth: clampedWidth,