diff --git a/apps/web/src/components/files/FilterControls.tsx b/apps/web/src/components/files/FilterControls.tsx index 3eb0acc9..c5ba4985 100644 --- a/apps/web/src/components/files/FilterControls.tsx +++ b/apps/web/src/components/files/FilterControls.tsx @@ -1,98 +1,247 @@ /** - * The Files filter vocabulary (spec: "Files views"): Type (kind or device), Repo, PR state. - * Each view composes the controls it needs; values live in the URL. + * The Files filter vocabulary (spec: "Files views"): Type, Device, Repo/Project, + * PR state, Merged only. Every view shows one bar: optional search, one + * Filters button (with a count) that opens a popover of sections, optional + * trailing controls, and the active filters as removable chips below. + * Values live in the URL. */ import { Select } from "@uploads/ui"; +import { Checkbox } from "@uploads/ui/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@uploads/ui/components/ui/popover"; +import { SlidersHorizontalIcon, XIcon } from "lucide-react"; +import type { ReactNode } from "react"; import { FILE_SURFACES, type FileSurface, type FileTypeClass } from "@uploads/comment-render/scope"; import { FILE_SURFACE_LABELS, FILE_TYPE_LABELS, - parseFileTypeParam, - parsePrStateParam, - parseSurfaceParam, type PrStateFilter, } from "../../lib/files-view-state"; -const SELECT_CLASS = "ul-select--sm min-h-9 max-w-full text-base sm:text-[13px] box-border"; const FILE_TYPE_ORDER: FileTypeClass[] = ["screenshot", "video", "other"]; +const PR_STATE_LABELS: Record = { + open: "Open", + merged: "Merged", + closed: "Closed", +}; export interface TypeFilter { type: FileTypeClass | null; surface: FileSurface | null; } -/** - * One select for what a file is: its kind (by extension) or the device it - * shows (the classifier's `ai.surface`). Picking one clears the other, so the - * bar keeps a single control; the URL keeps `type` and `surface` apart. - */ -export function TypeSelect({ +export interface FilterChip { + key: string; + label: string; + onRemove: () => void; +} + +export function FilterBar({ + search, + trailing, + chips, + children, +}: { + search?: ReactNode; + trailing?: ReactNode; + /** Active filters; their count badges the Filters button. */ + chips: FilterChip[]; + /** Popover body: FilterSection blocks. */ + children: ReactNode; +}) { + return ( +
+
+ {search} + + 0 ? `Filters, ${chips.length} active` : "Filters"} + className={`flex min-h-9 flex-none items-center gap-2 rounded-[6px] border px-3 text-[13px] cursor-pointer box-border hover:text-fg ${chips.length > 0 ? "border-primary/50 bg-primary/10 text-fg" : "border-line bg-transparent text-muted-foreground"}`} + /> + } + > + + + {children} + + + {trailing} +
+ {chips.length > 0 && ( +
+ {chips.map((chip) => ( + + ))} + {chips.length > 1 && ( + + )} +
+ )} +
+ ); +} + +export function FilterSection({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} + +/** Single choice as pills; pressing the active pill clears it. */ +function ChoicePills({ + label, + options, + value, + onChange, +}: { + label: string; + options: readonly { value: T; label: string }[]; + value: T | null; + onChange: (next: T | null) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +const TYPE_OPTIONS = FILE_TYPE_ORDER.map((type) => ({ + value: type, + label: FILE_TYPE_LABELS[type], +})); +const SURFACE_OPTIONS = FILE_SURFACES.map((surface) => ({ + value: surface, + label: FILE_SURFACE_LABELS[surface], +})); +const STATE_OPTIONS = (["open", "merged", "closed"] as const).map((state) => ({ + value: state, + label: PR_STATE_LABELS[state], +})); + +/** Type (by extension) and Device (the classifier's `ai.surface`); they combine. */ +export function TypeSections({ value, onChange, }: { value: TypeFilter; onChange: (next: TypeFilter) => void; }) { - const current = value.surface ? `surface:${value.surface}` : (value.type ?? ""); return ( - + <> + + onChange({ ...value, type })} + /> + + + onChange({ ...value, surface })} + /> + + ); } -export function RepoSelect({ +export function typeChips(value: TypeFilter, onChange: (next: TypeFilter) => void): FilterChip[] { + const chips: FilterChip[] = []; + if (value.type) { + chips.push({ + key: "type", + label: FILE_TYPE_LABELS[value.type], + onRemove: () => onChange({ ...value, type: null }), + }); + } + if (value.surface) { + chips.push({ + key: "surface", + label: FILE_SURFACE_LABELS[value.surface], + onRemove: () => onChange({ ...value, surface: null }), + }); + } + return chips; +} + +/** Repo (PR view) or project (By page) picker; "" means all. */ +export function ScopeSelect({ + label, + allLabel, value, options, onChange, }: { + label: string; + allLabel: string; value: string; options: string[]; - onChange: (repo: string) => void; + onChange: (next: string) => void; }) { const all = value && !options.includes(value) ? [...options, value] : options; return ( - + + + ); } -export function PrStateSelect({ +export function PrStateSection({ value, onChange, }: { @@ -100,16 +249,32 @@ export function PrStateSelect({ onChange: (state: PrStateFilter | null) => void; }) { return ( - + + + + ); +} + +export function prStateLabel(state: PrStateFilter): string { + return PR_STATE_LABELS[state]; +} + +export function MergedOnlyCheckbox({ + checked, + onChange, +}: { + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + ); } diff --git a/apps/web/src/components/files/PathFilterBar.tsx b/apps/web/src/components/files/PathFilterBar.tsx index ad3f41c8..eee201d9 100644 --- a/apps/web/src/components/files/PathFilterBar.tsx +++ b/apps/web/src/components/files/PathFilterBar.tsx @@ -1,9 +1,10 @@ /** - * By page filter bar: project select, path input with autocomplete - * (Cmd/Ctrl-K, "/") that also matches `screen` names, Grouped/Recent, Merged only. Moved from - * ScreenshotsByPath.tsx. The Type select leads the bar. + * By page filter bar: path input with autocomplete (Cmd/Ctrl-K, "/") that + * also matches `screen` names, then Filters (Type, Device, Project, Merged + * only) and a Grouped / Newest first icon toggle. */ -import { Input, Select } from "@uploads/ui"; +import { Input } from "@uploads/ui"; +import { LayoutGridIcon, Rows3Icon } from "lucide-react"; import { Kbd } from "@uploads/ui/components/ui/kbd"; import { useEffect, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import type { PathCatalogEntry } from "../../lib/api-client"; @@ -13,7 +14,21 @@ import { type GroupBy, type RecentView, } from "../../lib/workspace-screenshots"; -import { TypeSelect, type TypeFilter } from "./FilterControls"; +import { + FilterBar, + FilterSection, + MergedOnlyCheckbox, + ScopeSelect, + TypeSections, + typeChips, + type FilterChip, + type TypeFilter, +} from "./FilterControls"; + +const LAYOUT_OPTIONS: { sort: RecentView; label: string; Icon: typeof Rows3Icon }[] = [ + { sort: "grouped", label: "Grouped by page", Icon: Rows3Icon }, + { sort: "recent", label: "Newest first", Icon: LayoutGridIcon }, +]; export function PathFilterBar({ project, @@ -49,7 +64,6 @@ export function PathFilterBar({ onPickPath: (picked: { path: string; by: GroupBy }) => void; onMerged: (merged: boolean) => void; }) { - const options = project && !projects.includes(project) ? [...projects, project] : projects; const [suggestOpen, setSuggestOpen] = useState(false); const [active, setActive] = useState(-1); const value = path || q; @@ -95,135 +109,126 @@ export function PathFilterBar({ } }; + const chips: FilterChip[] = [ + ...typeChips(type, onType), + ...(project ? [{ key: "project", label: project, onRemove: () => onProject("") }] : []), + ...(merged ? [{ key: "merged", label: "Merged only", onRemove: () => onMerged(false) }] : []), + ]; + return ( -
- - -
- = 0 ? `wsp-path-suggest-${active}` : undefined} - role="combobox" - placeholder="Filter path or screen e.g. /catalog" - value={value} - spellCheck={false} - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - onChange={(event) => { - setSuggestOpen(true); - setActive(-1); - onQuery(event.target.value); - }} - onFocus={() => setSuggestOpen(true)} - onBlur={() => { - // Delay so a mousedown on an option can land first. - window.setTimeout(() => setSuggestOpen(false), 120); - }} - onKeyDown={onKeyDown} - /> - {value === "" && ( - {modKbd} - )} - {open && ( -
    - {suggestions.map((entry, index) => ( -
  • - -
  • - ))} -
- )} -
- {/* One flex item so the two toggle groups wrap together as a cluster — - separately they wrap independently and "Merged only" ends up - orphaned on its own row. */} -
-
- - + + = 0 ? `wsp-path-suggest-${active}` : undefined} + role="combobox" + placeholder="Search paths and screens" + value={value} + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + onChange={(event) => { + setSuggestOpen(true); + setActive(-1); + onQuery(event.target.value); + }} + onFocus={() => setSuggestOpen(true)} + onBlur={() => { + // Delay so a mousedown on an option can land first. + window.setTimeout(() => setSuggestOpen(false), 120); + }} + onKeyDown={onKeyDown} + /> + {value === "" && ( + + {modKbd} + + )} + {open && ( +
    + {suggestions.map((entry, index) => ( +
  • + +
  • + ))} +
+ )}
- {/* Persisted PR merge-state tagging: filters both the drill-in - (meta.gh.merged=true) and the grouped overview (?merged=1). */} + } + trailing={
- + {LAYOUT_OPTIONS.map(({ sort: option, label, Icon }) => ( + + ))}
-
-
+ } + > + + {projects.length > 0 || project ? ( + + ) : null} + {/* Persisted PR merge-state tagging: filters both the drill-in + (meta.gh.merged=true) and the grouped overview (?merged=1). */} + + + + ); } diff --git a/apps/web/src/components/files/PullsView.tsx b/apps/web/src/components/files/PullsView.tsx index e9dbb0b1..e20cf641 100644 --- a/apps/web/src/components/files/PullsView.tsx +++ b/apps/web/src/components/files/PullsView.tsx @@ -32,7 +32,14 @@ import { githubUrl } from "../../lib/gh-context"; import type { WorkspaceInfoStatus } from "../../lib/workspace-file-row"; import { lastUpdatedLabel } from "../../lib/workspace-screenshots"; import { CommandEmpty, InlineEmpty, RowsSkeleton } from "./FilesEmpty"; -import { PrStateSelect, RepoSelect, TypeSelect } from "./FilterControls"; +import { + FilterBar, + PrStateSection, + ScopeSelect, + TypeSections, + prStateLabel, + typeChips, +} from "./FilterControls"; import { LoadMoreFooter } from "./LoadMoreFooter"; import { RowOverflowMenu, useLiveLink, type LiveLinkControls } from "./LiveLink"; import type { PreviewHandlers } from "./ShotThumb"; @@ -141,15 +148,33 @@ function PullsViewInner({ return (
-
- setQuery({ ...query, ...next })} /> - setQuery({ ...query, ...next })), + ...(query.repo + ? [{ key: "repo", label: query.repo, onRemove: () => setQuery({ ...query, repo: "" }) }] + : []), + ...(query.state + ? [ + { + key: "state", + label: prStateLabel(query.state), + onRemove: () => setQuery({ ...query, state: null }), + }, + ] + : []), + ]} + > + setQuery({ ...query, ...next })} /> + setQuery({ ...query, repo })} /> - setQuery({ ...query, state })} /> -
+ setQuery({ ...query, state })} /> + {list.status === "loading" && !keptRows && } {list.status === "error" && ( diff --git a/apps/web/src/components/files/ReposView.tsx b/apps/web/src/components/files/ReposView.tsx index e8e3f0a4..4d0332fa 100644 --- a/apps/web/src/components/files/ReposView.tsx +++ b/apps/web/src/components/files/ReposView.tsx @@ -21,7 +21,7 @@ import { import type { WorkspaceInfoStatus } from "../../lib/workspace-file-row"; import { lastUpdatedLabel } from "../../lib/workspace-screenshots"; import { CommandEmpty, RowsSkeleton } from "./FilesEmpty"; -import { TypeSelect } from "./FilterControls"; +import { FilterBar, TypeSections, typeChips } from "./FilterControls"; import { GitHubMark } from "./gh-glyphs"; import { LoadMoreFooter } from "./LoadMoreFooter"; import { RowOverflowMenu, useLiveLink, type LiveLinkControls } from "./LiveLink"; @@ -90,9 +90,9 @@ function ReposViewInner({ return (
-
- setQuery({ ...query, ...next })} /> -
+ setQuery({ ...query, ...next }))}> + setQuery({ ...query, ...next })} /> + {list.status === "loading" && } {list.status === "error" && ( diff --git a/apps/web/src/components/files/ScopePage.tsx b/apps/web/src/components/files/ScopePage.tsx index 8507114e..4be47583 100644 --- a/apps/web/src/components/files/ScopePage.tsx +++ b/apps/web/src/components/files/ScopePage.tsx @@ -52,7 +52,7 @@ import { import type { WorkspaceInfoStatus } from "../../lib/workspace-file-row"; import { lastUpdatedLabel, pairedShotKeys } from "../../lib/workspace-screenshots"; import { CommandEmpty, InlineEmpty, RowsSkeleton } from "./FilesEmpty"; -import { TypeSelect } from "./FilterControls"; +import { FilterBar, TypeSections, typeChips } from "./FilterControls"; import { LoadMoreFooter } from "./LoadMoreFooter"; import { useLiveLink } from "./LiveLink"; import { ShotThumb, type PreviewHandlers } from "./ShotThumb"; @@ -326,25 +326,29 @@ function ScopePageInner({ )} -
- setQuery({ ...query, ...next })} /> - {number !== null && ( -
- -
- )} -
+ +
+ ) + } + > + setQuery({ ...query, ...next })} /> + {list.status === "loading" && (
diff --git a/apps/web/src/lib/files-view-state.test.ts b/apps/web/src/lib/files-view-state.test.ts index eea2155b..dc13cd70 100644 --- a/apps/web/src/lib/files-view-state.test.ts +++ b/apps/web/src/lib/files-view-state.test.ts @@ -246,6 +246,8 @@ describe("small mappers", () => { expect(typeEmptyNoun("screenshot")).toBe("screenshots"); expect(typeEmptyNoun("video")).toBe("videos"); expect(typeEmptyNoun("other")).toBe("other files"); + expect(typeEmptyNoun(null, "mobile")).toBe("mobile files"); + expect(typeEmptyNoun("video", "mobile")).toBe("mobile videos"); }); }); diff --git a/apps/web/src/lib/files-view-state.ts b/apps/web/src/lib/files-view-state.ts index 3b2f50fe..55b028cb 100644 --- a/apps/web/src/lib/files-view-state.ts +++ b/apps/web/src/lib/files-view-state.ts @@ -220,11 +220,15 @@ export function typeEmptyNoun( type: FileTypeClass | null, surface: FileSurface | null = null, ): string { - if (surface) return `${FILE_SURFACE_LABELS[surface].toLowerCase()} files`; - if (type === "screenshot") return "screenshots"; - if (type === "video") return "videos"; - if (type === "other") return "other files"; - return "files"; + const noun = + type === "screenshot" + ? "screenshots" + : type === "video" + ? "videos" + : type === "other" + ? "other files" + : "files"; + return surface ? `${FILE_SURFACE_LABELS[surface].toLowerCase()} ${noun}` : noun; } export type PullsEmptyCopy =