diff --git a/studio/backend/routes/settings.py b/studio/backend/routes/settings.py index fef18a9145..73fbb2dbed 100644 --- a/studio/backend/routes/settings.py +++ b/studio/backend/routes/settings.py @@ -734,6 +734,21 @@ SIDEBAR_MENU_ITEM_DEFAULTS = { "connections": False, } +# Navigable sidebar rows the user can pin/reorder; the boolean is each id's +# default pin state, matching the shipped layout. An unpinned row moves into the +# "More" flyout client-side, so it stays reachable. +SIDEBAR_NAV_ITEM_DEFAULTS = { + "projects": True, + "hub": True, + "images": True, + "train": True, + "video": False, + "recipes": False, + "export": False, +} + +MAX_SIDEBAR_NAV_INPUT_ITEMS = 4 * len(SIDEBAR_NAV_ITEM_DEFAULTS) + # The sidebarMenu validator below dedupes ids and re-fills any missing ones, so # the stored list is always exactly one entry per id. Cap the *incoming* list at # a generous multiple rather than len(defaults): a stale or duplicated payload @@ -766,6 +781,28 @@ def _default_sidebar_menu() -> "list[PersonalizationSidebarMenuItem]": ] +class PersonalizationSidebarNavItem(BaseModel): + model_config = ConfigDict(extra = "ignore") + + id: Literal[ + "projects", + "hub", + "images", + "train", + "video", + "recipes", + "export", + ] + pinned: bool = True + + +def _default_sidebar_nav() -> "list[PersonalizationSidebarNavItem]": + return [ + PersonalizationSidebarNavItem(id = item_id, pinned = pinned) + for item_id, pinned in SIDEBAR_NAV_ITEM_DEFAULTS.items() + ] + + class PersonalizationCustomization(BaseModel): model_config = ConfigDict(extra = "ignore") @@ -803,6 +840,12 @@ class PersonalizationCustomization(BaseModel): default_factory = _default_sidebar_menu, max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS, ) + # Order matters here: it is the sidebar's render order, so the validator + # preserves the client's sequence and only appends ids it didn't send. + sidebarNav: list[PersonalizationSidebarNavItem] = Field( + default_factory = _default_sidebar_nav, + max_length = MAX_SIDEBAR_NAV_INPUT_ITEMS, + ) @field_validator("sidebarMenu") @classmethod @@ -818,6 +861,21 @@ class PersonalizationCustomization(BaseModel): items.append(PersonalizationSidebarMenuItem(id = item_id, visible = visible)) return items + @field_validator("sidebarNav") + @classmethod + def _validate_sidebar_nav( + cls, value: list[PersonalizationSidebarNavItem] + ) -> list[PersonalizationSidebarNavItem]: + # Same contract as sidebarMenu: drop duplicate ids (keep the first) and + # re-append missing ones, so the stored list covers every nav row exactly + # once while keeping the client's order. + seen: set[str] = set() + items = [item for item in value if not (item.id in seen or seen.add(item.id))] + for item_id, pinned in SIDEBAR_NAV_ITEM_DEFAULTS.items(): + if item_id not in seen: + items.append(PersonalizationSidebarNavItem(id = item_id, pinned = pinned)) + return items + class PersonalizationAppearance(BaseModel): model_config = ConfigDict(extra = "ignore") diff --git a/studio/backend/tests/test_personalization_settings.py b/studio/backend/tests/test_personalization_settings.py index 7b5e70decc..c9e5a2ee2d 100644 --- a/studio/backend/tests/test_personalization_settings.py +++ b/studio/backend/tests/test_personalization_settings.py @@ -18,8 +18,10 @@ from auth.authentication import get_current_subject # noqa: E402 from routes import settings as settings_routes # noqa: E402 from routes.settings import ( # noqa: E402 MAX_SIDEBAR_MENU_INPUT_ITEMS, + MAX_SIDEBAR_NAV_INPUT_ITEMS, PersonalizationPayload, SIDEBAR_MENU_ITEM_DEFAULTS, + SIDEBAR_NAV_ITEM_DEFAULTS, ) @@ -144,6 +146,68 @@ def test_customization_sidebar_menu_rejects_pathological_length(): PersonalizationPayload.model_validate(_sidebar(huge)) +def _sidebar_nav(items): + return {"appearance": {"customization": {"sidebarNav": items}}} + + +def test_customization_sidebar_nav_defaults_match_shipped_layout(): + # An untouched payload pins the rows the sidebar ships with and leaves the + # rest for the "More" flyout, so a fresh account looks unchanged. + c = PersonalizationPayload().appearance.customization + assert [(i.id, i.pinned) for i in c.sidebarNav] == [ + ("projects", True), + ("hub", True), + ("images", True), + ("train", True), + ("video", False), + ("recipes", False), + ("export", False), + ] + + +def test_customization_sidebar_nav_preserves_order_and_normalizes(): + p = PersonalizationPayload.model_validate( + _sidebar_nav( + [ + {"id": "video", "pinned": True}, + {"id": "video", "pinned": False}, + {"id": "hub", "pinned": False}, + ] + ) + ) + # Order is the sidebar's render order, so the client's sequence survives: + # duplicates keep the first entry and unsent ids are appended with defaults. + assert [(i.id, i.pinned) for i in p.appearance.customization.sidebarNav] == [ + ("video", True), + ("hub", False), + ("projects", True), + ("images", True), + ("train", True), + ("recipes", False), + ("export", False), + ] + + +def test_customization_sidebar_nav_rejects_unknown_id(): + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate(_sidebar_nav([{"id": "chats"}])) + + +def test_customization_sidebar_nav_dedupes_oversized_payload(): + ids = list(SIDEBAR_NAV_ITEM_DEFAULTS) + doubled = [{"id": i} for i in ids] + [{"id": i} for i in ids] + assert len(doubled) > len(SIDEBAR_NAV_ITEM_DEFAULTS) + p = PersonalizationPayload.model_validate(_sidebar_nav(doubled)) + result = [i.id for i in p.appearance.customization.sidebarNav] + assert result == ids + + +def test_customization_sidebar_nav_rejects_pathological_length(): + huge = [{"id": "hub"} for _ in range(MAX_SIDEBAR_NAV_INPUT_ITEMS + 1)] + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate(_sidebar_nav(huge)) + + def test_customization_imported_fonts_validated(): ok = PersonalizationPayload.model_validate( { @@ -379,6 +443,17 @@ def test_personalization_route_roundtrip_real_shape(monkeypatch): {"id": "chat", "visible": False}, {"id": "connections", "visible": False}, ], + # Reordered and partly unpinned, so the round-trip proves the + # sidebar's render order survives a save unchanged. + "sidebarNav": [ + {"id": "images", "pinned": True}, + {"id": "video", "pinned": True}, + {"id": "hub", "pinned": True}, + {"id": "train", "pinned": True}, + {"id": "projects", "pinned": False}, + {"id": "recipes", "pinned": False}, + {"id": "export", "pinned": False}, + ], }, }, } diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 6e5a26648c..85404b8887 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -130,6 +130,7 @@ import { useAppearanceCustomStore, useSettingsDialogStore, } from "@/features/settings"; +import type { SidebarNavItemId } from "@/features/settings"; import { useEffectiveProfile, UserAvatar } from "@/features/profile"; import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { clearAuthTokens, logout } from "@/features/auth"; @@ -204,6 +205,22 @@ const SETTINGS_TAB_MENU_ITEMS: Record< connections: { icon: CloudIcon, labelKey: "settings.tabs.connections" }, }; +// A navigable sidebar row. The same definition renders either as a top-level +// NavItem or as a MoreMenuItem, depending on the user's pin preference. +type NavRowDef = { + icon: typeof ZapIcon; + label: string; + active: boolean; + disabled?: boolean; + tooltip?: string; + spinner?: boolean; + badge?: string; + onClick: () => void; + onIntent?: () => void; + className?: string; + children?: ReactNode; +}; + type ConversationExportFormat = "raw-jsonl" | "csv" | "sharegpt-jsonl"; // A pinned project shows this many recent chats before "Show more". @@ -403,6 +420,9 @@ export function AppSidebar() { const sidebarMenu = useAppearanceCustomStore( (s) => s.customization.sidebarMenu, ); + const sidebarNav = useAppearanceCustomStore( + (s) => s.customization.sidebarNav, + ); const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar); const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar); // Mac uses Cmd, others use Ctrl. Not Tauri-gated, so it's right on web too. @@ -668,13 +688,150 @@ export function AppSidebar() { ]); const chatDisabled = trainingInProgress; - // Highlight the "More" row while one of the routes it holds is showing. - const moreSectionActive = - pathname === "/video" || - pathname.startsWith("/video/") || - pathname === "/export" || - pathname.startsWith("/export/") || - isRecipesRoute; + + // One definition per navigable row, so the pinned list and the More flyout + // render the same row from the same source and can't drift apart. + const navRows: Record = { + projects: { + icon: Folder01Icon, + label: t("shell.navigation.projects"), + active: pathname === "/projects" || pathname.startsWith("/projects/"), + onClick: () => { + navigate({ to: "/projects" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/projects" })); + }, + className: "group/projects-item relative", + // The inline "new project" affordance only makes sense on a real row; in + // the flyout the row is just a link. + children: ( + + ), + }, + hub: { + icon: DashboardCircleIcon, + label: t("shell.navigation.hub"), + active: pathname === "/hub" || pathname.startsWith("/hub/"), + onClick: () => { + navigate({ to: "/hub" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/hub" })); + }, + }, + images: { + icon: Image03Icon, + label: t("shell.navigation.images"), + badge: t("shell.navigation.newBadge"), + active: pathname === "/images" || pathname.startsWith("/images/"), + onClick: () => { + navigate({ to: "/images" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/images" })); + }, + }, + train: { + icon: TestTubeOutlineIcon, + label: t("shell.navigation.train"), + active: pathname === "/studio" || pathname.startsWith("/studio/"), + disabled: chatOnly, + tooltip: trainDisabledHint, + spinner: trainingInProgress, + onClick: () => { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/studio" })); + }, + }, + // Video is diffusers-only (no native CPU engine), so a chat-only host can + // never load it; disable with a hint instead of bouncing off the root + // guard's redirect. + video: { + icon: FlimSlateIcon, + label: t("shell.navigation.video"), + badge: t("shell.navigation.newBadge"), + active: pathname === "/video" || pathname.startsWith("/video/"), + disabled: chatOnly, + tooltip: chatOnly + ? "Video generation needs an NVIDIA or AMD GPU." + : undefined, + onClick: () => { + navigate({ to: "/video" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/video" })); + }, + }, + recipes: { + icon: ChefHatIcon, + label: t("shell.navigation.recipes"), + active: isRecipesRoute, + onClick: () => { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/data-recipes" })); + preloadSilently( + import("@/features/data-recipes").then((module) => + module.preloadRecipes(), + ), + ); + }, + }, + export: { + icon: DownloadSquare01Icon, + label: t("shell.navigation.export"), + active: pathname === "/export" || pathname.startsWith("/export/"), + spinner: exportInProgress, + onClick: () => { + navigate({ to: "/export" }); + closeMobileIfOpen(); + }, + onIntent: () => { + preloadSilently(router.preloadRoute({ to: "/export" })); + preloadSilently( + import("@/features/export/export-navigation-cache").then((module) => + module.preloadExportData(), + ), + ); + }, + }, + }; + const pinnedNavIds = sidebarNav + .filter((item) => item.pinned) + .map((item) => item.id); + const overflowNavIds = sidebarNav + .filter((item) => !item.pinned) + .map((item) => item.id); + const showSidebarBrand = !usesCustomTitlebar; const showCompactMacBrand = showSidebarBrand && usesNativeMacTitlebar; @@ -1452,202 +1609,105 @@ export function AppSidebar() { - { - navigate({ to: "/hub" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently(router.preloadRoute({ to: "/hub" })); - }} - /> - { - navigate({ to: "/projects" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently(router.preloadRoute({ to: "/projects" })); - }} - className="group/projects-item relative" - > - - - { - navigate({ to: "/images" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently(router.preloadRoute({ to: "/images" })); - }} - /> - { - if (chatOnly) return; - navigate({ to: "/studio" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently(router.preloadRoute({ to: "/studio" })); - }} - /> + {/* Rows come from the saved pin order (Settings -> Appearance -> + Sidebar navigation). Pinned ids render here in order; the rest + fall into the More flyout below, so nothing is unreachable. */} + {pinnedNavIds.map((id) => { + const row = navRows[id]; + return ( + + {row.children} + + ); + })} {/* Secondary destinations behind one row, so the primary nav stays short. Hover or click opens it; the panel flies out to the right. */} - - 0 && ( + - {/* No `tooltip` prop on the button: with it, SidebarMenuButton - returns a Tooltip root and DropdownMenuTrigger asChild would - hand its ref/handlers to that instead of a DOM node, leaving - the trigger dead. Wrap it here instead, so both triggers - compose onto the same button. */} - - - - - - - {t("shell.navigation.more")} - - - - - {/* Only the collapsed rail needs it; expanded rows show their label. */} - - - - {/* Video is diffusers-only (no native CPU engine), so a chat-only host - can never load it; disable with a hint instead of bouncing off the - root guard's redirect. */} - { - navigate({ to: "/video" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently(router.preloadRoute({ to: "/video" })); - }} - /> - { - navigate({ to: "/data-recipes" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently( - router.preloadRoute({ to: "/data-recipes" }), + {/* No `tooltip` prop on the button: with it, SidebarMenuButton + returns a Tooltip root and DropdownMenuTrigger asChild would + hand its ref/handlers to that instead of a DOM node, leaving + the trigger dead. Wrap it here instead, so both triggers + compose onto the same button. */} + + + + navRows[id].active, + )} + className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:mx-auto" + > + + + {t("shell.navigation.more")} + + + + + {/* Only the collapsed rail needs it; expanded rows show their label. */} + + + + {overflowNavIds.map((id) => { + const row = navRows[id]; + return ( + ); - preloadSilently( - import("@/features/data-recipes").then((module) => - module.preloadRecipes(), - ), - ); - }} - /> - { - navigate({ to: "/export" }); - closeMobileIfOpen(); - }} - onIntent={() => { - preloadSilently(router.preloadRoute({ to: "/export" })); - preloadSilently( - import( - "@/features/export/export-navigation-cache" - ).then((module) => module.preloadExportData()), - ); - }} - /> - - - + })} + + + + )} diff --git a/studio/frontend/src/features/settings/components/sidebar-nav-customizer.tsx b/studio/frontend/src/features/settings/components/sidebar-nav-customizer.tsx new file mode 100644 index 0000000000..1f85db1c27 --- /dev/null +++ b/studio/frontend/src/features/settings/components/sidebar-nav-customizer.tsx @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + ChefHatIcon, + DashboardCircleIcon, + DownloadSquare01Icon, + DragDropVerticalIcon, + Edit03Icon, + FlimSlateIcon, + Folder01Icon, + Image03Icon, + MoreHorizontalIcon, + Search01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Reorder, useDragControls } from "motion/react"; +import { Switch } from "@/components/ui/switch"; +import { TestTubeOutlineIcon } from "@/lib/hugeicons-derived"; +import { useT } from "@/i18n"; +import type { TranslationKey } from "@/i18n"; +import type { IconSvgElement } from "@hugeicons/react"; +import type { SidebarNavItemPref } from "../stores/appearance-custom-store"; +import { useAppearanceCustomStore } from "../stores/appearance-custom-store"; + +const ITEM_META: Record< + SidebarNavItemPref["id"], + { icon: IconSvgElement; labelKey: TranslationKey } +> = { + projects: { icon: Folder01Icon, labelKey: "shell.navigation.projects" }, + hub: { icon: DashboardCircleIcon, labelKey: "shell.navigation.hub" }, + images: { icon: Image03Icon, labelKey: "shell.navigation.images" }, + train: { icon: TestTubeOutlineIcon, labelKey: "shell.navigation.train" }, + video: { icon: FlimSlateIcon, labelKey: "shell.navigation.video" }, + recipes: { icon: ChefHatIcon, labelKey: "shell.navigation.recipes" }, + export: { icon: DownloadSquare01Icon, labelKey: "shell.navigation.export" }, +}; + +function FixedRow({ icon, label }: { icon: IconSvgElement; label: string }) { + return ( +
+ {/* Spacer where the drag handle sits on movable rows. */} +
+ ); +} + +function MovableRow({ item }: { item: SidebarNavItemPref }) { + const t = useT(); + const controls = useDragControls(); + const patch = useAppearanceCustomStore((s) => s.patch); + const sidebarNav = useAppearanceCustomStore((s) => s.customization.sidebarNav); + const meta = ITEM_META[item.id]; + return ( + + + + {t(meta.labelKey)} + + patch({ + sidebarNav: sidebarNav.map((entry) => + entry.id === item.id ? { ...entry, pinned } : entry, + ), + }) + } + /> + + ); +} + +/** + * Pin and reorder the sidebar navigation rows. A row with its switch off moves + * into the "More" flyout instead of disappearing, so every page stays reachable. + * New Chat and Search render as static rows: they are actions pinned to the top, + * not destinations. + */ +export function SidebarNavCustomizer() { + const t = useT(); + const sidebarNav = useAppearanceCustomStore((s) => s.customization.sidebarNav); + const patch = useAppearanceCustomStore((s) => s.patch); + const unpinnedCount = sidebarNav.filter((item) => !item.pinned).length; + return ( +
+ + + item.id)} + onReorder={(ids: SidebarNavItemPref["id"][]) => + patch({ + sidebarNav: ids.flatMap( + (id) => sidebarNav.find((entry) => entry.id === id) ?? [], + ), + }) + } + className="flex flex-col" + > + {sidebarNav.map((item) => ( + + ))} + + {/* Only meaningful once something is unpinned -- with everything pinned the + sidebar has no More row at all. */} + {unpinnedCount > 0 && ( + <> +
+ + + )} +
+ ); +} diff --git a/studio/frontend/src/features/settings/index.ts b/studio/frontend/src/features/settings/index.ts index f27100a322..1065e5f750 100644 --- a/studio/frontend/src/features/settings/index.ts +++ b/studio/frontend/src/features/settings/index.ts @@ -26,6 +26,8 @@ export type { AppearanceCustomization, CustomModeColors, ReduceMotionSetting, + SidebarNavItemId, + SidebarNavItemPref, } from "./stores/appearance-custom-store"; export { useMonitorOverlayStore } from "./stores/monitor-overlay-store"; export type { diff --git a/studio/frontend/src/features/settings/settings-search.ts b/studio/frontend/src/features/settings/settings-search.ts index 63b492878f..4bc8d22a0a 100644 --- a/studio/frontend/src/features/settings/settings-search.ts +++ b/studio/frontend/src/features/settings/settings-search.ts @@ -57,6 +57,7 @@ export const SETTINGS_SEARCH_INDEX: Record = { "settings.appearance.custom.codeFontSize.label", "settings.appearance.custom.fontSmoothing.label", "settings.appearance.layout.compactSidebar", + "settings.appearance.sidebarNav.title", "settings.appearance.sidebarMenu.title", "settings.appearance.sidebarMenu.darkModeToggle", ], diff --git a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts index 0e449092e5..04a24f46ff 100644 --- a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts +++ b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts @@ -82,6 +82,42 @@ export const SIDEBAR_MENU_DEFAULT_VISIBLE: Record = connections: false, }; +/** + * Sidebar NAVIGATION rows the user can pin and reorder (distinct from the + * profile-menu entries above). New Chat and Search stay fixed at the top: they + * are actions, not destinations. Array order is render order; an unpinned row + * moves into the "More" flyout rather than disappearing, so no page becomes + * unreachable. + */ +export const SIDEBAR_NAV_ITEM_IDS = [ + "projects", + "hub", + "images", + "train", + "video", + "recipes", + "export", +] as const; + +export type SidebarNavItemId = (typeof SIDEBAR_NAV_ITEM_IDS)[number]; + +export type SidebarNavItemPref = { + id: SidebarNavItemId; + /** true = a top-level sidebar row; false = inside the "More" flyout. */ + pinned: boolean; +}; + +// Matches the shipped layout, so an untouched install looks unchanged. +export const SIDEBAR_NAV_DEFAULT_PINNED: Record = { + projects: true, + hub: true, + images: true, + train: true, + video: false, + recipes: false, + export: false, +}; + export const MAX_IMPORTED_FONTS = 3; /** Imported-font family name cap; must match the backend name max_length (100). */ export const MAX_IMPORTED_FONT_NAME_LENGTH = 100; @@ -113,6 +149,8 @@ export type AppearanceCustomization = { fontSmoothing: boolean; /** Order and visibility of the optional sidebar profile menu items. */ sidebarMenu: SidebarMenuItemPref[]; + /** Order of the sidebar nav rows, and which are pinned vs. under "More". */ + sidebarNav: SidebarNavItemPref[]; }; const EMPTY_MODE_COLORS: CustomModeColors = { @@ -138,6 +176,10 @@ export const DEFAULT_CUSTOMIZATION: AppearanceCustomization = { id, visible: SIDEBAR_MENU_DEFAULT_VISIBLE[id], })), + sidebarNav: SIDEBAR_NAV_ITEM_IDS.map((id) => ({ + id, + pinned: SIDEBAR_NAV_DEFAULT_PINNED[id], + })), }; export const UI_FONT_SIZE_RANGE = { min: 12, max: 20, default: 16 } as const; @@ -222,6 +264,27 @@ function isSidebarMenuItemId(value: unknown): value is SidebarMenuItemId { return SIDEBAR_MENU_ITEM_IDS.includes(value as SidebarMenuItemId); } +function isSidebarNavItemId(value: unknown): value is SidebarNavItemId { + return SIDEBAR_NAV_ITEM_IDS.includes(value as SidebarNavItemId); +} + +function sanitizeSidebarNav(value: unknown): SidebarNavItemPref[] { + const items: SidebarNavItemPref[] = []; + const seen = new Set(); + for (const entry of Array.isArray(value) ? value : []) { + const source = (entry ?? {}) as Partial; + if (!isSidebarNavItemId(source.id) || seen.has(source.id)) continue; + seen.add(source.id); + items.push({ id: source.id, pinned: source.pinned !== false }); + } + // Ids added after the payload was written land at the end with their default + // pin state, so a new tab shows up where the shipped layout puts it. + for (const id of SIDEBAR_NAV_ITEM_IDS) { + if (!seen.has(id)) items.push({ id, pinned: SIDEBAR_NAV_DEFAULT_PINNED[id] }); + } + return items; +} + function sanitizeSidebarMenu(value: unknown): SidebarMenuItemPref[] { const items: SidebarMenuItemPref[] = []; const seen = new Set(); @@ -273,6 +336,7 @@ export function sanitizeCustomization(value: unknown): AppearanceCustomization { : "system", fontSmoothing: source.fontSmoothing !== false, sidebarMenu: sanitizeSidebarMenu(source.sidebarMenu), + sidebarNav: sanitizeSidebarNav(source.sidebarNav), }; } diff --git a/studio/frontend/src/features/settings/tabs/appearance-tab.tsx b/studio/frontend/src/features/settings/tabs/appearance-tab.tsx index 1ee8c0d349..db8c97b43e 100644 --- a/studio/frontend/src/features/settings/tabs/appearance-tab.tsx +++ b/studio/frontend/src/features/settings/tabs/appearance-tab.tsx @@ -21,6 +21,7 @@ import { import { PaletteCards } from "../components/palette-cards"; import { SettingsRow } from "../components/settings-row"; import { SidebarMenuCustomizer } from "../components/sidebar-menu-customizer"; +import { SidebarNavCustomizer } from "../components/sidebar-nav-customizer"; import { SettingsGroupDivider, SettingsSection, @@ -151,6 +152,15 @@ export function AppearanceTab() { + +
+ +
+
+