mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Start the proxy sidebar's hosts and paths collapsed
Every domain and path node starts collapsed, so paths stay hidden until a host is expanded. The nodes the user expands are kept by id in local storage, so they stay open across launches; hosts seen later start collapsed. The Domains folder, selection and counts are as before. The shared tree is unchanged: the proxy hands it its own collapsed atom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e33aeee5ff
commit
974b9de673
5 files changed
+155
-6
No files matched your search
@@ -2,8 +2,9 @@ import type { Flow } from "@yaakapp-internal/proxy-lib";
|
|||||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||||
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai-family";
|
import { atomWithStorage } from "jotai/utils";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||||
import { flowsAtom, flowTotalAtom } from "../lib/store";
|
import { flowsAtom, flowTotalAtom } from "../lib/store";
|
||||||
|
|
||||||
/** A node in the sidebar tree — either a domain or a path segment. */
|
/** A node in the sidebar tree — either a domain or a path segment. */
|
||||||
@@ -14,8 +15,6 @@ export type SidebarItem = {
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapsedAtom = atomFamily((_treeId: string) => atom<Record<string, boolean>>({}));
|
|
||||||
|
|
||||||
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
export const SIDEBAR_TREE_ID = "proxy-sidebar";
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
||||||
@@ -23,6 +22,27 @@ const sidebarTreeAtom = atom<TreeNode<SidebarItem>>((get) => {
|
|||||||
return buildTree(exchanges, get(flowTotalAtom));
|
return buildTree(exchanges, get(flowTotalAtom));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The domains 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>({});
|
||||||
|
|
||||||
|
/** Domains and paths collapsed until the user expands them, for the tree */
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** The sidebar nodes selected, or null when the list shows all traffic */
|
/** The sidebar nodes selected, or null when the list shows all traffic */
|
||||||
const selectedNodeIdsAtom = atom((get) => {
|
const selectedNodeIdsAtom = atom((get) => {
|
||||||
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
|
const selectedIds = get(selectedIdsFamily(SIDEBAR_TREE_ID));
|
||||||
@@ -204,7 +224,7 @@ export function Sidebar() {
|
|||||||
<aside className="x-theme-sidebar bg-surface h-full w-full min-w-0 grid grid-rows-[minmax(0,1fr)] border-r border-border-subtle">
|
<aside 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
|
<Tree
|
||||||
treeId={treeId}
|
treeId={treeId}
|
||||||
collapsedAtom={collapsedAtom(treeId)}
|
collapsedAtom={collapsedAtom}
|
||||||
className="px-2 pt-2 pb-10"
|
className="px-2 pt-2 pb-10"
|
||||||
root={tree}
|
root={tree}
|
||||||
getItemKey={getItemKey}
|
getItemKey={getItemKey}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { applyCollapsed, collapsedMap, folderIds } from "./sidebarCollapse";
|
||||||
|
|
||||||
|
/** A node with these children, which `folderIds` counts as a folder when there are any */
|
||||||
|
function node(id: string, children: TreeNode<{ id: string }>[] = []): TreeNode<{ id: string }> {
|
||||||
|
return { item: { id }, parent: null, depth: 0, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = node("root", [
|
||||||
|
node("domains", [
|
||||||
|
node("domain:api.example.com", [
|
||||||
|
node("path:api.example.com/v1", [node("path:api.example.com/v1/users")]),
|
||||||
|
]),
|
||||||
|
node("domain:cdn.example.com", [node("path:cdn.example.com/app.js")]),
|
||||||
|
// A host only ever hit at its root has no paths, so nothing to collapse.
|
||||||
|
node("domain:empty.example.com"),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("collapsedMap", () => {
|
||||||
|
test("collapses every domain and path, and leaves the root and Domains open", () => {
|
||||||
|
const map = collapsedMap(folderIds(tree), {}, {});
|
||||||
|
expect(map).toEqual({
|
||||||
|
"domain:api.example.com": true,
|
||||||
|
"path:api.example.com/v1": true,
|
||||||
|
"domain:cdn.example.com": true,
|
||||||
|
});
|
||||||
|
expect(map.root).toBeUndefined();
|
||||||
|
expect(map.domains).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens what the user expanded, and a host seen later starts collapsed", () => {
|
||||||
|
const expanded = { "domain:api.example.com": true };
|
||||||
|
expect(collapsedMap(folderIds(tree), expanded, {})).toMatchObject({
|
||||||
|
"domain:api.example.com": false,
|
||||||
|
"path:api.example.com/v1": true,
|
||||||
|
"domain:cdn.example.com": true,
|
||||||
|
});
|
||||||
|
const later = node("root", [
|
||||||
|
node("domains", [node("domain:new.example.com", [node("path:new.example.com/x")])]),
|
||||||
|
]);
|
||||||
|
expect(collapsedMap(folderIds(later), expanded, {})["domain:new.example.com"]).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyCollapsed", () => {
|
||||||
|
test("keeps an expanded domain, and forgets it when collapsed again", () => {
|
||||||
|
const prev = collapsedMap(folderIds(tree), {}, {});
|
||||||
|
const opened = applyCollapsed(prev, { ...prev, "domain:api.example.com": false }, {}, {});
|
||||||
|
expect(opened.expanded).toEqual({ "domain:api.example.com": true });
|
||||||
|
|
||||||
|
// Persisted, so the next launch builds the map from it.
|
||||||
|
const relaunched = collapsedMap(folderIds(tree), opened.expanded, {});
|
||||||
|
expect(relaunched["domain:api.example.com"]).toBe(false);
|
||||||
|
|
||||||
|
const closed = applyCollapsed(
|
||||||
|
relaunched,
|
||||||
|
{ ...relaunched, "domain:api.example.com": true },
|
||||||
|
opened.expanded,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(closed.expanded).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the other nodes' flags apart from the persisted ones", () => {
|
||||||
|
const prev = collapsedMap(folderIds(tree), {}, {});
|
||||||
|
const next = applyCollapsed(prev, { ...prev, domains: true }, {}, {});
|
||||||
|
expect(next).toEqual({ expanded: {}, others: { domains: true } });
|
||||||
|
expect(collapsedMap(folderIds(tree), next.expanded, next.others).domains).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
|
/** Flags by node id, as the tree reads them: true is collapsed, and a missing id is open */
|
||||||
|
export type CollapsedMap = Record<string, boolean>;
|
||||||
|
|
||||||
|
/** Domains and paths start collapsed; the rest, the Domains folder, starts open as before */
|
||||||
|
export function startsCollapsed(id: string): boolean {
|
||||||
|
return id.startsWith("domain:") || id.startsWith("path:");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of the nodes with children, the only ones that collapse */
|
||||||
|
export function folderIds(root: TreeNode<{ id: string }>): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const walk = (node: TreeNode<{ id: string }>) => {
|
||||||
|
if ((node.children?.length ?? 0) === 0) return;
|
||||||
|
ids.push(node.item.id);
|
||||||
|
for (const child of node.children ?? []) walk(child);
|
||||||
|
};
|
||||||
|
walk(root);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree's collapsed flags: every domain and path collapsed unless the user expanded it, and
|
||||||
|
* other nodes as the user left them.
|
||||||
|
*/
|
||||||
|
export function collapsedMap(
|
||||||
|
folders: string[],
|
||||||
|
expanded: Record<string, boolean>,
|
||||||
|
others: CollapsedMap,
|
||||||
|
): CollapsedMap {
|
||||||
|
const map = { ...others };
|
||||||
|
for (const id of folders) {
|
||||||
|
if (startsCollapsed(id)) map[id] = !expanded[id];
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits the flags the tree sets into the domains and paths now expanded, which are kept, and
|
||||||
|
* the other nodes' flags. Only the nodes whose flag changed from `prev` count.
|
||||||
|
*/
|
||||||
|
export function applyCollapsed(
|
||||||
|
prev: CollapsedMap,
|
||||||
|
next: CollapsedMap,
|
||||||
|
expanded: Record<string, boolean>,
|
||||||
|
others: CollapsedMap,
|
||||||
|
): { expanded: Record<string, boolean>; others: CollapsedMap } {
|
||||||
|
const nextExpanded = { ...expanded };
|
||||||
|
const nextOthers = { ...others };
|
||||||
|
for (const id of new Set([...Object.keys(prev), ...Object.keys(next)])) {
|
||||||
|
const collapsed = next[id] === true;
|
||||||
|
if (collapsed === (prev[id] === true)) continue;
|
||||||
|
if (!startsCollapsed(id)) nextOthers[id] = collapsed;
|
||||||
|
else if (collapsed) delete nextExpanded[id];
|
||||||
|
else nextExpanded[id] = true;
|
||||||
|
}
|
||||||
|
return { expanded: nextExpanded, others: nextOthers };
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@yaakapp-internal/ui": "^1.0.0",
|
"@yaakapp-internal/ui": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
"jotai-family": "^1.0.1",
|
|
||||||
"motion": "^14.0.0",
|
"motion": "^14.0.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
Generated
-1
@@ -254,7 +254,6 @@
|
|||||||
"@yaakapp-internal/ui": "^1.0.0",
|
"@yaakapp-internal/ui": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
"jotai-family": "^1.0.1",
|
|
||||||
"motion": "^14.0.0",
|
"motion": "^14.0.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
Reference in new issue
Block a user