Skip to content
Closed
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
29 changes: 29 additions & 0 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,35 @@ describe("ElectronMenu", () => {
}).pipe(Effect.provide(TestLayer)),
);

it.effect("inserts a destructive separator when the first destructive item starts the menu", () =>
Effect.gen(function* () {
buildFromTemplateMock.mockImplementation(() => ({
popup: (options: Electron.PopupOptions) => {
options.callback?.();
},
}));

const electronMenu = yield* ElectronMenu.ElectronMenu;
yield* electronMenu.showContextMenu({
window: makeWindow(),
items: [
{ id: "delete-draft", label: "Delete draft", destructive: true },
{ id: "copy", label: "Copy" },
{ id: "delete-file", label: "Delete file", destructive: true },
],
position: Option.none(),
});

const template = buildFromTemplateMock.mock.calls[0]?.[0] as
| Electron.MenuItemConstructorOptions[]
| undefined;
assert.deepEqual(
template?.map((item) => item.type ?? item.label),
["Delete draft", "Copy", "separator", "Delete file"],
);
}).pipe(Effect.provide(TestLayer)),
);

it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
Effect.gen(function* () {
const popupMock = vi.fn();
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { VersionControlRouteScreen } from "./features/version-control/VersionControlRouteScreen";
import { VersionControlDiffRouteScreen } from "./features/version-control/VersionControlDiffRouteScreen";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
Expand Down Expand Up @@ -478,6 +480,8 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"GitCommit",
"GitConfirm",
"GitOverview",
"VersionControl",
"VersionControlDiff",
"NewTaskSheet",
"SettingsLegal",
"SettingsSheet",
Expand Down Expand Up @@ -705,6 +709,22 @@ const RootStackConfig = createNativeStackNavigator({
sheetGrabberVisible: true,
},
}),
VersionControl: createNativeStackScreen({
screen: VersionControlRouteScreen,
linking: `${THREAD_LINKING_PREFIX}/version-control`,
options: {
...GLASS_HEADER_OPTIONS,
presentation: "fullScreenModal",
title: "Version Control",
},
}),
VersionControlDiff: createNativeStackScreen({
screen: VersionControlDiffRouteScreen,
options: {
...SOLID_HEADER_OPTIONS,
title: "Diff",
},
}),
GitCommit: createNativeStackScreen({
screen: GitCommitSheet,
linking: `${THREAD_LINKING_PREFIX}/git/commit`,
Expand Down
41 changes: 24 additions & 17 deletions apps/mobile/src/connection/background-activity-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ function scopeForSubscription(
return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
}

export function retainMobileBackgroundActivityScope(
environmentId: EnvironmentId,
scope: BackgroundScope,
): () => void {
const key = stableScopeKey(environmentId, scope);
const current = retainedScopes.get(key);
if (current) {
current.refCount += 1;
} else {
retainedScopes.set(key, { environmentId, scope, refCount: 1 });
notify();
}
return () => {
const retained = retainedScopes.get(key);
if (!retained) return;
retained.refCount -= 1;
if (retained.refCount <= 0) {
retainedScopes.delete(key);
notify();
}
};
}

export function retainedMobileBackgroundScopes(
environmentId: EnvironmentId,
): ReadonlyArray<BackgroundScope> {
Expand All @@ -64,23 +87,7 @@ export function observeMobileBackgroundActivitySubscription(
if (scope === null) return Effect.succeed(Effect.void);
return Effect.sync(() => {
const environmentId = observation.environmentId as EnvironmentId;
const key = stableScopeKey(environmentId, scope);
const current = retainedScopes.get(key);
if (current) {
current.refCount += 1;
} else {
retainedScopes.set(key, { environmentId, scope, refCount: 1 });
notify();
}
return Effect.sync(() => {
const retained = retainedScopes.get(key);
if (!retained) return;
retained.refCount -= 1;
if (retained.refCount <= 0) {
retainedScopes.delete(key);
notify();
}
});
return Effect.sync(retainMobileBackgroundActivityScope(environmentId, scope));
});
}

Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/connection/background-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
import {
onRetainedMobileBackgroundScopesChange,
observeMobileBackgroundActivitySubscription,
retainMobileBackgroundActivityScope,
retainedMobileBackgroundScopes,
} from "./background-activity-scopes";

Expand Down Expand Up @@ -74,4 +75,14 @@ describe("mobile background activity", () => {
removeListener();
}),
);

it("retains Version Control git-ref demand explicitly", () => {
const environmentId = EnvironmentId.make("mobile-version-control-panel");
const scope = { type: "git-refs" as const, cwd: "/workspace" };
const release = retainMobileBackgroundActivityScope(environmentId, scope);

expect(retainedMobileBackgroundScopes(environmentId)).toEqual([scope]);
release();
expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]);
});
});
10 changes: 10 additions & 0 deletions apps/mobile/src/connection/background-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import {
const REPORT_INTERVAL_MS = 25_000;
const LEASE_TTL_MS = 45_000;
const BASELINE_SCOPES: ReadonlyArray<BackgroundScope> = [{ type: "provider-status" }];
let immediateReporter: (() => Promise<void>) | null = null;

export async function flushMobileBackgroundActivityReport(): Promise<void> {
await immediateReporter?.();
}

// `AppState.currentState` is a loosely typed string that can be unset before
// the first change event; anything outside the known states reports as unknown.
Expand Down Expand Up @@ -85,9 +90,13 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard(
{ concurrency: "unbounded", discard: true },
);
}).pipe(Effect.withSpan("mobile.backgroundActivity.report"));
const runtimeContext = yield* Effect.context<never>();
const runPromise = Effect.runPromiseWith(runtimeContext);
const reportImmediately = () => runPromise(report);

