Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
64 changes: 55 additions & 9 deletions src/main/services/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -54,9 +51,16 @@ export const isGitRepository = async (
directoryPath: string,
): Promise<boolean> => {
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;
Expand Down Expand Up @@ -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,
});
}
});
Expand All @@ -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",
Expand All @@ -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");
Expand Down
67 changes: 46 additions & 21 deletions src/renderer/features/task-detail/components/TaskActions.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,13 +15,26 @@ interface TaskActionsProps {
export const TaskActions: React.FC<TaskActionsProps> = ({
isRunning,
isCloningRepo,
cloneProgress,
runMode,
onRunTask,
onCancel,
onRunModeChange,
}) => {
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)";
Expand All @@ -32,29 +46,40 @@ export const TaskActions: React.FC<TaskActionsProps> = ({

return (
<Flex direction="column" gap="3">
<Flex gap="2">
<Button
variant="classic"
onClick={handleRunClick}
disabled={isRunning || isCloningRepo}
size="2"
style={{ flex: 1 }}
>
{getRunButtonLabel()}
</Button>
<Tooltip content="Toggle between Local or Cloud Agent">
<IconButton
size="2"
<Flex direction="column" gap="1" style={{ flex: 1 }}>
<Flex gap="2">
<Button
variant="classic"
color={runMode === "cloud" ? "blue" : "gray"}
onClick={handleRunClick}
disabled={isRunning || isCloningRepo}
onClick={() =>
onRunModeChange(runMode === "local" ? "cloud" : "local")
}
size="2"
style={{ flex: 1 }}
className="truncate"
>
{runMode === "cloud" ? <GlobeIcon /> : <GearIcon />}
</IconButton>
</Tooltip>
<span className="truncate">{getRunButtonLabel()}</span>
</Button>
<Tooltip content="Toggle between Local or Cloud Agent">
<IconButton
size="2"
variant="classic"
color={runMode === "cloud" ? "blue" : "gray"}
disabled={isRunning || isCloningRepo}
onClick={() =>
onRunModeChange(runMode === "local" ? "cloud" : "local")
}
>
{runMode === "cloud" ? <GlobeIcon /> : <GearIcon />}
</IconButton>
</Tooltip>
</Flex>
{/* Progress bar underneath the button */}
{isCloningRepo && cloneProgress && (
<Progress
value={cloneProgress.percent}
size="1"
aria-label={`Clone progress: ${cloneProgress.percent}%`}
/>
)}
</Flex>

{isRunning && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function TaskDetailPanel({ taskId, task }: TaskDetailPanelProps) {
<TaskActions
isRunning={execution.state.isRunning}
isCloningRepo={repository.isCloning}
cloneProgress={taskData.cloneProgress}
runMode={execution.state.runMode}
onRunTask={execution.actions.run}
onCancel={execution.actions.cancel}
Expand Down
23 changes: 22 additions & 1 deletion src/renderer/features/task-detail/hooks/useTaskData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) {
const { data: tasks = [] } = useTasks();
const { defaultWorkspace } = useAuthStore();
const getTaskState = useTaskExecutionStore((state) => state.getTaskState);
const initializeRepoPath = useTaskExecutionStore((state) => state.initializeRepoPath);
const initializeRepoPath = useTaskExecutionStore(
(state) => state.initializeRepoPath,
);

const task = useMemo(
() => tasks.find((t) => t.id === taskId) || initialTask,
Expand Down Expand Up @@ -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,
};
}
Loading
Loading