Persist collapae state

This commit is contained in:
Jack Kingsman
2026-02-14 19:10:44 -08:00
parent c91449260d
commit 9afaee24a0
2 changed files with 70 additions and 7 deletions

View File

@@ -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<CollapseState>;
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<CollapseState | null>(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(() => {

View File

@@ -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(
<Sidebar
contacts={[alice, relay]}
channels={[publicChannel, flightChannel, opsChannel]}
@@ -74,7 +74,7 @@ function renderSidebar(overrides?: {
/>
);
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();
});
});