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 { 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<EditorInfo[]>([]);
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,

View File

@@ -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<BranchInfo[]>([]);
const [aheadCount, setAheadCount] = useState(0);
const [behindCount, setBehindCount] = useState(0);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [currentWorktreePath, setCurrentWorktreePath] = useState<string | undefined>();
const [branchFilter, setBranchFilter] = useState('');
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
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(() => {

View File

@@ -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<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);
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,

View File

@@ -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<WorktreeInfo[]>([]);
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<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(() => {
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))

View File

@@ -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<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();
// Periodic interval check (5 seconds) to detect branch changes on disk