Skip to content
This repository was archived by the owner on Oct 21, 2026. It is now read-only.
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
104 changes: 104 additions & 0 deletions apps/desktop/src/wsl/DesktopWslEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,30 @@
import { describe, it } from "@effect/vitest";
import { afterAll, expect } from "vite-plus/test";
import * as NodeChildProcess from "node:child_process";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { ChildProcessSpawner } from "effect/process";

import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import {
buildWslRuntimeInstallScript,
buildWslRuntimeInvalidateScript,
buildWslRuntimePruneScript,
buildWslRuntimeProbeScript,
DesktopWslDistroListError,
DesktopWslEnvironment,
formatMissingToolsReason,
layer as wslEnvironmentLayer,
parseNodePath,
parseNodeVersion,
parseResolvedPath,
Expand Down Expand Up @@ -158,6 +166,102 @@ describe("probeWslDistros", () => {
});
});

describe("WSL command deadlines", () => {
const makeControlledEnvironment = Effect.gen(function* () {
const started = yield* Deferred.make<void>();
const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
const released = yield* Deferred.make<void>();
const spawner = ChildProcessSpawner.make(() =>
Effect.acquireRelease(
Deferred.succeed(started, undefined).pipe(
Effect.as(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Deferred.await(exited),
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}),
),
),
() => Deferred.succeed(released, undefined),
),
);
const environment = yield* DesktopWslEnvironment.pipe(
Effect.provide(
wslEnvironmentLayer.pipe(
Layer.provide([
DesktopEnvironment.layer({
dirname: "/repo/apps/desktop",
homeDirectory: "/home/test",
platform: "win32",
processArch: "x64",
appVersion: "1.2.3",
appPath: "/repo",
isPackaged: true,
resourcesPath: "/repo/resources",
runningUnderArm64Translation: false,
}).pipe(Layer.provide([Path.layer, DesktopConfig.layerTest({})])),
FileSystem.layerNoop({ exists: () => Effect.succeed(true) }),
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
]),
),
),
);
return { environment, started, exited, released };
});

it.effect("waits for a cold distro to finish starting after 15 seconds", () =>
Effect.gen(function* () {
const { environment, started, exited, released } = yield* makeControlledEnvironment;
const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped);
yield* Deferred.await(started);
yield* TestClock.adjust(Duration.seconds(15));
expect(yield* Deferred.isDone(released)).toBe(false);

yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0));
yield* Fiber.join(fiber);
expect(yield* Deferred.isDone(released)).toBe(true);
}),
);

it.effect("releases a stalled warmup process at the 30-second deadline", () =>
Effect.gen(function* () {
const { environment, started, released } = yield* makeControlledEnvironment;
const fiber = yield* environment.preWarm("Ubuntu").pipe(Effect.forkScoped);
yield* Deferred.await(started);
yield* TestClock.adjust(Duration.seconds(29));
expect(yield* Deferred.isDone(released)).toBe(false);

yield* TestClock.adjust(Duration.seconds(1));
yield* Fiber.join(fiber);
expect(yield* Deferred.isDone(released)).toBe(true);
}),
);

it.effect("still gives path conversion only 10 seconds and releases its process", () =>
Effect.gen(function* () {
const { environment, started, released } = yield* makeControlledEnvironment;
const fiber = yield* environment
.windowsToWslPath("Ubuntu", "C:\\project")
.pipe(Effect.forkScoped);
yield* Deferred.await(started);
yield* TestClock.adjust(Duration.seconds(9));
expect(yield* Deferred.isDone(released)).toBe(false);

yield* TestClock.adjust(Duration.seconds(1));
expect(yield* Fiber.join(fiber)).toEqual(Option.none());
expect(yield* Deferred.isDone(released)).toBe(true);
}),
);
});

