Put tools in a collapsible

This commit is contained in:
Jack Kingsman
2026-03-08 13:54:01 -07:00
parent e6743d2098
commit 0ac8e97ea2
2 changed files with 146 additions and 134 deletions
+140 -134
View File
@@ -28,6 +28,7 @@ type ConversationRow = {
}; };
type CollapseState = { type CollapseState = {
tools: boolean;
favorites: boolean; favorites: boolean;
channels: boolean; channels: boolean;
contacts: boolean; contacts: boolean;
@@ -37,6 +38,7 @@ type CollapseState = {
const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state'; const SIDEBAR_COLLAPSE_STATE_KEY = 'remoteterm-sidebar-collapse-state';
const DEFAULT_COLLAPSE_STATE: CollapseState = { const DEFAULT_COLLAPSE_STATE: CollapseState = {
tools: false,
favorites: false, favorites: false,
channels: false, channels: false,
contacts: false, contacts: false,
@@ -49,6 +51,7 @@ function loadCollapsedState(): CollapseState {
if (!raw) return DEFAULT_COLLAPSE_STATE; if (!raw) return DEFAULT_COLLAPSE_STATE;
const parsed = JSON.parse(raw) as Partial<CollapseState>; const parsed = JSON.parse(raw) as Partial<CollapseState>;
return { return {
tools: parsed.tools ?? DEFAULT_COLLAPSE_STATE.tools,
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites, favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels, channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts, contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
@@ -100,6 +103,7 @@ export function Sidebar({
const sortOrder = sortOrderProp; const sortOrder = sortOrderProp;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const initialCollapsedState = useMemo(loadCollapsedState, []); const initialCollapsedState = useMemo(loadCollapsedState, []);
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels); const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts); const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
@@ -262,6 +266,7 @@ export function Sidebar({
if (isSearching) { if (isSearching) {
if (!collapseSnapshotRef.current) { if (!collapseSnapshotRef.current) {
collapseSnapshotRef.current = { collapseSnapshotRef.current = {
tools: toolsCollapsed,
favorites: favoritesCollapsed, favorites: favoritesCollapsed,
channels: channelsCollapsed, channels: channelsCollapsed,
contacts: contactsCollapsed, contacts: contactsCollapsed,
@@ -269,7 +274,14 @@ export function Sidebar({
}; };
} }
if (favoritesCollapsed || channelsCollapsed || contactsCollapsed || repeatersCollapsed) { if (
toolsCollapsed ||
favoritesCollapsed ||
channelsCollapsed ||
contactsCollapsed ||
repeatersCollapsed
) {
setToolsCollapsed(false);
setFavoritesCollapsed(false); setFavoritesCollapsed(false);
setChannelsCollapsed(false); setChannelsCollapsed(false);
setContactsCollapsed(false); setContactsCollapsed(false);
@@ -281,17 +293,26 @@ export function Sidebar({
if (collapseSnapshotRef.current) { if (collapseSnapshotRef.current) {
const prev = collapseSnapshotRef.current; const prev = collapseSnapshotRef.current;
collapseSnapshotRef.current = null; collapseSnapshotRef.current = null;
setToolsCollapsed(prev.tools);
setFavoritesCollapsed(prev.favorites); setFavoritesCollapsed(prev.favorites);
setChannelsCollapsed(prev.channels); setChannelsCollapsed(prev.channels);
setContactsCollapsed(prev.contacts); setContactsCollapsed(prev.contacts);
setRepeatersCollapsed(prev.repeaters); setRepeatersCollapsed(prev.repeaters);
} }
}, [isSearching, favoritesCollapsed, channelsCollapsed, contactsCollapsed, repeatersCollapsed]); }, [
isSearching,
toolsCollapsed,
favoritesCollapsed,
channelsCollapsed,
contactsCollapsed,
repeatersCollapsed,
]);
useEffect(() => { useEffect(() => {
if (isSearching) return; if (isSearching) return;
const state: CollapseState = { const state: CollapseState = {
tools: toolsCollapsed,
favorites: favoritesCollapsed, favorites: favoritesCollapsed,
channels: channelsCollapsed, channels: channelsCollapsed,
contacts: contactsCollapsed, contacts: contactsCollapsed,
@@ -303,7 +324,14 @@ export function Sidebar({
} catch { } catch {
// Ignore localStorage write failures (e.g., disabled storage) // 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 // Separate favorites from regular items, and build combined favorites list
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } = const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
@@ -422,6 +450,38 @@ export function Sidebar({
</div> </div>
); );
const renderSidebarActionRow = ({
key,
active = false,
icon,
label,
onClick,
}: {
key: string;
active?: boolean;
icon: string;
label: React.ReactNode;
onClick: () => void;
}) => (
<div
key={key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={active ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
{icon}
</span>
<span className="flex-1 truncate text-muted-foreground">{label}</span>
</div>
);
const getSectionUnreadCount = (rows: ConversationRow[]): number => const getSectionUnreadCount = (rows: ConversationRow[]): number =>
rows.reduce((total, row) => total + row.unreadCount, 0); rows.reduce((total, row) => total + row.unreadCount, 0);
@@ -438,6 +498,77 @@ export function Sidebar({
const channelsUnreadCount = getSectionUnreadCount(channelRows); const channelsUnreadCount = getSectionUnreadCount(channelRows);
const contactsUnreadCount = getSectionUnreadCount(contactRows); const contactsUnreadCount = getSectionUnreadCount(contactRows);
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows); 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
<span
className={cn(
'ml-1 text-[11px]',
crackerRunning ? 'text-primary' : 'text-muted-foreground'
)}
>
({crackerRunning ? 'running' : 'idle'})
</span>
</>
),
onClick: onToggleCracker,
}),
]
: [];
const renderSectionHeader = ( const renderSectionHeader = (
title: string, title: string,
@@ -538,137 +669,12 @@ export function Sidebar({
{/* List */} {/* List */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Raw Packet Feed */} {/* Tools */}
{!query && ( {toolRows.length > 0 && (
<div <>
className={cn( {renderSectionHeader('Tools', toolsCollapsed, () => setToolsCollapsed((prev) => !prev))}
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', {(isSearching || !toolsCollapsed) && toolRows}
isActive('raw', 'raw') && 'bg-accent border-l-primary' </>
)}
role="button"
tabIndex={0}
aria-current={isActive('raw', 'raw') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'raw',
id: 'raw',
name: 'Raw Packet Feed',
})
}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
📡
</span>
<span className="flex-1 truncate text-muted-foreground">Packet Feed</span>
</div>
)}
{/* Node Map */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('map', 'map') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('map', 'map') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'map',
id: 'map',
name: 'Node Map',
})
}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🗺
</span>
<span className="flex-1 truncate text-muted-foreground">Node Map</span>
</div>
)}
{/* Mesh Visualizer */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('visualizer', 'visualizer') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('visualizer', 'visualizer') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'visualizer',
id: 'visualizer',
name: 'Mesh Visualizer',
})
}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
</span>
<span className="flex-1 truncate text-muted-foreground">Mesh Visualizer</span>
</div>
)}
{/* Message Search */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('search', 'search') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('search', 'search') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'search',
id: 'search',
name: 'Message Search',
})
}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🔍
</span>
<span className="flex-1 truncate text-muted-foreground">Message Search</span>
</div>
)}
{/* Cracker Toggle */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
showCracker && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={onToggleCracker}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🔓
</span>
<span className="flex-1 truncate text-muted-foreground">
{showCracker ? 'Hide' : 'Show'} Room Finder
<span
className={cn(
'ml-1 text-[11px]',
crackerRunning ? 'text-primary' : 'text-muted-foreground'
)}
>
({crackerRunning ? 'running' : 'idle'})
</span>
</span>
</div>
)} )}
{/* Mark All Read */} {/* Mark All Read */}
+6
View File
@@ -104,9 +104,11 @@ describe('Sidebar section summaries', () => {
it('expands collapsed sections during search and restores collapse state after clearing search', async () => { it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
const { opsChannel, aliceName } = renderSidebar(); 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: /Channels/i }));
fireEvent.click(screen.getByRole('button', { name: /Contacts/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(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
@@ -120,6 +122,7 @@ describe('Sidebar section summaries', () => {
fireEvent.change(search, { target: { value: '' } }); fireEvent.change(search, { target: { value: '' } });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).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', () => { it('persists collapsed section state across unmount and remount', () => {
const { opsChannel, aliceName, unmount } = renderSidebar(); 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: /Channels/i }));
fireEvent.click(screen.getByRole('button', { name: /Contacts/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(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
unmount(); unmount();
renderSidebar(); renderSidebar();
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument(); expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument(); expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
}); });