From b5a0ee99546dfe8fdb470c16a8f915f221dcdc03 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 13:26:04 +0700 Subject: [PATCH] feat: branch switching with stash-and-switch (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add branch switching and stash management to workspace header Adds the ability to switch git branches from the workspace header by clicking the branch name. Includes full stash support: when switching away from a dirty branch, users are prompted to stash changes; when switching to a branch with a Paseo stash, they're prompted to restore. An amber stash indicator in the header provides anytime restore/discard. New WebSocket messages: checkout_switch_branch, stash_save, stash_pop, stash_drop, stash_list. Stashes are tracked via git stash messages with a "paseo-auto-stash:" prefix convention. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: await query invalidation after branch switch and stash The branch switcher became non-clickable after switching because checkout query invalidation was fire-and-forget. Now we await invalidation so the UI has fresh checkout status (including the new branch name) before rendering. Also invalidate after stash-save so the checkout query sees clean state before the switch. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: emit workspace update immediately after branch switch The sidebar and header were slow to reflect the new branch name because they relied on the daemon's background git watcher to detect changes. Now the switch handler calls emitWorkspaceUpdateForCwd immediately after checkout, pushing the updated workspace descriptor to connected clients. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: stash detection and dirty-tree branch switch handling - Fix stash list parser: git prepends "On : " to stash messages, so use indexOf instead of startsWith to find the paseo-auto-stash prefix. - Remove switchBranchMutation in favor of inline async flow in handleBranchSelect. Now tries the switch first; if the server returns an uncommitted-changes error, offers the stash dialog automatically — no more red error toast on first attempt. - Move post-switch stash restore prompt into handleBranchSelect so the full flow (switch → check stashes → prompt) is sequential. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: toast.success → toast.show and await stash invalidation - Replace toast.success() with toast.show() — the ToastApi only exposes show/copied/error, not success. - Await invalidateStashAndCheckout in stashPop and stashDrop mutation onSuccess handlers so the stash indicator disappears immediately after restore/discard. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add stash diff preview and multi-stash management - Add stash_show message pair: server runs `git stash show -p` and returns ParsedDiffFile[] through the existing diff parser. - Stash indicator dropdown now lists ALL Paseo stashes (not just current branch) with per-stash Preview, Restore, and Discard actions. - Preview opens a full diff modal showing file-by-file changes with syntax-colored add/remove lines, file stats, and Restore/Discard action buttons. - Clean up: remove unused handleRestoreStash/handleDropStash callbacks, add Eye/Trash2 icons, import Modal/ScrollView. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use Fonts.mono instead of theme.fontFamily.mono The theme object doesn't have a fontFamily property. The codebase uses the Fonts constant from @/constants/theme for font family values. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: filter stashes to current branch and handle restore conflicts - Stash indicator only shows when the current branch has stashes, not all branches. Switching branches hides/shows it correctly. - When restoring a stash conflicts with uncommitted changes, offers to stash current changes first then restore the target stash (auto-adjusts stash index after the new save). - Multiple stashes on the same branch show as "Stash 1", "Stash 2" instead of repeating the branch name. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: improve branch switcher with recency sort, sticky search, and branch icons - Sort branches by committer date (most recent first) instead of alphabetical - Use full refname to fix normalization of branches with slashes - Filter out bare "origin" symref leaking as a branch name - Remove server-side search query; fetch all branches once and filter client-side - Port combobox sticky search design from dev (borderless bar above scroll area) - Add GitBranch icons to branch switcher items - Prioritize prefix matches in combobox search results * fix: move useIsCompactFormFactor hook above early return in ToastViewport The hook was called after `if (!toast) return null`, violating React's rules of hooks and causing "Rendered more hooks than during the previous render" crash. * fix: include untracked files in stash-and-switch flow git stash push without --include-untracked left untracked files behind, causing the clean-tree check to fail on the subsequent checkout. * refactor: strip stash management UI, keep stash-and-switch flow Remove the stash indicator icon, dropdown menu, preview modal, and stash_drop/stash_show RPCs. Keep the auto-stash on branch switch and the restore prompt on arrival — that's the useful surface. * refactor: extract filterAndRankComboboxOptions with tests * refactor: extract BranchSwitcher component and useBranchSwitcher hook --------- Co-authored-by: heyimsteve <8645831+heyimsteve@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .../app/src/components/branch-switcher.tsx | 125 +++++++++++++ packages/app/src/components/toast-host.tsx | 2 +- .../components/ui/combobox-options.test.ts | 43 +++++ .../app/src/components/ui/combobox-options.ts | 31 +++- packages/app/src/components/ui/combobox.tsx | 27 +-- packages/app/src/hooks/use-branch-switcher.ts | 164 ++++++++++++++++++ .../screens/workspace/workspace-screen.tsx | 35 +++- packages/server/src/client/daemon-client.ts | 76 ++++++++ packages/server/src/server/session.ts | 149 ++++++++++++++++ packages/server/src/shared/messages.ts | 96 ++++++++++ packages/server/src/utils/checkout-git.ts | 100 ++++++----- 11 files changed, 768 insertions(+), 80 deletions(-) create mode 100644 packages/app/src/components/branch-switcher.tsx create mode 100644 packages/app/src/hooks/use-branch-switcher.ts diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx new file mode 100644 index 000000000..f17b4cdd5 --- /dev/null +++ b/packages/app/src/components/branch-switcher.tsx @@ -0,0 +1,125 @@ +import { useRef } from "react"; +import { Pressable, Text, View } from "react-native"; +import { ChevronDown, GitBranch } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; + +interface BranchSwitcherProps { + currentBranchName: string | null; + title: string; + branchOptions: ComboboxOption[]; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onBranchSelect: (branchId: string) => void; +} + +export function BranchSwitcher({ + currentBranchName, + title, + branchOptions, + isOpen, + onOpenChange, + onBranchSelect, +}: BranchSwitcherProps) { + const { theme } = useUnistyles(); + const anchorRef = useRef(null); + + if (!currentBranchName) { + return ( + + {title} + + ); + } + + return ( + + onOpenChange(true)} + style={({ hovered, pressed }) => [ + styles.branchSwitcherTrigger, + (hovered || pressed) && styles.branchSwitcherTriggerHovered, + ]} + accessibilityRole="button" + accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`} + > + + + {title} + + + + ( + + } + /> + )} + /> + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerTitle: { + fontSize: theme.fontSize.base, + fontWeight: { + xs: "400", + md: "300", + }, + color: theme.colors.foreground, + flexShrink: 1, + }, + branchSwitcherTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + flexShrink: 1, + minWidth: 0, + }, + branchSwitcherTriggerHovered: { + backgroundColor: theme.colors.surface1, + }, +})); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index ca1f04575..9920d660b 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -109,6 +109,7 @@ export function ToastViewport({ }) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); + const isMobile = useIsCompactFormFactor(); const opacity = useRef(new Animated.Value(0)).current; const translateY = useRef(new Animated.Value(-8)).current; const timeoutRef = useRef | null>(null); @@ -182,7 +183,6 @@ export function ToastViewport({ return null; } - const isMobile = useIsCompactFormFactor(); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topOffset = diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts index 3c6a9addf..57396e163 100644 --- a/packages/app/src/components/ui/combobox-options.test.ts +++ b/packages/app/src/components/ui/combobox-options.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildVisibleComboboxOptions, + filterAndRankComboboxOptions, getComboboxFallbackIndex, orderVisibleComboboxOptions, } from "./combobox-options"; @@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => { }); }); +describe("filterAndRankComboboxOptions", () => { + const options = [ + { id: "feat/login", label: "feat/login" }, + { id: "main", label: "main" }, + { id: "feat/main-nav", label: "feat/main-nav" }, + { id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" }, + ]; + + it("returns all options when search is empty", () => { + expect(filterAndRankComboboxOptions(options, "")).toEqual(options); + }); + + it("filters by label substring", () => { + const result = filterAndRankComboboxOptions(options, "login"); + expect(result.map((o) => o.id)).toEqual(["feat/login"]); + }); + + it("filters by id substring", () => { + const result = filterAndRankComboboxOptions(options, "fix/"); + expect(result.map((o) => o.id)).toEqual(["fix/logout"]); + }); + + it("filters by description substring", () => { + const result = filterAndRankComboboxOptions(options, "logout bug"); + expect(result.map((o) => o.id)).toEqual(["fix/logout"]); + }); + + it("ranks prefix matches above substring matches", () => { + const result = filterAndRankComboboxOptions(options, "main"); + expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]); + }); + + it("is case-insensitive", () => { + const items = [{ id: "Alpha", label: "Alpha" }]; + expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1); + }); + + it("returns empty when nothing matches", () => { + expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]); + }); +}); + describe("combobox above-search ordering", () => { const visible = [ { id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const }, diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index 18a205427..8fc41832d 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: { ); } +export function filterAndRankComboboxOptions( + options: ComboboxOptionModel[], + search: string, +): ComboboxOptionModel[] { + if (!search) return options; + return options + .filter( + (opt) => + opt.label.toLowerCase().includes(search) || + opt.id.toLowerCase().includes(search) || + opt.description?.toLowerCase().includes(search), + ) + .sort((a, b) => { + const aPrefix = + a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search); + const bPrefix = + b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search); + if (aPrefix !== bPrefix) return aPrefix ? -1 : 1; + return 0; + }); +} + export function buildVisibleComboboxOptions( input: BuildVisibleComboboxOptionsInput, ): ComboboxOptionModel[] { const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; - const filteredOptions = normalizedSearch - ? input.options.filter( - (opt) => - opt.label.toLowerCase().includes(normalizedSearch) || - opt.id.toLowerCase().includes(normalizedSearch) || - opt.description?.toLowerCase().includes(normalizedSearch), - ) - : input.options; + const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch); const sanitizedSearchValue = input.searchQuery.trim(); const showCustomOption = shouldShowCustomComboboxOption({ diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 012d181a7..7093d3b51 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -660,13 +660,7 @@ export function Combobox({ ); - const defaultContent = ( - <> - {effectiveOptionsPosition === "above-search" ? optionsList : null} - {searchable ? searchInput : null} - {effectiveOptionsPosition === "below-search" ? optionsList : null} - - ); + const defaultContent = optionsList; const content = children ?? defaultContent; @@ -691,6 +685,7 @@ export function Combobox({ {title} {stickyHeader} + {!children && searchable ? searchInput : null} ) : ( <> + {searchable ? searchInput : null} {effectiveOptionsPosition === "above-search" ? ( {optionsList} - ) : null} - {searchable ? searchInput : null} - {effectiveOptionsPosition === "below-search" ? ( + ) : ( {optionsList} - ) : null} + )} )} @@ -784,15 +778,12 @@ const styles = StyleSheet.create((theme) => ({ searchInputContainer: { flexDirection: "row", alignItems: "center", - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, - borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[3], - marginHorizontal: theme.spacing[2], - marginBottom: theme.spacing[2], - marginTop: theme.spacing[1], gap: theme.spacing[2], + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, searchInput: { flex: 1, diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts new file mode 100644 index 000000000..543abeb50 --- /dev/null +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -0,0 +1,164 @@ +import { useState, useCallback, useMemo } from "react"; +import { useQuery, type QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { ComboboxOption } from "@/components/ui/combobox"; +import type { ToastApi } from "@/components/toast-host"; +import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +interface UseBranchSwitcherInput { + client: DaemonClient | null; + normalizedServerId: string; + normalizedWorkspaceId: string; + currentBranchName: string | null; + isGitCheckout: boolean; + isConnected: boolean; + toast: ToastApi; + queryClient: QueryClient; +} + +interface UseBranchSwitcherResult { + branchOptions: ComboboxOption[]; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + handleBranchSelect: (branchId: string) => void; + invalidateStashAndCheckout: () => Promise; +} + +export function useBranchSwitcher({ + client, + normalizedServerId, + normalizedWorkspaceId, + currentBranchName, + isGitCheckout, + isConnected, + toast, + queryClient, +}: UseBranchSwitcherInput): UseBranchSwitcherResult { + const [isOpen, setIsOpen] = useState(false); + + const branchSuggestionsQuery = useQuery({ + queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], + queryFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getBranchSuggestions({ + cwd: normalizedWorkspaceId, + limit: 200, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.branches ?? []; + }, + enabled: isOpen && isGitCheckout && Boolean(client) && isConnected, + retry: false, + staleTime: 15_000, + }); + + const branchOptions = useMemo(() => { + const branches = branchSuggestionsQuery.data ?? []; + return branches.map((name) => ({ id: name, label: name })); + }, [branchSuggestionsQuery.data]); + + const stashListQueryKey = useMemo( + () => ["stashList", normalizedServerId, normalizedWorkspaceId] as const, + [normalizedServerId, normalizedWorkspaceId], + ); + + const invalidateStashAndCheckout = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: stashListQueryKey }), + queryClient.invalidateQueries({ + queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId), + }), + ]); + }, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]); + + const stashAndSwitch = useCallback( + async (branchId: string) => { + if (!client) return; + const shouldStash = await confirmDialog({ + title: "Uncommitted changes", + message: + "You have uncommitted changes. Stash them before switching branches?", + confirmLabel: "Stash & Switch", + cancelLabel: "Cancel", + }); + if (!shouldStash) return; + + try { + const stashPayload = await client.stashSave(normalizedWorkspaceId, { + branch: currentBranchName ?? undefined, + }); + if (stashPayload.error) { + toast.error(stashPayload.error.message); + return; + } + await invalidateStashAndCheckout(); + const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId); + if (switchPayload.error) { + toast.error(switchPayload.error.message); + return; + } + await invalidateStashAndCheckout(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to stash changes"); + } + }, + [client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast], + ); + + const handleBranchSelect = useCallback( + (branchId: string) => { + if (branchId === currentBranchName) return; + + void (async () => { + if (!client) return; + try { + const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId); + if (payload.error) { + // If the error is about uncommitted changes, offer the stash dialog + if (payload.error.message.toLowerCase().includes("uncommitted")) { + await stashAndSwitch(branchId); + return; + } + toast.error(payload.error.message); + return; + } + // Success — refresh and check for stashes on the target branch + await invalidateStashAndCheckout(); + try { + const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true }); + const targetStash = stashPayload.entries.find((e) => e.branch === branchId); + if (targetStash) { + const shouldRestore = await confirmDialog({ + title: "Restore stashed changes?", + message: "This branch has stashed changes from a previous session. Would you like to restore them?", + confirmLabel: "Restore", + cancelLabel: "Later", + }); + if (shouldRestore) { + const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index); + if (popPayload.error) { + toast.error(popPayload.error.message); + } else { + toast.show("Stashed changes restored"); + } + await invalidateStashAndCheckout(); + } + } + } catch { + // Non-critical — user can still restore on next branch switch + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to switch branch"); + } + })(); + }, + [client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast], + ); + + return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout }; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ab53f23ac..a956b9dfd 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -34,6 +34,7 @@ import invariant from "tiny-invariant"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { ScreenHeader } from "@/components/headers/screen-header"; +import { BranchSwitcher } from "@/components/branch-switcher"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import { @@ -80,6 +81,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; import { useStableEvent } from "@/hooks/use-stable-event"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; @@ -759,6 +761,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD" ? trimNonEmpty(checkoutQuery.data.currentBranch) : null; + + const { + branchOptions, + isOpen: isBranchSwitcherOpen, + setIsOpen: setIsBranchSwitcherOpen, + handleBranchSelect, + invalidateStashAndCheckout, + } = useBranchSwitcher({ + client, + normalizedServerId, + normalizedWorkspaceId, + currentBranchName, + isGitCheckout, + isConnected, + toast, + queryClient, + }); + const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); @@ -1948,13 +1968,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ) : ( <> - - {workspaceHeader.title} - + { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout_switch_branch_request", + cwd, + branch, + }, + responseType: "checkout_switch_branch_response", + timeout: 30000, + }); + } + + async stashSave( + cwd: string, + options?: { branch?: string }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "stash_save_request", + cwd, + branch: options?.branch, + }, + responseType: "stash_save_response", + timeout: 30000, + }); + } + + async stashPop( + cwd: string, + stashIndex: number, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "stash_pop_request", + cwd, + stashIndex, + }, + responseType: "stash_pop_response", + timeout: 30000, + }); + } + + async stashList( + cwd: string, + options?: { paseoOnly?: boolean }, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "stash_list_request", + cwd, + paseoOnly: options?.paseoOnly, + }, + responseType: "stash_list_response", + timeout: 10000, + }); + } + async getPaseoWorktreeList( input: { cwd?: string; repoRoot?: string }, requestId?: string, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1c113e716..17e617b4c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1746,6 +1746,22 @@ export class Session { this.handleUnsubscribeCheckoutDiffRequest(msg); break; + case "checkout_switch_branch_request": + await this.handleCheckoutSwitchBranchRequest(msg); + break; + + case "stash_save_request": + await this.handleStashSaveRequest(msg); + break; + + case "stash_pop_request": + await this.handleStashPopRequest(msg); + break; + + case "stash_list_request": + await this.handleStashListRequest(msg); + break; + case "checkout_commit_request": await this.handleCheckoutCommitRequest(msg); break; @@ -4427,6 +4443,139 @@ export class Session { this.checkoutDiffSubscriptions.delete(msg.subscriptionId); } + private async handleCheckoutSwitchBranchRequest( + msg: Extract, + ): Promise { + const { cwd, branch, requestId } = msg; + + try { + await this.checkoutExistingBranch(cwd, branch); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + + // Push a workspace_update immediately so the sidebar/header reflect + // the new branch name without waiting for the background git watcher. + await this.emitWorkspaceUpdateForCwd(cwd); + + this.emit({ + type: "checkout_switch_branch_response", + payload: { + cwd, + success: true, + branch, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout_switch_branch_response", + payload: { + cwd, + success: false, + branch, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + // --------------------------------------------------------------------------- + // Stash handlers + // --------------------------------------------------------------------------- + + private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:"; + + private async handleStashSaveRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + try { + const branchLabel = msg.branch?.trim() ?? ""; + const message = branchLabel + ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}` + : `${Session.PASEO_STASH_PREFIX} unnamed`; + await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.emit({ + type: "stash_save_response", + payload: { cwd, success: true, error: null, requestId }, + }); + } catch (error) { + this.emit({ + type: "stash_save_response", + payload: { cwd, success: false, error: toCheckoutError(error), requestId }, + }); + } + } + + private async handleStashPopRequest( + msg: Extract, + ): Promise { + const { cwd, stashIndex, requestId } = msg; + try { + await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.emit({ + type: "stash_pop_response", + payload: { cwd, success: true, error: null, requestId }, + }); + } catch (error) { + this.emit({ + type: "stash_pop_response", + payload: { cwd, success: false, error: toCheckoutError(error), requestId }, + }); + } + } + + private async handleStashListRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + const paseoOnly = msg.paseoOnly !== false; + try { + const { stdout } = await execAsync("git stash list --format=%gd%x00%s", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const lines = stdout.trim().split("\n").filter(Boolean); + const entries: Array<{ + index: number; + message: string; + branch: string | null; + isPaseo: boolean; + }> = []; + + for (const line of lines) { + const sepIdx = line.indexOf("\0"); + if (sepIdx < 0) continue; + const refPart = line.slice(0, sepIdx); + const subject = line.slice(sepIdx + 1); + const indexMatch = refPart.match(/\{(\d+)\}/); + if (!indexMatch) continue; + const index = Number(indexMatch[1]); + const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX); + const isPaseo = prefixIdx >= 0; + const branch = isPaseo + ? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null + : null; + + if (paseoOnly && !isPaseo) continue; + entries.push({ index, message: subject, branch, isPaseo }); + } + + this.emit({ + type: "stash_list_response", + payload: { cwd, entries, error: null, requestId }, + }); + } catch (error) { + this.emit({ + type: "stash_list_response", + payload: { cwd, entries: [], error: toCheckoutError(error), requestId }, + }); + } + } + private async handleCheckoutCommitRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 1d89d5bea..bc207abf7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1057,6 +1057,37 @@ export const ValidateBranchRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutSwitchBranchRequestSchema = z.object({ + type: z.literal("checkout_switch_branch_request"), + cwd: z.string(), + branch: z.string(), + requestId: z.string(), +}); + +export const StashSaveRequestSchema = z.object({ + type: z.literal("stash_save_request"), + cwd: z.string(), + /** Branch name to tag the stash with for later identification. */ + branch: z.string().optional(), + requestId: z.string(), +}); + +export const StashPopRequestSchema = z.object({ + type: z.literal("stash_pop_request"), + cwd: z.string(), + /** Zero-based index from stash_list_response. */ + stashIndex: z.number().int().min(0), + requestId: z.string(), +}); + +export const StashListRequestSchema = z.object({ + type: z.literal("stash_list_request"), + cwd: z.string(), + /** If true, only return paseo-created stashes. Default true. */ + paseoOnly: z.boolean().optional(), + requestId: z.string(), +}); + export const BranchSuggestionsRequestSchema = z.object({ type: z.literal("branch_suggestions_request"), cwd: z.string(), @@ -1392,6 +1423,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrStatusRequestSchema, + CheckoutSwitchBranchRequestSchema, + StashSaveRequestSchema, + StashPopRequestSchema, + StashListRequestSchema, ValidateBranchRequestSchema, BranchSuggestionsRequestSchema, DirectorySuggestionsRequestSchema, @@ -2192,6 +2227,54 @@ export const CheckoutPrStatusResponseSchema = z.object({ }), }); +export const CheckoutSwitchBranchResponseSchema = z.object({ + type: z.literal("checkout_switch_branch_response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + branch: z.string(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +const StashEntrySchema = z.object({ + index: z.number().int().min(0), + message: z.string(), + branch: z.string().nullable(), + isPaseo: z.boolean(), +}); + +export const StashSaveResponseSchema = z.object({ + type: z.literal("stash_save_response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const StashPopResponseSchema = z.object({ + type: z.literal("stash_pop_response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const StashListResponseSchema = z.object({ + type: z.literal("stash_list_response"), + payload: z.object({ + cwd: z.string(), + entries: z.array(StashEntrySchema), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const ValidateBranchResponseSchema = z.object({ type: z.literal("validate_branch_response"), payload: z.object({ @@ -2578,6 +2661,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, + CheckoutSwitchBranchResponseSchema, + StashSaveResponseSchema, + StashPopResponseSchema, + StashListResponseSchema, ValidateBranchResponseSchema, BranchSuggestionsResponseSchema, DirectorySuggestionsResponseSchema, @@ -2793,6 +2880,15 @@ export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrStatusRequest = z.infer; export type CheckoutPrStatusResponse = z.infer; +export type CheckoutSwitchBranchRequest = z.infer; +export type CheckoutSwitchBranchResponse = z.infer; +export type StashSaveRequest = z.infer; +export type StashSaveResponse = z.infer; +export type StashPopRequest = z.infer; +export type StashPopResponse = z.infer; +export type StashListRequest = z.infer; +export type StashListResponse = z.infer; +export type StashEntry = z.infer; export type ValidateBranchRequest = z.infer; export type ValidateBranchResponse = z.infer; export type BranchSuggestionsRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index eb71a098b..ccfb38bde 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -157,7 +157,6 @@ type CheckoutFileChange = { isUntracked?: boolean; }; -type BranchSuggestionRefOrigin = "local" | "remote"; function normalizeBranchSuggestionName(raw: string): string | null { const trimmed = raw.trim(); @@ -176,47 +175,57 @@ function normalizeBranchSuggestionName(raw: string): string | null { normalized = normalized.slice("origin/".length); } - if (!normalized || normalized === "HEAD") { + if (!normalized || normalized === "HEAD" || normalized === "origin") { return null; } return normalized; } -async function listGitRefs(cwd: string, refPrefix: string): Promise { - const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, { - cwd, - env: READ_ONLY_GIT_ENV, - }); +interface GitRef { + name: string; + committerDate: number; +} + +async function listGitRefs(cwd: string, refPrefix: string): Promise { + const { stdout } = await execGit( + `git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`, + { cwd, env: READ_ONLY_GIT_ENV }, + ); return stdout .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) return null; + const [name, dateStr] = trimmed.split("\t"); + if (!name) return null; + return { name, committerDate: Number(dateStr) || 0 }; + }) + .filter((ref): ref is GitRef => ref !== null); } function sortBranchSuggestions( branchNames: string[], - localBranchNames: Set, + branchMeta: Map, query: string, ): string[] { const normalizedQuery = query.trim().toLowerCase(); const hasQuery = normalizedQuery.length > 0; return branchNames.sort((a, b) => { - const aLower = a.toLowerCase(); - const bLower = b.toLowerCase(); - if (hasQuery) { - const aPrefix = aLower.startsWith(normalizedQuery); - const bPrefix = bLower.startsWith(normalizedQuery); + const aPrefix = a.toLowerCase().startsWith(normalizedQuery); + const bPrefix = b.toLowerCase().startsWith(normalizedQuery); if (aPrefix !== bPrefix) { return aPrefix ? -1 : 1; } } - const aIsLocal = localBranchNames.has(a); - const bIsLocal = localBranchNames.has(b); - if (aIsLocal !== bIsLocal) { - return aIsLocal ? -1 : 1; + const aMeta = branchMeta.get(a); + const bMeta = branchMeta.get(b); + const aDate = aMeta?.committerDate ?? 0; + const bDate = bMeta?.committerDate ?? 0; + if (aDate !== bDate) { + return bDate - aDate; } return a.localeCompare(b); @@ -238,41 +247,40 @@ export async function listBranchSuggestions( listGitRefs(cwd, "refs/remotes/origin"), ]); - const merged = new Map>(); - for (const localRef of localRefs) { - const normalized = normalizeBranchSuggestionName(localRef); - if (!normalized) { - continue; - } - const origins = merged.get(normalized) ?? new Set(); - origins.add("local"); - merged.set(normalized, origins); - } - for (const remoteRef of remoteRefs) { - const normalized = normalizeBranchSuggestionName(remoteRef); - if (!normalized) { - continue; - } - const origins = merged.get(normalized) ?? new Set(); - origins.add("remote"); - merged.set(normalized, origins); + const branchMeta = new Map(); + + for (const ref of localRefs) { + const normalized = normalizeBranchSuggestionName(ref.name); + if (!normalized) continue; + const existing = branchMeta.get(normalized); + branchMeta.set(normalized, { + isLocal: true, + committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0), + }); } - const filteredNames = Array.from(merged.keys()).filter((name) => + for (const ref of remoteRefs) { + const normalized = normalizeBranchSuggestionName(ref.name); + if (!normalized) continue; + const existing = branchMeta.get(normalized); + if (!existing) { + branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate }); + } else { + branchMeta.set(normalized, { + ...existing, + committerDate: Math.max(ref.committerDate, existing.committerDate), + }); + } + } + + const filteredNames = Array.from(branchMeta.keys()).filter((name) => query ? name.toLowerCase().includes(query) : true, ); if (filteredNames.length === 0) { return []; } - const localBranchNames = new Set(); - for (const [name, origins] of merged) { - if (origins.has("local")) { - localBranchNames.add(name); - } - } - - const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query); + const ordered = sortBranchSuggestions(filteredNames, branchMeta, query); return ordered.slice(0, limit); }