import { ContextMenu, CONTROL_TOOLTIP_DELAY, type DropdownItem, Icon, IconButton, Tooltip, Tree, type TreeProps, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { useCallback, useEffect } from "react"; import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; import { SIDEBAR_TREE_ID } from "../lib/atoms"; import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt"; import { revealLabel, revealPath } from "../lib/opener"; import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse"; import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree"; import { noteSidebarInput } from "../lib/navigation"; import { hideSystemAtom, sidebarTreeAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; import { savedSearchDialogAtom } from "./SavedSearchDialog"; /** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */ const expandedAtom = atomWithStorage>("sidebar_expanded", {}, undefined, { getOnInit: true, }); /** The other nodes' collapsed flags, for this launch, as before */ const otherCollapsedAtom = atom({}); /** Every app's tree, site, host and path, and System, collapsed until the user expands them */ const collapsedAtom = atom( (get) => collapsedMap(folderIds(get(sidebarTreeAtom)), get(expandedAtom), get(otherCollapsedAtom)), (get, set, action: CollapsedMap | ((prev: CollapsedMap) => CollapsedMap)) => { const prev = get(collapsedAtom); const next = typeof action === "function" ? action(prev) : action; const split = applyCollapsed(prev, next, get(expandedAtom), get(otherCollapsedAtom)); set(expandedAtom, split.expanded); set(otherCollapsedAtom, split.others); }, ); function Count({ item }: { item: SidebarItem }) { const text = item.countLabel ?? (item.count > 0 ? item.count.toLocaleString() : null); if (text == null && item.kind !== "all") return null; return ( {text ?? "0"} ); } function ItemInner({ item }: { item: SidebarItem }) { if (item.kind === "group") { return (
{item.label}
); } return (
{item.kind === "all" && } {item.kind === "tunnelled" && } {item.kind === "saved" && } {item.kind === "folder" && } {item.appPath != null && } {item.kind === "folder" && item.path != null ? ( // Named by its last part; the whole path on hover {item.label} ) : ( {item.label} )} {item.sublabel != null && ( {item.sublabel} )} {item.decrypt != null && }
); } /** A row with nothing in its menu: the menu closes as it opens, so the row is not left marked */ function CloseAtOnce({ onClose }: { onClose: () => void }) { useEffect(() => onClose(), [onClose]); return null; } /** A small open lock on a host, site or app the decrypt list decrypts */ function DecryptedMark({ item }: { item: SidebarItem }) { const list = useDecryptList(); if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null; return ( ); } /** Beside the row's button rather than in it, since it is a button of its own */ function ItemRightSlot({ item }: { item: SidebarItem }) { return item.id === SYSTEM_ID ? : ; } /** The eye on System's row, which leaves its daemons out of All traffic */ function HideSystemToggle() { const [hidden, setHidden] = useAtom(hideSystemAtom); return ( { e.stopPropagation(); setHidden(!hidden); }} /> ); } export function Sidebar() { const tree = useAtomValue(sidebarTreeAtom); const getItemKey = useCallback( // The label too, as a saved search's changes when it is renamed (item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}:${item.label}`, [], ); const decryptList = useDecryptList(); const setDecryptList = useEditDecryptList(); const { mutate: saveDecryptList } = setDecryptList; const showToast = useShowToast(); const setSavedDialog = useSetAtom(savedSearchDialogAtom); const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", { onError: (err) => showToast({ id: "saved-search", message: err.message, color: "danger" }), }); // A program's folder, or the program, is shown in Finder, and a folder's path copied. const fail = useCallback( (message: string) => showToast({ id: "sidebar-path", message, color: "danger" }), [showToast], ); const pathItems = useCallback( (item: SidebarItem | undefined): DropdownItem[] => { const path = item?.path; if (item == null || path == null) return []; return [ { label: revealLabel(getOsType()), leftSlot: , onSelect: () => void revealPath(path, fail), }, { label: "Copy path", leftSlot: , hidden: item.kind !== "folder", onSelect: () => { navigator.clipboard.writeText(path).catch((err) => fail(String(err))); }, }, ]; }, [fail], ); // Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on. // A saved search is renamed or deleted. const getContextMenu = useCallback( (items: SidebarItem[]): DropdownItem[] => { const paths = items.length === 1 ? pathItems(items[0]) : []; const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); if (saved.length > 0) { const [first] = saved; return [ { label: "Rename", leftSlot: , hidden: saved.length > 1 || first == null, onSelect: () => { if (first != null) setSavedDialog({ mode: "rename", id: first.id, name: first.label }); }, }, { label: "Delete", color: "danger", leftSlot: , onSelect: () => { for (const s of saved) deleteSaved({ id: s.id }); }, }, ]; } const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt])); const action = decryptMenuAction(decryptList, subjects); if (action == null) return paths; return [ ...paths, ...(paths.length > 0 ? [{ type: "separator" } as const] : []), { label: action.label, icon: action.on ? "lock_open" : "lock", onSelect: () => { const blocked = action.on ? blockerOf(decryptList, subjects) : null; if (blocked != null) { showToast({ id: "decrypt-blocked", color: "notice", message: `Excluded by ${blocked}`, }); return; } // Only what this changes, applied to the list as it is then, so a second change // made before this one lands keeps it. const next = setDecrypted(decryptList, subjects, action.on); saveDecryptList(listEdit(decryptList, next)); }, }, ]; }, [decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved, pathItems], ); const renderContextMenu = useCallback["renderContextMenu"]>>( ({ items, position, onClose }) => items.length === 0 ? ( ) : ( ), [], ); return ( // The tree scrolls itself, in a row of bounded height, so its virtualizer mounts only the rows // in view rather than every site, host and path. // A click or key here marks the selection change it makes as the person's, so it writes the // search; the tree's own upkeep does not. // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- only notes input the tree handles ); }