From ac34e27b30a07486a52cb168717552888b40d68b Mon Sep 17 00:00:00 2001 From: Helge Klein Date: Fri, 6 Nov 2026 23:02:21 +0100 Subject: [PATCH 01/12] Duplicate requests --- .../FileBrowser/UnifiedSearchBar.tsx | 14 +++++++------- .../__tests__/UnifiedSearchBar.test.tsx | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx b/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx index 194e7b34..d129aee1 100644 --- a/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx +++ b/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx @@ -902,8 +902,8 @@ export function UnifiedSearchBar({ } if ( - (!hasSearched && query.length === 0 && effectiveMinQueryLength === 0) || - (newlyActivated && hasSearched && query.length >= effectiveMinQueryLength) + newlyActivated && + ((!hasSearched && query.length === 0 && effectiveMinQueryLength === 0) || (hasSearched && query.length >= effectiveMinQueryLength)) ) { void executeSearch(query); } @@ -1012,14 +1012,14 @@ export function UnifiedSearchBar({ lastResetProviderRef.current = provider; lastActivationTokenRef.current = activationToken; - activatedRef.current = isInputFocused; + activatedRef.current = isInputFocused || shouldSearchOnActivation; - if (shouldSearchOnActivation) { - void executeSearch(nextQuery); + if (activatedRef.current) { + providerRef.current.onActivate?.(); } - if (isInputFocused) { - providerRef.current.onActivate?.(); + if (shouldSearchOnActivation) { + void executeSearch(nextQuery); } }, [ activationToken, diff --git a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx index c3d9748b..4a4fdf93 100644 --- a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx @@ -1005,6 +1005,25 @@ describe("UnifiedSearchBar", () => { }); }); + it("starts one search when shortcut activation is followed by input focus", async () => { + const fetchResults = vi.fn(() => new Promise<[]>(() => {})); + const onActivate = vi.fn(); + const provider = { ...resultsProvider, fetchResults, onActivate }; + const { rerender } = renderWithProvider(); + + rerender( + + + + ); + await act(async () => screen.getByRole("textbox").focus()); + + expect(onActivate).toHaveBeenCalledTimes(1); + expect(fetchResults).toHaveBeenCalledTimes(1); + expect(onActivate.mock.invocationCallOrder[0]).toBeLessThan(fetchResults.mock.invocationCallOrder[0] ?? 0); + expect(fetchResults.mock.calls[0]?.[1]?.aborted).toBe(false); + }); + it("reopens the commands dropdown when the same mode is reactivated from outside the quick bar", async () => { const user = userEvent.setup(); From 966df26d6bd69739efbbb1e47e2c95ee53b1e526 Mon Sep 17 00:00:00 2001 From: Helge Klein Date: Fri, 6 Nov 2026 23:05:08 +0100 Subject: [PATCH 02/12] Quickbar overlay close bug --- .../FileBrowser/UnifiedSearchBar.tsx | 17 +++++---- .../__tests__/UnifiedSearchBar.test.tsx | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx b/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx index d129aee1..4d8aeed5 100644 --- a/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx +++ b/frontend/src/components/FileBrowser/UnifiedSearchBar.tsx @@ -41,7 +41,7 @@ import { } from "@mui/material"; import { useVirtualizer } from "@tanstack/react-virtual"; import type React from "react"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useTranslation } from "react-i18next"; import { usePillButtonMenu } from "../../hooks/usePillButtonMenu"; @@ -1069,14 +1069,17 @@ export function UnifiedSearchBar({ } }, [disableDropdown, executeSearch, getEffectiveMinQueryLength, query, refreshToken, suppressDropdown]); - useEffect(() => { - if (disableDropdown || suppressDropdown) { - return; + const resumeFocusedSearch = useEffectEvent(() => { + if (document.activeElement === effectiveInputRef.current && query.length >= getEffectiveMinQueryLength(query)) { + void executeSearch(query); } - if (document.activeElement === effectiveInputRef.current && provider.minQueryLength === 0) { - void executeSearch(""); + }); + + useEffect(() => { + if (!disableDropdown && !suppressDropdown && provider.minQueryLength === 0) { + resumeFocusedSearch(); } - }, [disableDropdown, effectiveInputRef, executeSearch, provider.minQueryLength, suppressDropdown]); + }, [disableDropdown, provider.minQueryLength, suppressDropdown]); useEffect(() => { if (isModeMenuOpen && isDropdownOpen) { diff --git a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx index 4a4fdf93..9a7977b2 100644 --- a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx @@ -1024,6 +1024,44 @@ describe("UnifiedSearchBar", () => { expect(fetchResults.mock.calls[0]?.[1]?.aborted).toBe(false); }); + it.each(["suppressDropdown", "disableDropdown"] as const)("preserves the query when %s is cleared", async (prop) => { + const user = userEvent.setup(); + const fetchResults = vi.fn(async (query) => [ + { + kind: "result", + id: query || "all-commands", + value: query || "all-commands", + icon: "command", + primaryText: query || "All commands", + }, + ]); + const provider = { ...commandsProvider, fetchResults }; + const { rerender } = renderWithProvider(); + + await user.click(screen.getByRole("textbox")); + await user.type(screen.getByRole("textbox"), "rename"); + expect(await screen.findByRole("option", { name: "rename" })).toBeInTheDocument(); + + rerender( + + + + ); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + fetchResults.mockClear(); + + rerender( + + + + ); + + expect(await screen.findByRole("option", { name: "rename" })).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("rename"); + expect(fetchResults).toHaveBeenCalledTimes(1); + expect(fetchResults.mock.calls[0]?.[0]).toBe("rename"); + }); + it("reopens the commands dropdown when the same mode is reactivated from outside the quick bar", async () => { const user = userEvent.setup(); From 9e802f0ba7e5db6d1e3bc347c2d2cb76b417ac90 Mon Sep 17 00:00:00 2001 From: Helge Klein Date: Fri, 6 Nov 2026 23:13:38 +0100 Subject: [PATCH 03/12] Firefox menu popout fix --- .../__tests__/UnifiedSearchBar.test.tsx | 32 +++++++++++++++++++ .../search/useFileSearchProvider.tsx | 4 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx index 9a7977b2..b2c7e603 100644 --- a/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx +++ b/frontend/src/components/FileBrowser/__tests__/UnifiedSearchBar.test.tsx @@ -4,10 +4,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { setLocale, translate } from "../../../i18n"; import { browserHistoryService } from "../../../services/browserHistoryService"; import { SambeeThemeProvider } from "../../../theme/ThemeContext"; +import { type FileEntry, FileType } from "../../../types"; import { getQuickBarResultRowHeight, QUICK_BAR_RESULT_GROUP_HEADER_HEIGHT, QUICK_BAR_RESULT_ITEM_HEIGHT } from "../QuickBarResultRow"; import { SEARCH_CONTRACT_VERSION } from "../search/recentHistory"; import type { SearchProvider, SearchResultsUpdate } from "../search/types"; import { useDirectorySearchProvider } from "../search/useDirectorySearchProvider"; +import { useFileSearchProvider } from "../search/useFileSearchProvider"; import { UnifiedSearchBar } from "../UnifiedSearchBar"; const virtualizerSizeCache = new Map(); @@ -1005,6 +1007,36 @@ describe("UnifiedSearchBar", () => { }); }); + it("fetches file history when the source updates during first activation", async () => { + const searchRecentFiles = vi.spyOn(browserHistoryService, "searchRecentFiles").mockResolvedValue({ results: [], result_limit: 50 }); + const initialFiles: FileEntry[] = []; + const loadedFiles = [{ name: "report.txt", type: FileType.FILE }] as FileEntry[]; + function SearchHarness({ active }: { active: boolean }) { + const fileProvider = useFileSearchProvider({ + connectionId: "connection-1", + currentPath: "", + files: active ? loadedFiles : initialFiles, + connectionName: "Demo", + resultLimit: 50, + getConnectionName: () => "Demo", + onOpenCurrentFile: vi.fn(), + onOpenRecentFile: vi.fn(), + }); + return ; + } + const { rerender } = renderWithProvider(); + + rerender( + + + + ); + await act(async () => screen.getByRole("textbox").focus()); + + expect(await screen.findByRole("option", { name: /report.txt/ })).toBeInTheDocument(); + expect(searchRecentFiles).toHaveBeenCalledTimes(1); + }); + it("starts one search when shortcut activation is followed by input focus", async () => { const fetchResults = vi.fn(() => new Promise<[]>(() => {})); const onActivate = vi.fn(); diff --git a/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx b/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx index e52ec1dd..908f1113 100644 --- a/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx +++ b/frontend/src/components/FileBrowser/search/useFileSearchProvider.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { BrowserOpenMode } from "../../../pages/FileBrowser/types"; import { type BrowserHistoryService, browserHistoryService } from "../../../services/browserHistoryService"; @@ -63,7 +63,7 @@ export function useFileSearchProvider({ const sourceBindingRef = useRef({ connectionId, currentPath, files, history, resultLimit }); const [historyUnavailable, setHistoryUnavailable] = useState(false); - useEffect(() => { + useLayoutEffect(() => { sourceBindingRef.current = { connectionId, currentPath, files, history, resultLimit }; return () => { requestGenerationRef.current += 1; From 9d6fad22354a88a9a59f1f84ddf706ce5fefecd3 Mon Sep 17 00:00:00 2001 From: Helge Klein Date: Fri, 6 Nov 2026 23:23:39 +0100 Subject: [PATCH 04/12] Pairing required message in settings --- .../Settings/CompanionPolicyDelivery.tsx | 6 +-- .../CompanionPolicyDelivery.test.tsx | 39 +++++++++++----- frontend/src/i18n/resources.ts | 3 ++ .../__tests__/companionSettingsSync.test.ts | 46 ++++++++++++++++++- .../src/services/companionSettingsSync.ts | 19 ++++---- .../docs-reports/docs-structure-report.html | 2 +- .../index.md | 2 + 7 files changed, 91 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Settings/CompanionPolicyDelivery.tsx b/frontend/src/components/Settings/CompanionPolicyDelivery.tsx index 545655c7..5fd3d52b 100644 --- a/frontend/src/components/Settings/CompanionPolicyDelivery.tsx +++ b/frontend/src/components/Settings/CompanionPolicyDelivery.tsx @@ -20,8 +20,8 @@ export function CompanionPolicyDelivery() { const { t } = useTranslation(); const snapshot = useCompanionSettingsSync(); const busy = snapshot.phase === "synchronizing"; - const failed = snapshot.phase === "delivery_failed"; - const warning = failed || snapshot.phase === "authority_conflict" || snapshot.phase === "update_required"; + const needsRetry = snapshot.phase === "delivery_failed" || snapshot.phase === "pairing_required"; + const warning = needsRetry || snapshot.phase === "authority_conflict" || snapshot.phase === "update_required"; const phaseLabel = (phase: SettingsSyncPhase) => t(`settings.companionSync.${phase}`); return ( @@ -47,7 +47,7 @@ export function CompanionPolicyDelivery() { disabled={busy} onClick={() => companionSettingsSync.request(false, true)} > - {t(failed ? "common.actions.retry" : "settings.directorySearch.refresh")} + {t(needsRetry ? "common.actions.retry" : "settings.directorySearch.refresh")} diff --git a/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx b/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx index 1e8678eb..8639506f 100644 --- a/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx +++ b/frontend/src/components/Settings/__tests__/CompanionPolicyDelivery.test.tsx @@ -36,6 +36,16 @@ describe("Companion shared synchronization status", () => { expect(request).toHaveBeenCalledExactlyOnceWith(false, true); }); + it("shows pairing recovery instructions instead of a delivery failure", async () => { + snapshot.phase = "pairing_required"; + render(); + expect(screen.getByText("Theme: Pairing required")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Pair this browser in Settings > Local Drives"); + expect(screen.queryByText(/Delivery failed/)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(request).toHaveBeenCalledExactlyOnceWith(false, true); + }); + it("disables refresh during delivery and keeps notices inside the surface", () => { snapshot.phase = "synchronizing"; render(); @@ -43,16 +53,21 @@ describe("Companion shared synchronization status", () => { expect(screen.getByTestId("companion-settings-surface")).toContainElement(screen.getByRole("status")); }); - it.each(["defaults_active", "synchronized", "synchronizing", "update_required", "authority_conflict", "delivery_failed"] as const)( - "keeps every %s state inside the shared surface", - (phase) => { - snapshot.phase = phase; - snapshot.sections = { theme: phase, localization: "synchronized", directory_index: "defaults_active" }; - render(); - const surface = screen.getByTestId("companion-settings-surface"); - expect(surface).toContainElement(screen.getByRole("status")); - expect(surface).toHaveTextContent("Localization: Synchronized"); - expect(surface).toHaveTextContent("Directory search policy: Built-in defaults"); - } - ); + it.each([ + "defaults_active", + "synchronized", + "synchronizing", + "update_required", + "authority_conflict", + "pairing_required", + "delivery_failed", + ] as const)("keeps every %s state inside the shared surface", (phase) => { + snapshot.phase = phase; + snapshot.sections = { theme: phase, localization: "synchronized", directory_index: "defaults_active" }; + render(); + const surface = screen.getByTestId("companion-settings-surface"); + expect(surface).toContainElement(screen.getByRole("status")); + expect(surface).toHaveTextContent("Localization: Synchronized"); + expect(surface).toHaveTextContent("Directory search policy: Built-in defaults"); + }); }); diff --git a/frontend/src/i18n/resources.ts b/frontend/src/i18n/resources.ts index 50790d92..fd638cde 100644 --- a/frontend/src/i18n/resources.ts +++ b/frontend/src/i18n/resources.ts @@ -208,6 +208,7 @@ export const EN_TRANSLATIONS = { synchronizing: "Synchronizing", update_required: "Update required", authority_conflict: "Different settings authority", + pairing_required: "Pairing required", delivery_failed: "Delivery failed; last valid settings active", }, defaults_active: "Built-in defaults are active until settings arrive from a paired server.", @@ -215,6 +216,8 @@ export const EN_TRANSLATIONS = { synchronizing: "Settings are synchronizing. The last valid settings remain active.", update_required: "Update Sambee and Companion to use shared settings delivery.", authority_conflict: "This server does not manage Companion settings. Pair the intended server again to change authority.", + pairing_required: + "Companion could not authenticate this browser. Pair this browser in Settings > Local Drives, or pair it again to repair the connection. Settings checks are paused until you retry or pair successfully.", delivery_failed: "Settings delivery failed. Check the connection and retry. The last valid settings and directory search remain available.", authority: "Settings authority: {{origin}}", diff --git a/frontend/src/services/__tests__/companionSettingsSync.test.ts b/frontend/src/services/__tests__/companionSettingsSync.test.ts index d428af93..9aa40b60 100644 --- a/frontend/src/services/__tests__/companionSettingsSync.test.ts +++ b/frontend/src/services/__tests__/companionSettingsSync.test.ts @@ -11,9 +11,11 @@ const mocks = vi.hoisted(() => ({ identity: null as null | (() => void), clear: null as null | (() => void), authenticated: true, + hasSecret: true, })); vi.mock("../api", () => ({ default: { getCompanionSettingsMetadata: mocks.metadata, issueCompanionSettingsGrant: mocks.grant } })); -vi.mock("../companion", () => ({ +vi.mock("../companion", async (importOriginal) => ({ + ...(await importOriginal()), default: { getSettingsStatus: mocks.status, requestSettingsChallenge: mocks.challenge, redeemSettingsGrant: mocks.redeem }, })); vi.mock("../authConfig", () => ({ isAuthRequired: async () => true })); @@ -36,7 +38,7 @@ vi.mock("../authSession", () => ({ subscribeToBootstrap: () => vi.fn(), }, })); -vi.mock("../companionSession", () => ({ companionSession: { hasSecret: () => true, subscribe: () => vi.fn() } })); +vi.mock("../companionSession", () => ({ companionSession: { hasSecret: () => mocks.hasSecret, subscribe: () => vi.fn() } })); vi.mock("../logger", () => ({ logger: { initializeBackendTracing: vi.fn().mockResolvedValue(undefined) } })); import { emitBackendRecoveryConfirmed } from "../backendRecoveryEvents"; @@ -85,6 +87,7 @@ describe("shared Companion settings lifecycle", () => { vi.useFakeTimers(); vi.clearAllMocks(); mocks.authenticated = true; + mocks.hasSecret = true; vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); mocks.metadata.mockResolvedValue(metadata()); mocks.status.mockResolvedValue(status()); @@ -107,6 +110,45 @@ describe("shared Companion settings lifecycle", () => { expect(companionSettingsSync.getSnapshot().phase).toBe("synchronized"); }); + it("does not request settings without a stored pairing secret", async () => { + mocks.hasSecret = false; + companionSettingsSync.start(); + await settle(); + await vi.advanceTimersByTimeAsync(120_000); + expect(mocks.status).not.toHaveBeenCalled(); + expect(mocks.metadata).not.toHaveBeenCalled(); + }); + + it.each([ + { code: "companion_auth_signature_mismatch", detail: "Invalid authentication" }, + { code: "companion_pairing_not_found", detail: "Not paired with this origin" }, + { detail: "Invalid authentication" }, + { detail: "Not paired with this origin" }, + ])("blocks automatic retries after pairing failure: $detail ($code)", async (data) => { + mocks.status.mockRejectedValue({ response: { status: 403, data } }); + companionSettingsSync.start(); + await settle(); + + expect(companionSettingsSync.getSnapshot()).toMatchObject({ phase: "pairing_required", message: null }); + window.dispatchEvent(new Event("online")); + document.dispatchEvent(new Event("visibilitychange")); + emitBackendRecoveryConfirmed("pairing-retry-test"); + await vi.advanceTimersByTimeAsync(120_000); + expect(mocks.status).toHaveBeenCalledOnce(); + expect(mocks.grant).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + + companionSettingsSync.request(false, true); + await settle(); + expect(mocks.status).toHaveBeenCalledTimes(2); + + mocks.status.mockResolvedValue(status()); + notifyCompanionSettingsChanged(true); + await settle(); + expect(mocks.status).toHaveBeenCalledTimes(3); + expect(companionSettingsSync.getSnapshot().phase).toBe("synchronized"); + }); + it("delivers only a changed theme section", async () => { const native = status(); native.sections.theme!.marker = "old"; diff --git a/frontend/src/services/companionSettingsSync.ts b/frontend/src/services/companionSettingsSync.ts index c80da385..93362f55 100644 --- a/frontend/src/services/companionSettingsSync.ts +++ b/frontend/src/services/companionSettingsSync.ts @@ -12,7 +12,7 @@ import api from "./api"; import { isAuthRequired } from "./authConfig"; import { authSession } from "./authSession"; import { subscribeBackendRecoveryConfirmed } from "./backendRecoveryEvents"; -import companionService from "./companion"; +import companionService, { isCompanionAuthSignatureMismatch, isCompanionPairingMissing } from "./companion"; import { companionSession } from "./companionSession"; import { COMPANION_SETTINGS_CHANGED_EVENT } from "./companionSettingsEvents"; @@ -25,6 +25,7 @@ export type SettingsSyncPhase = | "synchronizing" | "update_required" | "authority_conflict" + | "pairing_required" | "delivery_failed"; export interface SettingsSyncSnapshot { phase: SettingsSyncPhase; @@ -278,19 +279,21 @@ class CompanionSettingsSynchronizer { const status = isApiError(error) ? error.response?.status : undefined; const code = isApiError(error) ? error.response?.data?.code : undefined; const phase = - status === 404 || status === 426 || code === "settings_update_required" + status === 404 || status === 426 || code === "settings_update_required" || code === "companion_browser_update_required" ? "update_required" : code === "settings_authority_conflict" ? "authority_conflict" - : "delivery_failed"; - this.blocked = phase === "authority_conflict" || phase === "update_required"; + : isCompanionAuthSignatureMismatch(error) || isCompanionPairingMissing(error) + ? "pairing_required" + : "delivery_failed"; + this.blocked = phase === "authority_conflict" || phase === "update_required" || phase === "pairing_required"; this.publish({ ...this.snapshot, phase, - message: getApiErrorMessage( - error, - "Settings delivery failed. Check the connection and retry. The last valid settings remain active." - ), + message: + phase === "pairing_required" + ? null + : getApiErrorMessage(error, "Settings delivery failed. Check the connection and retry. The last valid settings remain active."), sections: Object.fromEntries(Object.keys(this.snapshot.sections).map((name) => [name, phase])), }); delay = RETRY_DELAYS_MS[this.retries++] ?? CHECK_INTERVAL_MS; diff --git a/website-meta/docs-reports/docs-structure-report.html b/website-meta/docs-reports/docs-structure-report.html index 7d05afb2..d67d565f 100644 --- a/website-meta/docs-reports/docs-structure-report.html +++ b/website-meta/docs-reports/docs-structure-report.html @@ -268,7 +268,7 @@

Content diff

- + + + +