From faf85e038cf4b4daca2536dd10da45b18a24801e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 18:48:36 +0700 Subject: [PATCH 1/7] Fix host switcher status syncing --- packages/app/src/components/left-sidebar.tsx | 99 ++++++++++++-------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index c3eb01e31..55594f13d 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -5,8 +5,8 @@ import { useState, useEffect, useRef, - useSyncExternalStore, type Dispatch, + type ReactElement, type RefObject, type SetStateAction, } from "react"; @@ -44,8 +44,8 @@ import { import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { Combobox } from "@/components/ui/combobox"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { formatConnectionStatus } from "@/utils/daemons"; import { HEADER_INNER_HEIGHT, @@ -69,18 +69,12 @@ interface LeftSidebarProps { selectedAgentId?: string; } -interface HostOption { - id: string; - label: string; - description: string; -} - interface SidebarSharedProps { theme: SidebarTheme; activeServerId: string | null; activeHostLabel: string; activeHostStatusColor: string; - hostOptions: HostOption[]; + hostOptions: ComboboxOption[]; hostTriggerRef: RefObject; isHostPickerOpen: boolean; setIsHostPickerOpen: Dispatch>; @@ -95,6 +89,12 @@ interface SidebarSharedProps { handleHostSelect: (nextServerId: string) => void; handleOpenProject: () => void; handleSettings: () => void; + renderHostOption: (input: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ReactElement; } interface MobileSidebarProps extends SidebarSharedProps { @@ -124,28 +124,6 @@ export const LeftSidebar = memo(function LeftSidebar({ const closeToAgent = usePanelStore((state) => state.closeToAgent); const pathname = usePathname(); const daemons = useHosts(); - const runtime = getHostRuntimeStore(); - const runtimeConnectionStatusSignature = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => - daemons - .map( - (daemon) => - `${daemon.serverId}:${ - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" - }`, - ) - .join("|"), - () => - daemons - .map( - (daemon) => - `${daemon.serverId}:${ - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" - }`, - ) - .join("|"), - ); const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeDaemon = useMemo(() => { if (daemons.length === 0) { @@ -165,8 +143,9 @@ export const LeftSidebar = memo(function LeftSidebar({ const trimmed = activeDaemon.label?.trim(); return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; }, [activeDaemon]); + const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? ""); const activeHostStatus = activeServerId - ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting") + ? (activeHostSnapshot?.connectionStatus ?? "connecting") : "idle"; const activeHostStatusColor = activeHostStatus === "online" @@ -179,11 +158,25 @@ export const LeftSidebar = memo(function LeftSidebar({ daemons.map((daemon) => ({ id: daemon.serverId, label: daemon.label?.trim() || daemon.serverId, - description: formatConnectionStatus( - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting", - ), })), - [daemons, runtime, runtimeConnectionStatusSignature], + [daemons], + ); + const renderHostOption = useCallback( + ({ option, selected, active, onPress }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ( + + ), + [], ); const hostTriggerRef = useRef(null); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); @@ -276,6 +269,7 @@ export const LeftSidebar = memo(function LeftSidebar({ toggleProjectCollapsed, handleRefresh, handleHostSelect, + renderHostOption, }; if (isCompactLayout) { @@ -305,6 +299,33 @@ export const LeftSidebar = memo(function LeftSidebar({ ); }); +function HostSwitchOption({ + serverId, + label, + selected, + active, + onPress, +}: { + serverId: string; + label: string; + selected: boolean; + active: boolean; + onPress: () => void; +}) { + const snapshot = useHostRuntimeSnapshot(serverId); + const connectionStatus = snapshot?.connectionStatus ?? "connecting"; + + return ( + + ); +} + function SessionsButton({ onPress }: { onPress: () => void }) { const { theme } = useUnistyles(); const pathname = usePathname(); @@ -361,6 +382,7 @@ function MobileSidebar({ toggleProjectCollapsed, handleRefresh, handleHostSelect, + renderHostOption, handleOpenProject, handleSettings, insetsTop, @@ -606,6 +628,7 @@ function MobileSidebar({ options={hostOptions} value={activeServerId ?? ""} onSelect={handleHostSelect} + renderOption={renderHostOption} searchable={false} title="Switch host" searchPlaceholder="Search hosts..." @@ -639,6 +662,7 @@ function DesktopSidebar({ toggleProjectCollapsed, handleRefresh, handleHostSelect, + renderHostOption, handleOpenProject, handleSettings, insetsTop, @@ -789,6 +813,7 @@ function DesktopSidebar({ options={hostOptions} value={activeServerId ?? ""} onSelect={handleHostSelect} + renderOption={renderHostOption} searchable={false} title="Switch host" searchPlaceholder="Search hosts..." From 01af1389392d110fabaf4567fee3fa2dae18fde3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 18:50:50 +0700 Subject: [PATCH 2/7] feat(app): add keyboard shortcut to cycle themes --- packages/app/src/app/_layout.tsx | 12 ++++++++- .../app/src/hooks/use-keyboard-shortcuts.ts | 8 ++++++ packages/app/src/keyboard/actions.ts | 1 + .../app/src/keyboard/keyboard-shortcuts.ts | 26 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e1b41b450..da1cdec90 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -14,7 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; import { VoiceProvider } from "@/contexts/voice-context"; import { useAppSettings } from "@/hooks/use-settings"; -import { THEME_TO_UNISTYLES } from "@/styles/theme"; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { View, Text } from "react-native"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -358,6 +358,8 @@ interface AppContainerProps { chromeEnabled?: boolean; } +const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"]; + function AppContainer({ children, selectedAgentId, @@ -365,6 +367,7 @@ function AppContainer({ }: AppContainerProps) { const { theme } = useUnistyles(); const daemons = useHosts(); + const { settings, updateSettings } = useAppSettings(); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); @@ -373,6 +376,12 @@ function AppContainer({ const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const cycleTheme = useCallback(() => { + const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); + const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length; + void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! }); + }, [settings.theme, updateSettings]); + const isCompactLayout = useIsCompactFormFactor(); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; @@ -413,6 +422,7 @@ function AppContainer({ toggleFileExplorer, toggleBothSidebars, toggleFocusMode, + cycleTheme, }); const containerStyle = useMemo( diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index edbc739f1..4ec95766f 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -35,6 +35,7 @@ export function useKeyboardShortcuts({ toggleFileExplorer, toggleBothSidebars, toggleFocusMode, + cycleTheme, }: { enabled: boolean; isMobile: boolean; @@ -43,6 +44,7 @@ export function useKeyboardShortcuts({ toggleFileExplorer?: () => void; toggleBothSidebars?: () => void; toggleFocusMode?: () => void; + cycleTheme?: () => void; }) { const pathname = usePathname(); const hosts = useHosts(); @@ -267,6 +269,11 @@ export function useKeyboardShortcuts({ toggleFocusMode(); } return true; + case "theme.cycle": + if (cycleTheme) { + cycleTheme(); + } + return true; case "command-center.toggle": { const store = useKeyboardShortcutsStore.getState(); if (!store.commandCenterOpen) { @@ -413,6 +420,7 @@ export function useKeyboardShortcuts({ }; }, [ bindings, + cycleTheme, enabled, isMobile, openProjectPickerAction, diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index e9b8af965..dd0816712 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -43,6 +43,7 @@ export type KeyboardActionId = | "worktree.new" | "worktree.archive" | "view.toggle.focus" + | "theme.cycle" | "message-input.action"; export type KeyboardShortcutPayload = diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index bc9b65122..5d9cfcfab 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -744,6 +744,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Theme cycling --- + { + id: "theme-cycle-cmd-shift-t-mac", + action: "theme.cycle", + combo: "Cmd+Alt+T", + when: { mac: true, commandCenter: false }, + help: { + id: "cycle-theme", + section: "panels", + label: "Cycle theme", + keys: ["mod", "alt", "T"], + }, + }, + { + id: "theme-cycle-ctrl-alt-t-non-mac", + action: "theme.cycle", + combo: "Ctrl+Alt+T", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "cycle-theme", + section: "panels", + label: "Cycle theme", + keys: ["mod", "alt", "T"], + }, + }, + // --- Message input --- { id: "message-input-voice-toggle-cmd-shift-d-mac", From a315021c755dc3f6dc13ce8f1b9cd47f69d9526c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 19:14:31 +0700 Subject: [PATCH 3/7] Fix desktop badge workspace parity --- packages/app/src/hooks/use-favicon-status.ts | 36 ++++++++++--------- .../app/src/utils/desktop-badge-state.test.ts | 34 ++++++++++++++++++ packages/app/src/utils/desktop-badge-state.ts | 16 +++++++++ 3 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 packages/app/src/utils/desktop-badge-state.test.ts create mode 100644 packages/app/src/utils/desktop-badge-state.ts diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index ebe47890e..5818a495a 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -1,8 +1,14 @@ import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; +import { useShallow } from "zustand/shallow"; import { getIsElectronRuntimeMac } from "@/constants/layout"; import { useAggregatedAgents } from "./use-aggregated-agents"; import { getDesktopHost } from "@/desktop/host"; +import { useSessionStore } from "@/stores/session-store"; +import { + deriveMacDockBadgeCountFromWorkspaceStatuses, + type DesktopBadgeWorkspaceStatus, +} from "@/utils/desktop-badge-state"; type FaviconStatus = "none" | "running" | "attention"; type ColorScheme = "dark" | "light"; @@ -37,21 +43,6 @@ function deriveFaviconStatus( return "none"; } -function deriveMacDockBadgeCount( - agents: ReturnType["agents"], -): number | undefined { - const attentionCount = agents.filter( - (agent) => - (agent.pendingPermissionCount ?? 0) > 0 || - (agent.requiresAttention && agent.attentionReason === "finished"), - ).length; - if (attentionCount > 0) { - return attentionCount; - } - - return undefined; -} - function getFaviconUri(status: FaviconStatus, colorScheme: ColorScheme): string { const image = FAVICON_IMAGES[colorScheme][status]; if (typeof image === "object" && "uri" in image) { @@ -112,6 +103,17 @@ async function updateMacDockBadge(count?: number) { export function useFaviconStatus() { const { agents } = useAggregatedAgents(); + const workspaceStatuses = useSessionStore( + useShallow((state) => { + const statuses: DesktopBadgeWorkspaceStatus[] = []; + for (const session of Object.values(state.sessions)) { + for (const workspace of session.workspaces.values()) { + statuses.push(workspace.status); + } + } + return statuses; + }), + ); const [colorScheme, setColorScheme] = useState(getSystemColorScheme); const lastDockBadgeCountRef = useRef(undefined); @@ -135,10 +137,10 @@ export function useFaviconStatus() { const status = deriveFaviconStatus(agents); updateFavicon(status, colorScheme); - const dockBadgeCount = deriveMacDockBadgeCount(agents); + const dockBadgeCount = deriveMacDockBadgeCountFromWorkspaceStatuses(workspaceStatuses); if (dockBadgeCount !== lastDockBadgeCountRef.current) { lastDockBadgeCountRef.current = dockBadgeCount; void updateMacDockBadge(dockBadgeCount); } - }, [agents, colorScheme]); + }, [agents, colorScheme, workspaceStatuses]); } diff --git a/packages/app/src/utils/desktop-badge-state.test.ts b/packages/app/src/utils/desktop-badge-state.test.ts new file mode 100644 index 000000000..9bcdd39bc --- /dev/null +++ b/packages/app/src/utils/desktop-badge-state.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/utils/desktop-badge-state.ts b/packages/app/src/utils/desktop-badge-state.ts new file mode 100644 index 000000000..9669492c5 --- /dev/null +++ b/packages/app/src/utils/desktop-badge-state.ts @@ -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; +} From e22fb519759b3b84358a1e0b70ac9a5325c75281 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 19:53:45 +0700 Subject: [PATCH 4/7] Improve git action toast feedback --- packages/app/src/components/git-diff-pane.tsx | 103 +++++++++++------ packages/app/src/components/toast-host.tsx | 49 ++++++-- packages/app/src/hooks/use-git-actions.ts | 105 +++++++++++------- 3 files changed, 174 insertions(+), 83 deletions(-) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 70e7135ba..cbb8b4ec4 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -80,6 +80,7 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; +import { useToast } from "@/contexts/toast-context"; import { formatDiffContentText, formatDiffGutterText, @@ -607,12 +608,12 @@ type DiffFlatItem = export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { const { theme } = useUnistyles(); + const toast = useToast(); const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); - const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = @@ -919,77 +920,106 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const toastActionError = useCallback( + (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : fallback; + toast.error(message); + }, + [toast], + ); + + const toastActionSuccess = useCallback( + (message: string) => { + toast.show(message, { variant: "success" }); + }, + [toast], + ); + const handleCommit = useCallback(() => { - setActionError(null); - void runCommit({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to commit"; - setActionError(message); - }); - }, [runCommit, serverId, cwd]); + void runCommit({ serverId, cwd }) + .then(() => { + toastActionSuccess("Committed"); + }) + .catch((err) => { + toastActionError(err, "Failed to commit"); + }); + }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { - setActionError(null); - void runPush({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to push"; - setActionError(message); - }); - }, [runPush, serverId, cwd]); + void runPush({ serverId, cwd }) + .then(() => { + toastActionSuccess("Pushed"); + }) + .catch((err) => { + toastActionError(err, "Failed to push"); + }); + }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); - setActionError(null); - void runCreatePr({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to create PR"; - setActionError(message); - }); - }, [persistShipDefault, runCreatePr, serverId, cwd]); + void runCreatePr({ serverId, cwd }) + .then(() => { + toastActionSuccess("PR created"); + }) + .catch((err) => { + toastActionError(err, "Failed to create PR"); + }); + }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } void persistShipDefault("merge"); - setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); + toastActionSuccess("Merged"); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge"; - setActionError(message); + toastActionError(err, "Failed to merge"); }); - }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); + }, [ + baseRef, + cwd, + persistShipDefault, + runMergeBranch, + serverId, + toast, + toastActionError, + toastActionSuccess, + ]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } - setActionError(null); - void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge from base"; - setActionError(message); - }); - }, [baseRef, runMergeFromBase, serverId, cwd]); + void runMergeFromBase({ serverId, cwd, baseRef }) + .then(() => { + toastActionSuccess("Updated"); + }) + .catch((err) => { + toastActionError(err, "Failed to merge from base"); + }); + }, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { - setActionError("Worktree path unavailable"); + toast.error("Worktree path unavailable"); return; } - setActionError(null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to archive worktree"; - setActionError(message); + toastActionError(err, "Failed to archive worktree"); }); - }, [runArchiveWorktree, router, serverId, cwd, status]); + }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { @@ -1458,7 +1488,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ) : null} - {actionError ? {actionError} : null} {prErrorMessage ? {prErrorMessage} : null} diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 9920d660b..8b4916875 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -47,6 +47,7 @@ export function useToastHost(): { toast: ToastState | null; dismiss: () => void; } { + const { theme } = useUnistyles(); const [toast, setToast] = useState(null); const idRef = useRef(0); @@ -84,11 +85,11 @@ export function useToastHost(): { copied: (label?: string) => show(label ? `Copied ${label}` : "Copied", { variant: "success", - icon: , + icon: , }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), }), - [show], + [show, theme.colors.foreground], ); const dismiss = useCallback(() => { @@ -113,6 +114,8 @@ export function ToastViewport({ const opacity = useRef(new Animated.Value(0)).current; const translateY = useRef(new Animated.Value(-8)).current; const timeoutRef = useRef | null>(null); + const dismissDeadlineRef = useRef(null); + const remainingDurationRef = useRef(0); const clearTimer = useCallback(() => { if (timeoutRef.current) { @@ -143,9 +146,39 @@ export function ToastViewport({ }); }, [clearTimer, onDismiss, opacity, translateY]); + const scheduleDismiss = useCallback( + (durationMs: number) => { + clearTimer(); + const nextDurationMs = Math.max(0, durationMs); + remainingDurationRef.current = nextDurationMs; + dismissDeadlineRef.current = Date.now() + nextDurationMs; + timeoutRef.current = setTimeout(() => { + animateOut(); + }, nextDurationMs); + }, + [animateOut, clearTimer], + ); + + const pauseDismiss = useCallback(() => { + if (dismissDeadlineRef.current !== null) { + remainingDurationRef.current = Math.max(0, dismissDeadlineRef.current - Date.now()); + } + dismissDeadlineRef.current = null; + clearTimer(); + }, [clearTimer]); + + const resumeDismiss = useCallback(() => { + if (!toast) { + return; + } + scheduleDismiss(remainingDurationRef.current || toast.durationMs); + }, [scheduleDismiss, toast]); + useEffect(() => { if (!toast) { clearTimer(); + dismissDeadlineRef.current = null; + remainingDurationRef.current = 0; opacity.setValue(0); translateY.setValue(-8); return; @@ -170,14 +203,13 @@ export function ToastViewport({ }), ]).start(); - timeoutRef.current = setTimeout(() => { - animateOut(); - }, toast.durationMs); + remainingDurationRef.current = toast.durationMs; + scheduleDismiss(toast.durationMs); return () => { clearTimer(); }; - }, [animateOut, clearTimer, opacity, toast, translateY]); + }, [clearTimer, opacity, scheduleDismiss, toast, translateY]); if (!toast) { return null; @@ -202,6 +234,8 @@ export function ToastViewport({ {toast.content} @@ -255,7 +288,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], backgroundColor: theme.colors.surface0, - borderRadius: theme.borderRadius.full, + borderRadius: theme.borderRadius["2xl"], borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, paddingVertical: theme.spacing[2], diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 9856685b2..535cd3687 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -7,6 +7,7 @@ import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; +import { useToast } from "@/contexts/toast-context"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -31,13 +32,12 @@ interface UseGitActionsInput { interface UseGitActionsResult { gitActions: GitActions; branchLabel: string; - actionError: string | null; isGit: boolean; } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { const router = useRouter(); - const [actionError, setActionError] = useState(null); + const toast = useToast(); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); @@ -125,78 +125,107 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const toastActionError = useCallback( + (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : fallback; + toast.error(message); + }, + [toast], + ); + + const toastActionSuccess = useCallback( + (message: string) => { + toast.show(message, { variant: "success" }); + }, + [toast], + ); + // Handlers const handleCommit = useCallback(() => { - setActionError(null); - void runCommit({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to commit"; - setActionError(message); - }); - }, [runCommit, serverId, cwd]); + void runCommit({ serverId, cwd }) + .then(() => { + toastActionSuccess("Committed"); + }) + .catch((err) => { + toastActionError(err, "Failed to commit"); + }); + }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { - setActionError(null); - void runPush({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to push"; - setActionError(message); - }); - }, [runPush, serverId, cwd]); + void runPush({ serverId, cwd }) + .then(() => { + toastActionSuccess("Pushed"); + }) + .catch((err) => { + toastActionError(err, "Failed to push"); + }); + }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); - setActionError(null); - void runCreatePr({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to create PR"; - setActionError(message); - }); - }, [persistShipDefault, runCreatePr, serverId, cwd]); + void runCreatePr({ serverId, cwd }) + .then(() => { + toastActionSuccess("PR created"); + }) + .catch((err) => { + toastActionError(err, "Failed to create PR"); + }); + }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } void persistShipDefault("merge"); - setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); + toastActionSuccess("Merged"); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge"; - setActionError(message); + toastActionError(err, "Failed to merge"); }); - }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); + }, [ + baseRef, + cwd, + persistShipDefault, + runMergeBranch, + serverId, + toast, + toastActionError, + toastActionSuccess, + ]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { - setActionError("Base ref unavailable"); + toast.error("Base ref unavailable"); return; } - setActionError(null); - void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to merge from base"; - setActionError(message); - }); - }, [baseRef, runMergeFromBase, serverId, cwd]); + void runMergeFromBase({ serverId, cwd, baseRef }) + .then(() => { + toastActionSuccess("Updated"); + }) + .catch((err) => { + toastActionError(err, "Failed to merge from base"); + }); + }, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { - setActionError("Worktree path unavailable"); + toast.error("Worktree path unavailable"); return; } - setActionError(null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to archive worktree"; - setActionError(message); + toastActionError(err, "Failed to archive worktree"); }); - }, [runArchiveWorktree, router, serverId, cwd, status]); + }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); // Derived state const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; @@ -333,5 +362,5 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRef, ]); - return { gitActions, branchLabel, actionError, isGit }; + return { gitActions, branchLabel, isGit }; } From 497159ada888fcf1cec5fc67ff13185f947b507c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 20:13:59 +0700 Subject: [PATCH 5/7] Add pull action and restructure git actions policy --- .../src/components/git-actions-policy.test.ts | 145 +++++++++-------- .../app/src/components/git-actions-policy.ts | 147 ++++++++++++------ .../components/git-actions-split-button.tsx | 3 +- packages/app/src/components/git-diff-pane.tsx | 28 ++++ packages/app/src/hooks/use-git-actions.ts | 28 ++++ .../workspace/workspace-git-actions.tsx | 10 +- .../src/stores/checkout-git-actions-store.ts | 17 ++ .../server/src/client/daemon-client.test.ts | 55 +++++++ packages/server/src/client/daemon-client.ts | 14 ++ packages/server/src/server/session.ts | 36 +++++ packages/server/src/shared/messages.ts | 20 +++ .../server/src/utils/checkout-git.test.ts | 23 +++ packages/server/src/utils/checkout-git.ts | 13 ++ 13 files changed, 422 insertions(+), 117 deletions(-) diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts index e1dc936f5..1ee48e87f 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/components/git-actions-policy.test.ts @@ -15,6 +15,7 @@ function createInput(overrides: Partial = {}): BuildGitAct baseRefAvailable: true, baseRefLabel: "main", aheadCount: 0, + behindBaseCount: 0, aheadOfOrigin: 0, behindOfOrigin: 0, shouldPromoteArchive: false, @@ -25,6 +26,11 @@ function createInput(overrides: Partial = {}): BuildGitAct status: "idle", handler: () => undefined, }, + pull: { + disabled: false, + status: "idle", + handler: () => undefined, + }, push: { disabled: false, status: "idle", @@ -56,87 +62,88 @@ function createInput(overrides: Partial = {}): BuildGitAct } describe("git-actions-policy", () => { - it("keeps the secondary menu order stable while the primary action changes", () => { - const noPrActions = buildGitActions(createInput()); - const withPrActions = buildGitActions( - createInput({ - hasRemote: true, - hasPullRequest: true, - pullRequestUrl: "https://example.com/pr/123", - aheadCount: 3, - aheadOfOrigin: 2, - shipDefault: "pr", - }), - ); + it("shows only remote sync actions on the base branch", () => { + const actions = buildGitActions(createInput({ hasRemote: true })); - expect(noPrActions.primary).toBeNull(); - expect(withPrActions.primary?.id).toBe("push"); - expect(noPrActions.secondary.map((action) => action.id)).toEqual([ - "merge-branch", - "pr", - "merge-from-base", - "push", - ]); - expect(withPrActions.secondary.map((action) => action.id)).toEqual([ - "merge-branch", - "pr", - "merge-from-base", - "push", - ]); + expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]); }); - it("disables hidden-before actions with explanations instead", () => { - const actions = buildGitActions(createInput()); - const actionById = new Map(actions.secondary.map((action) => [action.id, action])); - - expect(actionById.get("push")).toMatchObject({ - disabled: true, - description: "No remote configured", - }); - expect(actionById.get("pr")).toMatchObject({ - label: "Create PR", - disabled: true, - description: "Branch has no commits ahead of main", - }); - expect(actionById.get("merge-branch")).toMatchObject({ - disabled: true, - description: "No commits to merge into main", - }); - expect(actionById.get("merge-from-base")).toMatchObject({ - disabled: true, - description: "No remote configured", - }); - expect(actionById.has("archive-worktree")).toBe(false); - }); - - it("keeps the current primary action visible in the menu", () => { + it("prioritizes pull when the branch is behind origin", () => { const actions = buildGitActions( createInput({ hasRemote: true, + behindOfOrigin: 2, + }), + ); + + expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" }); + }); + + it("disables push with a short pull-first 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: true, + description: "Pull 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, + }); + }); + + it("keeps update-from-base off the base branch entirely", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + behindOfOrigin: 2, + }), + ); + + expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false); + }); + + it("keeps feature branch actions available off the base branch", () => { + const actions = buildGitActions( + createInput({ + hasRemote: true, + isOnBaseBranch: false, + aheadCount: 2, + behindBaseCount: 1, hasPullRequest: true, pullRequestUrl: "https://example.com/pr/456", }), ); - expect(actions.primary?.id).toBe("pr"); - expect( - actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), - ).toBe(true); - }); - - it("disables sync on the base branch when already up to date", () => { - const actions = buildGitActions( - createInput({ - hasRemote: true, - }), + expect(actions.secondary.map((action) => action.id)).toEqual([ + "pull", + "push", + "merge-from-base", + "merge-branch", + "pr", + ]); + expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe( + true, ); - const syncAction = actions.secondary.find((action) => action.id === "merge-from-base"); - - expect(syncAction).toMatchObject({ - label: "Sync", - disabled: true, - description: "Already up to date", - }); }); it("only shows archive worktree for paseo worktrees", () => { diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts index 768cc3a90..4220e8f96 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/components/git-actions-policy.ts @@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu"; export type GitActionId = | "commit" + | "pull" | "push" | "pr" | "merge-branch" @@ -47,6 +48,7 @@ export interface BuildGitActionsInput { baseRefAvailable: boolean; baseRefLabel: string; aheadCount: number; + behindBaseCount: number; aheadOfOrigin: number; behindOfOrigin: number; shouldPromoteArchive: boolean; @@ -54,7 +56,8 @@ export interface BuildGitActionsInput { runtime: Record; } -const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"]; +const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"]; +const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"]; export function buildGitActions(input: BuildGitActionsInput): GitActions { if (!input.isGit) { @@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { handler: input.runtime.commit.handler, }); + allActions.set("pull", { + id: "pull", + label: "Pull", + pendingLabel: "Pulling...", + successLabel: "Pulled", + disabled: input.runtime.pull.disabled || !canPull(input), + status: input.runtime.pull.status, + description: getPullDescription(input), + icon: input.runtime.pull.icon, + handler: input.runtime.pull.handler, + }); + allActions.set("push", { id: "push", label: "Push", pendingLabel: "Pushing...", successLabel: "Pushed", - disabled: input.runtime.push.disabled || !input.hasRemote, + disabled: input.runtime.push.disabled || !canPush(input), status: input.runtime.push.status, - description: input.hasRemote ? undefined : "No remote configured", + description: getPushDescription(input), icon: input.runtime.push.icon, handler: input.runtime.push.handler, }); @@ -93,11 +108,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: `Merge into ${input.baseRefLabel}`, pendingLabel: "Merging...", successLabel: "Merged", - disabled: - input.runtime["merge-branch"].disabled || - !input.baseRefAvailable || - input.hasUncommittedChanges || - input.aheadCount === 0, + disabled: input.runtime["merge-branch"].disabled || !canMergeBranch(input), status: input.runtime["merge-branch"].status, description: getMergeBranchDescription(input), icon: input.runtime["merge-branch"].icon, @@ -106,7 +117,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { allActions.set("merge-from-base", { id: "merge-from-base", - label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`, + label: `Update from ${input.baseRefLabel}`, pendingLabel: "Updating...", successLabel: "Updated", disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input), @@ -123,19 +134,27 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { successLabel: "Archived", disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree, status: input.runtime["archive-worktree"].status, - description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees", + description: input.isPaseoOwnedWorktree ? undefined : "Only for worktrees", icon: input.runtime["archive-worktree"].icon, handler: input.runtime["archive-worktree"].handler, }); const primaryActionId = getPrimaryActionId(input); const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null; - const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!); + + const secondaryIds = [...REMOTE_ACTION_IDS]; + if (!input.isOnBaseBranch) { + secondaryIds.push(...FEATURE_ACTION_IDS); + } if (input.isPaseoOwnedWorktree) { - secondary.push(allActions.get("archive-worktree")!); + secondaryIds.push("archive-worktree"); } - return { primary, secondary, menu: [] }; + return { + primary, + secondary: secondaryIds.map((id) => allActions.get(id)!), + menu: [], + }; } function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { @@ -145,20 +164,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null { if (input.hasUncommittedChanges) { return "commit"; } - if (input.aheadOfOrigin > 0 && input.hasRemote) { + if (canPull(input)) { + return "pull"; + } + if (canPush(input)) { return "push"; } + if (!input.isOnBaseBranch && canMergeFromBase(input)) { + return "merge-from-base"; + } if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) { return "pr"; } - if ( - input.isOnBaseBranch && - input.hasRemote && - (input.aheadOfOrigin > 0 || input.behindOfOrigin > 0) - ) { - return "merge-from-base"; - } - if (input.aheadCount > 0) { + if (!input.isOnBaseBranch && input.aheadCount > 0) { return input.shipDefault === "merge" ? "merge-branch" : "pr"; } return null; @@ -173,7 +191,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { successLabel: "View PR", disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled, status: input.runtime.pr.status, - description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable", + description: input.githubFeaturesEnabled ? undefined : "GitHub unavailable", icon: input.runtime.pr.icon, handler: input.runtime.pr.handler, }; @@ -192,57 +210,94 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { }; } +function canPull(input: BuildGitActionsInput): boolean { + return ( + input.hasRemote && + !input.hasUncommittedChanges && + input.behindOfOrigin > 0 + ); +} + +function canPush(input: BuildGitActionsInput): boolean { + return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0; +} + +function canMergeBranch(input: BuildGitActionsInput): boolean { + return ( + !input.isOnBaseBranch && + input.baseRefAvailable && + !input.hasUncommittedChanges && + input.aheadCount > 0 + ); +} + function canMergeFromBase(input: BuildGitActionsInput): boolean { - if (!input.baseRefAvailable || input.hasUncommittedChanges) { - return false; - } - if (!input.isOnBaseBranch) { - return true; - } + return ( + !input.isOnBaseBranch && + input.baseRefAvailable && + !input.hasUncommittedChanges && + input.behindBaseCount > 0 + ); +} + +function getPullDescription(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return false; + return "No remote"; } - return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0; + if (input.hasUncommittedChanges) { + return "Clean tree"; + } + if (input.behindOfOrigin === 0) { + return "Nothing to pull"; + } + return undefined; +} + +function getPushDescription(input: BuildGitActionsInput): string | undefined { + if (!input.hasRemote) { + return "No remote"; + } + if (input.behindOfOrigin > 0) { + return "Pull first"; + } + if (input.aheadOfOrigin === 0) { + return "Nothing to push"; + } + return undefined; } function getCreatePrDescription(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return "GitHub features unavailable"; + return "GitHub unavailable"; } if (input.aheadCount === 0) { - return `Branch has no commits ahead of ${input.baseRefLabel}`; + return "No new commits"; } return undefined; } function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "Base ref unavailable"; + return "No base"; } if (input.hasUncommittedChanges) { - return "Requires clean working tree"; + return "Clean tree"; } if (input.aheadCount === 0) { - return `No commits to merge into ${input.baseRefLabel}`; + return "No new commits"; } return undefined; } function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "Base ref unavailable"; + return "No base"; } if (input.hasUncommittedChanges) { - return "Requires clean working tree"; + return "Clean tree"; } - if (!input.isOnBaseBranch) { - return undefined; - } - if (!input.hasRemote) { - return "No remote configured"; - } - if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) { - return "Already up to date"; + if (input.behindBaseCount === 0) { + return "Up to date"; } return undefined; } diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index 3d45b68c0..f0a6bd802 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -73,7 +73,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps {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 ( {needsSeparator && index > 0 ? : null} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index cbb8b4ec4..1ec8d0988 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -28,6 +28,7 @@ import { Archive, ChevronDown, Columns2, + Download, GitBranch, GitCommitHorizontal, GitMerge, @@ -897,6 +898,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); + const pullStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "pull" }), + ); const pushStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "push" }), ); @@ -914,6 +918,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); + const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPush = useCheckoutGitActionsStore((state) => state.push); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); @@ -945,6 +950,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi }); }, [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(() => { void runPush({ serverId, cwd }) .then(() => { @@ -1071,6 +1086,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi : "Unknown"; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; + const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { @@ -1096,6 +1112,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi (postShipArchiveSuggested || isMergedPullRequest); const commitDisabled = actionsDisabled || commitStatus === "pending"; + const pullDisabled = actionsDisabled || pullStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; @@ -1202,6 +1219,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, + behindBaseCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, @@ -1213,6 +1231,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi icon: , handler: handleCommit, }, + pull: { + disabled: pullDisabled, + status: pullStatus, + icon: , + handler: handlePull, + }, push: { disabled: pushDisabled, status: pushStatus, @@ -1257,6 +1281,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi hasPullRequest, prStatus?.url, aheadCount, + behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, @@ -1267,18 +1292,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi baseRefLabel, shouldPromoteArchive, commitDisabled, + pullDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, + pullStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, + handlePull, handlePush, handleCreatePr, handleMergeBranch, diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 535cd3687..9d6bd3d1d 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -20,6 +20,7 @@ interface UseGitActionsInput { cwd: string; icons: { commit: ReactElement; + pull: ReactElement; push: ReactElement; viewPr: ReactElement; createPr: ReactElement; @@ -102,6 +103,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); + const pullStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ serverId, cwd, actionId: "pull" }), + ); const pushStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "push" }), ); @@ -119,6 +123,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); + const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPush = useCheckoutGitActionsStore((state) => state.push); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); @@ -151,6 +156,16 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }); }, [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(() => { void runPush({ serverId, cwd }) .then(() => { @@ -230,6 +245,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Derived state const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; + const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { @@ -249,6 +265,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use (postShipArchiveSuggested || isMergedPullRequest); const commitDisabled = actionsDisabled || commitStatus === "pending"; + const pullDisabled = actionsDisabled || pullStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; @@ -276,6 +293,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, + behindBaseCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, @@ -287,6 +305,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use icon: icons.commit, handler: handleCommit, }, + pull: { + disabled: pullDisabled, + status: pullStatus, + icon: icons.pull, + handler: handlePull, + }, push: { disabled: pushDisabled, status: pushStatus, @@ -331,6 +355,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use hasPullRequest, prStatus?.url, aheadCount, + behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, @@ -341,18 +366,21 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use baseRefLabel, shouldPromoteArchive, commitDisabled, + pullDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, + pullStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, + handlePull, handlePush, handleCreatePr, handleMergeBranch, diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/screens/workspace/workspace-git-actions.tsx index 9213243de..86fb15d9c 100644 --- a/packages/app/src/screens/workspace/workspace-git-actions.tsx +++ b/packages/app/src/screens/workspace/workspace-git-actions.tsx @@ -1,6 +1,13 @@ import { useMemo } from "react"; import { useUnistyles } from "react-native-unistyles"; -import { Archive, GitCommitHorizontal, GitMerge, RefreshCcw, Upload } from "lucide-react-native"; +import { + Archive, + Download, + GitCommitHorizontal, + GitMerge, + RefreshCcw, + Upload, +} from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { useGitActions } from "@/hooks/use-git-actions"; @@ -16,6 +23,7 @@ export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps) const icons = useMemo( () => ({ commit: , + pull: , push: , viewPr: , createPr: , diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index 44829373b..c5c5f1177 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -8,6 +8,7 @@ export type CheckoutGitActionStatus = "idle" | "pending" | "success"; export type CheckoutGitAsyncActionId = | "commit" + | "pull" | "push" | "create-pr" | "merge-branch" @@ -139,6 +140,7 @@ interface CheckoutGitActionsStoreState { }) => CheckoutGitActionStatus; commit: (params: { serverId: string; cwd: string }) => Promise; + pull: (params: { serverId: string; cwd: string }) => Promise; push: (params: { serverId: string; cwd: string }) => Promise; createPr: (params: { serverId: string; cwd: string }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; @@ -223,6 +225,21 @@ export const useCheckoutGitActionsStore = create() }); }, + pull: async ({ serverId, cwd }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: "pull", + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutPull(cwd); + if (payload.error) { + throw new Error(payload.error.message); + } + }, + }); + }, + push: async ({ serverId, cwd }) => { await runCheckoutAction({ serverId, diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 144032ed8..2d15251a3 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -780,6 +780,61 @@ describe("DaemonClient", () => { }); }); + test("requests checkout pull via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.checkoutPull("/tmp/project", "req-pull"); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout_pull_request"; + cwd: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout_pull_request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.requestId).toBe("req-pull"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout_pull_response", + payload: { + cwd: "/tmp/project", + requestId: "req-pull", + success: true, + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + requestId: "req-pull", + success: true, + error: null, + }); + }); + test("resubscribes checkout diff streams after reconnect", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 0ceacf94f..fdee4a5b8 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -25,6 +25,7 @@ import type { CheckoutCommitResponse, CheckoutMergeResponse, CheckoutMergeFromBaseResponse, + CheckoutPullResponse, CheckoutPushResponse, CheckoutPrCreateResponse, CheckoutPrStatusResponse, @@ -218,6 +219,7 @@ type CheckoutDiffPayload = Omit; type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; +type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; @@ -2339,6 +2341,18 @@ export class DaemonClient { }); } + async checkoutPull(cwd: string, requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout_pull_request", + cwd, + }, + responseType: "checkout_pull_response", + timeout: 60000, + }); + } + async checkoutPush(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8e50221c9..8d9807bfa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -157,6 +157,7 @@ import { commitChanges, mergeToBase, mergeFromBase, + pullCurrentBranch, pushCurrentBranch, createPullRequest, getPullRequestStatus, @@ -1767,6 +1768,10 @@ export class Session { await this.handleCheckoutMergeFromBaseRequest(msg); break; + case "checkout_pull_request": + await this.handleCheckoutPullRequest(msg); + break; + case "checkout_push_request": await this.handleCheckoutPushRequest(msg); break; @@ -4729,6 +4734,37 @@ export class Session { } } + private async handleCheckoutPullRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + await pullCurrentBranch(cwd); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + + this.emit({ + type: "checkout_pull_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_pull_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + private async handleCheckoutPushRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index bc207abf7..886d13941 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1029,6 +1029,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutPullRequestSchema = z.object({ + type: z.literal("checkout_pull_request"), + cwd: z.string(), + requestId: z.string(), +}); + export const CheckoutPushRequestSchema = z.object({ type: z.literal("checkout_push_request"), cwd: z.string(), @@ -1420,6 +1426,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutCommitRequestSchema, CheckoutMergeRequestSchema, CheckoutMergeFromBaseRequestSchema, + CheckoutPullRequestSchema, CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrStatusRequestSchema, @@ -2186,6 +2193,16 @@ export const CheckoutMergeFromBaseResponseSchema = z.object({ }), }); +export const CheckoutPullResponseSchema = z.object({ + type: z.literal("checkout_pull_response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPushResponseSchema = z.object({ type: z.literal("checkout_push_response"), payload: z.object({ @@ -2658,6 +2675,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutCommitResponseSchema, CheckoutMergeResponseSchema, CheckoutMergeFromBaseResponseSchema, + CheckoutPullResponseSchema, CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, @@ -2874,6 +2892,8 @@ export type CheckoutMergeRequest = z.infer; export type CheckoutMergeResponse = z.infer; export type CheckoutMergeFromBaseRequest = z.infer; export type CheckoutMergeFromBaseResponse = z.infer; +export type CheckoutPullRequest = z.infer; +export type CheckoutPullResponse = z.infer; export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; export type CheckoutPrCreateRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 40a8ebbe3..0d58ddb09 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -28,6 +28,7 @@ import { MergeConflictError, MergeFromBaseConflictError, NotGitRepoError, + pullCurrentBranch, pushCurrentBranch, resolveRepositoryDefaultBranch, parseWorktreeList, @@ -534,6 +535,28 @@ const x = 1; expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); }); + it("pulls the current branch from origin", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare -b main ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + const otherClone = join(tempDir, "other-clone"); + execSync(`git clone ${remoteDir} ${otherClone}`); + execSync("git config user.email 'test@test.com'", { cwd: otherClone }); + execSync("git config user.name 'Test'", { cwd: otherClone }); + writeFileSync(join(otherClone, "pulled.txt"), "remote\n"); + execSync("git add pulled.txt", { cwd: otherClone }); + execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone }); + const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim(); + execSync("git push", { cwd: otherClone }); + + await pullCurrentBranch(repoDir); + + execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir }); + expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n"); + }); + it("pushes the current branch to origin", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ccfb38bde..ef246e32f 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1757,6 +1757,19 @@ export async function mergeFromBase( } } +export async function pullCurrentBranch(cwd: string): Promise { + 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."); + } + await execAsync("git pull", { cwd }); +} + export async function pushCurrentBranch(cwd: string): Promise { await requireGitRepo(cwd); const currentBranch = await getCurrentBranch(cwd); From 70985088f6853aa8c7e492aad2454ac7086a5b71 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 20:29:26 +0700 Subject: [PATCH 6/7] Make pull abort safely on conflicts --- .../server/src/utils/checkout-git.test.ts | 58 +++++++++++++++++++ packages/server/src/utils/checkout-git.ts | 56 +++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0d58ddb09..019f8841f 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execSync } from "child_process"; import { + existsSync, mkdtempSync, rmSync, writeFileSync, @@ -557,6 +558,63 @@ const x = 1; expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n"); }); + it("aborts pull on merge conflicts and leaves no merge in progress", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare -b main ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + + writeFileSync(join(repoDir, "conflict.txt"), "local\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'local conflict commit'", { cwd: repoDir }); + + const otherClone = join(tempDir, "other-clone"); + execSync(`git clone ${remoteDir} ${otherClone}`); + execSync("git config user.email 'test@test.com'", { cwd: otherClone }); + execSync("git config user.name 'Test'", { cwd: otherClone }); + writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); + execSync("git add conflict.txt", { cwd: otherClone }); + execSync("git -c commit.gpgsign=false commit -m 'remote conflict commit'", { cwd: otherClone }); + execSync("git push", { cwd: otherClone }); + + await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); + + const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim(); + expect(porcelain).toBe(""); + expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow(); + }); + + it("aborts pull on rebase conflicts and leaves no rebase in progress", async () => { + const remoteDir = join(tempDir, "remote.git"); + execSync(`git init --bare -b main ${remoteDir}`); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync("git push -u origin main", { cwd: repoDir }); + execSync("git config pull.rebase true", { cwd: repoDir }); + + writeFileSync(join(repoDir, "conflict.txt"), "local\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { cwd: repoDir }); + + const otherClone = join(tempDir, "other-clone"); + execSync(`git clone ${remoteDir} ${otherClone}`); + execSync("git config user.email 'test@test.com'", { cwd: otherClone }); + execSync("git config user.name 'Test'", { cwd: otherClone }); + writeFileSync(join(otherClone, "conflict.txt"), "remote\n"); + execSync("git add conflict.txt", { cwd: otherClone }); + execSync("git -c commit.gpgsign=false commit -m 'remote rebase conflict commit'", { + cwd: otherClone, + }); + execSync("git push", { cwd: otherClone }); + + await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error); + + const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: repoDir }).toString().trim(); + const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim(); + expect(porcelain).toBe(""); + expect(existsSync(join(gitDir, "rebase-merge"))).toBe(false); + expect(existsSync(join(gitDir, "rebase-apply"))).toBe(false); + }); + it("pushes the current branch to origin", async () => { const remoteDir = join(tempDir, "remote.git"); execSync(`git init --bare -b main ${remoteDir}`); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index ef246e32f..db4e3cdfd 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2,7 +2,7 @@ import { exec, execFile } from "child_process"; import { spawnProcess } from "./spawn.js"; import { promisify } from "util"; import { resolve, dirname, basename } from "path"; -import { realpathSync } from "fs"; +import { existsSync, realpathSync } from "fs"; import { open as openFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; @@ -828,6 +828,46 @@ async function hasOriginRemote(cwd: string): Promise { return url !== null; } +async function resolveAbsoluteGitDir(cwd: string): Promise { + 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 { + 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 { try { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { @@ -1463,6 +1503,13 @@ export async function getCheckoutDiff( continue; } + // `git diff -w --name-status` can still report a modified path even when the + // whitespace-filtered patch and numstat are both empty. Skip emitting a + // structured placeholder in that case so whitespace-only edits truly disappear. + if (ignoreWhitespace && !trackedDiffTruncated && stat === null) { + continue; + } + structured.push({ path: change.path, isNew: change.isNew, @@ -1767,7 +1814,12 @@ export async function pullCurrentBranch(cwd: string): Promise { if (!hasRemote) { throw new Error("Remote 'origin' is not configured."); } - await execAsync("git pull", { cwd }); + try { + await execAsync("git pull", { cwd }); + } catch (error) { + await abortGitPullConflictState(cwd); + throw error; + } } export async function pushCurrentBranch(cwd: string): Promise { From 7a1e12f0c0a550f8662077adf4441dc06a577044 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 20:57:51 +0700 Subject: [PATCH 7/7] Replace git action disables with unavailability toasts --- .../src/components/git-actions-policy.test.ts | 17 +++- .../app/src/components/git-actions-policy.ts | 79 +++++++++++-------- .../components/git-actions-split-button.tsx | 25 +++++- .../app/src/components/ui/dropdown-menu.tsx | 10 +++ 4 files changed, 90 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts index 1ee48e87f..cd02f515a 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/components/git-actions-policy.test.ts @@ -79,7 +79,7 @@ describe("git-actions-policy", () => { expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" }); }); - it("disables push with a short pull-first message when the branch diverged", () => { + it("keeps push clickable with a clearer message when the branch diverged", () => { const actions = buildGitActions( createInput({ hasRemote: true, @@ -90,8 +90,8 @@ describe("git-actions-policy", () => { const pushAction = actions.secondary.find((action) => action.id === "push"); expect(pushAction).toMatchObject({ - disabled: true, - description: "Pull first", + disabled: false, + unavailableMessage: "Push isn't available yet because there are newer changes to bring in first", }); }); @@ -108,6 +108,17 @@ describe("git-actions-policy", () => { 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", }); }); diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts index 4220e8f96..df1c9e7a9 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/components/git-actions-policy.ts @@ -18,7 +18,7 @@ export interface GitAction { successLabel: string; disabled: boolean; status: ActionStatus; - description?: string; + unavailableMessage?: string; icon?: ReactElement; handler: () => void; } @@ -82,9 +82,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: "Pull", pendingLabel: "Pulling...", successLabel: "Pulled", - disabled: input.runtime.pull.disabled || !canPull(input), + disabled: input.runtime.pull.disabled, status: input.runtime.pull.status, - description: getPullDescription(input), + unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input), icon: input.runtime.pull.icon, handler: input.runtime.pull.handler, }); @@ -94,9 +94,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: "Push", pendingLabel: "Pushing...", successLabel: "Pushed", - disabled: input.runtime.push.disabled || !canPush(input), + disabled: input.runtime.push.disabled, status: input.runtime.push.status, - description: getPushDescription(input), + unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input), icon: input.runtime.push.icon, handler: input.runtime.push.handler, }); @@ -108,9 +108,10 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: `Merge into ${input.baseRefLabel}`, pendingLabel: "Merging...", successLabel: "Merged", - disabled: input.runtime["merge-branch"].disabled || !canMergeBranch(input), + disabled: input.runtime["merge-branch"].disabled, status: input.runtime["merge-branch"].status, - description: getMergeBranchDescription(input), + unavailableMessage: + input.runtime["merge-branch"].disabled ? undefined : getMergeBranchUnavailableMessage(input), icon: input.runtime["merge-branch"].icon, handler: input.runtime["merge-branch"].handler, }); @@ -120,9 +121,12 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: `Update from ${input.baseRefLabel}`, pendingLabel: "Updating...", successLabel: "Updated", - disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input), + disabled: input.runtime["merge-from-base"].disabled, status: input.runtime["merge-from-base"].status, - description: getMergeFromBaseDescription(input), + unavailableMessage: + input.runtime["merge-from-base"].disabled + ? undefined + : getMergeFromBaseUnavailableMessage(input), icon: input.runtime["merge-from-base"].icon, handler: input.runtime["merge-from-base"].handler, }); @@ -132,9 +136,12 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { label: "Archive worktree", pendingLabel: "Archiving...", successLabel: "Archived", - disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree, + disabled: input.runtime["archive-worktree"].disabled, status: input.runtime["archive-worktree"].status, - description: input.isPaseoOwnedWorktree ? undefined : "Only for worktrees", + unavailableMessage: + input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree + ? undefined + : "Archive isn't available here because this workspace was not created as a Paseo worktree", icon: input.runtime["archive-worktree"].icon, handler: input.runtime["archive-worktree"].handler, }); @@ -189,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { label: "View PR", pendingLabel: "View PR", successLabel: "View PR", - disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled, + disabled: input.runtime.pr.disabled, status: input.runtime.pr.status, - description: input.githubFeaturesEnabled ? undefined : "GitHub unavailable", + unavailableMessage: + input.runtime.pr.disabled || input.githubFeaturesEnabled + ? undefined + : "View PR isn't available right now because GitHub isn't connected", icon: input.runtime.pr.icon, handler: input.runtime.pr.handler, }; @@ -202,9 +212,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { label: "Create PR", pendingLabel: "Creating PR...", successLabel: "PR Created", - disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0, + disabled: input.runtime.pr.disabled, status: input.runtime.pr.status, - description: getCreatePrDescription(input), + unavailableMessage: + input.runtime.pr.disabled ? undefined : getCreatePrUnavailableMessage(input), icon: input.runtime.pr.icon, handler: input.runtime.pr.handler, }; @@ -240,64 +251,64 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean { ); } -function getPullDescription(input: BuildGitActionsInput): string | undefined { +function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return "No remote"; + return "Pull isn't available here because this branch is not connected to a remote yet"; } if (input.hasUncommittedChanges) { - return "Clean tree"; + return "Pull isn't available while you have local changes so commit or stash them first"; } if (input.behindOfOrigin === 0) { - return "Nothing to pull"; + return "Pull isn't available because this branch is already up to date"; } return undefined; } -function getPushDescription(input: BuildGitActionsInput): string | undefined { +function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.hasRemote) { - return "No remote"; + return "Push isn't available here because this branch is not connected to a remote yet"; } if (input.behindOfOrigin > 0) { - return "Pull first"; + return "Push isn't available yet because there are newer changes to bring in first"; } if (input.aheadOfOrigin === 0) { - return "Nothing to push"; + return "Push isn't available because there is nothing new to send"; } return undefined; } -function getCreatePrDescription(input: BuildGitActionsInput): string | undefined { +function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.githubFeaturesEnabled) { - return "GitHub unavailable"; + return "Create PR isn't available right now because GitHub isn't connected"; } if (input.aheadCount === 0) { - return "No new commits"; + return "Create PR isn't available because this branch doesn't have any new commits yet"; } return undefined; } -function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined { +function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "No base"; + return "Merge isn't available because we couldn't determine the base branch"; } if (input.hasUncommittedChanges) { - return "Clean tree"; + return "Merge isn't available while you have local changes so commit or stash them first"; } if (input.aheadCount === 0) { - return "No new commits"; + return "Merge isn't available because this branch doesn't have anything new to merge yet"; } return undefined; } -function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined { +function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined { if (!input.baseRefAvailable) { - return "No base"; + return "Update isn't available because we couldn't determine the base branch"; } if (input.hasUncommittedChanges) { - return "Clean tree"; + return "Update isn't available while you have local changes so commit or stash them first"; } if (input.behindBaseCount === 0) { - return "Up to date"; + return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`; } return undefined; } diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index f0a6bd802..7a5dffe12 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { View, Text, ActivityIndicator, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronDown, MoreVertical } from "lucide-react-native"; +import { ChevronDown, Info, MoreVertical } from "lucide-react-native"; import { DropdownMenu, DropdownMenuContent, @@ -11,6 +11,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useToast } from "@/contexts/toast-context"; import type { GitAction, GitActions } from "@/components/git-actions-policy"; interface GitActionsSplitButtonProps { @@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps { export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) { const { theme } = useUnistyles(); + const toast = useToast(); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const getActionDisplayLabel = useCallback((action: GitAction): string => { @@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps return action.label; }, []); + const handleActionSelect = useCallback( + (action: GitAction) => { + if (action.unavailableMessage) { + toast.show(action.unavailableMessage, { + durationMs: 3200, + icon: , + }); + return; + } + action.handler(); + }, + [theme.colors.foreground, toast], + ); + return ( {gitActions.primary ? ( @@ -87,6 +103,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps : undefined } disabled={action.disabled} + muted={Boolean(action.unavailableMessage)} status={action.status} pendingLabel={action.pendingLabel} successLabel={action.successLabel} @@ -95,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps action.id === "pr" && action.label === "View PR" } - description={action.description} - onSelect={action.handler} + onSelect={() => handleActionSelect(action)} > {action.label} @@ -126,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps testID={`changes-menu-${action.id}`} leading={action.icon} disabled={action.disabled} + muted={Boolean(action.unavailableMessage)} status={action.status} pendingLabel={action.pendingLabel} successLabel={action.successLabel} closeOnSelect={false} - onSelect={action.handler} + onSelect={() => handleActionSelect(action)} > {action.label} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 2441b14d4..405d6714b 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -460,6 +460,7 @@ export function DropdownMenuItem({ description, onSelect, disabled, + muted = false, destructive, selected, showSelectedCheck = false, @@ -477,6 +478,7 @@ export function DropdownMenuItem({ description?: string; onSelect?: () => void; disabled?: boolean; + muted?: boolean; destructive?: boolean; selected?: boolean; showSelectedCheck?: boolean; @@ -550,6 +552,7 @@ export function DropdownMenuItem({ ? styles.itemSelectedInteractive : null, isDisabled ? styles.itemDisabled : null, + muted && !isDisabled ? styles.itemMuted : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null, pressed && !isDisabled ? styles.itemPressed : null, ]} @@ -568,6 +571,7 @@ export function DropdownMenuItem({ destructive && !isSuccess ? styles.itemTextDestructive : null, isSuccess ? styles.itemTextSuccess : null, selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null, + muted && !isDisabled ? styles.itemTextMuted : null, ]} > {label} @@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({ itemDisabled: { opacity: 0.5, }, + itemMuted: { + opacity: 0.72, + }, itemText: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, fontWeight: theme.fontWeight.normal, }, + itemTextMuted: { + color: theme.colors.foregroundMuted, + }, itemTextDestructive: { color: theme.colors.destructive, },