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); }