Merge branch 'main' into dev

This commit is contained in:
Mohamed Boudra
2026-04-10 20:59:14 +07:00
23 changed files with 947 additions and 275 deletions
+11 -1
View File
@@ -14,7 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { THEME_TO_UNISTYLES } from "@/styles/theme";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -360,6 +360,8 @@ interface AppContainerProps {
chromeEnabled?: boolean;
}
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
function AppContainer({
children,
selectedAgentId,
@@ -367,6 +369,7 @@ function AppContainer({
}: AppContainerProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
@@ -375,6 +378,12 @@ function AppContainer({
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
@@ -415,6 +424,7 @@ function AppContainer({
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
});
const containerStyle = useMemo(
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
pull: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
@@ -56,87 +62,99 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
}
describe("git-actions-policy", () => {
it("keeps the secondary menu order stable while the primary action changes", () => {
const noPrActions = buildGitActions(createInput());
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
}),
);
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(noPrActions.primary).toBeNull();
expect(withPrActions.primary?.id).toBe("push");
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
"merge-branch",
"pr",
"merge-from-base",
"push",
]);
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
});
it("disables hidden-before actions with explanations instead", () => {
const actions = buildGitActions(createInput());
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
expect(actionById.get("push")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.get("pr")).toMatchObject({
label: "Create PR",
disabled: true,
description: "Branch has no commits ahead of main",
});
expect(actionById.get("merge-branch")).toMatchObject({
disabled: true,
description: "No commits to merge into main",
});
expect(actionById.get("merge-from-base")).toMatchObject({
disabled: true,
description: "No remote configured",
});
expect(actionById.has("archive-worktree")).toBe(false);
});
it("keeps the current primary action visible in the menu", () => {
it("prioritizes pull when the branch is behind origin", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 1,
}),
);
const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({
disabled: false,
unavailableMessage: "Push isn't available yet because there are newer changes to bring in first",
});
});
it("shows update-from-base only on feature branches that are behind the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
behindBaseCount: 3,
}),
);
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(updateAction).toMatchObject({
label: "Update from main",
disabled: false,
unavailableMessage: undefined,
});
});
it("uses a clear sentence when pull is unavailable", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const pullAction = actions.secondary.find((action) => action.id === "pull");
expect(pullAction).toMatchObject({
disabled: false,
unavailableMessage: "Pull isn't available because this branch is already up to date",
});
});
it("keeps update-from-base off the base branch entirely", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
});
it("keeps feature branch actions available off the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 1,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
}),
);
expect(actions.primary?.id).toBe("pr");
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
});
it("disables sync on the base branch when already up to date", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
}),
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"merge-from-base",
"merge-branch",
"pr",
]);
expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(
true,
);
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(syncAction).toMatchObject({
label: "Sync",
disabled: true,
description: "Already up to date",
});
});
it("only shows archive worktree for paseo worktrees", () => {
+125 -59
View File
@@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "pull"
| "push"
| "pr"
| "merge-branch"
@@ -17,7 +18,7 @@ export interface GitAction {
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
unavailableMessage?: string;
icon?: ReactElement;
handler: () => void;
}
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime.commit.handler,
});
allActions.set("pull", {
id: "pull",
label: "Pull",
pendingLabel: "Pulling...",
successLabel: "Pulled",
disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
handler: input.runtime.pull.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled || !input.hasRemote,
disabled: input.runtime.push.disabled,
status: input.runtime.push.status,
description: input.hasRemote ? undefined : "No remote configured",
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
@@ -93,25 +108,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled:
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input),
unavailableMessage:
input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
disabled: input.runtime["merge-from-base"].disabled,
status: input.runtime["merge-from-base"].status,
description: getMergeFromBaseDescription(input),
unavailableMessage:
input.runtime["merge-from-base"].disabled
? undefined
: getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
@@ -121,21 +136,32 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
const secondaryIds = [...REMOTE_ACTION_IDS];
if (!input.isOnBaseBranch) {
secondaryIds.push(...FEATURE_ACTION_IDS);
}
if (input.isPaseoOwnedWorktree) {
secondary.push(allActions.get("archive-worktree")!);
secondaryIds.push("archive-worktree");
}
return { primary, secondary, menu: [] };
return {
primary,
secondary: secondaryIds.map((id) => allActions.get(id)!),
menu: [],
};
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
@@ -145,20 +171,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.hasUncommittedChanges) {
return "commit";
}
if (input.aheadOfOrigin > 0 && input.hasRemote) {
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
}
return null;
@@ -171,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
@@ -184,65 +212,103 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
description: getCreatePrDescription(input),
unavailableMessage:
input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
return (
input.hasRemote &&
!input.hasUncommittedChanges &&
input.behindOfOrigin > 0
);
}
function canPush(input: BuildGitActionsInput): boolean {
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
}
function canMergeBranch(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.aheadCount > 0
);
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
return false;
}
if (!input.isOnBaseBranch) {
return true;
}
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.behindBaseCount > 0
);
}
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0) {
return "Pull isn't available because this branch is already up to date";
}
return undefined;
}
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Push isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin > 0) {
return "Push isn't available yet because there are newer changes to bring in first";
}
if (input.aheadOfOrigin === 0) {
return "Push isn't available because there is nothing new to send";
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "GitHub features unavailable";
return "Create PR isn't available right now because GitHub isn't connected";
}
if (input.aheadCount === 0) {
return `Branch has no commits ahead of ${input.baseRefLabel}`;
return "Create PR isn't available because this branch doesn't have any new commits yet";
}
return undefined;
}
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
return "Merge isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
return "Merge isn't available while you have local changes so commit or stash them first";
}
if (input.aheadCount === 0) {
return `No commits to merge into ${input.baseRefLabel}`;
return "Merge isn't available because this branch doesn't have anything new to merge yet";
}
return undefined;
}
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Base ref unavailable";
return "Update isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Requires clean working tree";
return "Update isn't available while you have local changes so commit or stash them first";
}
if (!input.isOnBaseBranch) {
return undefined;
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
if (input.behindBaseCount === 0) {
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
}
return undefined;
}
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, MoreVertical } from "lucide-react-native";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
return action.label;
}, []);
const handleActionSelect = useCallback(
(action: GitAction) => {
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: <Info size={16} color={theme.colors.foreground} />,
});
return;
}
action.handler();
},
[theme.colors.foreground, toast],
);
return (
<View style={styles.row}>
{gitActions.primary ? (
@@ -73,7 +89,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
const needsSeparator =
action.id === "merge-from-base" || action.id === "archive-worktree";
return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
@@ -86,6 +103,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
: undefined
}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
@@ -94,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
action.id === "pr" &&
action.label === "View PR"
}
description={action.description}
onSelect={action.handler}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
@@ -125,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={false}
onSelect={action.handler}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
+94 -37
View File
@@ -29,6 +29,7 @@ import {
Archive,
ChevronDown,
Columns2,
Download,
GitBranch,
GitCommitHorizontal,
GitMerge,
@@ -81,6 +82,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { useToast } from "@/contexts/toast-context";
import {
formatDiffContentText,
formatDiffGutterText,
@@ -607,12 +609,12 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
@@ -896,6 +898,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
@@ -913,83 +918,123 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
});
}, [runCommit, serverId, cwd]);
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
})
.catch((err) => {
toastActionError(err, "Failed to commit");
});
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runPull({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled");
})
.catch((err) => {
toastActionError(err, "Failed to pull");
});
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
});
}, [runPush, serverId, cwd]);
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
})
.catch((err) => {
toastActionError(err, "Failed to push");
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
toastActionError(err, "Failed to merge");
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
}, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
toast.error("Worktree path unavailable");
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
toastActionError(err, "Failed to archive worktree");
});
}, [runArchiveWorktree, router, serverId, cwd, status]);
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
@@ -1041,6 +1086,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
: "Unknown";
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => {
@@ -1066,6 +1112,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
(postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending";
const pullDisabled = actionsDisabled || pullStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
@@ -1172,6 +1219,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
@@ -1183,6 +1231,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
},
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
handler: handlePull,
},
push: {
disabled: pushDisabled,
status: pushStatus,
@@ -1227,6 +1281,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
hasPullRequest,
prStatus?.url,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
@@ -1237,18 +1292,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pullDisabled,
pushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus,
pullStatus,
pushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePull,
handlePush,
handleCreatePr,
handleMergeBranch,
@@ -1458,7 +1516,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</View>
) : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>
+62 -37
View File
@@ -5,8 +5,8 @@ import {
useState,
useEffect,
useRef,
useSyncExternalStore,
type Dispatch,
type ReactElement,
type RefObject,
type SetStateAction,
} from "react";
@@ -44,8 +44,8 @@ import {
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import {
HEADER_INNER_HEIGHT,
@@ -69,18 +69,12 @@ interface LeftSidebarProps {
selectedAgentId?: string;
}
interface HostOption {
id: string;
label: string;
description: string;
}
interface SidebarSharedProps {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
activeHostStatusColor: string;
hostOptions: HostOption[];
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
@@ -95,6 +89,12 @@ interface SidebarSharedProps {
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleSettings: () => void;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
}
interface MobileSidebarProps extends SidebarSharedProps {
@@ -124,28 +124,6 @@ export const LeftSidebar = memo(function LeftSidebar({
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join("|"),
() =>
daemons
.map(
(daemon) =>
`${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
}`,
)
.join("|"),
);
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => {
if (daemons.length === 0) {
@@ -165,8 +143,9 @@ export const LeftSidebar = memo(function LeftSidebar({
const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
? (activeHostSnapshot?.connectionStatus ?? "connecting")
: "idle";
const activeHostStatusColor =
activeHostStatus === "online"
@@ -179,11 +158,25 @@ export const LeftSidebar = memo(function LeftSidebar({
daemons.map((daemon) => ({
id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
),
})),
[daemons, runtime, runtimeConnectionStatusSignature],
[daemons],
);
const renderHostOption = useCallback(
({ option, selected, active, onPress }: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<HostSwitchOption
serverId={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
/>
),
[],
);
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
@@ -276,6 +269,7 @@ export const LeftSidebar = memo(function LeftSidebar({
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
};
if (isCompactLayout) {
@@ -305,6 +299,33 @@ export const LeftSidebar = memo(function LeftSidebar({
);
});
function HostSwitchOption({
serverId,
label,
selected,
active,
onPress,
}: {
serverId: string;
label: string;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const snapshot = useHostRuntimeSnapshot(serverId);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
return (
<ComboboxItem
label={label}
description={formatConnectionStatus(connectionStatus)}
selected={selected}
active={active}
onPress={onPress}
/>
);
}
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles();
const pathname = usePathname();
@@ -361,6 +382,7 @@ function MobileSidebar({
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
handleOpenProject,
handleSettings,
insetsTop,
@@ -606,6 +628,7 @@ function MobileSidebar({
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
@@ -639,6 +662,7 @@ function DesktopSidebar({
toggleProjectCollapsed,
handleRefresh,
handleHostSelect,
renderHostOption,
handleOpenProject,
handleSettings,
insetsTop,
@@ -789,6 +813,7 @@ function DesktopSidebar({
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
+41 -8
View File
@@ -47,6 +47,7 @@ export function useToastHost(): {
toast: ToastState | null;
dismiss: () => void;
} {
const { theme } = useUnistyles();
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
@@ -84,11 +85,11 @@ export function useToastHost(): {
copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", {
variant: "success",
icon: <CheckCircle2 size={18} />,
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
}),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}),
[show],
[show, theme.colors.foreground],
);
const dismiss = useCallback(() => {
@@ -113,6 +114,8 @@ export function ToastViewport({
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissDeadlineRef = useRef<number | null>(null);
const remainingDurationRef = useRef(0);
const clearTimer = useCallback(() => {
if (timeoutRef.current) {
@@ -143,9 +146,39 @@ export function ToastViewport({
});
}, [clearTimer, onDismiss, opacity, translateY]);
const scheduleDismiss = useCallback(
(durationMs: number) => {
clearTimer();
const nextDurationMs = Math.max(0, durationMs);
remainingDurationRef.current = nextDurationMs;
dismissDeadlineRef.current = Date.now() + nextDurationMs;
timeoutRef.current = setTimeout(() => {
animateOut();
}, nextDurationMs);
},
[animateOut, clearTimer],
);
const pauseDismiss = useCallback(() => {
if (dismissDeadlineRef.current !== null) {
remainingDurationRef.current = Math.max(0, dismissDeadlineRef.current - Date.now());
}
dismissDeadlineRef.current = null;
clearTimer();
}, [clearTimer]);
const resumeDismiss = useCallback(() => {
if (!toast) {
return;
}
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
}, [scheduleDismiss, toast]);
useEffect(() => {
if (!toast) {
clearTimer();
dismissDeadlineRef.current = null;
remainingDurationRef.current = 0;
opacity.setValue(0);
translateY.setValue(-8);
return;
@@ -170,14 +203,13 @@ export function ToastViewport({
}),
]).start();
timeoutRef.current = setTimeout(() => {
animateOut();
}, toast.durationMs);
remainingDurationRef.current = toast.durationMs;
scheduleDismiss(toast.durationMs);
return () => {
clearTimer();
};
}, [animateOut, clearTimer, opacity, toast, translateY]);
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
if (!toast) {
return null;
@@ -202,6 +234,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
@@ -219,7 +253,6 @@ export function ToastViewport({
<Text
testID="app-toast-message"
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
numberOfLines={2}
>
{toast.content}
</Text>
@@ -255,7 +288,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.full,
borderRadius: theme.borderRadius["2xl"],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
@@ -460,6 +460,7 @@ export function DropdownMenuItem({
description,
onSelect,
disabled,
muted = false,
destructive,
selected,
showSelectedCheck = false,
@@ -477,6 +478,7 @@ export function DropdownMenuItem({
description?: string;
onSelect?: () => void;
disabled?: boolean;
muted?: boolean;
destructive?: boolean;
selected?: boolean;
showSelectedCheck?: boolean;
@@ -550,6 +552,7 @@ export function DropdownMenuItem({
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
]}
@@ -568,6 +571,7 @@ export function DropdownMenuItem({
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
muted && !isDisabled ? styles.itemTextMuted : null,
]}
>
{label}
@@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({
itemDisabled: {
opacity: 0.5,
},
itemMuted: {
opacity: 0.72,
},
itemText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
itemTextMuted: {
color: theme.colors.foregroundMuted,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
+19 -17
View File
@@ -1,8 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
import { useSessionStore } from "@/stores/session-store";
import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -37,21 +43,6 @@ function deriveFaviconStatus(
return "none";
}
function deriveMacDockBadgeCount(
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): number | undefined {
const attentionCount = agents.filter(
(agent) =>
(agent.pendingPermissionCount ?? 0) > 0 ||
(agent.requiresAttention && agent.attentionReason === "finished"),
).length;
if (attentionCount > 0) {
return attentionCount;
}
return undefined;
}
function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) {
@@ -112,6 +103,17 @@ async function updateMacDockBadge(count?: number) {
export function useFaviconStatus() {
const { agents } = useAggregatedAgents();
const workspaceStatuses = useSessionStore(
useShallow((state) => {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
}),
);
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -135,10 +137,10 @@ export function useFaviconStatus() {
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
const dockBadgeCount = deriveMacDockBadgeCount(agents);
const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(dockBadgeCount);
}
}, [agents, colorScheme]);
}, [agents, colorScheme, workspaceStatuses]);
}
+95 -38
View File
@@ -7,6 +7,7 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -19,6 +20,7 @@ interface UseGitActionsInput {
cwd: string;
icons: {
commit: ReactElement;
pull: ReactElement;
push: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
@@ -31,13 +33,12 @@ interface UseGitActionsInput {
interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
actionError: string | null;
isGit: boolean;
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const router = useRouter();
const [actionError, setActionError] = useState<string | null>(null);
const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -102,6 +103,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
@@ -119,88 +123,129 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
// Handlers
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
});
}, [runCommit, serverId, cwd]);
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
})
.catch((err) => {
toastActionError(err, "Failed to commit");
});
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runPull({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled");
})
.catch((err) => {
toastActionError(err, "Failed to pull");
});
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
});
}, [runPush, serverId, cwd]);
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
})
.catch((err) => {
toastActionError(err, "Failed to push");
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
toastActionError(err, "Failed to merge");
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
}, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
toast.error("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
toast.error("Worktree path unavailable");
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
toastActionError(err, "Failed to archive worktree");
});
}, [runArchiveWorktree, router, serverId, cwd, status]);
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
// Derived state
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => {
@@ -220,6 +265,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
(postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending";
const pullDisabled = actionsDisabled || pullStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
@@ -247,6 +293,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
@@ -258,6 +305,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.commit,
handler: handleCommit,
},
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: icons.pull,
handler: handlePull,
},
push: {
disabled: pushDisabled,
status: pushStatus,
@@ -302,6 +355,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasPullRequest,
prStatus?.url,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
@@ -312,18 +366,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pullDisabled,
pushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus,
pullStatus,
pushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePull,
handlePush,
handleCreatePr,
handleMergeBranch,
@@ -333,5 +390,5 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRef,
]);
return { gitActions, branchLabel, actionError, isGit };
return { gitActions, branchLabel, isGit };
}
@@ -35,6 +35,7 @@ export function useKeyboardShortcuts({
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
}: {
enabled: boolean;
isMobile: boolean;
@@ -43,6 +44,7 @@ export function useKeyboardShortcuts({
toggleFileExplorer?: () => void;
toggleBothSidebars?: () => void;
toggleFocusMode?: () => void;
cycleTheme?: () => void;
}) {
const pathname = usePathname();
const hosts = useHosts();
@@ -267,6 +269,11 @@ export function useKeyboardShortcuts({
toggleFocusMode();
}
return true;
case "theme.cycle":
if (cycleTheme) {
cycleTheme();
}
return true;
case "command-center.toggle": {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
@@ -413,6 +420,7 @@ export function useKeyboardShortcuts({
};
}, [
bindings,
cycleTheme,
enabled,
isMobile,
openProjectPickerAction,
+1
View File
@@ -43,6 +43,7 @@ export type KeyboardActionId =
| "worktree.new"
| "worktree.archive"
| "view.toggle.focus"
| "theme.cycle"
| "message-input.action";
export type KeyboardShortcutPayload =
@@ -744,6 +744,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Theme cycling ---
{
id: "theme-cycle-cmd-shift-t-mac",
action: "theme.cycle",
combo: "Cmd+Alt+T",
when: { mac: true, commandCenter: false },
help: {
id: "cycle-theme",
section: "panels",
label: "Cycle theme",
keys: ["mod", "alt", "T"],
},
},
{
id: "theme-cycle-ctrl-alt-t-non-mac",
action: "theme.cycle",
combo: "Ctrl+Alt+T",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "cycle-theme",
section: "panels",
label: "Cycle theme",
keys: ["mod", "alt", "T"],
},
},
// --- Message input ---
{
id: "message-input-voice-toggle-cmd-shift-d-mac",
@@ -1,6 +1,13 @@
import { useMemo } from "react";
import { useUnistyles } from "react-native-unistyles";
import { Archive, GitCommitHorizontal, GitMerge, RefreshCcw, Upload } from "lucide-react-native";
import {
Archive,
Download,
GitCommitHorizontal,
GitMerge,
RefreshCcw,
Upload,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions";
@@ -16,6 +23,7 @@ export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps)
const icons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
@@ -8,6 +8,7 @@ export type CheckoutGitActionStatus = "idle" | "pending" | "success";
export type CheckoutGitAsyncActionId =
| "commit"
| "pull"
| "push"
| "create-pr"
| "merge-branch"
@@ -139,6 +140,7 @@ interface CheckoutGitActionsStoreState {
}) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
@@ -223,6 +225,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
pull: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "pull",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPull(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
push: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
isWorkspaceActionableForDesktopBadge,
} from "./desktop-badge-state";
describe("desktop-badge-state", () => {
it("treats attention-requiring workspace statuses as actionable", () => {
expect(isWorkspaceActionableForDesktopBadge("attention")).toBe(true);
expect(isWorkspaceActionableForDesktopBadge("needs_input")).toBe(true);
expect(isWorkspaceActionableForDesktopBadge("failed")).toBe(true);
});
it("ignores running and done workspace statuses", () => {
expect(isWorkspaceActionableForDesktopBadge("running")).toBe(false);
expect(isWorkspaceActionableForDesktopBadge("done")).toBe(false);
});
it("returns undefined when no visible workspaces need attention", () => {
expect(deriveMacDockBadgeCountFromWorkspaceStatuses(["done", "running"])).toBeUndefined();
});
it("counts only actionable visible workspaces", () => {
expect(
deriveMacDockBadgeCountFromWorkspaceStatuses([
"done",
"attention",
"running",
"needs_input",
"failed",
]),
).toBe(3);
});
});
@@ -0,0 +1,16 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
export function isWorkspaceActionableForDesktopBadge(
status: DesktopBadgeWorkspaceStatus,
): boolean {
return status === "attention" || status === "needs_input" || status === "failed";
}
export function deriveMacDockBadgeCountFromWorkspaceStatuses(
statuses: readonly DesktopBadgeWorkspaceStatus[],
): number | undefined {
const actionableCount = statuses.filter(isWorkspaceActionableForDesktopBadge).length;
return actionableCount > 0 ? actionableCount : undefined;
}
@@ -911,6 +911,61 @@ describe("DaemonClient", () => {
});
});
test("requests checkout pull via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutPull("/tmp/project", "req-pull");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout_pull_request";
cwd: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout_pull_request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.requestId).toBe("req-pull");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout_pull_response",
payload: {
cwd: "/tmp/project",
requestId: "req-pull",
success: true,
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
requestId: "req-pull",
success: true,
error: null,
});
});
test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -25,6 +25,7 @@ import type {
CheckoutCommitResponse,
CheckoutMergeResponse,
CheckoutMergeFromBaseResponse,
CheckoutPullResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
@@ -225,6 +226,7 @@ type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
@@ -2385,6 +2387,18 @@ export class DaemonClient {
});
}
async checkoutPull(cwd: string, requestId?: string): Promise<CheckoutPullPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_pull_request",
cwd,
},
responseType: "checkout_pull_response",
timeout: 60000,
});
}
async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
+36
View File
@@ -148,6 +148,7 @@ import {
commitChanges,
mergeToBase,
mergeFromBase,
pullCurrentBranch,
pushCurrentBranch,
createPullRequest,
getPullRequestStatus,
@@ -1685,6 +1686,10 @@ export class Session {
await this.handleCheckoutMergeFromBaseRequest(msg);
break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "checkout_push_request":
await this.handleCheckoutPushRequest(msg);
break;
@@ -4706,6 +4711,37 @@ export class Session {
}
}
private async handleCheckoutPullRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pull_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
await pullCurrentBranch(cwd);
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPushRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> {
+20
View File
@@ -1038,6 +1038,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutPullRequestSchema = z.object({
type: z.literal("checkout_pull_request"),
cwd: z.string(),
requestId: z.string(),
});
export const CheckoutPushRequestSchema = z.object({
type: z.literal("checkout_push_request"),
cwd: z.string(),
@@ -1464,6 +1470,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema,
CheckoutPullRequestSchema,
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
@@ -2326,6 +2333,16 @@ export const CheckoutMergeFromBaseResponseSchema = z.object({
}),
});
export const CheckoutPullResponseSchema = z.object({
type: z.literal("checkout_pull_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const CheckoutPushResponseSchema = z.object({
type: z.literal("checkout_push_response"),
payload: z.object({
@@ -2825,6 +2842,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema,
CheckoutPullResponseSchema,
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
@@ -3054,6 +3072,8 @@ export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>;
export type CheckoutPullRequest = z.infer<typeof CheckoutPullRequestSchema>;
export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>;
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import {
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
@@ -30,6 +31,7 @@ import {
MergeConflictError,
MergeFromBaseConflictError,
NotGitRepoError,
pullCurrentBranch,
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
@@ -558,6 +560,85 @@ const x = 1;
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
});
it("pulls the current branch from origin", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
writeFileSync(join(otherClone, "pulled.txt"), "remote\n");
execSync("git add pulled.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone });
const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim();
execSync("git push", { cwd: otherClone });
await pullCurrentBranch(repoDir);
execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir });
expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n");
});
it("aborts pull on merge conflicts and leaves no merge in progress", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
execSync("git add conflict.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local conflict commit'", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
execSync("git add conflict.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote conflict commit'", { cwd: otherClone });
execSync("git push", { cwd: otherClone });
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
expect(porcelain).toBe("");
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
});
it("aborts pull on rebase conflicts and leaves no rebase in progress", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
execSync("git push -u origin main", { cwd: repoDir });
execSync("git config pull.rebase true", { cwd: repoDir });
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
execSync("git add conflict.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { cwd: repoDir });
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
execSync("git config user.name 'Test'", { cwd: otherClone });
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
execSync("git add conflict.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote rebase conflict commit'", {
cwd: otherClone,
});
execSync("git push", { cwd: otherClone });
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: repoDir }).toString().trim();
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
expect(porcelain).toBe("");
expect(existsSync(join(gitDir, "rebase-merge"))).toBe(false);
expect(existsSync(join(gitDir, "rebase-apply"))).toBe(false);
});
it("pushes the current branch to origin", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
+63 -3
View File
@@ -2,7 +2,7 @@ import { exec, execFile } from "child_process";
import { spawnProcess } from "./spawn.js";
import { promisify } from "util";
import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs";
import { existsSync, realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import { z } from "zod";
@@ -862,6 +862,46 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
return url !== null;
}
async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const gitDir = stdout.trim();
return gitDir.length > 0 ? gitDir : null;
} catch {
return null;
}
}
async function abortGitPullConflictState(cwd: string): Promise<void> {
const gitDir = await resolveAbsoluteGitDir(cwd);
if (!gitDir) {
return;
}
const mergeHeadPath = resolve(gitDir, "MERGE_HEAD");
const rebaseMergePath = resolve(gitDir, "rebase-merge");
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
if (existsSync(mergeHeadPath)) {
try {
await execAsync("git merge --abort", { cwd });
} catch {
// ignore
}
}
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
try {
await execAsync("git rebase --abort", { cwd });
} catch {
// ignore
}
}
}
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
@@ -1555,10 +1595,12 @@ export async function getCheckoutDiff(
continue;
}
// `git diff -w --name-status` can still surface paths whose only changes were
// whitespace. Skip those empty tracked entries so callers don't see phantom files.
// `git diff -w --name-status` can still report a modified path even when the
// whitespace-filtered patch and numstat are both empty. Skip emitting a
// structured placeholder in that case so whitespace-only edits truly disappear.
if (
ignoreWhitespace &&
!trackedDiffTruncated &&
change.status.startsWith("M") &&
(!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0))
) {
@@ -1859,6 +1901,24 @@ export async function mergeFromBase(
}
}
export async function pullCurrentBranch(cwd: string): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch || currentBranch === "HEAD") {
throw new Error("Unable to determine current branch for pull");
}
const hasRemote = await hasOriginRemote(cwd);
if (!hasRemote) {
throw new Error("Remote 'origin' is not configured.");
}
try {
await execAsync("git pull", { cwd });
} catch (error) {
await abortGitPullConflictState(cwd);
throw error;
}
}
export async function pushCurrentBranch(cwd: string): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);