From 9afaee24a0daab99ae58810fd110e32fa02fd966 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 14 Feb 2026 19:10:44 -0800 Subject: [PATCH] Persist collapae state --- frontend/src/components/Sidebar.tsx | 51 ++++++++++++++++++++++++++--- frontend/src/test/sidebar.test.tsx | 26 +++++++++++++-- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 10baaa8..06829bd 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -35,6 +35,31 @@ type CollapseState = { repeaters: boolean; }; +const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state'; + +const DEFAULT_COLLAPSE_STATE: CollapseState = { + favorites: false, + channels: false, + contacts: false, + repeaters: false, +}; + +function loadCollapsedState(): CollapseState { + try { + const raw = localStorage.getItem(SIDEBAR_COLLAPSE_STATE_KEY); + if (!raw) return DEFAULT_COLLAPSE_STATE; + const parsed = JSON.parse(raw) as Partial; + return { + favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites, + channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels, + contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts, + repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters, + }; + } catch { + return DEFAULT_COLLAPSE_STATE; + } +} + interface SidebarProps { contacts: Contact[]; channels: Channel[]; @@ -75,10 +100,11 @@ export function Sidebar({ }: SidebarProps) { const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); - const [favoritesCollapsed, setFavoritesCollapsed] = useState(false); - const [channelsCollapsed, setChannelsCollapsed] = useState(false); - const [contactsCollapsed, setContactsCollapsed] = useState(false); - const [repeatersCollapsed, setRepeatersCollapsed] = useState(false); + const initialCollapsedState = useMemo(loadCollapsedState, []); + const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); + const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels); + const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts); + const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters); const collapseSnapshotRef = useRef(null); const handleSortToggle = () => { @@ -260,6 +286,23 @@ export function Sidebar({ } }, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); + useEffect(() => { + if (isSearching) return; + + const state: CollapseState = { + favorites: favoritesCollapsed, + channels: channelsCollapsed, + contacts: contactsCollapsed, + repeaters: repeatersCollapsed, + }; + + try { + localStorage.setItem(SIDEBAR_COLLAPSE_STATE_KEY, JSON.stringify(state)); + } catch { + // Ignore localStorage write failures (e.g., disabled storage) + } + }, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); + // Separate favorites from regular items, and build combined favorites list const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } = useMemo(() => { diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index a727e2e..f5230c4 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Sidebar } from '../components/Sidebar'; import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types'; @@ -54,7 +54,7 @@ function renderSidebar(overrides?: { const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }]; - render( + const view = render( ); - return { flightChannel, opsChannel, aliceName }; + return { ...view, flightChannel, opsChannel, aliceName }; } function getSectionHeaderContainer(title: string): HTMLElement { @@ -85,6 +85,10 @@ function getSectionHeaderContainer(title: string): HTMLElement { } describe('Sidebar section summaries', () => { + beforeEach(() => { + localStorage.clear(); + }); + it('shows muted section unread totals in each visible section header', () => { renderSidebar(); @@ -117,4 +121,20 @@ describe('Sidebar section summaries', () => { expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); }); }); + + it('persists collapsed section state across unmount and remount', () => { + const { opsChannel, aliceName, unmount } = renderSidebar(); + + fireEvent.click(screen.getByRole('button', { name: /Channels/i })); + fireEvent.click(screen.getByRole('button', { name: /Contacts/i })); + + expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); + expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); + + unmount(); + renderSidebar(); + + expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); + expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); + }); });