Skip to content

Commit 0e9d19d

Browse files
committed
feat: display real-time clone progress in UI
Adds visual feedback for repository cloning operations: - Button shows current clone stage (e.g., "Receiving objects", "Resolving deltas") - Progress bar underneath button displays percentage completion - Instant UI feedback with zero delay when initiating clone Technical improvements: - Frontend generates cloneId before IPC call to eliminate UI delay - Added --progress flag to git clone for progress output in non-interactive environments - Parse progress from git stderr and update UI in real-time - Clean architecture with frontend-controlled clone state creation
1 parent 2602982 commit 0e9d19d

File tree

9 files changed

+281
-72
lines changed

9 files changed

+281
-72
lines changed

src/main/preload.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
9494
cloneRepository: (
9595
repoUrl: string,
9696
targetPath: string,
97+
cloneId: string,
9798
): Promise<{ cloneId: string }> =>
98-
ipcRenderer.invoke("clone-repository", repoUrl, targetPath),
99+
ipcRenderer.invoke("clone-repository", repoUrl, targetPath, cloneId),
99100
onCloneProgress: (
100101
cloneId: string,
101102
listener: (event: {

src/main/services/git.ts

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ interface ValidationResult {
2626
error?: string;
2727
}
2828

29-
const generateCloneId = () =>
30-
`clone-${Date.now()}-${Math.random().toString(36).substring(7)}`;
31-
3229
const sendCloneProgress = (
3330
win: BrowserWindow,
3431
cloneId: string,
@@ -54,9 +51,16 @@ export const isGitRepository = async (
5451
directoryPath: string,
5552
): Promise<boolean> => {
5653
try {
54+
// Check if it's a git work tree
5755
await execAsync("git rev-parse --is-inside-work-tree", {
5856
cwd: directoryPath,
5957
});
58+
59+
// Also check if there's at least one commit (not an empty/cloning repo)
60+
await execAsync("git rev-parse HEAD", {
61+
cwd: directoryPath,
62+
});
63+
6064
return true;
6165
} catch {
6266
return false;
@@ -274,38 +278,93 @@ export function registerGitIpc(
274278
targetPath: string,
275279
win: BrowserWindow,
276280
): ChildProcess => {
277-
const cloneProcess = exec(`git clone "${repoUrl}" "${targetPath}"`, {
278-
maxBuffer: CLONE_MAX_BUFFER,
279-
});
281+
console.log(`[git clone] Starting clone:`);
282+
console.log(` - Clone ID: ${cloneId}`);
283+
console.log(` - Repository URL: ${repoUrl}`);
284+
console.log(` - Target Path: ${targetPath}`);
285+
286+
// Expand home directory for SSH config path
287+
const homeDir = os.homedir();
288+
const sshConfigPath = path.join(homeDir, ".ssh", "config");
289+
290+
console.log(` - SSH Config Path: ${sshConfigPath}`);
291+
292+
// Use GIT_SSH_COMMAND to ensure SSH uses the config file
293+
const env = {
294+
...process.env,
295+
GIT_SSH_COMMAND: `ssh -F ${sshConfigPath}`,
296+
};
297+
298+
const cloneProcess = exec(
299+
`git clone --progress "${repoUrl}" "${targetPath}"`,
300+
{
301+
maxBuffer: CLONE_MAX_BUFFER,
302+
env,
303+
},
304+
);
280305

281306
sendCloneProgress(win, cloneId, {
282307
status: "cloning",
283308
message: `Cloning ${repoUrl}...`,
284309
});
285310

311+
// Collect all output for debugging
312+
let stdoutData = "";
313+
let stderrData = "";
314+
315+
cloneProcess.stdout?.on("data", (data: Buffer) => {
316+
const text = data.toString();
317+
stdoutData += text;
318+
console.log(`[git clone stdout] ${text}`);
319+
if (activeClones.get(cloneId)) {
320+
sendCloneProgress(win, cloneId, {
321+
status: "cloning",
322+
message: text.trim(),
323+
});
324+
}
325+
});
326+
286327
cloneProcess.stderr?.on("data", (data: Buffer) => {
328+
const text = data.toString();
329+
stderrData += text;
330+
console.log(`[git clone stderr] ${text}`);
331+
287332
if (activeClones.get(cloneId)) {
333+
// Parse progress from git output (e.g., "Receiving objects: 45% (6234/13948)")
334+
const progressMatch = text.match(/(\w+\s+\w+):\s+(\d+)%/);
335+
let progressMessage = text.trim();
336+
337+
if (progressMatch) {
338+
const [, stage, percent] = progressMatch;
339+
progressMessage = `${stage}: ${percent}%`;
340+
}
341+
288342
sendCloneProgress(win, cloneId, {
289343
status: "cloning",
290-
message: data.toString().trim(),
344+
message: progressMessage,
291345
});
292346
}
293347
});
294348

295349
cloneProcess.on("close", (code: number) => {
296350
if (!activeClones.get(cloneId)) return;
297351

352+
console.log(`[git clone] Process closed with code ${code}`);
353+
console.log(`[git clone] stdout: ${stdoutData}`);
354+
console.log(`[git clone] stderr: ${stderrData}`);
355+
298356
const status = code === 0 ? "complete" : "error";
299357
const message =
300358
code === 0
301359
? "Repository cloned successfully"
302-
: `Clone failed with exit code ${code}`;
360+
: `Clone failed with exit code ${code}. stderr: ${stderrData}`;
303361

304362
sendCloneProgress(win, cloneId, { status, message });
305363
activeClones.delete(cloneId);
306364
});
307365

308366
cloneProcess.on("error", (error: Error) => {
367+
console.error(`[git clone] Process error:`, error);
309368
if (activeClones.get(cloneId)) {
310369
sendCloneProgress(win, cloneId, {
311370
status: "error",
@@ -324,8 +383,8 @@ export function registerGitIpc(
324383
_event: IpcMainInvokeEvent,
325384
repoUrl: string,
326385
targetPath: string,
386+
cloneId: string,
327387
): Promise<{ cloneId: string }> => {
328-
const cloneId = generateCloneId();
329388
const win = getMainWindow();
330389

331390
if (!win) throw new Error("Main window not available");

src/renderer/features/task-detail/components/TaskActions.tsx

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { GearIcon, GlobeIcon } from "@radix-ui/react-icons";
2-
import { Button, Flex, IconButton, Tooltip } from "@radix-ui/themes";
2+
import { Button, Flex, IconButton, Progress, Tooltip } from "@radix-ui/themes";
33
import type React from "react";
44

55
interface TaskActionsProps {
66
isRunning: boolean;
77
isCloningRepo: boolean;
8+
cloneProgress: { message: string; percent: number } | null;
89
runMode: "local" | "cloud";
910
onRunTask: () => void;
1011
onCancel: () => void;
@@ -14,13 +15,26 @@ interface TaskActionsProps {
1415
export const TaskActions: React.FC<TaskActionsProps> = ({
1516
isRunning,
1617
isCloningRepo,
18+
cloneProgress,
1719
runMode,
1820
onRunTask,
1921
onCancel,
2022
onRunModeChange,
2123
}) => {
2224
const getRunButtonLabel = () => {
2325
if (isRunning) return "Running...";
26+
if (isCloningRepo && cloneProgress) {
27+
// Extract just the action part (e.g., "Receiving objects" from "Receiving objects: 45% (1234/5678)")
28+
// Handles various git progress formats
29+
const actionMatch = cloneProgress.message.match(
30+
/^(remote:\s*)?(.+?):\s*\d+%/,
31+
);
32+
if (actionMatch) {
33+
return actionMatch[2].trim();
34+
}
35+
// Fallback: if no percentage, return message as-is (e.g., "Cloning into...")
36+
return cloneProgress.message;
37+
}
2438
if (isCloningRepo) return "Cloning...";
2539
if (runMode === "cloud") return "Run (Cloud)";
2640
return "Run (Local)";
@@ -32,29 +46,40 @@ export const TaskActions: React.FC<TaskActionsProps> = ({
3246

3347
return (
3448
<Flex direction="column" gap="3">
35-
<Flex gap="2">
36-
<Button
37-
variant="classic"
38-
onClick={handleRunClick}
39-
disabled={isRunning || isCloningRepo}
40-
size="2"
41-
style={{ flex: 1 }}
42-
>
43-
{getRunButtonLabel()}
44-
</Button>
45-
<Tooltip content="Toggle between Local or Cloud Agent">
46-
<IconButton
47-
size="2"
49+
<Flex direction="column" gap="1" style={{ flex: 1 }}>
50+
<Flex gap="2">
51+
<Button
4852
variant="classic"
49-
color={runMode === "cloud" ? "blue" : "gray"}
53+
onClick={handleRunClick}
5054
disabled={isRunning || isCloningRepo}
51-
onClick={() =>
52-
onRunModeChange(runMode === "local" ? "cloud" : "local")
53-
}
55+
size="2"
56+
style={{ flex: 1 }}
57+
className="truncate"
5458
>
55-
{runMode === "cloud" ? <GlobeIcon /> : <GearIcon />}
56-
</IconButton>
57-
</Tooltip>
59+
<span className="truncate">{getRunButtonLabel()}</span>
60+
</Button>
61+
<Tooltip content="Toggle between Local or Cloud Agent">
62+
<IconButton
63+
size="2"
64+
variant="classic"
65+
color={runMode === "cloud" ? "blue" : "gray"}
66+
disabled={isRunning || isCloningRepo}
67+
onClick={() =>
68+
onRunModeChange(runMode === "local" ? "cloud" : "local")
69+
}
70+
>
71+
{runMode === "cloud" ? <GlobeIcon /> : <GearIcon />}
72+
</IconButton>
73+
</Tooltip>
74+
</Flex>
75+
{/* Progress bar underneath the button */}
76+
{isCloningRepo && cloneProgress && (
77+
<Progress
78+
value={cloneProgress.percent}
79+
size="1"
80+
aria-label={`Clone progress: ${cloneProgress.percent}%`}
81+
/>
82+
)}
5883
</Flex>
5984

6085
{isRunning && (

src/renderer/features/task-detail/components/TaskDetailPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function TaskDetailPanel({ taskId, task }: TaskDetailPanelProps) {
9999
<TaskActions
100100
isRunning={execution.state.isRunning}
101101
isCloningRepo={repository.isCloning}
102+
cloneProgress={taskData.cloneProgress}
102103
runMode={execution.state.runMode}
103104
onRunTask={execution.actions.run}
104105
onCancel={execution.actions.cancel}

src/renderer/features/task-detail/hooks/useTaskData.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) {
1515
const { data: tasks = [] } = useTasks();
1616
const { defaultWorkspace } = useAuthStore();
1717
const getTaskState = useTaskExecutionStore((state) => state.getTaskState);
18-
const initializeRepoPath = useTaskExecutionStore((state) => state.initializeRepoPath);
18+
const initializeRepoPath = useTaskExecutionStore(
19+
(state) => state.initializeRepoPath,
20+
);
1921

2022
const task = useMemo(
2123
() => tasks.find((t) => t.id === taskId) || initialTask,
@@ -50,12 +52,31 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) {
5052
: false,
5153
);
5254

55+
const cloneProgress = cloneStore(
56+
(state) => {
57+
if (!task.repository_config) return null;
58+
const repoKey = `${task.repository_config.organization}/${task.repository_config.repository}`;
59+
const cloneOp = state.getCloneForRepo(repoKey);
60+
if (!cloneOp?.latestMessage) return null;
61+
62+
const percentMatch = cloneOp.latestMessage.match(/(\d+)%/);
63+
const percent = percentMatch ? Number.parseInt(percentMatch[1], 10) : 0;
64+
65+
return {
66+
message: cloneOp.latestMessage,
67+
percent,
68+
};
69+
},
70+
(a, b) => a?.message === b?.message && a?.percent === b?.percent,
71+
);
72+
5373
return {
5474
task,
5575
repoPath: taskState.repoPath,
5676
repoExists: taskState.repoExists,
5777
derivedPath,
5878
isCloning,
79+
cloneProgress,
5980
defaultWorkspace,
6081
};
6182
}

0 commit comments

Comments
 (0)