describe("WSL runtime cache", () => {
it.each([
[
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/wsl/DesktopWslEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts";

const PROCESS_TERMINATE_GRACE = Duration.seconds(1);
const LIST_TIMEOUT = Duration.seconds(8);
const PRE_WARM_TIMEOUT = Duration.seconds(10);
// Booting a stopped distro needs more time than converting paths in a running one.
const PRE_WARM_TIMEOUT = Duration.seconds(30);
const WSLPATH_TIMEOUT = Duration.seconds(10);
const PROBE_TIMEOUT = Duration.seconds(10);
const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm
: "You are up to date."}
</Text>
) : null}
{!supportsEnvironmentUpdate(config.environment.capabilities) ? (
{checkedRelease?.targetVersion &&
!supportsEnvironmentUpdate(config.environment.capabilities) ? (
<Text className="text-sm text-foreground-muted">
{capabilities?.serverSelfUpdate === "desktop-managed"
? "Update the desktop app on this machine."
Expand Down
125 changes: 56 additions & 69 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
AndroidSheetHeader,
} from "../../../components/ui/AndroidScreenHeader";
import { AndroidAnchoredMenu } from "../../../components/ui/AndroidAnchoredMenu";
import { ListRow } from "../../../components/ui/ListRow";
import { ListSection } from "../../../components/ui/ListSection";
import { ScreenContent } from "../../../components/ui/ScreenContent";
import { useAdaptiveWorkspaceLayout } from "../../layout/AdaptiveWorkspaceLayout";
import { AppText as Text } from "../../../components/ui/AppText";
Expand All @@ -40,7 +42,7 @@ import { useSelectedThreadGitState } from "../../../state/use-selected-thread-gi
import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
import { vcsEnvironment } from "../../../state/vcs";
import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
import { menuItemIconName, statusSummary } from "./gitSheetComponents";
import {
LinkedPullRequestRow,
linkedPullRequestKey,
Expand Down Expand Up @@ -245,46 +247,34 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
) : undefined
}
>
<View
className={`overflow-hidden bg-card android:rounded-[20px] ios:border ios:border-border ${
isInspector ? "ios:rounded-2xl ios:px-3 ios:py-1" : "ios:rounded-[22px] ios:px-4 ios:py-1"
}`}
>
{sheetMenuItems.map(({ item, disabledReason }, index) => (
<View key={`${item.id}-${item.label}`}>
{index > 0 && Platform.OS !== "android" ? (
<View className="ml-12 h-px bg-border" />
) : null}
<SheetListRow
icon={menuItemIconName(item.icon)}
title={item.label}
subtitle={disabledReason ?? rowStatusDetail(item)}
disabled={item.disabled}
onPress={() => void onPressMenuItem(item)}
/>
</View>
<ListSection>
{sheetMenuItems.map(({ item, disabledReason }) => (
<ListRow
key={`${item.id}-${item.label}`}
icon={menuItemIconName(item.icon)}
label={item.label}
description={disabledReason ?? rowStatusDetail(item)}
disabled={item.disabled}
onPress={() => void onPressMenuItem(item)}
/>
))}
{behindCount > 0 ? (
<>
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
icon="arrow.down.circle"
title="Pull latest"
subtitle={
canWriteSourceControl
? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`
: "This connection cannot change source control."
}
disabled={!canWriteSourceControl || busy || !isRepo}
onPress={() => void gitActions.onPullSelectedThreadBranch()}
/>
</>
<ListRow
icon="arrow.down.circle"
label="Pull latest"
description={
canWriteSourceControl
? `${behindCount} commit${behindCount === 1 ? "" : "s"} behind upstream`
: "This connection cannot change source control."
}
disabled={!canWriteSourceControl || busy || !isRepo}
onPress={() => void gitActions.onPullSelectedThreadBranch()}
/>
) : null}
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
<ListRow
icon="text.bubble"
title="Review changes"
subtitle="Inspect changes, uncommitted edits, and turn diffs"
label="Review changes"
description="Inspect changes, uncommitted edits, and turn diffs"
disabled={busy || !isRepo}
onPress={() => {
const params = { environmentId, threadId };
Expand All @@ -295,11 +285,10 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
);
}}
/>
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
<ListRow
icon="point.topleft.down.curvedto.point.bottomright.up"
title="Branches & worktrees"
subtitle={
label="Branches & worktrees"
description={
canChangeThreadBranch
? "Switch branch, create branch, or move to a worktree"
: "View branches and worktrees"
Expand All @@ -313,39 +302,37 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
}
/>
{linkedPrActions.canLink ? (
<>
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
icon="link"
title="Link pull request"
subtitle="Attach a pull request by URL or number"
onPress={linkedPrActions.promptLink}
/>
</>
<ListRow
icon="link"
label="Link pull request"
description="Attach a pull request by URL or number"
onPress={linkedPrActions.promptLink}
/>
) : null}
</View>
</ListSection>

{linkedPrLines.length > 0 ? (
<View className="gap-2">
<Text className="px-1 text-xs font-t3-bold text-foreground-muted">
Linked pull requests
</Text>
<View className="overflow-hidden bg-card android:rounded-[20px] ios:rounded-2xl ios:border ios:border-border">
{linkedPrLines.map((line, index) => (
<LinkedPullRequestRow
key={linkedPullRequestKey(line)}
line={line}
separator={index < linkedPrLines.length - 1}
surfaceClassName="text-card"
openPullRequest={openPullRequest}
actions={linkedPrActions}
/>
))}
</View>
</View>
<ListSection title="Linked pull requests">
{linkedPrLines.map((line, index) => (
<LinkedPullRequestRow
key={linkedPullRequestKey(line)}
line={line}
separator={index < linkedPrLines.length - 1}
surfaceClassName="text-grouped-card"
openPullRequest={openPullRequest}
actions={linkedPrActions}
/>
))}
</ListSection>
) : null}

{currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
{currentWorktreePath ? (
<ListSection title="Worktree">
<Text selectable textRole="footnote" className="p-4 text-foreground-muted">
{currentWorktreePath}
</Text>
</ListSection>
) : null}
</ScrollView>
);

Expand Down
55 changes: 0 additions & 55 deletions apps/mobile/src/features/threads/git/gitSheetComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,61 +69,6 @@ export function SheetActionButton(props: {
);
}

export function MetaCard(props: { readonly label: string; readonly value: string }) {
return (
<View className="bg-card px-4 py-3 android:rounded-[20px] ios:rounded-[18px] ios:border ios:border-border">
<Text className="text-foreground-muted text-2xs font-t3-bold tracking-[0.9px] uppercase">
{props.label}
</Text>
<Text selectable className="text-foreground text-sm font-medium" numberOfLines={1}>
{props.value}
</Text>
</View>
);
}

export function SheetListRow(props: {
readonly icon: ComponentProps<typeof SymbolView>["name"];
readonly iconTintClassName?: string | undefined;
readonly title: string;
readonly subtitle?: string | null;
readonly disabled?: boolean;
readonly onPress: () => void;
}) {
return (
<Pressable
className="flex-row items-center py-3 disabled:opacity-[0.45] android:min-h-16 android:gap-4 android:px-4 android:active:bg-subtle ios:gap-3 ios:px-1"
disabled={props.disabled}
onPress={props.onPress}
>
<View className="items-center justify-center android:size-6 ios:bg-subtle ios:h-9 ios:w-9 ios:rounded-full">
<SymbolView
name={props.icon}
size={Platform.OS === "android" ? 24 : 16}
tintColorClassName={props.iconTintClassName ?? "accent-icon"}
type="monochrome"
/>
</View>
<View className="flex-1 gap-0.5">
<Text className="text-foreground text-base android:font-t3-medium ios:font-t3-bold">
{props.title}
</Text>
{props.subtitle ? (
<Text className="text-foreground-muted text-xs leading-snug">{props.subtitle}</Text>
) : null}
</View>
{Platform.OS !== "android" ? (
<SymbolView
name="chevron.right"
size={13}
tintColorClassName="accent-icon-subtle"
type="monochrome"
/>
) : null}
</Pressable>
);
}

export function menuItemIconName(
icon: "commit" | "push" | "pr",
): ComponentProps<typeof SymbolView>["name"] {
Expand Down
Loading
Loading