mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
195 lines
7.3 KiB
TypeScript
195 lines
7.3 KiB
TypeScript
import {
|
|
ContextMenu,
|
|
type DropdownItem,
|
|
Icon,
|
|
IconButton,
|
|
Tree,
|
|
type TreeProps,
|
|
} from "@yaakapp-internal/ui";
|
|
import classNames from "classnames";
|
|
import { atom, useAtom, useAtomValue } from "jotai";
|
|
import { atomWithStorage } from "jotai/utils";
|
|
import { useCallback, useEffect } from "react";
|
|
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
|
import { SIDEBAR_TREE_ID } from "../lib/atoms";
|
|
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
|
|
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
|
import { type SidebarItem, SYSTEM_ID } from "../lib/sidebarTree";
|
|
import { hideSystemAtom, noteSidebarInput, sidebarTreeAtom } from "../lib/store";
|
|
import { useShowToast } from "../lib/toast";
|
|
import { AppIcon } from "./AppIcon";
|
|
|
|
/** The apps, sites, hosts and paths the user expanded, by node id, kept across launches */
|
|
const expandedAtom = atomWithStorage<Record<string, boolean>>("sidebar_expanded", {}, undefined, {
|
|
getOnInit: true,
|
|
});
|
|
|
|
/** The other nodes' collapsed flags, for this launch, as before */
|
|
const otherCollapsedAtom = atom<CollapsedMap>({});
|
|
|
|
/** 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 (
|
|
<span className="ml-auto pl-2 text-text-subtlest text-2xs tabular-nums shrink-0">
|
|
{text ?? "0"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ItemInner({ item }: { item: SidebarItem }) {
|
|
if (item.kind === "group") {
|
|
return (
|
|
<div className="flex items-center gap-2 w-full min-w-0 text-xs text-text-subtlest">
|
|
<span className="truncate">{item.label}</span>
|
|
<Count item={item} />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex items-center gap-2 w-full min-w-0">
|
|
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
|
|
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
|
|
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
|
|
{item.appPath != null && <AppIcon path={item.appPath} />}
|
|
<span className="truncate">{item.label}</span>
|
|
{item.sublabel != null && (
|
|
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
|
|
)}
|
|
{item.decrypt != null && <DecryptedMark item={item} />}
|
|
<Count item={item} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<Icon icon="lock_open" size="xs" title="Decrypted" className="shrink-0 text-text-subtlest" />
|
|
);
|
|
}
|
|
|
|
/** 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 ? <HideSystemToggle /> : <span aria-hidden />;
|
|
}
|
|
|
|
/** The eye on System's row, which leaves its daemons out of All traffic */
|
|
function HideSystemToggle() {
|
|
const [hidden, setHidden] = useAtom(hideSystemAtom);
|
|
return (
|
|
<IconButton
|
|
size="2xs"
|
|
icon={hidden ? "eye_closed" : "eye"}
|
|
iconColor="secondary"
|
|
title={hidden ? "Show System in All traffic" : "Hide System from All traffic"}
|
|
className={classNames(
|
|
"!h-4 !w-4 mr-1",
|
|
!hidden && "opacity-0 [.tree-item:hover_&]:opacity-100 focus-visible:opacity-100",
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setHidden(!hidden);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const tree = useAtomValue(sidebarTreeAtom);
|
|
const getItemKey = useCallback(
|
|
(item: SidebarItem) => `${item.id}:${item.count}:${item.countLabel ?? ""}`,
|
|
[],
|
|
);
|
|
const decryptList = useDecryptList();
|
|
const setDecryptList = useEditDecryptList();
|
|
const { mutate: saveDecryptList } = setDecryptList;
|
|
const showToast = useShowToast();
|
|
|
|
// Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on.
|
|
const getContextMenu = useCallback(
|
|
(items: SidebarItem[]): DropdownItem[] => {
|
|
const subjects = items.flatMap((i) => (i.decrypt == null ? [] : [i.decrypt]));
|
|
const action = decryptMenuAction(decryptList, subjects);
|
|
if (action == null) return [];
|
|
return [
|
|
{
|
|
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],
|
|
);
|
|
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
|
|
({ items, position, onClose }) =>
|
|
items.length === 0 ? (
|
|
<CloseAtOnce onClose={onClose} />
|
|
) : (
|
|
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
|
),
|
|
[],
|
|
);
|
|
|
|
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
|
|
<aside
|
|
onPointerDownCapture={noteSidebarInput}
|
|
onKeyDownCapture={noteSidebarInput}
|
|
className="x-theme-sidebar bg-surface h-full w-full min-w-0 grid grid-rows-[minmax(0,1fr)] border-r border-border-subtle"
|
|
>
|
|
<Tree
|
|
treeId={SIDEBAR_TREE_ID}
|
|
collapsedAtom={collapsedAtom}
|
|
className="px-2 pt-2 pb-10"
|
|
root={tree}
|
|
getItemKey={getItemKey}
|
|
ItemInner={ItemInner}
|
|
ItemRightSlot={ItemRightSlot}
|
|
getContextMenu={getContextMenu}
|
|
renderContextMenu={renderContextMenu}
|
|
/>
|
|
</aside>
|
|
);
|
|
}
|