refactor(ui): migrate worktree panel to React Query

- Migrate use-worktrees to useWorktrees query hook
- Migrate use-branches to useWorktreeBranches query hook
- Migrate use-available-editors to useAvailableEditors query hook
- Migrate use-worktree-actions to use mutation hooks
- Update worktree-panel component to use query data
- Remove manual state management for loading/errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-15 16:21:36 +01:00
parent 3411256366
commit d1219a225c
5 changed files with 141 additions and 297 deletions

View File

@@ -1,65 +1,46 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useMemo, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store'; 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'; import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience // Re-export EditorInfo for convenience
export type { EditorInfo }; 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() { export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]); const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(true); const { data: editors = [], isLoading } = useAvailableEditorsQuery();
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);
}
}, []);
/** /**
* 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 * Use this when the user has installed/uninstalled editors
*/ */
const refresh = useCallback(async () => { const { mutate: refreshMutate, isPending: isRefreshing } = useMutation({
setIsRefreshing(true); mutationFn: async () => {
try {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors(); const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) { if (!result.success) {
setEditors(result.result.editors); throw new Error(result.error || 'Failed to refresh editors');
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
} }
} catch (error) { return result.result?.editors ?? [];
logger.error('Failed to refresh editors:', error); },
} finally { onSuccess: (newEditors) => {
setIsRefreshing(false); // Update the cache with new editors
} queryClient.setQueryData(queryKeys.worktrees.editors(), newEditors);
}, [fetchAvailableEditors]); },
});
useEffect(() => { const refresh = useCallback(() => {
fetchAvailableEditors(); refreshMutate();
}, [fetchAvailableEditors]); }, [refreshMutate]);
return { return {
editors, editors,

View File

@@ -1,66 +1,43 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { useWorktreeBranches } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron'; import type { GitRepoStatus } from '../types';
import type { BranchInfo, GitRepoStatus } from '../types';
const logger = createLogger('Branches');
/**
* 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() { export function useBranches() {
const [branches, setBranches] = useState<BranchInfo[]>([]); const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [branchFilter, setBranchFilter] = useState(''); const [branchFilter, setBranchFilter] = useState('');
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
isGitRepo: true,
hasCommits: true,
});
/** Helper to reset branch state to initial values */ const {
const resetBranchState = useCallback(() => { data: branchData,
setBranches([]); isLoading: isLoadingBranches,
setAheadCount(0); refetch,
setBehindCount(0); } = 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( const fetchBranches = useCallback(
async (worktreePath: string) => { (worktreePath: string) => {
setIsLoadingBranches(true); if (worktreePath === currentWorktreePath) {
try { // Same path - just refetch to get latest data
const api = getElectronAPI(); refetch();
if (!api?.worktree?.listBranches) { } else {
logger.warn('List branches API not available'); // Different path - update the tracked path (triggers new query)
return; setCurrentWorktreePath(worktreePath);
}
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);
} }
}, },
[resetBranchState] [currentWorktreePath, refetch]
); );
const resetBranchFilter = useCallback(() => { const resetBranchFilter = useCallback(() => {

View File

@@ -1,152 +1,64 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger'; import {
import { getElectronAPI } from '@/lib/electron'; useSwitchBranch,
import { toast } from 'sonner'; usePullWorktree,
usePushWorktree,
useOpenInEditor,
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types'; import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions'); export function useWorktreeActions() {
// 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<GitStatusErrorCode, string> = {
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<Array<{ path: string; branch: string }> | undefined>;
fetchBranches: (worktreePath: string) => Promise<void>;
}
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
const [isActivating, setIsActivating] = useState(false); const [isActivating, setIsActivating] = useState(false);
// Use React Query mutations
const switchBranchMutation = useSwitchBranch();
const pullMutation = usePullWorktree();
const pushMutation = usePushWorktree();
const openInEditorMutation = useOpenInEditor();
const handleSwitchBranch = useCallback( const handleSwitchBranch = useCallback(
async (worktree: WorktreeInfo, branchName: string) => { async (worktree: WorktreeInfo, branchName: string) => {
if (isSwitching || branchName === worktree.branch) return; if (switchBranchMutation.isPending || branchName === worktree.branch) return;
setIsSwitching(true); switchBranchMutation.mutate({
try { worktreePath: worktree.path,
const api = getElectronAPI(); branchName,
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);
}
}, },
[isSwitching, fetchWorktrees] [switchBranchMutation]
); );
const handlePull = useCallback( const handlePull = useCallback(
async (worktree: WorktreeInfo) => { async (worktree: WorktreeInfo) => {
if (isPulling) return; if (pullMutation.isPending) return;
setIsPulling(true); pullMutation.mutate(worktree.path);
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);
}
}, },
[isPulling, fetchWorktrees] [pullMutation]
); );
const handlePush = useCallback( const handlePush = useCallback(
async (worktree: WorktreeInfo) => { async (worktree: WorktreeInfo) => {
if (isPushing) return; if (pushMutation.isPending) return;
setIsPushing(true); pushMutation.mutate({
try { worktreePath: worktree.path,
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);
}
}, },
[isPushing, fetchBranches, fetchWorktrees] [pushMutation]
); );
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => { const handleOpenInEditor = useCallback(
try { async (worktree: WorktreeInfo, editorCommand?: string) => {
const api = getElectronAPI(); openInEditorMutation.mutate({
if (!api?.worktree?.openInEditor) { worktreePath: worktree.path,
logger.warn('Open in editor API not available'); editorCommand,
return; });
} },
const result = await api.worktree.openInEditor(worktree.path, editorCommand); [openInEditorMutation]
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);
}
}, []);
return { return {
isPulling, isPulling: pullMutation.isPending,
isPushing, isPushing: pushMutation.isPending,
isSwitching, isSwitching: switchBranchMutation.isPending,
isActivating, isActivating,
setIsActivating, setIsActivating,
handleSwitchBranch, handleSwitchBranch,

View File

@@ -1,12 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/store/app-store'; 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 { pathsEqual } from '@/lib/utils';
import type { WorktreeInfo } from '../types'; import type { WorktreeInfo } from '../types';
const logger = createLogger('Worktrees');
interface UseWorktreesOptions { interface UseWorktreesOptions {
projectPath: string; projectPath: string;
refreshTrigger?: number; refreshTrigger?: number;
@@ -18,59 +17,46 @@ export function useWorktrees({
refreshTrigger = 0, refreshTrigger = 0,
onRemovedWorktrees, onRemovedWorktrees,
}: UseWorktreesOptions) { }: UseWorktreesOptions) {
const [isLoading, setIsLoading] = useState(false); const queryClient = useQueryClient();
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath)); const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree); const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
const setWorktreesInStore = useAppStore((s) => s.setWorktrees); const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees); const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
const fetchWorktrees = useCallback( // Use the React Query hook
async (options?: { silent?: boolean }) => { const { data, isLoading, refetch } = useWorktreesQuery(projectPath);
if (!projectPath) return; const worktrees = (data?.worktrees ?? []) as WorktreeInfo[];
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]
);
// Sync worktrees to Zustand store when they change
useEffect(() => { useEffect(() => {
fetchWorktrees(); if (worktrees.length > 0) {
}, [fetchWorktrees]); setWorktreesInStore(projectPath, worktrees);
}
}, [worktrees, projectPath, setWorktreesInStore]);
// Handle removed worktrees callback when data changes
const prevRemovedWorktreesRef = useRef<string | null>(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(() => { useEffect(() => {
if (refreshTrigger > 0) { if (refreshTrigger > 0) {
fetchWorktrees().then((removedWorktrees) => { // Invalidate and refetch to get fresh data including any removed worktrees
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) { queryClient.invalidateQueries({
onRemovedWorktrees(removedWorktrees); queryKey: queryKeys.worktrees.all(projectPath),
}
}); });
} }
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]); }, [refreshTrigger, projectPath, queryClient]);
// Use a ref to track the current worktree to avoid running validation // 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) // when selection changes (which could cause a race condition with stale worktrees list)
@@ -108,6 +94,14 @@ export function useWorktrees({
[projectPath, setCurrentWorktree] [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 currentWorktreePath = currentWorktree?.path ?? null;
const selectedWorktree = currentWorktreePath const selectedWorktree = currentWorktreePath
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath)) ? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))

View File

@@ -5,6 +5,7 @@ import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query'; import { useIsMobile } from '@/hooks/use-media-query';
import { useWorktreeInitScript } from '@/hooks/queries';
import type { WorktreePanelProps, WorktreeInfo } from './types'; import type { WorktreePanelProps, WorktreeInfo } from './types';
import { import {
useWorktrees, useWorktrees,
@@ -79,42 +80,21 @@ export function WorktreePanel({
handlePull, handlePull,
handlePush, handlePush,
handleOpenInEditor, handleOpenInEditor,
} = useWorktreeActions({ } = useWorktreeActions();
fetchWorktrees,
fetchBranches,
});
const { hasRunningFeatures } = useRunningFeatures({ const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds, runningFeatureIds,
features, features,
}); });
// Track whether init script exists for the project // Check if init script exists for the project using React Query
const [hasInitScript, setHasInitScript] = useState(false); const { data: initScriptData } = useWorktreeInitScript(projectPath);
const hasInitScript = initScriptData?.exists ?? false;
// Log panel state management // Log panel state management
const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null); const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(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(); const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk // Periodic interval check (5 seconds) to detect branch changes on disk