mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 15:29:09 +01:00
prevent content flash in settings
This commit is contained in:
@@ -4,10 +4,9 @@ import { Dialogs } from "@wailsio/runtime";
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { Command } from "cmdk";
|
import { Command } from "cmdk";
|
||||||
import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react";
|
import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react";
|
||||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||||
import type { Profile } from "@bindings/services/models.js";
|
import type { Profile } from "@bindings/services/models.js";
|
||||||
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
|
|
||||||
import { Tooltip } from "@/components/Tooltip";
|
import { Tooltip } from "@/components/Tooltip";
|
||||||
import { useProfile } from "@/contexts/ProfileContext";
|
import { useProfile } from "@/contexts/ProfileContext";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@@ -17,14 +16,12 @@ type ProfileDropdownProps = {
|
|||||||
onManageProfiles?: () => void;
|
onManageProfiles?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADD_VALUE = "__add_profile__";
|
|
||||||
const MANAGE_VALUE = "__manage_profiles__";
|
const MANAGE_VALUE = "__manage_profiles__";
|
||||||
|
|
||||||
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { activeProfile, profiles, addProfile, switchProfile } = useProfile();
|
const { activeProfile, profiles, switchProfile } = useProfile();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [newProfileOpen, setNewProfileOpen] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const sortedProfiles = [...profiles].sort((a, b) => {
|
const sortedProfiles = [...profiles].sort((a, b) => {
|
||||||
@@ -54,28 +51,11 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
void guarded(t("profile.error.switchTitle"), () => switchProfile(name));
|
void guarded(t("profile.error.switchTitle"), () => switchProfile(name));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
setOpen(false);
|
|
||||||
setNewProfileOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManage = () => {
|
const handleManage = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onManageProfiles?.();
|
onManageProfiles?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateProfile = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await addProfile(name);
|
|
||||||
await switchProfile(name);
|
|
||||||
} catch (e) {
|
|
||||||
await Dialogs.Error({
|
|
||||||
Title: t("profile.error.createTitle"),
|
|
||||||
Message: formatErrorMessage(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayName = activeProfile || t("profile.selector.loading");
|
const displayName = activeProfile || t("profile.selector.loading");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,20 +110,6 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={"pt-1"}>
|
<div className={"pt-1"}>
|
||||||
<Command.Item
|
|
||||||
value={ADD_VALUE}
|
|
||||||
onSelect={handleAdd}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 my-0.5",
|
|
||||||
"rounded-md outline-none cursor-default text-sm",
|
|
||||||
"data-[selected=true]:bg-nb-gray-900",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PlusCircle size={14} className="shrink-0" />
|
|
||||||
<span className="truncate flex-1">
|
|
||||||
{t("profile.dropdown.addProfile")}
|
|
||||||
</span>
|
|
||||||
</Command.Item>
|
|
||||||
<Command.Item
|
<Command.Item
|
||||||
value={MANAGE_VALUE}
|
value={MANAGE_VALUE}
|
||||||
onSelect={handleManage}
|
onSelect={handleManage}
|
||||||
@@ -165,11 +131,6 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Portal>
|
</Popover.Portal>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
<ProfileCreationModal
|
|
||||||
open={newProfileOpen}
|
|
||||||
onOpenChange={setNewProfileOpen}
|
|
||||||
onCreate={handleCreateProfile}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Events } from "@wailsio/runtime";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||||
@@ -15,11 +16,18 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
|||||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||||
|
|
||||||
// The settings window opens at General by default. Navigation state (e.g. the
|
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
|
||||||
// update-available header trigger jumps to About) or a `?tab=` query param
|
|
||||||
// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from
|
// The settings window mounts once at app startup (hidden) and stays at the
|
||||||
// the profile dropdown) override the default. No persistence across opens —
|
// single URL `/#/settings` forever — no SetURL between opens, so the
|
||||||
// a user who wants to revisit a deep tab gets there in two clicks.
|
// `AppLayout` provider stack never re-mounts and we never see the
|
||||||
|
// `SettingsSkeleton` flash mid-reload. Tab is local state, driven by:
|
||||||
|
// - the `netbird:settings:open` Wails event from `WindowManager.OpenSettings`
|
||||||
|
// (sets the target tab, then Go calls `Show`/`Focus`); and
|
||||||
|
// - the same event with payload `"general"` from the close hook, so the
|
||||||
|
// window is already on General the next time Show fires (common case).
|
||||||
|
// In-window navigation state (e.g. the update-available header jump to About)
|
||||||
|
// still wins for that one render.
|
||||||
//
|
//
|
||||||
// The `h-12` draggable strip at the top accounts for the macOS
|
// The `h-12` draggable strip at the top accounts for the macOS
|
||||||
// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light
|
// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light
|
||||||
@@ -27,17 +35,19 @@ import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
|||||||
// Header height so AppRightPanel ends up the same height in both windows.
|
// Header height so AppRightPanel ends up the same height in both windows.
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const queryTab = searchParams.get("tab");
|
|
||||||
const navState = location.state as { tab?: string } | null;
|
const navState = location.state as { tab?: string } | null;
|
||||||
const [active, setActive] = useState(
|
const [active, setActive] = useState(() => navState?.tab ?? "general");
|
||||||
() => navState?.tab ?? queryTab ?? "general",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navState?.tab) setActive(navState.tab);
|
if (navState?.tab) setActive(navState.tab);
|
||||||
}, [navState?.tab, location.key]);
|
}, [navState?.tab, location.key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return Events.On(EVENT_SETTINGS_OPEN, (e: { data: string }) => {
|
||||||
|
setActive(e.data || "general");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ const EventTriggerLogin = "trigger-login"
|
|||||||
// and tears down the daemon's pending SSO wait.
|
// and tears down the daemon's pending SSO wait.
|
||||||
const EventBrowserLoginCancel = "browser-login:cancel"
|
const EventBrowserLoginCancel = "browser-login:cancel"
|
||||||
|
|
||||||
|
// EventSettingsOpen tells the (already-mounted, currently-hidden) settings
|
||||||
|
// window which tab to land on, then drives Window.Show()/Focus() from the
|
||||||
|
// React side. Routing the open through the React layer avoids the
|
||||||
|
// SetURL-on-every-open path that re-mounted the entire provider tree and
|
||||||
|
// flashed the SettingsSkeleton between opens.
|
||||||
|
const EventSettingsOpen = "netbird:settings:open"
|
||||||
|
|
||||||
// WindowManager opens auxiliary application windows on demand from the
|
// WindowManager opens auxiliary application windows on demand from the
|
||||||
// frontend. The main window is created up-front in main.go; this service is
|
// frontend. The main window is created up-front in main.go; this service is
|
||||||
// for secondary surfaces (Settings, BrowserLogin, Session*, InstallProgress).
|
// for secondary surfaces (Settings, BrowserLogin, Session*, InstallProgress).
|
||||||
@@ -82,25 +89,34 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
// Hide on close instead of destroying — preserves in-window React state
|
// Hide on close instead of destroying — preserves in-window React state
|
||||||
// across reopens. Mirrors the main window's close behaviour.
|
// across reopens. Mirrors the main window's close behaviour. Resetting
|
||||||
|
// the active tab to General on hide means the *next* OpenSettings("")
|
||||||
|
// finds the window already on General, so showing it is a single Show()
|
||||||
|
// with nothing to update first — no flash.
|
||||||
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||||
e.Cancel()
|
e.Cancel()
|
||||||
|
s.app.Event.Emit(EventSettingsOpen, "general")
|
||||||
s.settings.Hide()
|
s.settings.Hide()
|
||||||
})
|
})
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSettings shows the settings window (created hidden at startup). If
|
// OpenSettings asks the (already-mounted, currently-hidden) settings window
|
||||||
// `tab` is non-empty the settings React layer reads it from the start URL
|
// to land on `tab` and bring itself to front. Empty `tab` lands on General.
|
||||||
// and selects that tab (e.g. "profiles") instead of whatever tab was active
|
//
|
||||||
// when the user last closed the window. Passing an empty tab keeps the
|
// The window stays at a single URL (`/#/settings`) for its entire lifetime:
|
||||||
// existing in-window state.
|
// calling SetURL on every open re-loaded the WKWebView, which re-mounted the
|
||||||
|
// `AppLayout` provider stack and visibly flashed the `SettingsSkeleton` while
|
||||||
|
// `SettingsContext` re-fetched config. Instead, the React side keeps tab in
|
||||||
|
// local state and listens for `EventSettingsOpen` to switch it. The close
|
||||||
|
// hook (above) already resets state to "general", so the common-case
|
||||||
|
// reopen-on-gear path has nothing to update — Show is a no-op repaint.
|
||||||
func (s *WindowManager) OpenSettings(tab string) {
|
func (s *WindowManager) OpenSettings(tab string) {
|
||||||
s.mu.Lock()
|
target := tab
|
||||||
defer s.mu.Unlock()
|
if target == "" {
|
||||||
if tab != "" {
|
target = "general"
|
||||||
s.settings.SetURL("/#/settings?tab=" + url.QueryEscape(tab))
|
|
||||||
}
|
}
|
||||||
|
s.app.Event.Emit(EventSettingsOpen, target)
|
||||||
s.settings.Show()
|
s.settings.Show()
|
||||||
s.settings.Focus()
|
s.settings.Focus()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user