yield* Effect.acquireRelease(
Effect.sync(() => {
immediateReporter = reportImmediately;
const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport);
const subscription = AppState.addEventListener("change", (nextState) => {
appState = nextState;
Expand All @@ -97,6 +106,7 @@ export const mobileBackgroundActivityReporterLayer = Layer.effectDiscard(
}),
({ removeScopeListener, subscription }) =>
Effect.sync(() => {
if (immediateReporter === reportImmediately) immediateReporter = null;
removeScopeListener();
subscription.remove();
}),
Expand Down
28 changes: 28 additions & 0 deletions apps/mobile/src/features/threads/ThreadGitControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,20 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) {
});
}, [environmentId, props.onOpenGitInspector, navigation, threadId]);

const openVersionControl = useCallback(() => {
navigation.navigate("VersionControl", {
environmentId: String(environmentId),
threadId: String(threadId),
});
}, [environmentId, navigation, threadId]);

return {
currentBranchLabel,
isRepo,
openFiles,
openGitInspector,
openReview,
openVersionControl,
quickAction,
quickActionHint,
quickActionIcon,
Expand Down Expand Up @@ -346,6 +354,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
onPress: (): void => void model.runQuickAction(),
type: "action",
},
{
description: "Actionable branches, stashes, and remotes",
disabled: !model.isRepo,
icon: {
name: "point.topleft.down.curvedto.point.bottomright.up",
type: "sfSymbol",
},
label: "Version Control",
onPress: model.openVersionControl,
type: "action",
},
{
description: "Turn diffs and worktree changes",
disabled: !model.isRepo,
Expand Down Expand Up @@ -375,6 +394,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
model.openFiles,
model.openGitInspector,
model.openReview,
model.openVersionControl,
model.quickAction.disabled,
model.quickAction.label,
model.quickActionHint,
Expand Down Expand Up @@ -539,6 +559,14 @@ function threadGitMenuDefinition(
void model.runQuickAction();
},
},
{
id: "git-version-control",
title: "Version Control",
icon: "point.topleft.down.curvedto.point.bottomright.up",
disabled: !model.isRepo,
subtitle: "Actionable branches, stashes, and remotes",
onPress: model.openVersionControl,
},
{
id: "git-review",
title: "Review changes",
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/git/GitBranchesSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) {
accessibilityState={{ selected: branch.current, disabled: busy || disabled }}
disabled={busy || disabled}
onPress={() => {
void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => {
navigation.goBack();
void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => {
if (result !== null) navigation.goBack();
});
}}
>
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
</>
) : null}
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
icon="point.topleft.down.curvedto.point.bottomright.up"
title="Version Control"
subtitle="Actionable branches, selected files, stashes, and remotes"
disabled={busy || !isRepo}
onPress={() =>
navigation.navigate("VersionControl", {
environmentId: String(environmentId),
threadId: String(threadId),
})
}
/>
{Platform.OS !== "android" ? <View className="ml-12 h-px bg-border" /> : null}
<SheetListRow
icon="text.bubble"
title="Review changes"
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ import {
export { snoozeWakeLabel };

/**
* Thread List v2 model, ported from the web sidebar v2
* (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx).
* Thread List v2 model, ported from the web's default sidebar
* (apps/web/src/components/Sidebar.logic.ts + Sidebar.tsx). The pre-v2 sidebar
* implementation now lives in LegacySidebar.tsx.
*
* Four visual states, three colors: color is reserved for "act now"
* (approval), "in motion" (working), and "broken" (failed). Ready is the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { VcsPanelCommitSummary, VcsPanelFileChange } from "@t3tools/contracts";
import { useEffect, useState, type ReactNode } from "react";
import { Pressable } from "react-native";
import { AppText as Text } from "../../components/AppText";
import type { useVersionControlPanelApi } from "./useVersionControlPanelApi";
import { VersionControlList } from "./VersionControlList";

export function VersionControlCommitFiles({
commit,
cwd,
api,
renderFile,
}: {
readonly commit: VcsPanelCommitSummary;
readonly cwd: string;
readonly api: ReturnType<typeof useVersionControlPanelApi>;
readonly renderFile: (file: VcsPanelFileChange) => ReactNode;
}) {
const [files, setFiles] = useState<readonly VcsPanelFileChange[] | null>(null);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!commit.filesDeferred) return;
let active = true;
void api
.commitFiles({ cwd, sha: commit.sha }, attempt > 0)
.then((result) => {
if (active) setFiles(result.files);
})
.catch(() => {
if (active) setError(true);
});
return () => {
active = false;
};
}, [api, cwd, commit.sha, commit.filesDeferred, attempt]);
if (error)
return (
<Pressable
accessibilityRole="button"
onPress={() => {
setError(false);
setFiles(null);
setAttempt((value) => value + 1);
}}
>
<Text>Could not load files. Retry</Text>
</Pressable>
);
const loaded = commit.filesDeferred ? files : commit.files;
if (!loaded) return <Text>Loading files...</Text>;
if (!loaded.length) return <Text>No changed files.</Text>;
return <VersionControlList items={loaded} getKey={(file) => file.path} renderItem={renderFile} />;
}
Loading
Loading