From 0ac8e97ea2c237a765bbf586a6c878d3777e4a16 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 8 Mar 2026 13:54:01 -0700 Subject: [PATCH] Put tools in a collapsible --- frontend/src/components/Sidebar.tsx | 274 ++++++++++++++-------------- frontend/src/test/sidebar.test.tsx | 6 + 2 files changed, 146 insertions(+), 134 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index baea922..6702589 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -28,6 +28,7 @@ type ConversationRow = { }; type CollapseState = { + tools: boolean; favorites: boolean; channels: boolean; contacts: boolean; @@ -37,6 +38,7 @@ type CollapseState = { const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state'; const DEFAULT_COLLAPSE_STATE: CollapseState = { + tools: false, favorites: false, channels: false, contacts: false, @@ -49,6 +51,7 @@ function loadCollapsedState(): CollapseState { if (!raw) return DEFAULT_COLLAPSE_STATE; const parsed = JSON.parse(raw) as Partial; return { + tools: parsed.tools ?? DEFAULT_COLLAPSE_STATE.tools, favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites, channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels, contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts, @@ -100,6 +103,7 @@ export function Sidebar({ const sortOrder = sortOrderProp; const [searchQuery, setSearchQuery] = useState(''); const initialCollapsedState = useMemo(loadCollapsedState, []); + const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools); const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels); const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts); @@ -262,6 +266,7 @@ export function Sidebar({ if (isSearching) { if (!collapseSnapshotRef.current) { collapseSnapshotRef.current = { + tools: toolsCollapsed, favorites: favoritesCollapsed, channels: channelsCollapsed, contacts: contactsCollapsed, @@ -269,7 +274,14 @@ export function Sidebar({ }; } - if (favoritesCollapsed || channelsCollapsed || contactsCollapsed || repeatersCollapsed) { + if ( + toolsCollapsed || + favoritesCollapsed || + channelsCollapsed || + contactsCollapsed || + repeatersCollapsed + ) { + setToolsCollapsed(false); setFavoritesCollapsed(false); setChannelsCollapsed(false); setContactsCollapsed(false); @@ -281,17 +293,26 @@ export function Sidebar({ if (collapseSnapshotRef.current) { const prev = collapseSnapshotRef.current; collapseSnapshotRef.current = null; + setToolsCollapsed(prev.tools); setFavoritesCollapsed(prev.favorites); setChannelsCollapsed(prev.channels); setContactsCollapsed(prev.contacts); setRepeatersCollapsed(prev.repeaters); } - }, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); + }, [ + isSearching, + toolsCollapsed, + favoritesCollapsed, + channelsCollapsed, + contactsCollapsed, + repeatersCollapsed, + ]); useEffect(() => { if (isSearching) return; const state: CollapseState = { + tools: toolsCollapsed, favorites: favoritesCollapsed, channels: channelsCollapsed, contacts: contactsCollapsed, @@ -303,7 +324,14 @@ export function Sidebar({ } catch { // Ignore localStorage write failures (e.g., disabled storage) } - }, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); + }, [ + isSearching, + toolsCollapsed, + favoritesCollapsed, + channelsCollapsed, + contactsCollapsed, + repeatersCollapsed, + ]); // Separate favorites from regular items, and build combined favorites list const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } = @@ -422,6 +450,38 @@ export function Sidebar({ ); + const renderSidebarActionRow = ({ + key, + active = false, + icon, + label, + onClick, + }: { + key: string; + active?: boolean; + icon: string; + label: React.ReactNode; + onClick: () => void; + }) => ( +
+ + {label} +
+ ); + const getSectionUnreadCount = (rows: ConversationRow[]): number => rows.reduce((total, row) => total + row.unreadCount, 0); @@ -438,6 +498,77 @@ export function Sidebar({ const channelsUnreadCount = getSectionUnreadCount(channelRows); const contactsUnreadCount = getSectionUnreadCount(contactRows); const repeatersUnreadCount = getSectionUnreadCount(repeaterRows); + const toolRows = !query + ? [ + renderSidebarActionRow({ + key: 'tool-raw', + active: isActive('raw', 'raw'), + icon: 'πŸ“‘', + label: 'Packet Feed', + onClick: () => + handleSelectConversation({ + type: 'raw', + id: 'raw', + name: 'Raw Packet Feed', + }), + }), + renderSidebarActionRow({ + key: 'tool-map', + active: isActive('map', 'map'), + icon: 'πŸ—ΊοΈ', + label: 'Node Map', + onClick: () => + handleSelectConversation({ + type: 'map', + id: 'map', + name: 'Node Map', + }), + }), + renderSidebarActionRow({ + key: 'tool-visualizer', + active: isActive('visualizer', 'visualizer'), + icon: '✨', + label: 'Mesh Visualizer', + onClick: () => + handleSelectConversation({ + type: 'visualizer', + id: 'visualizer', + name: 'Mesh Visualizer', + }), + }), + renderSidebarActionRow({ + key: 'tool-search', + active: isActive('search', 'search'), + icon: 'πŸ”', + label: 'Message Search', + onClick: () => + handleSelectConversation({ + type: 'search', + id: 'search', + name: 'Message Search', + }), + }), + renderSidebarActionRow({ + key: 'tool-cracker', + active: showCracker, + icon: 'πŸ”“', + label: ( + <> + {showCracker ? 'Hide' : 'Show'} Room Finder + + ({crackerRunning ? 'running' : 'idle'}) + + + ), + onClick: onToggleCracker, + }), + ] + : []; const renderSectionHeader = ( title: string, @@ -538,137 +669,12 @@ export function Sidebar({ {/* List */}
- {/* Raw Packet Feed */} - {!query && ( -
- handleSelectConversation({ - type: 'raw', - id: 'raw', - name: 'Raw Packet Feed', - }) - } - > - - Packet Feed -
- )} - - {/* Node Map */} - {!query && ( -
- handleSelectConversation({ - type: 'map', - id: 'map', - name: 'Node Map', - }) - } - > - - Node Map -
- )} - - {/* Mesh Visualizer */} - {!query && ( -
- handleSelectConversation({ - type: 'visualizer', - id: 'visualizer', - name: 'Mesh Visualizer', - }) - } - > - - Mesh Visualizer -
- )} - - {/* Message Search */} - {!query && ( -
- handleSelectConversation({ - type: 'search', - id: 'search', - name: 'Message Search', - }) - } - > - - Message Search -
- )} - - {/* Cracker Toggle */} - {!query && ( -
- - - {showCracker ? 'Hide' : 'Show'} Room Finder - - ({crackerRunning ? 'running' : 'idle'}) - - -
+ {/* Tools */} + {toolRows.length > 0 && ( + <> + {renderSectionHeader('Tools', toolsCollapsed, () => setToolsCollapsed((prev) => !prev))} + {(isSearching || !toolsCollapsed) && toolRows} + )} {/* Mark All Read */} diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index 76a4b3e..a123688 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -104,9 +104,11 @@ describe('Sidebar section summaries', () => { it('expands collapsed sections during search and restores collapse state after clearing search', async () => { const { opsChannel, aliceName } = renderSidebar(); + fireEvent.click(screen.getByRole('button', { name: /Tools/i })); fireEvent.click(screen.getByRole('button', { name: /Channels/i })); fireEvent.click(screen.getByRole('button', { name: /Contacts/i })); + expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); @@ -120,6 +122,7 @@ describe('Sidebar section summaries', () => { fireEvent.change(search, { target: { value: '' } }); await waitFor(() => { + expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); }); @@ -128,15 +131,18 @@ describe('Sidebar section summaries', () => { it('persists collapsed section state across unmount and remount', () => { const { opsChannel, aliceName, unmount } = renderSidebar(); + fireEvent.click(screen.getByRole('button', { name: /Tools/i })); fireEvent.click(screen.getByRole('button', { name: /Channels/i })); fireEvent.click(screen.getByRole('button', { name: /Contacts/i })); + expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); unmount(); renderSidebar(); + expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); });