diff --git a/src/main/preload.ts b/src/main/preload.ts index 7da3fcd4..da6c98a7 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -94,8 +94,9 @@ contextBridge.exposeInMainWorld("electronAPI", { cloneRepository: ( repoUrl: string, targetPath: string, + cloneId: string, ): Promise<{ cloneId: string }> => - ipcRenderer.invoke("clone-repository", repoUrl, targetPath), + ipcRenderer.invoke("clone-repository", repoUrl, targetPath, cloneId), onCloneProgress: ( cloneId: string, listener: (event: { diff --git a/src/main/services/git.ts b/src/main/services/git.ts index 254cb92c..a5b87202 100644 --- a/src/main/services/git.ts +++ b/src/main/services/git.ts @@ -26,9 +26,6 @@ interface ValidationResult { error?: string; } -const generateCloneId = () => - `clone-${Date.now()}-${Math.random().toString(36).substring(7)}`; - const sendCloneProgress = ( win: BrowserWindow, cloneId: string, @@ -54,9 +51,16 @@ export const isGitRepository = async ( directoryPath: string, ): Promise => { try { + // Check if it's a git work tree await execAsync("git rev-parse --is-inside-work-tree", { cwd: directoryPath, }); + + // Also check if there's at least one commit (not an empty/cloning repo) + await execAsync("git rev-parse HEAD", { + cwd: directoryPath, + }); + return true; } catch { return false; @@ -274,20 +278,61 @@ export function registerGitIpc( targetPath: string, win: BrowserWindow, ): ChildProcess => { - const cloneProcess = exec(`git clone "${repoUrl}" "${targetPath}"`, { - maxBuffer: CLONE_MAX_BUFFER, - }); + // Expand home directory for SSH config path + const homeDir = os.homedir(); + const sshConfigPath = path.join(homeDir, ".ssh", "config"); + + // Use GIT_SSH_COMMAND to ensure SSH uses the config file + const env = { + ...process.env, + GIT_SSH_COMMAND: `ssh -F ${sshConfigPath}`, + }; + + const cloneProcess = exec( + `git clone --progress "${repoUrl}" "${targetPath}"`, + { + maxBuffer: CLONE_MAX_BUFFER, + env, + }, + ); sendCloneProgress(win, cloneId, { status: "cloning", message: `Cloning ${repoUrl}...`, }); + // Collect all output for debugging + let _stdoutData = ""; + let stderrData = ""; + + cloneProcess.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + _stdoutData += text; + if (activeClones.get(cloneId)) { + sendCloneProgress(win, cloneId, { + status: "cloning", + message: text.trim(), + }); + } + }); + cloneProcess.stderr?.on("data", (data: Buffer) => { + const text = data.toString(); + stderrData += text; + if (activeClones.get(cloneId)) { + // Parse progress from git output (e.g., "Receiving objects: 45% (6234/13948)") + const progressMatch = text.match(/(\w+\s+\w+):\s+(\d+)%/); + let progressMessage = text.trim(); + + if (progressMatch) { + const [, stage, percent] = progressMatch; + progressMessage = `${stage}: ${percent}%`; + } + sendCloneProgress(win, cloneId, { status: "cloning", - message: data.toString().trim(), + message: progressMessage, }); } }); @@ -299,13 +344,14 @@ export function registerGitIpc( const message = code === 0 ? "Repository cloned successfully" - : `Clone failed with exit code ${code}`; + : `Clone failed with exit code ${code}. stderr: ${stderrData}`; sendCloneProgress(win, cloneId, { status, message }); activeClones.delete(cloneId); }); cloneProcess.on("error", (error: Error) => { + console.error(`[git clone] Process error:`, error); if (activeClones.get(cloneId)) { sendCloneProgress(win, cloneId, { status: "error", @@ -324,8 +370,8 @@ export function registerGitIpc( _event: IpcMainInvokeEvent, repoUrl: string, targetPath: string, + cloneId: string, ): Promise<{ cloneId: string }> => { - const cloneId = generateCloneId(); const win = getMainWindow(); if (!win) throw new Error("Main window not available"); diff --git a/src/renderer/features/task-detail/components/TaskActions.tsx b/src/renderer/features/task-detail/components/TaskActions.tsx index fdc00496..706669e2 100644 --- a/src/renderer/features/task-detail/components/TaskActions.tsx +++ b/src/renderer/features/task-detail/components/TaskActions.tsx @@ -1,10 +1,11 @@ import { GearIcon, GlobeIcon } from "@radix-ui/react-icons"; -import { Button, Flex, IconButton, Tooltip } from "@radix-ui/themes"; +import { Button, Flex, IconButton, Progress, Tooltip } from "@radix-ui/themes"; import type React from "react"; interface TaskActionsProps { isRunning: boolean; isCloningRepo: boolean; + cloneProgress: { message: string; percent: number } | null; runMode: "local" | "cloud"; onRunTask: () => void; onCancel: () => void; @@ -14,6 +15,7 @@ interface TaskActionsProps { export const TaskActions: React.FC = ({ isRunning, isCloningRepo, + cloneProgress, runMode, onRunTask, onCancel, @@ -21,6 +23,18 @@ export const TaskActions: React.FC = ({ }) => { const getRunButtonLabel = () => { if (isRunning) return "Running..."; + if (isCloningRepo && cloneProgress) { + // Extract just the action part (e.g., "Receiving objects" from "Receiving objects: 45% (1234/5678)") + // Handles various git progress formats + const actionMatch = cloneProgress.message.match( + /^(remote:\s*)?(.+?):\s*\d+%/, + ); + if (actionMatch) { + return actionMatch[2].trim(); + } + // Fallback: if no percentage, return message as-is (e.g., "Cloning into...") + return cloneProgress.message; + } if (isCloningRepo) return "Cloning..."; if (runMode === "cloud") return "Run (Cloud)"; return "Run (Local)"; @@ -32,29 +46,40 @@ export const TaskActions: React.FC = ({ return ( - - - - + + + + + onRunModeChange(runMode === "local" ? "cloud" : "local") + } + > + {runMode === "cloud" ? : } + + + + {/* Progress bar underneath the button */} + {isCloningRepo && cloneProgress && ( + + )} {isRunning && ( diff --git a/src/renderer/features/task-detail/components/TaskDetailPanel.tsx b/src/renderer/features/task-detail/components/TaskDetailPanel.tsx index de728645..23bac6bb 100644 --- a/src/renderer/features/task-detail/components/TaskDetailPanel.tsx +++ b/src/renderer/features/task-detail/components/TaskDetailPanel.tsx @@ -99,6 +99,7 @@ export function TaskDetailPanel({ taskId, task }: TaskDetailPanelProps) { state.getTaskState); - const initializeRepoPath = useTaskExecutionStore((state) => state.initializeRepoPath); + const initializeRepoPath = useTaskExecutionStore( + (state) => state.initializeRepoPath, + ); const task = useMemo( () => tasks.find((t) => t.id === taskId) || initialTask, @@ -50,12 +52,31 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) { : false, ); + const cloneProgress = cloneStore( + (state) => { + if (!task.repository_config) return null; + const repoKey = `${task.repository_config.organization}/${task.repository_config.repository}`; + const cloneOp = state.getCloneForRepo(repoKey); + if (!cloneOp?.latestMessage) return null; + + const percentMatch = cloneOp.latestMessage.match(/(\d+)%/); + const percent = percentMatch ? Number.parseInt(percentMatch[1], 10) : 0; + + return { + message: cloneOp.latestMessage, + percent, + }; + }, + (a, b) => a?.message === b?.message && a?.percent === b?.percent, + ); + return { task, repoPath: taskState.repoPath, repoExists: taskState.repoExists, derivedPath, isCloning, + cloneProgress, defaultWorkspace, }; } diff --git a/src/renderer/features/task-detail/stores/taskExecutionStore.ts b/src/renderer/features/task-detail/stores/taskExecutionStore.ts index 5695b641..d77a7ce6 100644 --- a/src/renderer/features/task-detail/stores/taskExecutionStore.ts +++ b/src/renderer/features/task-detail/stores/taskExecutionStore.ts @@ -74,14 +74,15 @@ async function validateRepositoryAccess( return false; } - // Check if it's a git repository (optional - just for informational purposes) + // Check if it's a valid git repository with actual content const isRepo = await window.electronAPI?.validateRepo(path); if (!isRepo) { addLog({ - type: "token", + type: "error", ts: Date.now(), - content: `Note: Selected folder is not a git repository: ${path}`, + message: `Folder is not a valid git repository: ${path}`, }); + return false; } return true; @@ -484,7 +485,115 @@ export const useTaskExecutionStore = create()( effectiveRepoPath, (log) => store.addLog(taskId, log), ); - if (!isValid) { + + if (!isValid && task.repository_config) { + const repoKey = getRepoKey( + task.repository_config.organization, + task.repository_config.repository, + ); + + store.addLog(taskId, { + type: "token", + ts: Date.now(), + content: `Repository not found at ${effectiveRepoPath}. Cloning ${repoKey}...`, + }); + + // Create clone state before selectRepository to avoid UI delay + const cloneId = `clone-${Date.now()}-${Math.random().toString(36).substring(7)}`; + cloneStore + .getState() + .startClone(cloneId, task.repository_config, effectiveRepoPath); + + const { repositoryWorkspaceStore } = await import( + "@stores/repositoryWorkspaceStore" + ); + + try { + await repositoryWorkspaceStore + .getState() + .selectRepository(task.repository_config, cloneId); + + store.addLog(taskId, { + type: "token", + ts: Date.now(), + content: `Waiting for repository clone to complete...`, + }); + + const maxAttempts = 5; + let attempts = 0; + let repoCloned = false; + let lastProgressMessage = ""; + + while (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Check if clone operation completed + const { operations } = cloneStore.getState(); + const cloneOp = Object.values(operations).find( + (op) => + getRepoKey( + op.repository.organization, + op.repository.repository, + ) === repoKey, + ); + + if (cloneOp?.status === "complete") { + // Clone completed successfully + repoCloned = true; + break; + } + + if (cloneOp?.status === "error") { + throw new Error(cloneOp.error || "Clone failed"); + } + + // Show progress updates every 5 seconds + if (cloneOp && attempts % 5 === 0 && cloneOp.latestMessage) { + const latestMessage = cloneOp.latestMessage; + if (latestMessage !== lastProgressMessage) { + lastProgressMessage = latestMessage; + store.addLog(taskId, { + type: "token", + ts: Date.now(), + content: `Clone progress: ${latestMessage}`, + }); + } + } + + // If no clone operation exists anymore, check if repo is valid + if (!cloneOp) { + const exists = + await window.electronAPI?.validateRepo(effectiveRepoPath); + if (exists) { + repoCloned = true; + break; + } + } + + attempts++; + } + + if (!repoCloned) { + throw new Error("Repository clone timed out after 10 minutes"); + } + + store.addLog(taskId, { + type: "token", + ts: Date.now(), + content: `Repository cloned successfully! Starting task...`, + }); + + // Revalidate the repo + await store.revalidateRepo(taskId); + } catch (error) { + store.addLog(taskId, { + type: "error", + ts: Date.now(), + message: `Failed to clone repository: ${error instanceof Error ? error.message : "Unknown error"}. Please try running the task again after the clone completes.`, + }); + return; + } + } else if (!isValid) { return; } diff --git a/src/renderer/stores/cloneStore.ts b/src/renderer/stores/cloneStore.ts index 1c435e89..66c52bde 100644 --- a/src/renderer/stores/cloneStore.ts +++ b/src/renderer/stores/cloneStore.ts @@ -1,6 +1,5 @@ import { useTaskExecutionStore } from "@features/task-detail/stores/taskExecutionStore"; import type { RepositoryConfig } from "@shared/types"; -import { toast } from "@utils/toast"; import { create } from "zustand"; type CloneStatus = "cloning" | "complete" | "error"; @@ -10,9 +9,8 @@ interface CloneOperation { repository: RepositoryConfig; targetPath: string; status: CloneStatus; - messages: string[]; + latestMessage?: string; error?: string; - toastId?: string | number; unsubscribe?: () => void; } @@ -48,13 +46,7 @@ export const cloneStore = create((set, get) => { }); }; - const handleComplete = ( - cloneId: string, - repoKey: string, - toastId: string | number, - ) => { - toast.success(`${repoKey} cloned successfully`, { id: toastId }); - + const handleComplete = (cloneId: string, _repoKey: string) => { const operation = get().operations[cloneId]; if (operation) { updateTaskRepoExists(operation.targetPath, true); @@ -66,17 +58,7 @@ export const cloneStore = create((set, get) => { ); }; - const handleError = ( - cloneId: string, - repoKey: string, - message: string, - toastId: string | number, - ) => { - toast.error(`Failed to clone ${repoKey}`, { - id: toastId, - description: message, - }); - + const handleError = (cloneId: string, _repoKey: string, _message: string) => { const operation = get().operations[cloneId]; if (operation) { updateTaskRepoExists(operation.targetPath, false); @@ -90,7 +72,6 @@ export const cloneStore = create((set, get) => { startClone: (cloneId, repository, targetPath) => { const repoKey = getRepoKey(repository); - const toastId = toast.loading(`Cloning ${repoKey}`); const unsubscribe = window.electronAPI.onCloneProgress( cloneId, @@ -101,9 +82,9 @@ export const cloneStore = create((set, get) => { if (!operation) return; if (event.status === "complete") { - handleComplete(cloneId, repoKey, operation.toastId!); + handleComplete(cloneId, repoKey); } else if (event.status === "error") { - handleError(cloneId, repoKey, event.message, operation.toastId!); + handleError(cloneId, repoKey, event.message); } }, ); @@ -116,8 +97,7 @@ export const cloneStore = create((set, get) => { repository, targetPath, status: "cloning", - messages: [], - toastId, + latestMessage: `Cloning ${repoKey}...`, unsubscribe, }, }, @@ -135,7 +115,7 @@ export const cloneStore = create((set, get) => { [cloneId]: { ...operation, status, - messages: [...operation.messages, message], + latestMessage: message, error: status === "error" ? message : operation.error, }, }, diff --git a/src/renderer/stores/repositoryWorkspaceStore.ts b/src/renderer/stores/repositoryWorkspaceStore.ts index bd6067aa..73fefd38 100644 --- a/src/renderer/stores/repositoryWorkspaceStore.ts +++ b/src/renderer/stores/repositoryWorkspaceStore.ts @@ -12,7 +12,10 @@ interface RepositoryWorkspaceState { isValidating: boolean; isInitiatingClone: boolean; - selectRepository: (repo: RepositoryConfig) => Promise; + selectRepository: ( + repo: RepositoryConfig, + existingCloneId?: string, + ) => Promise; clearRepository: () => void; validateAndUpdatePath: () => Promise; } @@ -110,6 +113,7 @@ export const repositoryWorkspaceStore = create()( const initiateClone = async ( repo: RepositoryConfig, targetPath: string, + existingCloneId?: string, ) => { const sshCheck = await window.electronAPI.checkSSHAccess(); @@ -122,12 +126,16 @@ export const repositoryWorkspaceStore = create()( return; } + const cloneId = + existingCloneId || + `clone-${Date.now()}-${Math.random().toString(36).substring(7)}`; + + if (!existingCloneId) { + cloneStore.getState().startClone(cloneId, repo, targetPath); + } + const repoUrl = `git@github.com:${getRepoKey(repo)}.git`; - const { cloneId } = await window.electronAPI.cloneRepository( - repoUrl, - targetPath, - ); - cloneStore.getState().startClone(cloneId, repo, targetPath); + await window.electronAPI.cloneRepository(repoUrl, targetPath, cloneId); }; const handleMismatch = async ( @@ -187,11 +195,15 @@ export const repositoryWorkspaceStore = create()( } }, - selectRepository: async (repo: RepositoryConfig) => { + selectRepository: async ( + repo: RepositoryConfig, + existingCloneId?: string, + ) => { const repoKey = `${repo.organization}/${repo.repository}`; const { isCloning } = cloneStore.getState(); - if (isCloning(repoKey)) { + // Skip check if cloneId provided (clone state already created by caller) + if (!existingCloneId && isCloning(repoKey)) { await window.electronAPI.showMessageBox({ type: "warning", title: "Repository cloning", @@ -237,7 +249,7 @@ export const repositoryWorkspaceStore = create()( }); try { - await initiateClone(repo, targetPath); + await initiateClone(repo, targetPath, existingCloneId); startPolling(); } finally { set({ isInitiatingClone: false }); diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index e19c963a..4883d8f4 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -51,6 +51,7 @@ export interface IElectronAPI { cloneRepository: ( repoUrl: string, targetPath: string, + cloneId: string, ) => Promise<{ cloneId: string }>; onCloneProgress: ( cloneId: string,