| author | |
| committer | |
| log | c50b0b4ef8d61ffe3de1cfa6249f906709f7da1f |
| tree | beb27ec08608ad752946d5ab05793e25b6b4408b |
| parent | 929f7e6479d00754dee4e4554b24b25478b8064d |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 85 insertions(+), 6 deletions(-)
apps/web/src/components/settings/ConnectionsSettings.logic.test.ts+20| ... | ... | @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | import { |
| 4 | 4 | applyWslEnableSelection, |
| 5 | 5 | isQrShareableEndpoint, |
| 6 | isWslSettingsRowVisible, | |
| 6 | 7 | selectQrEndpointOption, |
| 7 | 8 | } from "./ConnectionsSettings.logic"; |
| 8 | 9 | |
| ... | ... | @@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { |
| 15 | 16 | preflightError: null, |
| 16 | 17 | }; |
| 17 | 18 | |
| 19 | describe("isWslSettingsRowVisible", () => { | |
| 20 | it("shows the retry row when the WSL state failed to load", () => { | |
| 21 | expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); | |
| 22 | }); | |
| 23 | ||
| 24 | it("hides an unavailable and unused WSL snapshot", () => { | |
| 25 | expect( | |
| 26 | isWslSettingsRowVisible({ | |
| 27 | state: { ...baseWslState, available: false, wslOnly: false }, | |
| 28 | error: null, | |
| 29 | }), | |
| 30 | ).toBe(false); | |
| 31 | }); | |
| 32 | ||
| 33 | it("shows an available WSL snapshot", () => { | |
| 34 | expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true); | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 18 | 38 | describe("applyWslEnableSelection", () => { |
| 19 | 39 | it("clears WSL-only and updates the distro before enabling both backends", async () => { |
| 20 | 40 | const calls: Array<string> = []; |
apps/web/src/components/settings/ConnectionsSettings.logic.ts+8| ... | ... | @@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { |
| 11 | 11 | return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | export function isWslSettingsRowVisible(input: { | |
| 15 | readonly state: DesktopWslState | null; | |
| 16 | readonly error: string | null; | |
| 17 | }): boolean { | |
| 18 | const { state, error } = input; | |
| 19 | return state ? state.available || state.enabled || state.wslOnly : error !== null; | |
| 20 | } | |
| 21 | ||
| 14 | 22 | export type QrEndpointOption = { |
| 15 | 23 | /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ |
| 16 | 24 | readonly id: string; |
apps/web/src/components/settings/ConnectionsSettings.tsx+11-5| ... | ... | @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls |
| 43 | 43 | import { |
| 44 | 44 | applyWslEnableSelection, |
| 45 | 45 | isQrShareableEndpoint, |
| 46 | isWslSettingsRowVisible, | |
| 46 | 47 | selectQrEndpointOption, |
| 47 | 48 | } from "./ConnectionsSettings.logic"; |
| 48 | 49 | import { |
| ... | ... | @@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { |
| 2766 | 2767 | // retry so the row doesn't flicker away, and the button reflects the |
| 2767 | 2768 | // loading state. With no error we simply haven't loaded yet (or WSL |
| 2768 | 2769 | // management isn't available), so render nothing. |
| 2769 | if (desktopWslError && canManageLocalBackend) { | |
| 2770 | if ( | |
| 2771 | isWslSettingsRowVisible({ state: null, error: desktopWslError }) && | |
| 2772 | canManageLocalBackend | |
| 2773 | ) { | |
| 2770 | 2774 | return ( |
| 2771 | 2775 | <SettingsRow |
| 2772 | title="WSL backend" | |
| 2776 | {...searchableSetting("wsl-backend")} | |
| 2773 | 2777 | description="Couldn't load the WSL backend state." |
| 2774 | 2778 | status={<span className="block text-destructive">{desktopWslError}</span>} |
| 2775 | 2779 | control={ |
| ... | ... | @@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { |
| 2794 | 2798 | // be stranded on a WSL preference they can't clear, so render a recovery |
| 2795 | 2799 | // row that switches back to Windows. When WSL is unavailable AND unused, |
| 2796 | 2800 | // there's nothing to recover — keep the section hidden as before. |
| 2801 | if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { | |
| 2802 | return null; | |
| 2803 | } | |
| 2797 | 2804 | if (!desktopWslState.available) { |
| 2798 | if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; | |
| 2799 | 2805 | return ( |
| 2800 | 2806 | <SettingsRow |
| 2801 | title="WSL backend" | |
| 2807 | {...searchableSetting("wsl-backend")} | |
| 2802 | 2808 | description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference." |
| 2803 | 2809 | status={ |
| 2804 | 2810 | desktopWslError ? ( |
| ... | ... | @@ -2835,7 +2841,7 @@ export function ConnectionsSettings() { |
| 2835 | 2841 | return ( |
| 2836 | 2842 | <> |
| 2837 | 2843 | <SettingsRow |
| 2838 | title="WSL backend" | |
| 2844 | {...searchableSetting("wsl-backend")} | |
| 2839 | 2845 | description="Run a second backend inside a WSL distro alongside the Windows one. Pick a distro to start it; pick Off to stop it. Projects opened against the WSL backend live on the Linux side; Windows projects stay where they are." |
| 2840 | 2846 | status={ |
| 2841 | 2847 | desktopWslError ? ( |
apps/web/src/components/settings/SettingsSidebarNav.tsx+22-1| ... | ... | @@ -7,6 +7,8 @@ import { |
| 7 | 7 | type ComponentType, |
| 8 | 8 | type KeyboardEvent, |
| 9 | 9 | } from "react"; |
| 10 | import { useEnvironmentQuery } from "~/state/query"; | |
| 11 | import { desktopWslStateAtom } from "~/state/desktopWslState"; | |
| 10 | 12 | import { |
| 11 | 13 | ArchiveIcon, |
| 12 | 14 | BlocksIcon, |
| ... | ... | @@ -21,6 +23,8 @@ import { |
| 21 | 23 | } from "lucide-react"; |
| 22 | 24 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 23 | 25 | |
| 26 | import { isElectron } from "~/env"; | |
| 27 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | |
| 24 | 28 | import { Button } from "../ui/button"; |
| 25 | 29 | import { Input } from "../ui/input"; |
| 26 | 30 | import { Kbd } from "../ui/kbd"; |
| ... | ... | @@ -38,6 +42,7 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; |
| 38 | 42 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 39 | 43 | import { |
| 40 | 44 | searchSettings, |
| 45 | SETTINGS_SEARCH_ITEMS, | |
| 41 | 46 | SETTINGS_SECTION_LABELS, |
| 42 | 47 | type SettingsPath, |
| 43 | 48 | type SettingsSearchItem, |
| ... | ... | @@ -78,10 +83,26 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 78 | 83 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 79 | 84 | const [query, setQuery] = useState(""); |
| 80 | 85 | const [activeResultIndex, setActiveResultIndex] = useState(0); |
| 81 | const results = useMemo(() => searchSettings(query), [query]); | |
| 86 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); | |
| 87 | const searchableItems = useMemo(() => { | |
| 88 | const wslState = desktopWsl.data; | |
| 89 | const rowRenders = isWslSettingsRowVisible({ | |
| 90 | state: wslState, | |
| 91 | error: desktopWsl.error, | |
| 92 | }); | |
| 93 | if (rowRenders) { | |
| 94 | return SETTINGS_SEARCH_ITEMS; | |
| 95 | } | |
| 96 | return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); | |
| 97 | }, [desktopWsl.data, desktopWsl.error]); | |
| 98 | const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); | |
| 82 | 99 | const isSearching = query.trim().length > 0; |
| 83 | 100 | const hasResults = results.length > 0; |
| 84 | 101 | |
| 102 | useEffect(() => { | |
| 103 | setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); | |
| 104 | }, [results.length]); | |
| 105 | ||
| 85 | 106 | useEffect(() => { |
| 86 | 107 | const result = results[activeResultIndex]; |
| 87 | 108 | if (!result) return; |
apps/web/src/components/settings/settingsSearch.test.ts+11| ... | ... | @@ -71,6 +71,17 @@ describe("searchSettings", () => { |
| 71 | 71 | it("hides desktop-only settings from browser search", () => { |
| 72 | 72 | expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); |
| 73 | 73 | expect(searchSettings("quit confirmation")).toEqual([]); |
| 74 | expect(searchSettings("wsl")).toEqual([]); | |
| 75 | }); | |
| 76 | ||
| 77 | it("registers the WSL backend as a desktop-only setting", () => { | |
| 78 | expect(SETTINGS_SEARCH_ITEMS).toContainEqual({ | |
| 79 | id: "wsl-backend", | |
| 80 | title: "WSL backend", | |
| 81 | to: "/settings/connections", | |
| 82 | desktopOnly: true, | |
| 83 | windowsOnly: true, | |
| 84 | }); | |
| 74 | 85 | }); |
| 75 | 86 | |
| 76 | 87 | it("keeps catalog result ids unique", () => { |
apps/web/src/components/settings/settingsSearch.ts+13| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { isElectron } from "~/env"; |
| 2 | import { isWindowsPlatform } from "~/lib/utils"; | |
| 2 | 3 | |
| 3 | 4 | export type SettingsPath = |
| 4 | 5 | | "/settings/general" |
| ... | ... | @@ -18,6 +19,9 @@ export interface SettingsSearchItem { |
| 18 | 19 | // Its row only renders in the desktop app, so a browser result would land on |
| 19 | 20 | // an anchor that isn't there. |
| 20 | 21 | readonly desktopOnly?: boolean; |
| 22 | // Its row only renders on Windows desktop, so other desktop platforms must | |
| 23 | // not expose a result that points to a missing anchor. | |
| 24 | readonly windowsOnly?: boolean; | |
| 21 | 25 | } |
| 22 | 26 | |
| 23 | 27 | /** |
| ... | ... | @@ -259,6 +263,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 259 | 263 | title: "Remote environments", |
| 260 | 264 | to: "/settings/connections", |
| 261 | 265 | }, |
| 266 | { | |
| 267 | id: "wsl-backend", | |
| 268 | title: "WSL backend", | |
| 269 | to: "/settings/connections", | |
| 270 | desktopOnly: true, | |
| 271 | windowsOnly: true, | |
| 272 | }, | |
| 262 | 273 | { |
| 263 | 274 | id: "archive", |
| 264 | 275 | title: "Archived threads", |
| ... | ... | @@ -304,6 +315,8 @@ export function searchSettings( |
| 304 | 315 | return items.filter( |
| 305 | 316 | (item) => |
| 306 | 317 | (isElectron || item.desktopOnly !== true) && |
| 318 | (!item.windowsOnly || | |
| 319 | isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) && | |
| 307 | 320 | normalizeSearchText(item.title).includes(normalizedQuery), |
| 308 | 321 | ); |
| 309 | 322 | } |