From 1c15e9976b853d13870e34ceb23bead889a06e74 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Wed, 20 May 2026 13:17:13 +0200 Subject: [PATCH] add profiles tab to settings --- client/ui/CLAUDE.md | 4 +- client/ui/frontend/CLAUDE.md | 2 +- client/ui/frontend/src/components/Badge.tsx | 64 ++++ .../frontend/src/components/ProfileAvatar.tsx | 24 +- .../frontend/src/i18n/locales/en/common.json | 10 +- client/ui/frontend/src/layouts/Header.tsx | 8 +- .../src/modules/settings/LanguagePicker.tsx | 19 +- .../src/modules/settings/Settings.tsx | 21 +- .../settings/SettingsNavigationTriggers.tsx | 6 + .../src/modules/settings/SettingsProfiles.tsx | 311 ++++++++++++++++++ client/ui/services/windowmanager.go | 15 +- client/ui/tray.go | 2 +- 12 files changed, 450 insertions(+), 36 deletions(-) create mode 100644 client/ui/frontend/src/components/Badge.tsx create mode 100644 client/ui/frontend/src/modules/settings/SettingsProfiles.tsx diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 5e4f41664..24e094c54 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -35,7 +35,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr | `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. | | `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). | | `Update` | `update.go` | `Trigger` (enforced installer) / `GetInstallerResult` / `Quit` (used by the `/update` page after a successful install). | -| `WindowManager` | `windowmanager.go` | `OpenSettings` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin`. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). | +| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). | | `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. | | `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage`, persists, and broadcasts `netbird:preferences:changed`. | @@ -88,7 +88,7 @@ Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-sid The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`: -- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings`) **and** the tray's Settings menu entry (`tray.go openSettings` → `WindowManager.OpenSettings`). Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. +- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`SettingsProfiles.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise. - **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes. - **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 354c73fa2..63e1380f7 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -28,7 +28,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar | `/update` | `Update` (pages) | none | Main window during enforced-update install | | `/session-expired` | `SessionExpiredDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) | | `/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) | -| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) | +| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). The `Profiles` tab (`modules/settings/SettingsProfiles.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")` — `Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. | | `*` | `` | `AppLayout` | Catch-all | `AppLayout` wraps `Header + ` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. The remaining order is structural — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints `` so it has to be outermost in z-index but innermost in the tree. `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`). diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx new file mode 100644 index 000000000..6ffddb7b8 --- /dev/null +++ b/client/ui/frontend/src/components/Badge.tsx @@ -0,0 +1,64 @@ +import { forwardRef, type ComponentType, type HTMLAttributes } from "react"; +import type { LucideProps } from "lucide-react"; +import { cn } from "@/lib/cn"; + +export type BadgeVariant = + | "info" + | "neutral" + | "brand" + | "success" + | "warning" + | "danger"; + +type Props = HTMLAttributes & { + /** Visual color scheme. Defaults to `info` (sky), used as the + * "Active profile" indicator. */ + variant?: BadgeVariant; + /** Optional leading lucide icon. */ + icon?: ComponentType; + /** Override icon size. Defaults to 10px to match the compact pill. */ + iconSize?: number; +}; + +const VARIANT_CLASSES: Record = { + info: "bg-sky-900 border border-sky-700 text-sky-200", + neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird", + success: "bg-green-900 border border-green-700 text-green-200", + warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", + danger: "bg-red-900 border border-red-700 text-red-200", +}; + +// Pill shape sized for inline use next to text. `top-px` nudges the badge +// down so its midline aligns with the surrounding text baseline; `leading-none` +// lets the small text sit flush in the pill without the line-height padding +// inflating it. +export const Badge = forwardRef(function Badge( + { + variant = "info", + icon: Icon, + iconSize = 10, + className, + children, + ...rest + }, + ref, +) { + return ( + + {Icon && } + {children} + + ); +}); + +export default Badge; diff --git a/client/ui/frontend/src/components/ProfileAvatar.tsx b/client/ui/frontend/src/components/ProfileAvatar.tsx index 47531973f..e9fd8ff36 100644 --- a/client/ui/frontend/src/components/ProfileAvatar.tsx +++ b/client/ui/frontend/src/components/ProfileAvatar.tsx @@ -1,15 +1,17 @@ import { ButtonHTMLAttributes, forwardRef } from "react"; import { - Beaker, Briefcase, Building, + Cloud, + Construction, + FlaskConical, Gamepad2, GraduationCap, House, - Cloud, - ServerCog, + Radio, + Server, SquareCode, - TestTube, + Terminal, UserCircle, UserPlus, Users, @@ -17,17 +19,21 @@ import { } from "lucide-react"; import { cn } from "@/lib/cn"; +// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical +// just like "test" does. The list is scanned in order, so more-specific +// tokens (e.g. "staging" before "stage") should come first when they share +// roots. const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ [/(default|personal)/i, UserCircle], [/(work|business|office|company|corp|corporate)/i, Briefcase], [/(home|house|private)/i, House], [/(dev|development|developer|code|coding|engineering)/i, SquareCode], - [/(local|localhost|loopback)/i, SquareCode], - [/(stage|staging)/i, Beaker], - [/(test|testing|qa)/i, TestTube], + [/(local|localhost|loopback)/i, Terminal], + [/(stage|staging|preprod|pre-prod)/i, Construction], + [/(test|testing|qa)/i, FlaskConical], [/(prod|production)/i, Cloud], - [/(live)/i, Cloud], - [/(selfhosted|self-hosted|on-prem|onprem)/i, ServerCog], + [/(live)/i, Radio], + [/(selfhosted|self-hosted|on-prem|onprem)/i, Server], [/(school|university|edu|study|student)/i, GraduationCap], [/(client|customer)/i, Building], [/(family)/i, Users], diff --git a/client/ui/frontend/src/i18n/locales/en/common.json b/client/ui/frontend/src/i18n/locales/en/common.json index 2ecd6451a..ed8acc4a7 100644 --- a/client/ui/frontend/src/i18n/locales/en/common.json +++ b/client/ui/frontend/src/i18n/locales/en/common.json @@ -74,7 +74,7 @@ "profile.selector.newProfile": "New Profile", "profile.selector.moreOptions": "More options", "profile.selector.deregister": "Deregister", - "profile.selector.delete": "Delete Profile", + "profile.selector.delete": "Delete", "profile.dialog.title": "Enter Profile Name", "profile.dialog.description": "Choose a memorable name.", @@ -104,6 +104,13 @@ "profile.dropdown.manageProfiles": "Manage Profiles", "profile.dropdown.settings": "Settings", + "settings.profiles.section.profiles": "Profiles", + "settings.profiles.intro": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.", + "settings.profiles.addProfile": "Add Profile", + "settings.profiles.active": "Active", + "settings.profiles.emptyTitle": "No Profiles", + "settings.profiles.emptyDescription": "Create a profile to connect to a NetBird management server.", + "settings.error.loadTitle": "Load Settings Failed", "settings.error.saveTitle": "Save Settings Failed", "settings.error.debugBundleTitle": "Debug Bundle Failed", @@ -111,6 +118,7 @@ "settings.tabs.general": "General", "settings.tabs.network": "Network", "settings.tabs.security": "Security", + "settings.tabs.profiles": "Profiles", "settings.tabs.ssh": "SSH", "settings.tabs.advanced": "Advanced", "settings.tabs.troubleshooting": "Troubleshooting", diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index daee9fbd1..4ea1561cf 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -29,7 +29,11 @@ export const Header = () => { const openSettings = () => { setMenuOpen(false); - void WindowManager.OpenSettings().catch(() => {}); + void WindowManager.OpenSettings("").catch(() => {}); + }; + + const openManageProfiles = () => { + void WindowManager.OpenSettings("profiles").catch(() => {}); }; const selectMode = (mode: ViewMode) => { @@ -48,7 +52,7 @@ export const Header = () => { >
- +
diff --git a/client/ui/frontend/src/modules/settings/LanguagePicker.tsx b/client/ui/frontend/src/modules/settings/LanguagePicker.tsx index 41b8ce063..fa9e0f33f 100644 --- a/client/ui/frontend/src/modules/settings/LanguagePicker.tsx +++ b/client/ui/frontend/src/modules/settings/LanguagePicker.tsx @@ -28,8 +28,7 @@ for (const path in FLAG_URLS) { if (match) flagByCode[match[1]] = FLAG_URLS[path]; } -const flagFor = (code: string): string | undefined => - flagByCode[code.toLowerCase().split("-")[0]]; +const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]]; function Flag({ code, label }: { code: string; label: string }) { const src = flagFor(code); @@ -132,11 +131,11 @@ export function LanguagePicker() { e.preventDefault()} className={cn( "w-[var(--radix-popover-trigger-width)]", - "rounded-md border border-nb-gray-700 bg-nb-gray-900 shadow-lg p-1 z-50", + "rounded-md border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50", "origin-[var(--radix-popover-content-transform-origin)]", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0", @@ -188,11 +187,9 @@ export function LanguagePicker() { value={`${lang.displayName} ${lang.englishName} ${lang.code}`} onSelect={() => void select(lang.code)} className={cn( - "flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none", - "text-xs font-semibold text-nb-gray-100", - "data-[selected=true]:bg-nb-gray-850 my-0.5", - checked && - "bg-nb-gray-800 data-[selected=true]:bg-nb-gray-800", + "flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none my-0.5", + "text-xs font-semibold text-nb-gray-200", + "data-[selected=true]:bg-nb-gray-900 data-[selected=true]:text-nb-gray-50", )} > {checked && ( )} diff --git a/client/ui/frontend/src/modules/settings/Settings.tsx b/client/ui/frontend/src/modules/settings/Settings.tsx index 1fa9bf975..ea0bc5629 100644 --- a/client/ui/frontend/src/modules/settings/Settings.tsx +++ b/client/ui/frontend/src/modules/settings/Settings.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router-dom"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { cn } from "@/lib/cn"; import { MainRightSide } from "@/layouts/MainRightSide.tsx"; @@ -9,20 +9,26 @@ import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx"; import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx"; import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx"; import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx"; +import { SettingsProfiles } from "@/modules/settings/SettingsProfiles.tsx"; import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx"; import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; import { SettingsDevelopment } from "@/modules/settings/SettingsDevelopment.tsx"; -// The settings window always opens at General. The only way to land on a -// different tab is via navigation state (e.g. the update-available header -// trigger jumps to About). No persistence across opens — a user who wants -// to revisit a deep tab gets there in two clicks. +// The settings window opens at General by default. Navigation state (e.g. the +// 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 profile dropdown) override the default. No persistence across opens — +// a user who wants to revisit a deep tab gets there in two clicks. export const Settings = () => { const location = useLocation(); + const [searchParams] = useSearchParams(); + const queryTab = searchParams.get("tab"); const navState = location.state as { tab?: string } | null; - const [active, setActive] = useState(() => navState?.tab ?? "general"); + const [active, setActive] = useState( + () => navState?.tab ?? queryTab ?? "general", + ); useEffect(() => { if (navState?.tab) setActive(navState.tab); @@ -48,6 +54,9 @@ export const Settings = () => { + + + diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx index 7f45170f6..f3b558dfd 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx @@ -12,6 +12,7 @@ import { ShieldIcon, SlidersHorizontalIcon, SquareTerminalIcon, + UserCircleIcon, } from "lucide-react"; export const SettingsNavigationTriggers = () => { @@ -42,6 +43,11 @@ export const SettingsNavigationTriggers = () => { icon={ShieldIcon} title={t("settings.tabs.security")} /> + { + if (a.name === activeProfile) return -1; + if (b.name === activeProfile) return 1; + return a.name.localeCompare(b.name); + }); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await Dialogs.Error({ + Title: title, + Message: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + const handleDeregister = async (name: string) => { + const cancelLabel = i18next.t("common.cancel"); + const confirmLabel = i18next.t("profile.deregister.confirm"); + const result = await Dialogs.Warning({ + Title: i18next.t("profile.deregister.title"), + Message: i18next.t("profile.deregister.message", { name }), + Buttons: [ + { Label: cancelLabel, IsCancel: true }, + { Label: confirmLabel, IsDefault: true }, + ], + }); + if (result !== confirmLabel) return; + void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name)); + }; + + const handleDelete = async (name: string) => { + if (name === DEFAULT_PROFILE) return; + const cancelLabel = i18next.t("common.cancel"); + const confirmLabel = i18next.t("common.delete"); + const result = await Dialogs.Warning({ + Title: i18next.t("profile.delete.title"), + Message: i18next.t("profile.delete.message", { name }), + Buttons: [ + { Label: cancelLabel, IsCancel: true }, + { Label: confirmLabel, IsDefault: true }, + ], + }); + if (result !== confirmLabel) return; + void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name)); + }; + + const handleCreate = async (name: string) => { + try { + await addProfile(name); + await switchProfile(name); + } catch (e) { + await Dialogs.Error({ + Title: i18next.t("profile.error.createTitle"), + Message: e instanceof Error ? e.message : String(e), + }); + } + }; + + return ( + <> + + {t("settings.profiles.intro")} + + + + + + + + + + + ); +} + +function BottomBar({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +type ProfileRowProps = { + profile: Profile; + isActive: boolean; + onDeregister: () => void; + onDelete: () => void; +}; + +const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => { + const { t } = useTranslation(); + const Icon = pickProfileIcon(profile.name) ?? UserCircle; + const showEmail = !!profile.email; + + return ( + + +
+ +
+
+ + {profile.name} + + {isActive && {t("settings.profiles.active")}} +
+ {showEmail && } +
+
+ + + + + + ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; + +type RowActionsProps = { + canDeregister: boolean; + canDelete: boolean; + onDeregister: () => void; + onDelete: () => void; +}; + +const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => { + const { t } = useTranslation(); + return ( +
+
+ ); +}; + +type ActionIconButtonProps = { + label: string; + icon: typeof LogOut; + onClick: () => void; + variant?: "default" | "danger"; + /** When true the button still occupies space (preserves row layout) + * but is invisible and non-interactive. */ + hidden?: boolean; +}; + +const ActionIconButton = ({ + label, + icon: Icon, + onClick, + variant = "default", + hidden = false, +}: ActionIconButtonProps) => { + const button = ( + + ); + if (hidden) return button; + return ( + + {button} + + ); +}; diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index c1b913151..059e176f9 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -53,10 +53,16 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo } // OpenSettings shows the settings window, creating it on first use (and -// after the user has closed a previous instance). -func (s *WindowManager) OpenSettings() { +// after the user has closed a previous instance). If `tab` is non-empty the +// settings React layer reads it from the start URL and selects that tab +// (e.g. "profiles") instead of the default "general". +func (s *WindowManager) OpenSettings(tab string) { s.mu.Lock() defer s.mu.Unlock() + startURL := "/#/settings" + if tab != "" { + startURL = "/#/settings?tab=" + url.QueryEscape(tab) + } if s.settings == nil { s.settings = s.app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", @@ -68,7 +74,7 @@ func (s *WindowManager) OpenSettings() { MaximiseButtonState: application.ButtonHidden, CloseButtonState: application.ButtonEnabled, BackgroundColour: application.NewRGB(24, 26, 29), - URL: "/#/settings", + URL: startURL, Mac: application.MacWindow{ InvisibleTitleBarHeight: 38, Backdrop: application.MacBackdropTranslucent, @@ -81,6 +87,9 @@ func (s *WindowManager) OpenSettings() { s.settings = nil s.mu.Unlock() }) + } else if tab != "" { + // Re-open onto a specific tab when the window is already alive. + s.settings.SetURL(startURL) } s.settings.Show() s.settings.Focus() diff --git a/client/ui/tray.go b/client/ui/tray.go index ee8e11a4c..1cb16cf40 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -304,7 +304,7 @@ func (t *Tray) buildMenu() *application.Menu { // block-inbound, auto-connect, notifications) and profile switching // all live in the in-window Settings page now. The tray menu only // surfaces the day-to-day actions. - t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings() }) + t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) t.debugItem = menu.Add(t.loc.T("tray.menu.debugBundle")).OnClick(func(*application.Context) { t.openRoute("/debug") }) menu.AddSeparator()