diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts index a3db9750..1d184c73 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-available-editors.ts @@ -1,65 +1,46 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { createLogger } from '@automaker/utils/logger'; +import { useMemo, useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; +import { useAvailableEditors as useAvailableEditorsQuery } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; import type { EditorInfo } from '@automaker/types'; -const logger = createLogger('AvailableEditors'); - // Re-export EditorInfo for convenience export type { EditorInfo }; +/** + * Hook for fetching and managing available editors + * + * Uses React Query for data fetching with caching. + * Provides a refresh function that clears server cache and re-detects editors. + */ export function useAvailableEditors() { - const [editors, setEditors] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isRefreshing, setIsRefreshing] = useState(false); - - const fetchAvailableEditors = useCallback(async () => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.getAvailableEditors) { - setIsLoading(false); - return; - } - const result = await api.worktree.getAvailableEditors(); - if (result.success && result.result?.editors) { - setEditors(result.result.editors); - } - } catch (error) { - logger.error('Failed to fetch available editors:', error); - } finally { - setIsLoading(false); - } - }, []); + const queryClient = useQueryClient(); + const { data: editors = [], isLoading } = useAvailableEditorsQuery(); /** - * Refresh editors by clearing the server cache and re-detecting + * Mutation to refresh editors by clearing the server cache and re-detecting * Use this when the user has installed/uninstalled editors */ - const refresh = useCallback(async () => { - setIsRefreshing(true); - try { + const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({ + mutationFn: async () => { const api = getElectronAPI(); - if (!api?.worktree?.refreshEditors) { - // Fallback to regular fetch if refresh not available - await fetchAvailableEditors(); - return; - } const result = await api.worktree.refreshEditors(); - if (result.success && result.result?.editors) { - setEditors(result.result.editors); - logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`); + if (!result.success) { + throw new Error(result.error || 'Failed to refresh editors'); } - } catch (error) { - logger.error('Failed to refresh editors:', error); - } finally { - setIsRefreshing(false); - } - }, [fetchAvailableEditors]); + return result.result?.editors ?? []; + }, + onSuccess: (newEditors) => { + // Update the cache with new editors + queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors); + }, + }); - useEffect(() => { - fetchAvailableEditors(); - }, [fetchAvailableEditors]); + const refresh = useCallback(() => { + refreshMutate(); + }, [refreshMutate]); return { editors, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts index 1cb1cec6..eeca9729 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-branches.ts @@ -1,66 +1,43 @@ import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { getElectronAPI } from '@/lib/electron'; -import type { BranchInfo, GitRepoStatus } from '../types'; - -const logger = createLogger('Branches'); +import { useWorktreeBranches } from '@/hooks/queries'; +import type { GitRepoStatus } from '../types'; +/** + * Hook for managing branch data with React Query + * + * Uses useWorktreeBranches for data fetching while maintaining + * the current interface for backward compatibility. Tracks which + * worktree path is currently being viewed and fetches branches on demand. + */ export function useBranches() { - const [branches, setBranches] = useState([]); - const [aheadCount, setAheadCount] = useState(0); - const [behindCount, setBehindCount] = useState(0); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [currentWorktreePath, setCurrentWorktreePath] = useState(); const [branchFilter, setBranchFilter] = useState(''); - const [gitRepoStatus, setGitRepoStatus] = useState({ - isGitRepo: true, - hasCommits: true, - }); - /** Helper to reset branch state to initial values */ - const resetBranchState = useCallback(() => { - setBranches([]); - setAheadCount(0); - setBehindCount(0); - }, []); + const { + data: branchData, + isLoading: isLoadingBranches, + refetch, + } = useWorktreeBranches(currentWorktreePath); + + const branches = branchData?.branches ?? []; + const aheadCount = branchData?.aheadCount ?? 0; + const behindCount = branchData?.behindCount ?? 0; + const gitRepoStatus: GitRepoStatus = { + isGitRepo: branchData?.isGitRepo ?? true, + hasCommits: branchData?.hasCommits ?? true, + }; const fetchBranches = useCallback( - async (worktreePath: string) => { - setIsLoadingBranches(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.listBranches) { - logger.warn('List branches API not available'); - return; - } - const result = await api.worktree.listBranches(worktreePath); - if (result.success && result.result) { - setBranches(result.result.branches); - setAheadCount(result.result.aheadCount || 0); - setBehindCount(result.result.behindCount || 0); - setGitRepoStatus({ isGitRepo: true, hasCommits: true }); - } else if (result.code === 'NOT_GIT_REPO') { - // Not a git repository - clear branches silently without logging an error - resetBranchState(); - setGitRepoStatus({ isGitRepo: false, hasCommits: false }); - } else if (result.code === 'NO_COMMITS') { - // Git repo but no commits yet - clear branches silently without logging an error - resetBranchState(); - setGitRepoStatus({ isGitRepo: true, hasCommits: false }); - } else if (!result.success) { - // Other errors - log them - logger.warn('Failed to fetch branches:', result.error); - resetBranchState(); - } - } catch (error) { - logger.error('Failed to fetch branches:', error); - resetBranchState(); - // Reset git status to unknown state on network/API errors - setGitRepoStatus({ isGitRepo: true, hasCommits: true }); - } finally { - setIsLoadingBranches(false); + (worktreePath: string) => { + if (worktreePath === currentWorktreePath) { + // Same path - just refetch to get latest data + refetch(); + } else { + // Different path - update the tracked path (triggers new query) + setCurrentWorktreePath(worktreePath); } }, - [resetBranchState] + [currentWorktreePath, refetch] ); const resetBranchFilter = useCallback(() => { diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index f1f245dc..50eddc58 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -1,152 +1,64 @@ import { useState, useCallback } from 'react'; -import { createLogger } from '@automaker/utils/logger'; -import { getElectronAPI } from '@/lib/electron'; -import { toast } from 'sonner'; +import { + useSwitchBranch, + usePullWorktree, + usePushWorktree, + useOpenInEditor, +} from '@/hooks/mutations'; import type { WorktreeInfo } from '../types'; -const logger = createLogger('WorktreeActions'); - -// Error codes that need special user-friendly handling -const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const; -type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number]; - -// User-friendly messages for git status errors -const GIT_STATUS_ERROR_MESSAGES: Record = { - NOT_GIT_REPO: 'This directory is not a git repository', - NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.', -}; - -/** - * Helper to handle git status errors with user-friendly messages. - * @returns true if the error was a git status error and was handled, false otherwise. - */ -function handleGitStatusError(result: { code?: string; error?: string }): boolean { - const errorCode = result.code as GitStatusErrorCode | undefined; - if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) { - toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error); - return true; - } - return false; -} - -interface UseWorktreeActionsOptions { - fetchWorktrees: () => Promise | undefined>; - fetchBranches: (worktreePath: string) => Promise; -} - -export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) { - const [isPulling, setIsPulling] = useState(false); - const [isPushing, setIsPushing] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); +export function useWorktreeActions() { const [isActivating, setIsActivating] = useState(false); + // Use React Query mutations + const switchBranchMutation = useSwitchBranch(); + const pullMutation = usePullWorktree(); + const pushMutation = usePushWorktree(); + const openInEditorMutation = useOpenInEditor(); + const handleSwitchBranch = useCallback( async (worktree: WorktreeInfo, branchName: string) => { - if (isSwitching || branchName === worktree.branch) return; - setIsSwitching(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.switchBranch) { - toast.error('Switch branch API not available'); - return; - } - const result = await api.worktree.switchBranch(worktree.path, branchName); - if (result.success && result.result) { - toast.success(result.result.message); - fetchWorktrees(); - } else { - if (handleGitStatusError(result)) return; - toast.error(result.error || 'Failed to switch branch'); - } - } catch (error) { - logger.error('Switch branch failed:', error); - toast.error('Failed to switch branch'); - } finally { - setIsSwitching(false); - } + if (switchBranchMutation.isPending || branchName === worktree.branch) return; + switchBranchMutation.mutate({ + worktreePath: worktree.path, + branchName, + }); }, - [isSwitching, fetchWorktrees] + [switchBranchMutation] ); const handlePull = useCallback( async (worktree: WorktreeInfo) => { - if (isPulling) return; - setIsPulling(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.pull) { - toast.error('Pull API not available'); - return; - } - const result = await api.worktree.pull(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - fetchWorktrees(); - } else { - if (handleGitStatusError(result)) return; - toast.error(result.error || 'Failed to pull latest changes'); - } - } catch (error) { - logger.error('Pull failed:', error); - toast.error('Failed to pull latest changes'); - } finally { - setIsPulling(false); - } + if (pullMutation.isPending) return; + pullMutation.mutate(worktree.path); }, - [isPulling, fetchWorktrees] + [pullMutation] ); const handlePush = useCallback( async (worktree: WorktreeInfo) => { - if (isPushing) return; - setIsPushing(true); - try { - const api = getElectronAPI(); - if (!api?.worktree?.push) { - toast.error('Push API not available'); - return; - } - const result = await api.worktree.push(worktree.path); - if (result.success && result.result) { - toast.success(result.result.message); - fetchBranches(worktree.path); - fetchWorktrees(); - } else { - if (handleGitStatusError(result)) return; - toast.error(result.error || 'Failed to push changes'); - } - } catch (error) { - logger.error('Push failed:', error); - toast.error('Failed to push changes'); - } finally { - setIsPushing(false); - } + if (pushMutation.isPending) return; + pushMutation.mutate({ + worktreePath: worktree.path, + }); }, - [isPushing, fetchBranches, fetchWorktrees] + [pushMutation] ); - const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.openInEditor) { - logger.warn('Open in editor API not available'); - return; - } - const result = await api.worktree.openInEditor(worktree.path, editorCommand); - if (result.success && result.result) { - toast.success(result.result.message); - } else if (result.error) { - toast.error(result.error); - } - } catch (error) { - logger.error('Open in editor failed:', error); - } - }, []); + const handleOpenInEditor = useCallback( + async (worktree: WorktreeInfo, editorCommand?: string) => { + openInEditorMutation.mutate({ + worktreePath: worktree.path, + editorCommand, + }); + }, + [openInEditorMutation] + ); return { - isPulling, - isPushing, - isSwitching, + isPulling: pullMutation.isPending, + isPushing: pushMutation.isPending, + isSwitching: switchBranchMutation.isPending, isActivating, setIsActivating, handleSwitchBranch, diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts index 1575f38a..6a3276ec 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktrees.ts @@ -1,12 +1,11 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { createLogger } from '@automaker/utils/logger'; +import { useEffect, useCallback, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useAppStore } from '@/store/app-store'; -import { getElectronAPI } from '@/lib/electron'; +import { useWorktrees as useWorktreesQuery } from '@/hooks/queries'; +import { queryKeys } from '@/lib/query-keys'; import { pathsEqual } from '@/lib/utils'; import type { WorktreeInfo } from '../types'; -const logger = createLogger('Worktrees'); - interface UseWorktreesOptions { projectPath: string; refreshTrigger?: number; @@ -18,59 +17,46 @@ export function useWorktrees({ refreshTrigger = 0, onRemovedWorktrees, }: UseWorktreesOptions) { - const [isLoading, setIsLoading] = useState(false); - const [worktrees, setWorktrees] = useState([]); + const queryClient = useQueryClient(); const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); const setWorktreesInStore = useAppStore((s) => s.setWorktrees); const useWorktreesEnabled = useAppStore((s) => s.useWorktrees); - const fetchWorktrees = useCallback( - async (options?: { silent?: boolean }) => { - if (!projectPath) return; - const silent = options?.silent ?? false; - if (!silent) { - setIsLoading(true); - } - try { - const api = getElectronAPI(); - if (!api?.worktree?.listAll) { - logger.warn('Worktree API not available'); - return; - } - const result = await api.worktree.listAll(projectPath, true); - if (result.success && result.worktrees) { - setWorktrees(result.worktrees); - setWorktreesInStore(projectPath, result.worktrees); - } - // Return removed worktrees so they can be handled by the caller - return result.removedWorktrees; - } catch (error) { - logger.error('Failed to fetch worktrees:', error); - return undefined; - } finally { - if (!silent) { - setIsLoading(false); - } - } - }, - [projectPath, setWorktreesInStore] - ); + // Use the React Query hook + const { data, isLoading, refetch } = useWorktreesQuery(projectPath); + const worktrees = (data?.worktrees ?? []) as WorktreeInfo[]; + // Sync worktrees to Zustand store when they change useEffect(() => { - fetchWorktrees(); - }, [fetchWorktrees]); + if (worktrees.length > 0) { + setWorktreesInStore(projectPath, worktrees); + } + }, [worktrees, projectPath, setWorktreesInStore]); + // Handle removed worktrees callback when data changes + const prevRemovedWorktreesRef = useRef(null); + useEffect(() => { + if (data?.removedWorktrees && data.removedWorktrees.length > 0) { + // Create a stable key to avoid duplicate callbacks + const key = JSON.stringify(data.removedWorktrees); + if (key !== prevRemovedWorktreesRef.current) { + prevRemovedWorktreesRef.current = key; + onRemovedWorktrees?.(data.removedWorktrees); + } + } + }, [data?.removedWorktrees, onRemovedWorktrees]); + + // Handle refresh trigger useEffect(() => { if (refreshTrigger > 0) { - fetchWorktrees().then((removedWorktrees) => { - if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) { - onRemovedWorktrees(removedWorktrees); - } + // Invalidate and refetch to get fresh data including any removed worktrees + queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), }); } - }, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]); + }, [refreshTrigger, projectPath, queryClient]); // Use a ref to track the current worktree to avoid running validation // when selection changes (which could cause a race condition with stale worktrees list) @@ -108,6 +94,14 @@ export function useWorktrees({ [projectPath, setCurrentWorktree] ); + // fetchWorktrees for backward compatibility - now just triggers a refetch + const fetchWorktrees = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: queryKeys.worktrees.all(projectPath), + }); + return refetch(); + }, [projectPath, queryClient, refetch]); + const currentWorktreePath = currentWorktree?.path ?? null; const selectedWorktree = currentWorktreePath ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 2cc844f4..8550ce7d 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -5,6 +5,7 @@ import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { getHttpApiClient } from '@/lib/http-api-client'; import { useIsMobile } from '@/hooks/use-media-query'; +import { useWorktreeInitScript } from '@/hooks/queries'; import type { WorktreePanelProps, WorktreeInfo } from './types'; import { useWorktrees, @@ -79,42 +80,21 @@ export function WorktreePanel({ handlePull, handlePush, handleOpenInEditor, - } = useWorktreeActions({ - fetchWorktrees, - fetchBranches, - }); + } = useWorktreeActions(); const { hasRunningFeatures } = useRunningFeatures({ runningFeatureIds, features, }); - // Track whether init script exists for the project - const [hasInitScript, setHasInitScript] = useState(false); + // Check if init script exists for the project using React Query + const { data: initScriptData } = useWorktreeInitScript(projectPath); + const hasInitScript = initScriptData?.exists ?? false; // Log panel state management const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelWorktree, setLogPanelWorktree] = useState(null); - useEffect(() => { - if (!projectPath) { - setHasInitScript(false); - return; - } - - const checkInitScript = async () => { - try { - const api = getHttpApiClient(); - const result = await api.worktree.getInitScript(projectPath); - setHasInitScript(result.success && result.exists); - } catch { - setHasInitScript(false); - } - }; - - checkInitScript(); - }, [projectPath]); - const isMobile = useIsMobile(); // Periodic interval check (5 seconds) to detect branch changes on disk