/** * The list's search panel (`ReviewListSearchPanelFactory` / `GHPRSearchPanelFactory`): * the search field (Enter applies the text; the history icon or ⌥↓ opens the last 10 * searches), then the Quick Filters funnel and a horizontally scrolling row of chips — * State, Author, Label, Assignee, Review, Sort. * * Chips are IntelliJ's `FilterComponent`: the filter's name and a chevron while unset, * else the value and a reset ✕; Delete / Backspace resets. The popup opens 4px below. * As in IntelliJ, closing a chip's popup without choosing resets the chip. */ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { tx, useTx } from "@/platform/i18n"; import { Icon, IconButton, Menu, SearchField, showContextMenu, type MenuItem } from "@/ui"; import type { GHLabel, GHUser } from "../api/types"; import { filterCount, type PrSearchValue } from "../data/model"; import { useAuthorCandidates, useRepositoryList } from "../data/repository"; import { loadSearchHistory } from "../data/searchHistory"; import type { BatchesState } from "../data/batches"; import { LabelSwatch } from "../ui/chips"; import { ChooserPopup, type ChooserItemPresentation } from "../ui/ChooserPopup"; import { isQuickFilterSelected, quickFilters, REVIEW_OPTIONS, reviewText, searchHistoryText, SORT_OPTIONS, sortText, STATE_OPTIONS, stateText, withQuery, type ChipOption, } from "./model"; import styles from "./List.module.css"; type Apply = (v: PrSearchValue) => void; // --------------------------------------------------------------------------- // Search field function SearchTextField({ search, apply }: { search: PrSearchValue; apply: Apply }) { const applied = search.searchQuery ?? ""; const [text, setText] = useState(applied); // The text follows the search when it changes elsewhere (history, Quick Filters, Clear). const [shown, setShown] = useState(applied); if (shown !== applied) { setShown(applied); setText(applied); } const field = useRef(null); const showHistory = () => { const r = field.current?.getBoundingClientRect(); const history = loadSearchHistory(); const items: MenuItem[] = history.length ? history.map((v, i) => ({ id: `h${i}`, label: searchHistoryText(v), verbatim: true, run: () => apply(v), })) : [{ label: "Nothing to show", disabled: true }]; showContextMenu({ x: (r?.left ?? 0) + 2, y: r?.bottom ?? 0 }, items); }; return (
{ if (e.key !== "Enter") return; e.preventDefault(); apply(withQuery(search, text)); }} aria-label="Search pull requests" />
); } // --------------------------------------------------------------------------- // Quick Filters function QuickFilterButton({ search, apply, login }: { search: PrSearchValue; apply: Apply; login: string }) { const [open, setOpen] = useState(false); const anchor = useRef(null); const count = filterCount(search); const items: MenuItem[] = [ { kind: "header", label: "Quick Filters" }, ...quickFilters(login).map((q): MenuItem => ({ id: q.id, label: q.title, checked: isQuickFilterSelected(search, q), run: () => apply(q.filter), })), { kind: "separator" }, ...(count > 0 ? [{ id: "clear", label: tx`Clear ${count} Filters`, verbatim: true, run: () => apply({}) }] : []), ]; return ( setOpen((o) => !o)} /> {count > 0 && } setOpen(false)} anchor={anchor} items={items} aria-label="Quick Filters" /> ); } // --------------------------------------------------------------------------- // Chips type PopupRender = (anchor: HTMLElement, done: (value: string | undefined) => void) => ReactNode; function FilterChip({ title, value, onChange, popup, }: { title: string; /** The chip's text for the current value; undefined when unset. */ value: string | undefined; onChange: (value: string | undefined) => void; popup: PopupRender; }) { const t = useTx(); const chip = useRef(null); const [anchor, setAnchor] = useState(null); const set = value !== undefined; const name = t(title); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); onChange(undefined); } }; return ( {set && ( )} {anchor && popup(anchor, (v) => { setAnchor(null); onChange(v); })} ); } const ROW = 28; /** List popups show up to 7 rows under the search field. */ const optionsHeight = (n: number) => 44 + Math.min(n, 7) * ROW + 4; /** `ChooserPopupUtil.showChooserPopup` / `showChooserPopupWithIncrementalLoading`: the chosen value, or undefined when closed without choosing. */ function Chooser({ anchor, done, title, items, getKey, present, state, height, }: { anchor: HTMLElement; done: (item: T | undefined) => void; title: string; items: readonly T[]; getKey: (item: T) => string; present: (item: T) => ChooserItemPresentation; state?: Pick, "loading" | "error">; height?: number; }) { const chosen = useRef<{ item: T } | null>(null); return ( mode="single" anchor={anchor} items={items} getKey={getKey} present={present} loading={state?.loading} error={state?.error} height={height} aria-label={title} onChoose={(item) => (chosen.current = { item })} onClose={() => done(chosen.current?.item)} /> ); } function OptionChip({ title, options, value, text, onChange, }: { title: string; options: readonly ChipOption[]; value: V | undefined; text: (v: V) => string; onChange: (v: V | undefined) => void; }) { return ( onChange(v as V | undefined)} popup={(anchor, done) => ( > anchor={anchor} title={title} items={options} getKey={(o) => o.value} present={(o) => ({ text: tx(o.label ?? o.text) })} height={optionsHeight(options.length)} done={(o) => done(o?.value)} /> )} /> ); } const presentUser = (u: GHUser): ChooserItemPresentation => ({ text: u.login, detail: u.name, avatarUrl: u.avatarUrl }); const userKey = (u: GHUser) => u.id || u.login; function AuthorChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) { const authors = useAuthorCandidates(); return ( anchor={anchor} title="Author" items={authors.items} state={authors} getKey={userKey} present={presentUser} done={(u) => done(u?.login)} /> ); } function AssigneeChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) { const assignees = useRepositoryList("assignees"); return ( anchor={anchor} title="Assignee" items={assignees.items} state={assignees} getKey={userKey} present={presentUser} done={(u) => done(u?.login)} /> ); } function LabelChooser({ anchor, done }: { anchor: HTMLElement; done: (v: string | undefined) => void }) { const labels = useRepositoryList("labels"); return ( anchor={anchor} title="Label" items={labels.items} state={labels} getKey={(l) => l.id || l.name} present={(l) => ({ text: l.name, leading: })} done={(l) => done(l?.name)} /> ); } /** The chips' row: scrolls horizontally, fading the clipped edges (`GradientViewport`). */ function ChipsRow({ children }: { children: ReactNode }) { const ref = useRef(null); const [fade, setFade] = useState<"left" | "right" | "both" | undefined>(undefined); const update = () => { const el = ref.current; if (!el) return; const left = el.scrollLeft > 0; const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; setFade(left && right ? "both" : left ? "left" : right ? "right" : undefined); }; useEffect(() => { update(); const el = ref.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, []); return (
{ // A vertical wheel scrolls the row sideways, as JBScrollPane's FORCE_HORIZONTAL_SCROLL. const el = ref.current; if (el && Math.abs(e.deltaY) > Math.abs(e.deltaX)) el.scrollLeft += e.deltaY; }} role="toolbar" aria-label="Filters" > {children}
); } // --------------------------------------------------------------------------- export function SearchPanel({ search, apply, login }: { search: PrSearchValue; apply: Apply; login: string }) { const patch = (p: Partial) => { const next: PrSearchValue = { ...search, ...p }; for (const k of Object.keys(p) as (keyof PrSearchValue)[]) if (next[k] === undefined) delete next[k]; apply(next); }; return (
patch({ state })} /> patch({ author })} popup={(anchor, done) => } /> patch({ label })} popup={(anchor, done) => } /> patch({ assignee })} popup={(anchor, done) => } /> patch({ reviewState })} /> patch({ sort })} />
); }