mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
163 lines
5.8 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
),
|
|
);
|