Files
prowler/ui/store/side-panel.ts
2026-07-16 16:02:22 +02:00

163 lines
5.8 KiB
TypeScript

import { create } from "zustand";
import { persist } from "zustand/middleware";
import {
clampSidePanelWidth,
SIDE_PANEL_DEFAULT_WIDTH,
SIDE_PANEL_DETAIL_MIN_WIDTH,
} from "@/lib/ui-layout";
export const SIDE_PANEL_TAB = {
AI_CHAT: "ai-chat",
// Dynamic tab registered by the page currently showing a detail view
// (finding/resource). Never persisted and never in the static registry.
CONTEXT: "context",
} as const;
export type SidePanelTabId =
(typeof SIDE_PANEL_TAB)[keyof typeof SIDE_PANEL_TAB];
interface SidePanelContextTab {
label: string;
// Called when the panel is dismissed so the owning table can clear its
// selection (the owner controls `open`; the panel never owns detail state).
onRequestClose: () => void;
}
interface SidePanelState {
isOpen: boolean;
selectedTab: SidePanelTabId;
// Lazy-mount latch: panel content mounts on first open and then stays
// mounted (hidden) so scroll position and composer drafts survive closes.
hasBeenOpened: boolean;
// User-resizable width in px (persisted); MainLayout pushes by this amount.
width: number;
isResizing: boolean;
contextTab: SidePanelContextTab | null;
// Token of the current context-tab owner (0 = none): several detail views
// can be mounted at once, but only the owner portals content and only the
// owner's unregister takes effect.
contextOwnerToken: number;
// Portal target the context owner renders its detail content into.
contextOutlet: HTMLElement | null;
// One-time discovery callout on the navbar trigger (persisted): true once
// the user dismissed it or reached the AI chat panel by any entry point.
hasSeenAiTriggerHint: boolean;
openPanel: (tab?: SidePanelTabId) => void;
closePanel: (tab?: SidePanelTabId) => void;
togglePanel: () => void;
setWidth: (width: number) => void;
setIsResizing: (isResizing: boolean) => void;
registerContextTab: (tab: SidePanelContextTab) => number;
unregisterContextTab: (token: number) => void;
setContextOutlet: (element: HTMLElement | null) => void;
markAiTriggerHintSeen: () => void;
}
// Monotonic so a recycled token can never let a stale detail view unregister
// (or portal over) a later registrant.
let contextTabTokenCounter = 0;
export const useSidePanelStore = create<SidePanelState>()(
persist(
(set, get) => ({
isOpen: false,
selectedTab: SIDE_PANEL_TAB.AI_CHAT,
hasBeenOpened: false,
width: SIDE_PANEL_DEFAULT_WIDTH,
isResizing: false,
contextTab: null,
contextOwnerToken: 0,
contextOutlet: null,
hasSeenAiTriggerHint: false,
openPanel: (tab) => {
const selectedTab = tab ?? get().selectedTab;
set({
isOpen: true,
hasBeenOpened: true,
selectedTab,
// Reaching the AI chat by any entry point retires the discovery
// callout: the user no longer needs to be pointed at the trigger.
...(selectedTab === SIDE_PANEL_TAB.AI_CHAT
? { hasSeenAiTriggerHint: true }
: {}),
});
},
// Tab-scoped close only applies while that tab is showing, so a stale
// closer never hides a panel someone else switched to another tab.
closePanel: (tab) => {
const { selectedTab, contextTab } = get();
if (tab && tab !== selectedTab) return;
set({ isOpen: false });
// Dismissing the panel dismisses the detail view it hosts.
contextTab?.onRequestClose();
},
togglePanel: () => {
const { isOpen } = get();
if (isOpen) {
get().closePanel();
return;
}
set({
isOpen: true,
hasBeenOpened: true,
...(get().selectedTab === SIDE_PANEL_TAB.AI_CHAT
? { hasSeenAiTriggerHint: true }
: {}),
});
},
setWidth: (width) => set({ width: clampSidePanelWidth(width) }),
setIsResizing: (isResizing) => set({ isResizing }),
registerContextTab: (tab) => {
// A new detail view takes over the single context tab: ask the
// previous owner to close itself so it clears its own selection.
get().contextTab?.onRequestClose();
const token = ++contextTabTokenCounter;
set((state) => ({
contextTab: tab,
contextOwnerToken: token,
selectedTab: SIDE_PANEL_TAB.CONTEXT,
isOpen: true,
hasBeenOpened: true,
// Detail content needs drawer-like room; never shrink a wider
// user-chosen width.
width: clampSidePanelWidth(
Math.max(state.width, SIDE_PANEL_DETAIL_MIN_WIDTH),
),
}));
return token;
},
unregisterContextTab: (token) => {
// Only the current owner may unregister: a replaced detail view
// unmounting later must not tear down its successor.
if (token !== get().contextOwnerToken) return;
set((state) => ({
contextTab: null,
contextOwnerToken: 0,
// When the detail tab was showing, its content is gone: close the
// panel and leave the AI tab as the next default.
...(state.selectedTab === SIDE_PANEL_TAB.CONTEXT
? { isOpen: false, selectedTab: SIDE_PANEL_TAB.AI_CHAT }
: {}),
}));
},
setContextOutlet: (element) => set({ contextOutlet: element }),
markAiTriggerHintSeen: () => set({ hasSeenAiTriggerHint: true }),
}),
{
name: "side-panel-store",
// Only the last-used tab, width, and the one-time hint persist; the
// panel never auto-reopens and a context tab cannot exist on a fresh
// load.
partialize: (state) => ({
selectedTab:
state.selectedTab === SIDE_PANEL_TAB.CONTEXT
? SIDE_PANEL_TAB.AI_CHAT
: state.selectedTab,
width: state.width,
hasSeenAiTriggerHint: state.hasSeenAiTriggerHint,
}),
},
),
);