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 { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context"; import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings"; 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 { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
@@ -360,6 +360,8 @@ interface AppContainerProps {
chromeEnabled?: boolean; chromeEnabled?: boolean;
} }
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
function AppContainer({ function AppContainer({
children, children,
selectedAgentId, selectedAgentId,
@@ -367,6 +369,7 @@ function AppContainer({
}: AppContainerProps) { }: AppContainerProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemons = useHosts(); const daemons = useHosts();
const { settings, updateSettings } = useAppSettings();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
@@ -375,6 +378,12 @@ function AppContainer({
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth); 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 isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
@@ -415,6 +424,7 @@ function AppContainer({
toggleFileExplorer, toggleFileExplorer,
toggleBothSidebars, toggleBothSidebars,
toggleFocusMode, toggleFocusMode,
cycleTheme,
}); });
const containerStyle = useMemo( const containerStyle = useMemo(
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
baseRefAvailable: true, baseRefAvailable: true,
baseRefLabel: "main", baseRefLabel: "main",
aheadCount: 0, aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0, aheadOfOrigin: 0,
behindOfOrigin: 0, behindOfOrigin: 0,
shouldPromoteArchive: false, shouldPromoteArchive: false,
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle", status: "idle",
handler: () => undefined, handler: () => undefined,
}, },
pull: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: { push: {
disabled: false, disabled: false,
status: "idle", status: "idle",
@@ -56,87 +62,99 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
} }
describe("git-actions-policy", () => { describe("git-actions-policy", () => {
it("keeps the secondary menu order stable while the primary action changes", () => { it("shows only remote sync actions on the base branch", () => {
const noPrActions = buildGitActions(createInput()); const actions = buildGitActions(createInput({ hasRemote: true }));
const withPrActions = buildGitActions(
createInput({
hasRemote: true,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/123",
aheadCount: 3,
aheadOfOrigin: 2,
shipDefault: "pr",
}),
);
expect(noPrActions.primary).toBeNull(); expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
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",
]);
}); });
it("disables hidden-before actions with explanations instead", () => { it("prioritizes pull when the branch is behind origin", () => {
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", () => {
const actions = buildGitActions( const actions = buildGitActions(
createInput({ createInput({
hasRemote: true, 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, hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456", pullRequestUrl: "https://example.com/pr/456",
}), }),
); );
expect(actions.primary?.id).toBe("pr"); expect(actions.secondary.map((action) => action.id)).toEqual([
expect( "pull",
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), "push",
).toBe(true); "merge-from-base",
}); "merge-branch",
"pr",
it("disables sync on the base branch when already up to date", () => { ]);
const actions = buildGitActions( expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(
createInput({ true,
hasRemote: 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", () => { 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 = export type GitActionId =
| "commit" | "commit"
| "pull"
| "push" | "push"
| "pr" | "pr"
| "merge-branch" | "merge-branch"
@@ -17,7 +18,7 @@ export interface GitAction {
successLabel: string; successLabel: string;
disabled: boolean; disabled: boolean;
status: ActionStatus; status: ActionStatus;
description?: string; unavailableMessage?: string;
icon?: ReactElement; icon?: ReactElement;
handler: () => void; handler: () => void;
} }
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
baseRefAvailable: boolean; baseRefAvailable: boolean;
baseRefLabel: string; baseRefLabel: string;
aheadCount: number; aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number; aheadOfOrigin: number;
behindOfOrigin: number; behindOfOrigin: number;
shouldPromoteArchive: boolean; shouldPromoteArchive: boolean;
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
runtime: Record<GitActionId, GitActionRuntimeState>; 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 { export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) { if (!input.isGit) {
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime.commit.handler, 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", { allActions.set("push", {
id: "push", id: "push",
label: "Push", label: "Push",
pendingLabel: "Pushing...", pendingLabel: "Pushing...",
successLabel: "Pushed", successLabel: "Pushed",
disabled: input.runtime.push.disabled || !input.hasRemote, disabled: input.runtime.push.disabled,
status: input.runtime.push.status, 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, icon: input.runtime.push.icon,
handler: input.runtime.push.handler, handler: input.runtime.push.handler,
}); });
@@ -93,25 +108,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: `Merge into ${input.baseRefLabel}`, label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...", pendingLabel: "Merging...",
successLabel: "Merged", successLabel: "Merged",
disabled: disabled: input.runtime["merge-branch"].disabled,
input.runtime["merge-branch"].disabled ||
!input.baseRefAvailable ||
input.hasUncommittedChanges ||
input.aheadCount === 0,
status: input.runtime["merge-branch"].status, status: input.runtime["merge-branch"].status,
description: getMergeBranchDescription(input), unavailableMessage:
input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon, icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler, handler: input.runtime["merge-branch"].handler,
}); });
allActions.set("merge-from-base", { allActions.set("merge-from-base", {
id: "merge-from-base", id: "merge-from-base",
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`, label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...", pendingLabel: "Updating...",
successLabel: "Updated", 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, 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, icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler, handler: input.runtime["merge-from-base"].handler,
}); });
@@ -121,21 +136,32 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
label: "Archive worktree", label: "Archive worktree",
pendingLabel: "Archiving...", pendingLabel: "Archiving...",
successLabel: "Archived", successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree, disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status, 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, icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler, handler: input.runtime["archive-worktree"].handler,
}); });
const primaryActionId = getPrimaryActionId(input); const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null; 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) { 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 { function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
@@ -145,20 +171,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.hasUncommittedChanges) { if (input.hasUncommittedChanges) {
return "commit"; return "commit";
} }
if (input.aheadOfOrigin > 0 && input.hasRemote) { if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push"; return "push";
} }
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) { if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr"; return "pr";
} }
if ( if (!input.isOnBaseBranch && input.aheadCount > 0) {
input.isOnBaseBranch &&
input.hasRemote &&
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
) {
return "merge-from-base";
}
if (input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr"; return input.shipDefault === "merge" ? "merge-branch" : "pr";
} }
return null; return null;
@@ -171,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "View PR", label: "View PR",
pendingLabel: "View PR", pendingLabel: "View PR",
successLabel: "View PR", successLabel: "View PR",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled, disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status, 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, icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler, handler: input.runtime.pr.handler,
}; };
@@ -184,65 +212,103 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
label: "Create PR", label: "Create PR",
pendingLabel: "Creating PR...", pendingLabel: "Creating PR...",
successLabel: "PR Created", successLabel: "PR Created",
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0, disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status, status: input.runtime.pr.status,
description: getCreatePrDescription(input), unavailableMessage:
input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon, icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler, 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 { function canMergeFromBase(input: BuildGitActionsInput): boolean {
if (!input.baseRefAvailable || input.hasUncommittedChanges) { return (
return false; !input.isOnBaseBranch &&
} input.baseRefAvailable &&
if (!input.isOnBaseBranch) { !input.hasUncommittedChanges &&
return true; input.behindBaseCount > 0
} );
if (!input.hasRemote) {
return false;
}
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 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) { if (!input.githubFeaturesEnabled) {
return "GitHub features unavailable"; return "Create PR isn't available right now because GitHub isn't connected";
} }
if (input.aheadCount === 0) { 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; return undefined;
} }
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined { function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) { if (!input.baseRefAvailable) {
return "Base ref unavailable"; return "Merge isn't available because we couldn't determine the base branch";
} }
if (input.hasUncommittedChanges) { 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) { 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; return undefined;
} }
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined { function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) { if (!input.baseRefAvailable) {
return "Base ref unavailable"; return "Update isn't available because we couldn't determine the base branch";
} }
if (input.hasUncommittedChanges) { 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) { if (input.behindBaseCount === 0) {
return undefined; return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
}
if (!input.hasRemote) {
return "No remote configured";
}
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
return "Already up to date";
} }
return undefined; return undefined;
} }
@@ -1,7 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native"; import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, MoreVertical } from "lucide-react-native"; import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy"; import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps { interface GitActionsSplitButtonProps {
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) { export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => { const getActionDisplayLabel = useCallback((action: GitAction): string => {
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
return action.label; 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 ( return (
<View style={styles.row}> <View style={styles.row}>
{gitActions.primary ? ( {gitActions.primary ? (
@@ -73,7 +89,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu"> <DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => { {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 ( return (
<View key={action.id}> <View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null} {needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
@@ -86,6 +103,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
: undefined : undefined
} }
disabled={action.disabled} disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status} status={action.status}
pendingLabel={action.pendingLabel} pendingLabel={action.pendingLabel}
successLabel={action.successLabel} successLabel={action.successLabel}
@@ -94,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
action.id === "pr" && action.id === "pr" &&
action.label === "View PR" action.label === "View PR"
} }
description={action.description} onSelect={() => handleActionSelect(action)}
onSelect={action.handler}
> >
{action.label} {action.label}
</DropdownMenuItem> </DropdownMenuItem>
@@ -125,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
testID={`changes-menu-${action.id}`} testID={`changes-menu-${action.id}`}
leading={action.icon} leading={action.icon}
disabled={action.disabled} disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status} status={action.status}
pendingLabel={action.pendingLabel} pendingLabel={action.pendingLabel}
successLabel={action.successLabel} successLabel={action.successLabel}
closeOnSelect={false} closeOnSelect={false}
onSelect={action.handler} onSelect={() => handleActionSelect(action)}
> >
{action.label} {action.label}
</DropdownMenuItem> </DropdownMenuItem>
+94 -37
View File
@@ -29,6 +29,7 @@ import {
Archive, Archive,
ChevronDown, ChevronDown,
Columns2, Columns2,
Download,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
GitMerge, GitMerge,
@@ -81,6 +82,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { useToast } from "@/contexts/toast-context";
import { import {
formatDiffContentText, formatDiffContentText,
formatDiffGutterText, formatDiffGutterText,
@@ -607,12 +609,12 @@ type DiffFlatItem =
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
@@ -896,6 +898,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const commitStatus = useCheckoutGitActionsStore((state) => const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }), state.getStatus({ serverId, cwd, actionId: "commit" }),
); );
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) => const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }), 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 runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push); const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); 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(() => { const handleCommit = useCallback(() => {
setActionError(null); void runCommit({ serverId, cwd })
void runCommit({ serverId, cwd }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to commit"; toastActionSuccess("Committed");
setActionError(message); })
}); .catch((err) => {
}, [runCommit, serverId, cwd]); 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(() => { const handlePush = useCallback(() => {
setActionError(null); void runPush({ serverId, cwd })
void runPush({ serverId, cwd }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to push"; toastActionSuccess("Pushed");
setActionError(message); })
}); .catch((err) => {
}, [runPush, serverId, cwd]); toastActionError(err, "Failed to push");
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => { const handleCreatePr = useCallback(() => {
void persistShipDefault("pr"); void persistShipDefault("pr");
setActionError(null); void runCreatePr({ serverId, cwd })
void runCreatePr({ serverId, cwd }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to create PR"; toastActionSuccess("PR created");
setActionError(message); })
}); .catch((err) => {
}, [persistShipDefault, runCreatePr, serverId, cwd]); toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => { const handleMergeBranch = useCallback(() => {
if (!baseRef) { if (!baseRef) {
setActionError("Base ref unavailable"); toast.error("Base ref unavailable");
return; return;
} }
void persistShipDefault("merge"); void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }) void runMergeBranch({ serverId, cwd, baseRef })
.then(() => { .then(() => {
setPostShipArchiveSuggested(true); setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge"; toastActionError(err, "Failed to merge");
setActionError(message);
}); });
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); }, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => { const handleMergeFromBase = useCallback(() => {
if (!baseRef) { if (!baseRef) {
setActionError("Base ref unavailable"); toast.error("Base ref unavailable");
return; return;
} }
setActionError(null); void runMergeFromBase({ serverId, cwd, baseRef })
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to merge from base"; toastActionSuccess("Updated");
setActionError(message); })
}); .catch((err) => {
}, [baseRef, runMergeFromBase, serverId, cwd]); toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd; const worktreePath = status?.cwd;
if (!worktreePath) { if (!worktreePath) {
setActionError("Worktree path unavailable"); toast.error("Worktree path unavailable");
return; return;
} }
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath }) void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => { .then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree"; toastActionError(err, "Failed to archive worktree");
setActionError(message);
}); });
}, [runArchiveWorktree, router, serverId, cwd, status]); }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
const renderFlatItem = useCallback( const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => { ({ item }: { item: DiffFlatItem }) => {
@@ -1041,6 +1086,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
: "Unknown"; : "Unknown";
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => { const baseRefLabel = useMemo(() => {
@@ -1066,6 +1112,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
(postShipArchiveSuggested || isMergedPullRequest); (postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending"; const commitDisabled = actionsDisabled || commitStatus === "pending";
const pullDisabled = actionsDisabled || pullStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
@@ -1172,6 +1219,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
baseRefAvailable: Boolean(baseRef), baseRefAvailable: Boolean(baseRef),
baseRefLabel, baseRefLabel,
aheadCount, aheadCount,
behindBaseCount,
aheadOfOrigin, aheadOfOrigin,
behindOfOrigin, behindOfOrigin,
shouldPromoteArchive, shouldPromoteArchive,
@@ -1183,6 +1231,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />, icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit, handler: handleCommit,
}, },
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
handler: handlePull,
},
push: { push: {
disabled: pushDisabled, disabled: pushDisabled,
status: pushStatus, status: pushStatus,
@@ -1227,6 +1281,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
hasPullRequest, hasPullRequest,
prStatus?.url, prStatus?.url,
aheadCount, aheadCount,
behindBaseCount,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
githubFeaturesEnabled, githubFeaturesEnabled,
@@ -1237,18 +1292,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
baseRefLabel, baseRefLabel,
shouldPromoteArchive, shouldPromoteArchive,
commitDisabled, commitDisabled,
pullDisabled,
pushDisabled, pushDisabled,
prDisabled, prDisabled,
mergeDisabled, mergeDisabled,
mergeFromBaseDisabled, mergeFromBaseDisabled,
archiveDisabled, archiveDisabled,
commitStatus, commitStatus,
pullStatus,
pushStatus, pushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
archiveStatus, archiveStatus,
handleCommit, handleCommit,
handlePull,
handlePush, handlePush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
@@ -1458,7 +1516,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</View> </View>
) : null} ) : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null} {prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}> <View style={styles.diffContainer}>
+62 -37
View File
@@ -5,8 +5,8 @@ import {
useState, useState,
useEffect, useEffect,
useRef, useRef,
useSyncExternalStore,
type Dispatch, type Dispatch,
type ReactElement,
type RefObject, type RefObject,
type SetStateAction, type SetStateAction,
} from "react"; } from "react";
@@ -44,8 +44,8 @@ import {
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
import { import {
HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT,
@@ -69,18 +69,12 @@ interface LeftSidebarProps {
selectedAgentId?: string; selectedAgentId?: string;
} }
interface HostOption {
id: string;
label: string;
description: string;
}
interface SidebarSharedProps { interface SidebarSharedProps {
theme: SidebarTheme; theme: SidebarTheme;
activeServerId: string | null; activeServerId: string | null;
activeHostLabel: string; activeHostLabel: string;
activeHostStatusColor: string; activeHostStatusColor: string;
hostOptions: HostOption[]; hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>; hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean; isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>; setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
@@ -95,6 +89,12 @@ interface SidebarSharedProps {
handleHostSelect: (nextServerId: string) => void; handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void; handleOpenProject: () => void;
handleSettings: () => void; handleSettings: () => void;
renderHostOption: (input: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
} }
interface MobileSidebarProps extends SidebarSharedProps { interface MobileSidebarProps extends SidebarSharedProps {
@@ -124,28 +124,6 @@ export const LeftSidebar = memo(function LeftSidebar({
const closeToAgent = usePanelStore((state) => state.closeToAgent); const closeToAgent = usePanelStore((state) => state.closeToAgent);
const pathname = usePathname(); const pathname = usePathname();
const daemons = useHosts(); 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 activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
const activeDaemon = useMemo(() => { const activeDaemon = useMemo(() => {
if (daemons.length === 0) { if (daemons.length === 0) {
@@ -165,8 +143,9 @@ export const LeftSidebar = memo(function LeftSidebar({
const trimmed = activeDaemon.label?.trim(); const trimmed = activeDaemon.label?.trim();
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
}, [activeDaemon]); }, [activeDaemon]);
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
const activeHostStatus = activeServerId const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting") ? (activeHostSnapshot?.connectionStatus ?? "connecting")
: "idle"; : "idle";
const activeHostStatusColor = const activeHostStatusColor =
activeHostStatus === "online" activeHostStatus === "online"
@@ -179,11 +158,25 @@ export const LeftSidebar = memo(function LeftSidebar({
daemons.map((daemon) => ({ daemons.map((daemon) => ({
id: daemon.serverId, id: daemon.serverId,
label: daemon.label?.trim() || 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 hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
@@ -276,6 +269,7 @@ export const LeftSidebar = memo(function LeftSidebar({
toggleProjectCollapsed, toggleProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
renderHostOption,
}; };
if (isCompactLayout) { 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 }) { function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const pathname = usePathname();
@@ -361,6 +382,7 @@ function MobileSidebar({
toggleProjectCollapsed, toggleProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
renderHostOption,
handleOpenProject, handleOpenProject,
handleSettings, handleSettings,
insetsTop, insetsTop,
@@ -606,6 +628,7 @@ function MobileSidebar({
options={hostOptions} options={hostOptions}
value={activeServerId ?? ""} value={activeServerId ?? ""}
onSelect={handleHostSelect} onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false} searchable={false}
title="Switch host" title="Switch host"
searchPlaceholder="Search hosts..." searchPlaceholder="Search hosts..."
@@ -639,6 +662,7 @@ function DesktopSidebar({
toggleProjectCollapsed, toggleProjectCollapsed,
handleRefresh, handleRefresh,
handleHostSelect, handleHostSelect,
renderHostOption,
handleOpenProject, handleOpenProject,
handleSettings, handleSettings,
insetsTop, insetsTop,
@@ -789,6 +813,7 @@ function DesktopSidebar({
options={hostOptions} options={hostOptions}
value={activeServerId ?? ""} value={activeServerId ?? ""}
onSelect={handleHostSelect} onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false} searchable={false}
title="Switch host" title="Switch host"
searchPlaceholder="Search hosts..." searchPlaceholder="Search hosts..."
+41 -8
View File
@@ -47,6 +47,7 @@ export function useToastHost(): {
toast: ToastState | null; toast: ToastState | null;
dismiss: () => void; dismiss: () => void;
} { } {
const { theme } = useUnistyles();
const [toast, setToast] = useState<ToastState | null>(null); const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0); const idRef = useRef(0);
@@ -84,11 +85,11 @@ export function useToastHost(): {
copied: (label?: string) => copied: (label?: string) =>
show(label ? `Copied ${label}` : "Copied", { show(label ? `Copied ${label}` : "Copied", {
variant: "success", variant: "success",
icon: <CheckCircle2 size={18} />, icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
}), }),
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
}), }),
[show], [show, theme.colors.foreground],
); );
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
@@ -113,6 +114,8 @@ export function ToastViewport({
const opacity = useRef(new Animated.Value(0)).current; const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current; const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissDeadlineRef = useRef<number | null>(null);
const remainingDurationRef = useRef(0);
const clearTimer = useCallback(() => { const clearTimer = useCallback(() => {
if (timeoutRef.current) { if (timeoutRef.current) {
@@ -143,9 +146,39 @@ export function ToastViewport({
}); });
}, [clearTimer, onDismiss, opacity, translateY]); }, [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(() => { useEffect(() => {
if (!toast) { if (!toast) {
clearTimer(); clearTimer();
dismissDeadlineRef.current = null;
remainingDurationRef.current = 0;
opacity.setValue(0); opacity.setValue(0);
translateY.setValue(-8); translateY.setValue(-8);
return; return;
@@ -170,14 +203,13 @@ export function ToastViewport({
}), }),
]).start(); ]).start();
timeoutRef.current = setTimeout(() => { remainingDurationRef.current = toast.durationMs;
animateOut(); scheduleDismiss(toast.durationMs);
}, toast.durationMs);
return () => { return () => {
clearTimer(); clearTimer();
}; };
}, [animateOut, clearTimer, opacity, toast, translateY]); }, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
if (!toast) { if (!toast) {
return null; return null;
@@ -202,6 +234,8 @@ export function ToastViewport({
<View style={styles.container} pointerEvents="box-none"> <View style={styles.container} pointerEvents="box-none">
<Animated.View <Animated.View
testID={toast.testID ?? "app-toast"} testID={toast.testID ?? "app-toast"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
style={[ style={[
styles.toast, styles.toast,
toast.variant === "success" ? styles.toastSuccess : null, toast.variant === "success" ? styles.toastSuccess : null,
@@ -219,7 +253,6 @@ export function ToastViewport({
<Text <Text
testID="app-toast-message" testID="app-toast-message"
style={[styles.message, toast.variant === "error" ? styles.messageError : null]} style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
numberOfLines={2}
> >
{toast.content} {toast.content}
</Text> </Text>
@@ -255,7 +288,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius["2xl"],
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -460,6 +460,7 @@ export function DropdownMenuItem({
description, description,
onSelect, onSelect,
disabled, disabled,
muted = false,
destructive, destructive,
selected, selected,
showSelectedCheck = false, showSelectedCheck = false,
@@ -477,6 +478,7 @@ export function DropdownMenuItem({
description?: string; description?: string;
onSelect?: () => void; onSelect?: () => void;
disabled?: boolean; disabled?: boolean;
muted?: boolean;
destructive?: boolean; destructive?: boolean;
selected?: boolean; selected?: boolean;
showSelectedCheck?: boolean; showSelectedCheck?: boolean;
@@ -550,6 +552,7 @@ export function DropdownMenuItem({
? styles.itemSelectedInteractive ? styles.itemSelectedInteractive
: null, : null,
isDisabled ? styles.itemDisabled : null, isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null, pressed && !isDisabled ? styles.itemPressed : null,
]} ]}
@@ -568,6 +571,7 @@ export function DropdownMenuItem({
destructive && !isSuccess ? styles.itemTextDestructive : null, destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null, isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null, selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
muted && !isDisabled ? styles.itemTextMuted : null,
]} ]}
> >
{label} {label}
@@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({
itemDisabled: { itemDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
itemMuted: {
opacity: 0.72,
},
itemText: { itemText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foreground, color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
itemTextMuted: {
color: theme.colors.foregroundMuted,
},
itemTextDestructive: { itemTextDestructive: {
color: theme.colors.destructive, color: theme.colors.destructive,
}, },
+19 -17
View File
@@ -1,8 +1,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout"; import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents"; import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host"; 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 FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light"; type ColorScheme = "dark" | "light";
@@ -37,21 +43,6 @@ function deriveFaviconStatus(
return "none"; 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 { function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string {
const image = FAVICON_IMAGES[colorScheme][status]; const image = FAVICON_IMAGES[colorScheme][status];
if (typeof image === "object" && "uri" in image) { if (typeof image === "object" && "uri" in image) {
@@ -112,6 +103,17 @@ async function updateMacDockBadge(count?: number) {
export function useFaviconStatus() { export function useFaviconStatus() {
const { agents } = useAggregatedAgents(); 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 [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
const lastDockBadgeCountRef = useRef<number | undefined>(undefined); const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
@@ -135,10 +137,10 @@ export function useFaviconStatus() {
const status = deriveFaviconStatus(agents); const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme); updateFavicon(status, colorScheme);
const dockBadgeCount = deriveMacDockBadgeCount(agents); const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses);
if (dockBadgeCount !== lastDockBadgeCountRef.current) { if (dockBadgeCount !== lastDockBadgeCountRef.current) {
lastDockBadgeCountRef.current = dockBadgeCount; lastDockBadgeCountRef.current = dockBadgeCount;
void updateMacDockBadge(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 { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -19,6 +20,7 @@ interface UseGitActionsInput {
cwd: string; cwd: string;
icons: { icons: {
commit: ReactElement; commit: ReactElement;
pull: ReactElement;
push: ReactElement; push: ReactElement;
viewPr: ReactElement; viewPr: ReactElement;
createPr: ReactElement; createPr: ReactElement;
@@ -31,13 +33,12 @@ interface UseGitActionsInput {
interface UseGitActionsResult { interface UseGitActionsResult {
gitActions: GitActions; gitActions: GitActions;
branchLabel: string; branchLabel: string;
actionError: string | null;
isGit: boolean; isGit: boolean;
} }
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const router = useRouter(); const router = useRouter();
const [actionError, setActionError] = useState<string | null>(null); const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -102,6 +103,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const commitStatus = useCheckoutGitActionsStore((state) => const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }), state.getStatus({ serverId, cwd, actionId: "commit" }),
); );
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) => const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }), 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 runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push); const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); 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 // Handlers
const handleCommit = useCallback(() => { const handleCommit = useCallback(() => {
setActionError(null); void runCommit({ serverId, cwd })
void runCommit({ serverId, cwd }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to commit"; toastActionSuccess("Committed");
setActionError(message); })
}); .catch((err) => {
}, [runCommit, serverId, cwd]); 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(() => { const handlePush = useCallback(() => {
setActionError(null); void runPush({ serverId, cwd })
void runPush({ serverId, cwd }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to push"; toastActionSuccess("Pushed");
setActionError(message); })
}); .catch((err) => {
}, [runPush, serverId, cwd]); toastActionError(err, "Failed to push");
});
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => { const handleCreatePr = useCallback(() => {
void persistShipDefault("pr"); void persistShipDefault("pr");
setActionError(null); void runCreatePr({ serverId, cwd })
void runCreatePr({ serverId, cwd }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to create PR"; toastActionSuccess("PR created");
setActionError(message); })
}); .catch((err) => {
}, [persistShipDefault, runCreatePr, serverId, cwd]); toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => { const handleMergeBranch = useCallback(() => {
if (!baseRef) { if (!baseRef) {
setActionError("Base ref unavailable"); toast.error("Base ref unavailable");
return; return;
} }
void persistShipDefault("merge"); void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }) void runMergeBranch({ serverId, cwd, baseRef })
.then(() => { .then(() => {
setPostShipArchiveSuggested(true); setPostShipArchiveSuggested(true);
toastActionSuccess("Merged");
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge"; toastActionError(err, "Failed to merge");
setActionError(message);
}); });
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); }, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
toast,
toastActionError,
toastActionSuccess,
]);
const handleMergeFromBase = useCallback(() => { const handleMergeFromBase = useCallback(() => {
if (!baseRef) { if (!baseRef) {
setActionError("Base ref unavailable"); toast.error("Base ref unavailable");
return; return;
} }
setActionError(null); void runMergeFromBase({ serverId, cwd, baseRef })
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { .then(() => {
const message = err instanceof Error ? err.message : "Failed to merge from base"; toastActionSuccess("Updated");
setActionError(message); })
}); .catch((err) => {
}, [baseRef, runMergeFromBase, serverId, cwd]); toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => { const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd; const worktreePath = status?.cwd;
if (!worktreePath) { if (!worktreePath) {
setActionError("Worktree path unavailable"); toast.error("Worktree path unavailable");
return; return;
} }
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath }) void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => { .then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree"; toastActionError(err, "Failed to archive worktree");
setActionError(message);
}); });
}, [runArchiveWorktree, router, serverId, cwd, status]); }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
// Derived state // Derived state
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => { const baseRefLabel = useMemo(() => {
@@ -220,6 +265,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
(postShipArchiveSuggested || isMergedPullRequest); (postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending"; const commitDisabled = actionsDisabled || commitStatus === "pending";
const pullDisabled = actionsDisabled || pullStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
@@ -247,6 +293,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRefAvailable: Boolean(baseRef), baseRefAvailable: Boolean(baseRef),
baseRefLabel, baseRefLabel,
aheadCount, aheadCount,
behindBaseCount,
aheadOfOrigin, aheadOfOrigin,
behindOfOrigin, behindOfOrigin,
shouldPromoteArchive, shouldPromoteArchive,
@@ -258,6 +305,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.commit, icon: icons.commit,
handler: handleCommit, handler: handleCommit,
}, },
pull: {
disabled: pullDisabled,
status: pullStatus,
icon: icons.pull,
handler: handlePull,
},
push: { push: {
disabled: pushDisabled, disabled: pushDisabled,
status: pushStatus, status: pushStatus,
@@ -302,6 +355,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasPullRequest, hasPullRequest,
prStatus?.url, prStatus?.url,
aheadCount, aheadCount,
behindBaseCount,
isPaseoOwnedWorktree, isPaseoOwnedWorktree,
isOnBaseBranch, isOnBaseBranch,
githubFeaturesEnabled, githubFeaturesEnabled,
@@ -312,18 +366,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRefLabel, baseRefLabel,
shouldPromoteArchive, shouldPromoteArchive,
commitDisabled, commitDisabled,
pullDisabled,
pushDisabled, pushDisabled,
prDisabled, prDisabled,
mergeDisabled, mergeDisabled,
mergeFromBaseDisabled, mergeFromBaseDisabled,
archiveDisabled, archiveDisabled,
commitStatus, commitStatus,
pullStatus,
pushStatus, pushStatus,
prCreateStatus, prCreateStatus,
mergeStatus, mergeStatus,
mergeFromBaseStatus, mergeFromBaseStatus,
archiveStatus, archiveStatus,
handleCommit, handleCommit,
handlePull,
handlePush, handlePush,
handleCreatePr, handleCreatePr,
handleMergeBranch, handleMergeBranch,
@@ -333,5 +390,5 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRef, baseRef,
]); ]);
return { gitActions, branchLabel, actionError, isGit }; return { gitActions, branchLabel, isGit };
} }
@@ -35,6 +35,7 @@ export function useKeyboardShortcuts({
toggleFileExplorer, toggleFileExplorer,
toggleBothSidebars, toggleBothSidebars,
toggleFocusMode, toggleFocusMode,
cycleTheme,
}: { }: {
enabled: boolean; enabled: boolean;
isMobile: boolean; isMobile: boolean;
@@ -43,6 +44,7 @@ export function useKeyboardShortcuts({
toggleFileExplorer?: () => void; toggleFileExplorer?: () => void;
toggleBothSidebars?: () => void; toggleBothSidebars?: () => void;
toggleFocusMode?: () => void; toggleFocusMode?: () => void;
cycleTheme?: () => void;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const hosts = useHosts(); const hosts = useHosts();
@@ -267,6 +269,11 @@ export function useKeyboardShortcuts({
toggleFocusMode(); toggleFocusMode();
} }
return true; return true;
case "theme.cycle":
if (cycleTheme) {
cycleTheme();
}
return true;
case "command-center.toggle": { case "command-center.toggle": {
const store = useKeyboardShortcutsStore.getState(); const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) { if (!store.commandCenterOpen) {
@@ -413,6 +420,7 @@ export function useKeyboardShortcuts({
}; };
}, [ }, [
bindings, bindings,
cycleTheme,
enabled, enabled,
isMobile, isMobile,
openProjectPickerAction, openProjectPickerAction,
+1
View File
@@ -43,6 +43,7 @@ export type KeyboardActionId =
| "worktree.new" | "worktree.new"
| "worktree.archive" | "worktree.archive"
| "view.toggle.focus" | "view.toggle.focus"
| "theme.cycle"
| "message-input.action"; | "message-input.action";
export type KeyboardShortcutPayload = 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 --- // --- Message input ---
{ {
id: "message-input-voice-toggle-cmd-shift-d-mac", id: "message-input-voice-toggle-cmd-shift-d-mac",
@@ -1,6 +1,13 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useUnistyles } from "react-native-unistyles"; 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 { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions"; import { useGitActions } from "@/hooks/use-git-actions";
@@ -16,6 +23,7 @@ export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps)
const icons = useMemo( const icons = useMemo(
() => ({ () => ({
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />, commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
pull: <Download size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />, push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />, viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <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 = export type CheckoutGitAsyncActionId =
| "commit" | "commit"
| "pull"
| "push" | "push"
| "create-pr" | "create-pr"
| "merge-branch" | "merge-branch"
@@ -139,6 +140,7 @@ interface CheckoutGitActionsStoreState {
}) => CheckoutGitActionStatus; }) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>; commit: (params: { serverId: string; cwd: string }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>; push: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (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>; 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 }) => { push: async ({ serverId, cwd }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, 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 () => { test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
@@ -25,6 +25,7 @@ import type {
CheckoutCommitResponse, CheckoutCommitResponse,
CheckoutMergeResponse, CheckoutMergeResponse,
CheckoutMergeFromBaseResponse, CheckoutMergeFromBaseResponse,
CheckoutPullResponse,
CheckoutPushResponse, CheckoutPushResponse,
CheckoutPrCreateResponse, CheckoutPrCreateResponse,
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
@@ -225,6 +226,7 @@ type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["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> { async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId, requestId,
+36
View File
@@ -148,6 +148,7 @@ import {
commitChanges, commitChanges,
mergeToBase, mergeToBase,
mergeFromBase, mergeFromBase,
pullCurrentBranch,
pushCurrentBranch, pushCurrentBranch,
createPullRequest, createPullRequest,
getPullRequestStatus, getPullRequestStatus,
@@ -1685,6 +1686,10 @@ export class Session {
await this.handleCheckoutMergeFromBaseRequest(msg); await this.handleCheckoutMergeFromBaseRequest(msg);
break; break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "checkout_push_request": case "checkout_push_request":
await this.handleCheckoutPushRequest(msg); await this.handleCheckoutPushRequest(msg);
break; 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( private async handleCheckoutPushRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> { ): Promise<void> {
+20
View File
@@ -1038,6 +1038,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({
requestId: z.string(), 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({ export const CheckoutPushRequestSchema = z.object({
type: z.literal("checkout_push_request"), type: z.literal("checkout_push_request"),
cwd: z.string(), cwd: z.string(),
@@ -1464,6 +1470,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitRequestSchema, CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema, CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema, CheckoutMergeFromBaseRequestSchema,
CheckoutPullRequestSchema,
CheckoutPushRequestSchema, CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema, CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema, 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({ export const CheckoutPushResponseSchema = z.object({
type: z.literal("checkout_push_response"), type: z.literal("checkout_push_response"),
payload: z.object({ payload: z.object({
@@ -2825,6 +2842,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitResponseSchema, CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema, CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema, CheckoutMergeFromBaseResponseSchema,
CheckoutPullResponseSchema,
CheckoutPushResponseSchema, CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema, CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema, CheckoutPrStatusResponseSchema,
@@ -3054,6 +3072,8 @@ export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>; export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>; export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>; 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 CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>; export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>; export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process"; import { execSync } from "child_process";
import { import {
existsSync,
mkdtempSync, mkdtempSync,
rmSync, rmSync,
writeFileSync, writeFileSync,
@@ -30,6 +31,7 @@ import {
MergeConflictError, MergeConflictError,
MergeFromBaseConflictError, MergeFromBaseConflictError,
NotGitRepoError, NotGitRepoError,
pullCurrentBranch,
pushCurrentBranch, pushCurrentBranch,
resolveRepositoryDefaultBranch, resolveRepositoryDefaultBranch,
parseWorktreeList, parseWorktreeList,
@@ -558,6 +560,85 @@ const x = 1;
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); 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 () => { it("pushes the current branch to origin", async () => {
const remoteDir = join(tempDir, "remote.git"); const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`); 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 { spawnProcess } from "./spawn.js";
import { promisify } from "util"; import { promisify } from "util";
import { resolve, dirname, basename } from "path"; 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 { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache"; import { TTLCache } from "@isaacs/ttlcache";
import { z } from "zod"; import { z } from "zod";
@@ -862,6 +862,46 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
return url !== null; 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> { export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
try { try {
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
@@ -1555,10 +1595,12 @@ export async function getCheckoutDiff(
continue; continue;
} }
// `git diff -w --name-status` can still surface paths whose only changes were // `git diff -w --name-status` can still report a modified path even when the
// whitespace. Skip those empty tracked entries so callers don't see phantom files. // whitespace-filtered patch and numstat are both empty. Skip emitting a
// structured placeholder in that case so whitespace-only edits truly disappear.
if ( if (
ignoreWhitespace && ignoreWhitespace &&
!trackedDiffTruncated &&
change.status.startsWith("M") && change.status.startsWith("M") &&
(!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0)) (!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> { export async function pushCurrentBranch(cwd: string): Promise<void> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd); const currentBranch = await getCurrentBranch(cwd);