feat: branch switching with stash-and-switch (#231)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix: stash detection and dirty-tree branch switch handling

- Fix stash list parser: git prepends "On <branch>: " 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-04-10 14:26:04 +08:00
committed by GitHub
co-authored by heyimsteve Claude Opus 4.6
parent 8a9d738438
commit b5a0ee9954
11 changed files with 768 additions and 80 deletions
@@ -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<View>(null);
if (!currentBranchName) {
return (
<Text
testID="workspace-header-title"
style={styles.headerTitle}
numberOfLines={1}
>
{title}
</Text>
);
}
return (
<View ref={anchorRef} collapsable={false}>
<Pressable
testID="workspace-header-branch-switcher"
onPress={() => onOpenChange(true)}
style={({ hovered, pressed }) => [
styles.branchSwitcherTrigger,
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
]}
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
<GitBranch
size={14}
color={theme.colors.foregroundMuted}
/>
<Text
testID="workspace-header-title"
style={styles.headerTitle}
numberOfLines={1}
>
{title}
</Text>
<ChevronDown
size={12}
color={theme.colors.foregroundMuted}
/>
</Pressable>
<Combobox
options={branchOptions}
value={currentBranchName}
onSelect={onBranchSelect}
searchable
placeholder="Switch branch..."
searchPlaceholder="Filter branches..."
emptyText="No branches found."
title="Switch branch"
open={isOpen}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={280}
renderOption={({ option, selected, active, onPress }) => (
<ComboboxItem
key={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
<GitBranch
size={14}
color={theme.colors.foregroundMuted}
/>
}
/>
)}
/>
</View>
);
}
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,
},
}));
+1 -1
View File
@@ -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<ReturnType<typeof setTimeout> | 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 =
@@ -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 },
@@ -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({
+9 -18
View File
@@ -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({
<Text style={styles.comboboxTitle}>{title}</Text>
</View>
{stickyHeader}
{!children && searchable ? searchInput : null}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
@@ -746,6 +741,7 @@ export function Combobox({
</>
) : (
<>
{searchable ? searchInput : null}
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={desktopOptionsScrollRef}
@@ -760,9 +756,7 @@ export function Combobox({
>
{optionsList}
</ScrollView>
) : null}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? (
) : (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
@@ -771,7 +765,7 @@ export function Combobox({
>
{optionsList}
</ScrollView>
) : null}
)}
</>
)}
</Animated.View>
@@ -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,
@@ -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<void>;
}
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<ComboboxOption[]>(() => {
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 };
}
@@ -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)
</>
) : (
<>
<Text
testID="workspace-header-title"
style={styles.headerTitle}
numberOfLines={1}
>
{workspaceHeader.title}
</Text>
<BranchSwitcher
currentBranchName={currentBranchName}
title={workspaceHeader.title}
branchOptions={branchOptions}
isOpen={isBranchSwitcherOpen}
onOpenChange={setIsBranchSwitcherOpen}
onBranchSelect={handleBranchSelect}
/>
<Text
testID="workspace-header-subtitle"
style={styles.headerProjectTitle}