diff --git a/packages/app/e2e/browser/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts index c413b6413e..9799242390 100644 --- a/packages/app/e2e/browser/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -1,4 +1,7 @@ +import { writeFile, unlink } from "node:fs/promises"; +import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { gotoAppShell } from "../support/helpers/app"; import { expectNoBranchSwitcherInWorkspaceHeader, @@ -14,6 +17,88 @@ import { waitForSidebarHydration, } from "../support/helpers/workspace-ui"; +// Hold the real daemon response until the test has observed the pending UI. +async function holdBranchSuggestions(page: Page) { + let requestCount = 0; + let release!: () => void; + let gate = new Promise((resolve) => { + release = resolve; + }); + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((message) => { + const envelope = JSON.parse(message.toString()); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request") + requestCount++; + server.send(message); + }); + server.onMessage(async (message) => { + const envelope = JSON.parse(message.toString()); + if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") { + await gate; + } + browser.send(message); + }); + }); + return { + release: () => release(), + holdNextResponse: () => { + gate = new Promise((resolve) => { + release = resolve; + }); + }, + requestCount: () => requestCount, + }; +} + +async function expectInitialBranchLoading(page: Page, release: () => void): Promise { + await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click(); + const picker = page.getByTestId("combobox-desktop-container"); + await expect(picker.getByText("Loading...", { exact: true })).toBeVisible(); + await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0); + release(); + await expect(picker.getByText("dev", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(picker).not.toBeVisible(); +} + +async function expectFailedRefreshRecovery( + page: Page, + repoPath: string, + responses: Awaited>, +) { + // Expire the actual query freshness window before reopening the cached picker. + const started = Date.now(); + await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000); + const picker = page.getByTestId("combobox-desktop-container"); + const packedRefs = path.join(repoPath, ".git", "packed-refs"); + await writeFile(packedRefs, "invalid packed refs\n"); + try { + await page + .getByRole("button", { name: "Current branch: dev. Press to switch branch." }) + .click(); + await expect(picker.getByRole("alert")).toHaveText( + "Could not load branches. Reopen the picker to try again.", + ); + } finally { + await unlink(packedRefs); + } + await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0); + responses.holdNextResponse(); + const beforeRetry = responses.requestCount(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click(); + await expect(picker.getByRole("alert")).toHaveText("Loading..."); + await expect.poll(responses.requestCount).toBe(beforeRetry + 1); + await expect(picker).toBeVisible(); + responses.release(); + await expect(picker.getByRole("alert")).toHaveCount(0); + await expect(picker.getByText("main", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expectWorkspaceBranch(page, "dev"); + expect(responses.requestCount()).toBe(beforeRetry + 1); +} + async function renameWorkspaceViaSidebar( page: Page, input: { workspaceId: string; title: string }, @@ -59,6 +144,7 @@ test.describe("Branch switcher", () => { try { expect(workspace.workspaceName).toBe("main"); + const releaseBranches = await holdBranchSuggestions(page); await gotoAppShell(page); await waitForSidebarHydration(page); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); @@ -82,8 +168,10 @@ test.describe("Branch switcher", () => { // and switching it checks out the real branch on disk. await openChangesPanel(page); await expectWorkspaceBranch(page, "main"); + await expectInitialBranchLoading(page, releaseBranches.release); await switchBranchFromChangesPanel(page, { from: "main", to: "dev" }); await expectWorkspaceBranch(page, "dev"); + await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches); // The custom title is unaffected by the branch switch. await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 }); diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 737035d540..2b23ff775e 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -41,7 +41,15 @@ export function BranchSwitcher({ const toast = useToast(); const queryClient = useQueryClient(); - const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ + const { + branchOptions, + isLoadingBranches, + isFetchingBranches, + branchError, + isOpen, + setIsOpen, + handleBranchSelect, + } = useBranchSwitcher({ client, normalizedServerId: serverId, normalizedWorkspaceId: workspaceId, @@ -73,6 +81,22 @@ export function BranchSwitcher({ [branchLeadingSlot], ); + let emptyText = t("branchSwitcher.empty"); + if (isLoadingBranches) emptyText = t("common.states.loading"); + else if (branchError) emptyText = ""; + + const errorFooter = useMemo( + () => + branchError ? ( + + + {isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")} + + + ) : undefined, + [branchError, isFetchingBranches, t], + ); + if (!currentBranchName) { return null; } @@ -85,6 +109,7 @@ export function BranchSwitcher({ testID={testID} label={currentBranchName} open={isOpen} + disabled={!isConnected} onPress={handleOpen} accessibilityRole="button" accessibilityLabel={t("branchSwitcher.currentBranch", { @@ -103,7 +128,8 @@ export function BranchSwitcher({ searchable placeholder={t("branchSwitcher.placeholder")} searchPlaceholder={t("branchSwitcher.searchPlaceholder")} - emptyText={t("branchSwitcher.empty")} + emptyText={emptyText} + footer={errorFooter} title={t("branchSwitcher.title")} open={isOpen} onOpenChange={setIsOpen} @@ -122,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, minWidth: 0, }, + footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, + error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts index e8e33b7f25..5a47c5117e 100644 --- a/packages/app/src/hooks/use-branch-switcher.ts +++ b/packages/app/src/hooks/use-branch-switcher.ts @@ -22,6 +22,9 @@ interface UseBranchSwitcherInput { interface UseBranchSwitcherResult { branchOptions: ComboboxOption[]; + isLoadingBranches: boolean; + isFetchingBranches: boolean; + branchError: Error | null; isOpen: boolean; setIsOpen: (open: boolean) => void; handleBranchSelect: (branchId: string) => void; @@ -185,5 +188,14 @@ export function useBranchSwitcher({ ], ); - return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout }; + return { + branchOptions, + isLoadingBranches: branchSuggestionsQuery.isPending, + isFetchingBranches: branchSuggestionsQuery.isFetching, + branchError: branchSuggestionsQuery.error, + isOpen, + setIsOpen, + handleBranchSelect, + invalidateStashAndCheckout, + }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b731187128..48e7f15d4f 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1619,6 +1619,7 @@ export const ar: TranslationResources = { openPath: "فتح المسار", }, branchSwitcher: { + failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.", triggerTooltip: "تبديل فرع مساحة العمل", currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.", placeholder: "تبديل الفرع...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index bd148ca12b..bf1444d3ad 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1649,6 +1649,7 @@ export const en = { placeholder: "Switch branch...", searchPlaceholder: "Filter branches...", empty: "No branches found.", + failedToLoad: "Could not load branches. Reopen the picker to try again.", title: "Switch branch", uncommittedTitle: "Uncommitted changes", uncommittedMessage: "You have uncommitted changes. Stash them before switching branches?", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e662f7624e..cf2534f323 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1665,6 +1665,7 @@ export const es: TranslationResources = { openPath: "Abrir ruta", }, branchSwitcher: { + failedToLoad: "No se pudieron cargar las ramas. Vuelve a abrir el selector para reintentarlo.", triggerTooltip: "Cambiar rama del espacio de trabajo", currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.", placeholder: "Cambiar de rama...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3eee928d57..73acbfb9e8 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1661,6 +1661,7 @@ export const fr: TranslationResources = { openPath: "Ouvrir le chemin", }, branchSwitcher: { + failedToLoad: "Impossible de charger les branches. Rouvrez le sélecteur pour réessayer.", triggerTooltip: "Changer la branche de l’espace de travail", currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.", placeholder: "Changer de branche…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index fbf3fd7ab8..a845f43303 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1636,6 +1636,7 @@ export const ja: TranslationResources = { openPath: "パスを開く", }, branchSwitcher: { + failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。", triggerTooltip: "ワークスペースのブランチを切り替え", currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。", placeholder: "ブランチを切り替え...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 94272f7143..033affa05e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1629,6 +1629,7 @@ export const ko: TranslationResources = { openPath: "경로 열기", }, branchSwitcher: { + failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.", triggerTooltip: "워크스페이스 브랜치 전환", currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.", placeholder: "브랜치 전환...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e8d3b166b8..c48f2974bc 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1650,6 +1650,7 @@ export const ptBR: TranslationResources = { openPath: "Abrir caminho", }, branchSwitcher: { + failedToLoad: "Não foi possível carregar as branches. Reabra o seletor para tentar novamente.", triggerTooltip: "Trocar branch do workspace", currentBranch: "Branch atual: {{branchName}}. Pressione para trocar de branch.", placeholder: "Trocar branch...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f9eda82e87..226c03cfe4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1647,6 +1647,7 @@ export const ru: TranslationResources = { openPath: "Открыть путь", }, branchSwitcher: { + failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.", triggerTooltip: "Переключить ветку рабочего пространства", currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.", placeholder: "Сменить ветку...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 16b5e2cfa9..f95fdc9dfb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = { openPath: "打开路径", }, branchSwitcher: { + failedToLoad: "无法加载分支,请重新打开选择器以重试。", triggerTooltip: "切换工作区分支", currentBranch: "当前分支:{{branchName}}。按下以切换分支。", placeholder: "切换分支...",