diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index f562d1206..62963fa87 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -37,7 +37,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr | `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. | | `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). 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`. | +| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. | `DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 5025e43d0..c2a755e9a 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -76,7 +76,7 @@ State that crosses screens / windows lives in context. Each provider is mounted ### Default/Advanced view + no client-side persistence -The Header's "more" dropdown owns a `viewMode: "default" | "advanced"` `useState` and calls `Window.SetSize(width, 640)` directly on change. Sizes live in `VIEW_SIZE` at the top of `Header.tsx`: Default = 380×640, Advanced = 900×640 — the 640 height matches the Settings window so chrome height is consistent across surfaces. Every app launch starts in Default (the Go-side main window is created 380×640 in `main.go`). **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`). +The `ViewModeProvider` (`src/lib/viewMode.tsx`, mounted in `AppLayout`) owns a `viewMode: "default" | "advanced"` state and is consumed by `Header.tsx`'s "more" dropdown via `useViewMode()`. `setViewMode` does three things: updates state, calls `Window.SetSize(width, 640)`, and persists via `Preferences.SetViewMode`. Sizes live in `VIEW_SIZE` at the top of `viewMode.tsx`: Default = 380×640, Advanced = 900×640 — the 640 height matches the Settings window so chrome height is consistent across surfaces. The view is persisted user-side (see Go-side `preferences.Store`): `main.go` opens the main window at the saved width so the user never sees a 380→900 flash on launch, and the provider hydrates its React state from `Preferences.Get()` in a mount effect (no resize triggered there — Go already sized it). **No `localStorage` / `sessionStorage` / cookies anywhere in the frontend** — persistence is the Go side's job (settings → `SetConfig`, language → `Preferences.SetLanguage`, view mode → `Preferences.SetViewMode`). ## Localisation (i18n) diff --git a/client/ui/frontend/src/lib/viewMode.tsx b/client/ui/frontend/src/lib/viewMode.tsx index 988f8f116..c80aeae43 100644 --- a/client/ui/frontend/src/lib/viewMode.tsx +++ b/client/ui/frontend/src/lib/viewMode.tsx @@ -1,5 +1,7 @@ -import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; import { Window } from "@wailsio/runtime"; +import { Preferences } from "@bindings/services"; +import { ViewMode as ViewModePref } from "@bindings/preferences/models.js"; export type ViewMode = "default" | "advanced"; @@ -20,12 +22,33 @@ const ViewModeContext = createContext(null); export const ViewModeProvider = ({ children }: { children: ReactNode }) => { const [viewMode, setMode] = useState("default"); + + // Hydrate from the persisted preference. The Go side has already sized + // the main window to match (see main.go), so this only catches the + // React state and dropdown checkmark up — no resize is triggered here. + useEffect(() => { + let cancelled = false; + void Preferences.Get() + .then((prefs) => { + if (cancelled) return; + const saved = prefs?.viewMode as ViewMode | undefined; + if (saved === "default" || saved === "advanced") { + setMode(saved); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + const setViewMode = useCallback( (mode: ViewMode) => { setMode((prev) => { if (prev === mode) return prev; const { width, height } = VIEW_SIZE[mode]; void Window.SetSize(width, height).catch(() => {}); + void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {}); return mode; }); }, diff --git a/client/ui/main.go b/client/ui/main.go index 27b4cfc9b..80e5f206c 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -178,10 +178,17 @@ func main() { app.RegisterService(application.NewService(services.NewI18n(bundle))) app.RegisterService(application.NewService(services.NewPreferences(prefStore))) + // Open the main window at the width matching the user's last view + // choice so an Advanced-mode user doesn't see the window pop from 380px + // to 900px on every launch. Height is the same in both modes. + initialWidth := 380 + if prefStore.Get().ViewMode == preferences.ViewModeAdvanced { + initialWidth = 900 + } window := app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "main", Title: "NetBird", - Width: 380, + Width: initialWidth, Height: 640, Hidden: true, BackgroundColour: application.NewRGB(24, 26, 29),