mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix(app): show branch loading and fetch errors (#8938)
* fix(app): distinguish branch loading from empty results Preserve branch query pending and error states through the switcher so first open does not report missing branches before the response. Show request failures with a retry action. * fix(app): keep branch retry progress and keyboard focus Verify failed cached refresh and keyboard retry through the real browser and daemon. Keep focus in the footer while Retry is disabled so repeated Enter cannot select a cached branch. * fix(app): preserve picker interaction when retrying branch loads Use the existing close-and-reopen retry behavior with clear feedback rather than introducing an action that competes with native list keyboard ownership.
This commit is contained in:
@@ -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<void>((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<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
requestCount: () => requestCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
|
||||
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<ReturnType<typeof holdBranchSuggestions>>,
|
||||
) {
|
||||
// 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 });
|
||||
|
||||
@@ -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 ? (
|
||||
<View style={styles.footer}>
|
||||
<Text accessibilityRole="alert" style={styles.error}>
|
||||
{isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")}
|
||||
</Text>
|
||||
</View>
|
||||
) : 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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1619,6 +1619,7 @@ export const ar: TranslationResources = {
|
||||
openPath: "فتح المسار",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "تعذّر تحميل الفروع. أعد فتح قائمة الفروع للمحاولة مرة أخرى.",
|
||||
triggerTooltip: "تبديل فرع مساحة العمل",
|
||||
currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.",
|
||||
placeholder: "تبديل الفرع...",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -1636,6 +1636,7 @@ export const ja: TranslationResources = {
|
||||
openPath: "パスを開く",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "ブランチを読み込めませんでした。選択画面を開き直してお試しください。",
|
||||
triggerTooltip: "ワークスペースのブランチを切り替え",
|
||||
currentBranch: "現在のブランチ: {{branchName}}。押してブランチを切り替えてください。",
|
||||
placeholder: "ブランチを切り替え...",
|
||||
|
||||
@@ -1629,6 +1629,7 @@ export const ko: TranslationResources = {
|
||||
openPath: "경로 열기",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "브랜치를 불러오지 못했습니다. 선택기를 다시 열어 재시도하세요.",
|
||||
triggerTooltip: "워크스페이스 브랜치 전환",
|
||||
currentBranch: "현재 브랜치: {{branchName}}. 브랜치를 전환하려면 누르세요.",
|
||||
placeholder: "브랜치 전환...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -1647,6 +1647,7 @@ export const ru: TranslationResources = {
|
||||
openPath: "Открыть путь",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "Не удалось загрузить ветки. Откройте список заново, чтобы повторить попытку.",
|
||||
triggerTooltip: "Переключить ветку рабочего пространства",
|
||||
currentBranch: "Текущая ветка: {{branchName}}. Нажмите, чтобы переключить ветку.",
|
||||
placeholder: "Сменить ветку...",
|
||||
|
||||
@@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = {
|
||||
openPath: "打开路径",
|
||||
},
|
||||
branchSwitcher: {
|
||||
failedToLoad: "无法加载分支,请重新打开选择器以重试。",
|
||||
triggerTooltip: "切换工作区分支",
|
||||
currentBranch: "当前分支:{{branchName}}。按下以切换分支。",
|
||||
placeholder: "切换分支...",
|
||||
|
||||
Reference in New Issue
Block a user