Add search filter and expand/collapse to find page

- Find page now has same controls as files page
- Both pages support expand all / collapse all buttons
- FileSystem component accepts controlled expandAll state
- Fixed single-key groups to not show as collapsible
This commit is contained in:
cc
2026-04-14 17:48:30 +02:00
parent ed34adfc7a
commit 2ae939b0c3
5 changed files with 169 additions and 37 deletions
+40 -12
View File
@@ -18,6 +18,7 @@ export default function Files() {
const [loading, setLoading] = useState(true);
const [files, setFiles] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [expandAll, setExpandAll] = useState<boolean | null>(null);
const [debouncedKeyword] = useDebounce(keyword, 200);
@@ -39,6 +40,11 @@ export default function Files() {
const isFiltering = debouncedKeyword.length > 0;
// Reset expandAll when filter changes
useEffect(() => {
setExpandAll(isFiltering ? true : null);
}, [isFiltering]);
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
@@ -62,17 +68,39 @@ export default function Files() {
</Button>
)}
</div>
{!loading && (
<div className="text-sm text-muted-foreground whitespace-nowrap">
{isFiltering ? (
<>
{filtered.length} of {files.length} paths
</>
) : (
<>{files.length} paths</>
)}
</div>
)}
<div className="flex items-center gap-3">
{!loading && files.length > 0 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setExpandAll(true)}
className="h-8 px-2 text-xs"
>
Expand All
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExpandAll(false)}
className="h-8 px-2 text-xs"
>
Collapse All
</Button>
</div>
)}
{!loading && (
<div className="text-sm text-muted-foreground whitespace-nowrap">
{isFiltering ? (
<>
{filtered.length} of {files.length} paths
</>
) : (
<>{files.length} paths</>
)}
</div>
)}
</div>
</div>
{loading ? (
@@ -96,7 +124,7 @@ export default function Files() {
)}
</div>
) : (
<FileSystem os={os} list={filtered} expandAll={isFiltering} />
<FileSystem os={os} list={filtered} expandAll={expandAll} />
)}
</div>
);
+113 -10
View File
@@ -1,11 +1,14 @@
"use client";
import FileSystem from "@/components/filesystem";
import { createEngine } from "@/lib/engine";
import { useState, useEffect, useMemo } from "react";
import { redirect, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import FileSystem from "@/components/filesystem";
import { createEngine } from "@/lib/engine";
export default function FindByKey() {
const params = useSearchParams();
@@ -24,7 +27,12 @@ export default function FindByKey() {
redirect("/404");
}
const [loading, setLoading] = useState(true);
const [paths, setPaths] = useState<string[]>([]);
const [keyword, setKeyword] = useState("");
const [expandAll, setExpandAll] = useState<boolean | null>(null);
const [debouncedKeyword] = useDebounce(keyword, 200);
useEffect(() => {
async function fetchPaths() {
@@ -34,20 +42,115 @@ export default function FindByKey() {
const result = await engine.getPathsForKey(build, key);
setPaths(result);
}
fetchPaths();
setLoading(true);
fetchPaths().finally(() => setLoading(false));
}, [group, build, key]);
const filtered = useMemo(
() =>
paths.filter((path) =>
path.toLowerCase().includes(debouncedKeyword.toLowerCase())
),
[debouncedKeyword, paths]
);
const isFiltering = debouncedKeyword.length > 0;
// Reset expandAll when filter changes
useEffect(() => {
setExpandAll(isFiltering ? true : null);
}, [isFiltering]);
return (
<div>
<header>
<h1 className="text-gray-800">
<header className="mb-4">
<h1 className="text-foreground">
Binaries that have the following entitlement:
</h1>
<p>
<code className="text-sm break-all text-red-700">{key}</code>
<code className="text-sm break-all text-red-700 dark:text-red-400">{key}</code>
</p>
</header>
<FileSystem os={os} list={paths} />
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Filter paths..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-9 pr-9"
/>
{keyword && (
<Button
variant="ghost"
size="sm"
onClick={() => setKeyword("")}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-3">
{!loading && paths.length > 0 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setExpandAll(true)}
className="h-8 px-2 text-xs"
>
Expand All
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExpandAll(false)}
className="h-8 px-2 text-xs"
>
Collapse All
</Button>
</div>
)}
{!loading && (
<div className="text-sm text-muted-foreground whitespace-nowrap">
{isFiltering ? (
<>
{filtered.length} of {paths.length} paths
</>
) : (
<>{paths.length} paths</>
)}
</div>
)}
</div>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 py-1">
<div className="h-4 w-4 bg-muted rounded animate-pulse" />
<div
className="h-5 bg-muted rounded animate-pulse"
style={{ width: `${120 + Math.random() * 200}px` }}
/>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{paths.length === 0 ? (
<p>No binaries found with this entitlement.</p>
) : (
<p>No paths match &quot;{keyword}&quot;</p>
)}
</div>
) : (
<FileSystem os={os} list={filtered} expandAll={expandAll} />
)}
</div>
);
}
+3 -9
View File
@@ -4,13 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDebounce } from "use-debounce";
import Link from "next/link";
import {
Search,
X,
ChevronRight,
ChevronDown,
ChevronsUpDown,
} from "lucide-react";
import { Search, X, ChevronRight, ChevronDown } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -104,8 +98,8 @@ function KeyGroup({
}
}, [forceOpen]);
// Single standalone key - just show it inline
if (keys.length === 1 && keys[0] === prefix) {
// Single key in group - just show it inline without collapsible wrapper
if (keys.length === 1) {
return (
<div className="py-1">
<KeyBadge keyName={keys[0]} prefix="" os={os} />
+12 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import {
Collapsible,
CollapsibleContent,
@@ -63,7 +63,7 @@ function Tree({
item: TreeWithFullPath;
os: string;
depth: number;
expandAll: boolean;
expandAll: boolean | null;
}) {
const entries = Object.entries(item);
const files = entries.filter(([, v]) => typeof v === "string");
@@ -110,13 +110,19 @@ function TreeFolder({
item: TreeWithFullPath;
os: string;
depth: number;
expandAll: boolean;
expandAll: boolean | null;
}) {
const [open, setOpen] = useState(expandAll || depth < 1);
const [open, setOpen] = useState(expandAll === true || (expandAll === null && depth < 1));
const itemCount = countItems(item);
const maxDepth = getMaxDepth(item);
const isShallow = maxDepth === 0;
useEffect(() => {
if (expandAll !== null) {
setOpen(expandAll);
}
}, [expandAll]);
return (
<li>
<Collapsible open={open} onOpenChange={setOpen}>
@@ -158,11 +164,11 @@ function TreeFolder({
export default function FileSystem({
list,
os,
expandAll = false,
expandAll = null,
}: {
list: string[];
os: string;
expandAll?: boolean;
expandAll?: boolean | null;
}) {
const tree = filesToTree(list);
return (