From ca1494b54a183bce70ef0ca0a659a74396d78936 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 11:21:26 -0700 Subject: [PATCH] Fill out Yaak Proxy's sidebar menus, and move System's toggle into its menu Rows for apps, folders, sites, hosts and paths get Filter by This Value, Exclude This Value and Copy Value, as the list's cells have, covering every selected row. Opening a row's menu no longer applies the row, so the menu adds to the filter as it is. System's eye becomes Show in All Traffic in its menu, and Domains counts its hosts as a plain number. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/Sidebar.tsx | 188 +++++++++++++++--------- apps/yaak-proxy/lib/hideSystem.test.ts | 32 ++++ apps/yaak-proxy/lib/navigation.ts | 18 ++- apps/yaak-proxy/lib/sidebarTree.test.ts | 121 ++++++++++++++- apps/yaak-proxy/lib/sidebarTree.ts | 69 +++++++-- apps/yaak-proxy/lib/store.ts | 2 +- 6 files changed, 346 insertions(+), 84 deletions(-) create mode 100644 apps/yaak-proxy/lib/hideSystem.test.ts diff --git a/apps/yaak-proxy/components/Sidebar.tsx b/apps/yaak-proxy/components/Sidebar.tsx index 4c7ce1292..cef3aecfa 100644 --- a/apps/yaak-proxy/components/Sidebar.tsx +++ b/apps/yaak-proxy/components/Sidebar.tsx @@ -3,23 +3,27 @@ import { 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 { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { atomWithStorage } from "jotai/utils"; -import { useCallback, useEffect } from "react"; +import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react"; import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList"; import { useRpcMutation } from "../hooks/useRpcMutation"; -import { SIDEBAR_TREE_ID } from "../lib/atoms"; +import { appliedFilterAtom, 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 { + copyRowsText, + filterWithRows, + type SidebarItem, + SYSTEM_ID, + valueRows, +} from "../lib/sidebarTree"; +import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation"; import { hideSystemAtom, sidebarTreeAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { useShowToast } from "../lib/toast"; @@ -48,7 +52,7 @@ const collapsedAtom = atom( ); function Count({ item }: { item: SidebarItem }) { - const text = item.countLabel ?? (item.count > 0 ? item.count.toLocaleString() : null); + const text = item.count > 0 ? item.count.toLocaleString() : null; if (text == null && item.kind !== "all") return null; return ( @@ -90,6 +94,23 @@ function ItemInner({ item }: { item: SidebarItem }) { ); } +/** Input in the sidebar itself, not in a row's menu, whose events reach it through its portal */ +function inSidebar(e: SyntheticEvent): boolean { + return e.target instanceof Node && e.currentTarget.contains(e.target); +} + +/** A press that opens a context menu: the secondary button, or Control and a click on macOS */ +function opensMenu(e: PointerEvent): boolean { + return e.button !== 0 || (e.ctrlKey && getOsType() === "macos"); +} + +/** The menu's sections that have items, a separator between each */ +function withSeparators(sections: DropdownItem[][]): DropdownItem[] { + return sections + .filter((s) => s.some((i) => !i.hidden)) + .flatMap((s, n) => (n === 0 ? s : [{ type: "separator" } as const, ...s])); +} + /** 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]); @@ -107,40 +128,15 @@ function DecryptedMark({ item }: { item: SidebarItem }) { ); } -/** 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 store = useStore(); 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}`, + (item: SidebarItem) => `${item.id}:${item.count}:${item.label}`, [], ); + const [hideSystem, setHideSystem] = useAtom(hideSystemAtom); const decryptList = useDecryptList(); const setDecryptList = useEditDecryptList(); const { mutate: saveDecryptList } = setDecryptList; @@ -178,10 +174,51 @@ export function Sidebar() { [fail], ); - // Decrypt or Stop Decrypting the selected hosts, sites and apps, from new connections on. - // A saved search is renamed or deleted. + // The selected apps, folders, sites, hosts and paths added to the filter, either way, at once, + // or their values copied, as a cell's menu in the list does. + const filterItems = useCallback( + (items: SidebarItem[]): DropdownItem[] => { + if (valueRows(items).length === 0) return []; + const add = (negated: boolean) => + applyFilter(store, filterWithRows(store.get(appliedFilterAtom), items, negated)); + return [ + { label: "Filter by This Value", icon: "filter", onSelect: () => add(false) }, + { label: "Exclude This Value", icon: "minus_circle", onSelect: () => add(true) }, + { + label: "Copy Value", + icon: "copy", + onSelect: async () => { + try { + await navigator.clipboard.writeText(copyRowsText(items)); + showToast({ message: "Copied value" }); + } catch (err) { + showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" }); + } + }, + }, + ]; + }, + [store, showToast], + ); + + // Filter by the selected rows, Decrypt or Stop Decrypting the selected hosts, sites and apps, + // from new connections on, then a program's folder. A saved search is renamed or deleted, and + // System is shown in All traffic or left out of it. const getContextMenu = useCallback( (items: SidebarItem[]): DropdownItem[] => { + if (items.length === 1 && items[0]?.id === SYSTEM_ID) { + return [ + { + label: "Show in All Traffic", + leftSlot: hideSystem ? ( + + ) : ( + + ), + onSelect: () => setHideSystem(!hideSystem), + }, + ]; + } const paths = items.length === 1 ? pathItems(items[0]) : []; const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }])); if (saved.length > 0) { @@ -208,32 +245,43 @@ export function Sidebar() { } 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)); - }, - }, - ]; + const decrypt: DropdownItem[] = + action == null + ? [] + : [ + { + 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)); + }, + }, + ]; + return withSeparators([filterItems(items), decrypt, paths]); }, - [decryptList, saveDecryptList, showToast, setSavedDialog, deleteSaved, pathItems], + [ + decryptList, + saveDecryptList, + showToast, + setSavedDialog, + deleteSaved, + pathItems, + filterItems, + hideSystem, + setHideSystem, + ], ); const renderContextMenu = useCallback["renderContextMenu"]>>( ({ items, position, onClose }) => @@ -249,11 +297,18 @@ export function Sidebar() { // 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. + // search; the tree's own upkeep does not. Opening a row's menu selects it too, but leaves the + // search as it is, for the menu's Filter by This Value to add to, and the rows as it says. // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- only notes input the tree handles