From dc3f794373dffc3fb1176e48709dcb394246b157 Mon Sep 17 00:00:00 2001 From: michaelhan Date: Sat, 25 Jul 2026 01:17:38 -0700 Subject: [PATCH] Settings: pin and reorder the sidebar navigation Adds a "Sidebar navigation" section to Settings -> Appearance, above the existing profile-menu customizer, with the same drag-to-reorder + switch UI. - New sidebarNav preference: one { id, pinned } entry per navigable row (projects, hub, images, train, video, recipes, export), array order = render order. Defaults match the shipped layout, so an untouched install is unchanged. - Unpinning moves a row into the More flyout rather than hiding it, so no page becomes unreachable. New chat and Search stay fixed as actions. - app-sidebar now renders from one navRows descriptor map, so a pinned row and its flyout counterpart cannot drift; the More row appears only when something is unpinned and highlights off whatever it actually holds. - Mirrored in the backend PersonalizationCustomization: without it the model's extra="ignore" would drop the field, and because sync replaces local state with the server's copy once customization is saved, the user's pin order would reset on the next sync. The validator dedupes and back-fills like sidebarMenu but preserves the client's order, since here order is meaningful. Frontend typecheck, i18n parity and catalog checks pass; 32 personalization tests pass, including a round-trip asserting a reordered list survives a save. --- studio/backend/routes/settings.py | 58 +++ .../tests/test_personalization_settings.py | 75 +++ .../frontend/src/components/app-sidebar.tsx | 456 ++++++++++-------- .../components/sidebar-nav-customizer.tsx | 156 ++++++ .../frontend/src/features/settings/index.ts | 2 + .../src/features/settings/settings-search.ts | 1 + .../stores/appearance-custom-store.ts | 64 +++ .../features/settings/tabs/appearance-tab.tsx | 10 + studio/frontend/src/i18n/locales/en.ts | 9 + studio/frontend/src/i18n/locales/zh-CN.ts | 9 + 10 files changed, 642 insertions(+), 198 deletions(-) create mode 100644 studio/frontend/src/features/settings/components/sidebar-nav-customizer.tsx 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() { + +
+ +
+
+