Skip to content

Commit 2b8b6c9

Browse files
authored
feat: add worktree provisioning view (#1300)
replace the long loading times for worktree creation with a view which displays the git stream of setting up a worktree, including any hooks that get triggered by that command
1 parent c999682 commit 2b8b6c9

19 files changed

Lines changed: 398 additions & 103 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
id = "a5fe304a-8007-4435-951f-fcc982247457" # DO NOT EDIT MANUALLY
2+
version = 1
3+
4+
name = "twig"
5+
6+
[setup]
7+
script = "pnpm install"

apps/code/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { NotificationService } from "../services/notification/service";
3030
import { OAuthService } from "../services/oauth/service";
3131
import { PosthogPluginService } from "../services/posthog-plugin/service";
3232
import { ProcessTrackingService } from "../services/process-tracking/service";
33+
import { ProvisioningService } from "../services/provisioning/service";
3334
import { settingsStore } from "../services/settingsStore";
3435
import { ShellService } from "../services/shell/service";
3536
import { SleepService } from "../services/sleep/service";
@@ -61,6 +62,7 @@ container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService);
6162
container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
6263
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
6364
container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService);
65+
container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService);
6466

6567
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
6668
container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService);

apps/code/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,6 @@ export const MAIN_TOKENS = Object.freeze({
4949
TaskLinkService: Symbol.for("Main.TaskLinkService"),
5050
WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"),
5151
EnvironmentService: Symbol.for("Main.EnvironmentService"),
52+
ProvisioningService: Symbol.for("Main.ProvisioningService"),
5253
WorkspaceService: Symbol.for("Main.WorkspaceService"),
5354
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { injectable } from "inversify";
2+
import { TypedEventEmitter } from "../../utils/typed-event-emitter";
3+
4+
export const ProvisioningEvent = {
5+
Output: "output",
6+
} as const;
7+
8+
export interface ProvisioningOutputPayload {
9+
taskId: string;
10+
data: string;
11+
}
12+
13+
export interface ProvisioningServiceEvents {
14+
[ProvisioningEvent.Output]: ProvisioningOutputPayload;
15+
}
16+
17+
@injectable()
18+
export class ProvisioningService extends TypedEventEmitter<ProvisioningServiceEvents> {
19+
emitOutput(taskId: string, data: string): void {
20+
this.emit(ProvisioningEvent.Output, { taskId, data });
21+
}
22+
}

apps/code/src/main/services/workspace/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const worktreeInfoSchema = z.object({
1111
branchName: z.string().nullable(),
1212
baseBranch: z.string(),
1313
createdAt: z.string(),
14+
output: z.string().optional(),
1415
});
1516

1617
export const workspaceInfoSchema = z.object({

apps/code/src/main/services/workspace/service.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { FileWatcherService } from "../file-watcher/service";
2828
import type { FocusService } from "../focus/service";
2929
import { FocusServiceEvent } from "../focus/service";
3030
import type { ProcessTrackingService } from "../process-tracking/service";
31+
import type { ProvisioningService } from "../provisioning/service";
3132
import { getWorktreeLocation } from "../settingsStore";
3233
import type { SuspensionService } from "../suspension/service.js";
3334
import type {
@@ -128,6 +129,9 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
128129
@inject(MAIN_TOKENS.SuspensionService)
129130
private suspensionService!: SuspensionService;
130131

132+
@inject(MAIN_TOKENS.ProvisioningService)
133+
private provisioningService!: ProvisioningService;
134+
131135
private creatingWorkspaces = new Map<string, Promise<WorkspaceInfo>>();
132136
private branchWatcherInitialized = false;
133137

@@ -448,12 +452,17 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
448452
const selectedBranch = branch ?? defaultBranch;
449453
const isTrunkSelected = selectedBranch === defaultBranch;
450454

455+
const onOutput = (data: string) => {
456+
this.provisioningService.emitOutput(taskId, data);
457+
};
458+
451459
if (isTrunkSelected) {
452460
log.info(
453461
`Trunk branch selected (${defaultBranch}), creating detached worktree`,
454462
);
455463
worktree = await worktreeManager.createWorktree({
456464
baseBranch: defaultBranch,
465+
onOutput,
457466
});
458467
log.info(
459468
`Created detached worktree from trunk: ${worktree.worktreeName} at ${worktree.worktreePath}`,
@@ -463,10 +472,11 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
463472
`Non-trunk branch selected (${selectedBranch}), attempting checkout`,
464473
);
465474
try {
466-
worktree =
467-
await worktreeManager.createWorktreeForExistingBranch(
468-
selectedBranch,
469-
);
475+
worktree = await worktreeManager.createWorktreeForExistingBranch(
476+
selectedBranch,
477+
undefined,
478+
{ onOutput },
479+
);
470480
log.info(
471481
`Created worktree with branch checkout: ${worktree.worktreeName} at ${worktree.worktreePath} (branch: ${selectedBranch})`,
472482
);
@@ -481,6 +491,7 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
481491
);
482492
worktree = await worktreeManager.createWorktree({
483493
baseBranch: selectedBranch,
494+
onOutput,
484495
});
485496
log.info(
486497
`Created detached worktree from occupied branch: ${worktree.worktreeName} at ${worktree.worktreePath}`,

apps/code/src/main/trpc/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { notificationRouter } from "./routers/notification";
2222
import { oauthRouter } from "./routers/oauth";
2323
import { osRouter } from "./routers/os";
2424
import { processTrackingRouter } from "./routers/process-tracking";
25+
import { provisioningRouter } from "./routers/provisioning";
2526
import { secureStoreRouter } from "./routers/secure-store";
2627
import { shellRouter } from "./routers/shell";
2728
import { skillsRouter } from "./routers/skills";
@@ -57,6 +58,7 @@ export const trpcRouter = router({
5758
logs: logsRouter,
5859
os: osRouter,
5960
processTracking: processTrackingRouter,
61+
provisioning: provisioningRouter,
6062
sleep: sleepRouter,
6163
suspension: suspensionRouter,
6264
secureStore: secureStoreRouter,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { container } from "../../di/container";
2+
import { MAIN_TOKENS } from "../../di/tokens";
3+
import {
4+
ProvisioningEvent,
5+
type ProvisioningService,
6+
} from "../../services/provisioning/service";
7+
import { publicProcedure, router } from "../trpc";
8+
9+
const getService = () =>
10+
container.get<ProvisioningService>(MAIN_TOKENS.ProvisioningService);
11+
12+
export const provisioningRouter = router({
13+
onOutput: publicProcedure.subscription(async function* (opts) {
14+
const service = getService();
15+
for await (const data of service.toIterable(ProvisioningEvent.Output, {
16+
signal: opts.signal,
17+
})) {
18+
yield data;
19+
}
20+
}),
21+
});

apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ export function EnvironmentSelector({
3232
const selectedEnvironment = environments.find((env) => env.id === value);
3333
const displayText = selectedEnvironment?.name ?? "No environment";
3434

35+
const NONE_VALUE = "__none__";
36+
3537
const handleChange = (newValue: string) => {
36-
onChange(newValue || null);
38+
onChange(newValue === NONE_VALUE ? null : newValue || null);
3739
setOpen(false);
3840
};
3941

@@ -70,6 +72,9 @@ export function EnvironmentSelector({
7072
<Combobox.Empty>No environments found.</Combobox.Empty>
7173

7274
<Combobox.Group heading="Environments">
75+
<Combobox.Item key={NONE_VALUE} value={NONE_VALUE}>
76+
No environment
77+
</Combobox.Item>
7378
{environments.map((env) => (
7479
<Combobox.Item
7580
key={env.id}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { BackgroundWrapper } from "@components/BackgroundWrapper";
2+
import { Box, Flex, Spinner, Text } from "@radix-ui/themes";
3+
import { useTRPC } from "@renderer/trpc/client";
4+
import { useSubscription } from "@trpc/tanstack-react-query";
5+
import { useEffect, useRef, useState } from "react";
6+
7+
interface ProvisioningViewProps {
8+
taskId: string;
9+
}
10+
11+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is required to strip ANSI sequences
12+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
13+
14+
function stripAnsi(text: string): string {
15+
return text.replace(ANSI_RE, "");
16+
}
17+
18+
function processOutput(lines: string[], chunk: string): string[] {
19+
const next = [...lines];
20+
const parts = chunk.split("\n");
21+
22+
for (let i = 0; i < parts.length; i++) {
23+
const part = parts[i];
24+
const crSegments = part.split("\r");
25+
const lastSegment = crSegments[crSegments.length - 1];
26+
27+
if (i === 0 && next.length > 0) {
28+
if (crSegments.length > 1) {
29+
next[next.length - 1] = lastSegment;
30+
} else {
31+
next[next.length - 1] += lastSegment;
32+
}
33+
} else {
34+
next.push(lastSegment);
35+
}
36+
}
37+
38+
return next;
39+
}
40+
41+
export function ProvisioningView({ taskId }: ProvisioningViewProps) {
42+
const trpc = useTRPC();
43+
const [lines, setLines] = useState<string[]>([]);
44+
const scrollRef = useRef<HTMLPreElement>(null);
45+
46+
useSubscription(
47+
trpc.provisioning.onOutput.subscriptionOptions(undefined, {
48+
onData: (data) => {
49+
if (data.taskId !== taskId) return;
50+
setLines((prev) => processOutput(prev, stripAnsi(data.data)));
51+
},
52+
}),
53+
);
54+
55+
useEffect(() => {
56+
const el = scrollRef.current;
57+
if (el) {
58+
el.scrollTop = el.scrollHeight;
59+
}
60+
}, []);
61+
62+
return (
63+
<BackgroundWrapper>
64+
<Flex direction="column" height="100%" p="3" gap="2">
65+
<Flex align="center" gap="2">
66+
<Spinner size="1" />
67+
<Text size="1" weight="medium">
68+
Setting up worktree...
69+
</Text>
70+
</Flex>
71+
<Box
72+
style={{
73+
flex: 1,
74+
minHeight: 0,
75+
borderRadius: "var(--radius-2)",
76+
background: "var(--color-surface)",
77+
border: "1px solid var(--gray-a5)",
78+
}}
79+
>
80+
<pre
81+
ref={scrollRef}
82+
style={{
83+
margin: 0,
84+
padding: "var(--space-2)",
85+
height: "100%",
86+
overflow: "auto",
87+
fontSize: "var(--font-size-1)",
88+
lineHeight: "var(--line-height-2)",
89+
fontFamily: "var(--code-font-family)",
90+
whiteSpace: "pre-wrap",
91+
wordBreak: "break-all",
92+
color: "var(--gray-12)",
93+
}}
94+
>
95+
{lines.join("\n")}
96+
</pre>
97+
</Box>
98+
</Flex>
99+
</BackgroundWrapper>
100+
);
101+
}

0 commit comments

Comments
 (0)