diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index 9989ad4219..c3b45c8e9f 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -703,3 +703,12 @@ select:disabled { @media (prefers-reduced-motion: reduce) { .ow-app-progress-dot, .ow-app-loading-grid { animation: none; } } + +/* Find on a settings page: every match, and the one being visited. The + colors match the marks "Find in conversation" draws. */ +::highlight(settings-find) { + background-color: color-mix(in srgb, var(--amber-4) 70%, transparent); +} +::highlight(settings-find-current) { + background-color: var(--amber-7); +} diff --git a/apps/app/src/components/find-field.tsx b/apps/app/src/components/find-field.tsx new file mode 100644 index 0000000000..8cc2013355 --- /dev/null +++ b/apps/app/src/components/find-field.tsx @@ -0,0 +1,116 @@ +import type { RefObject } from "react"; +import { ChevronDown, ChevronUp, Search, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +type FindFieldProps = { + inputRef: RefObject; + /** Names the field: its placeholder and accessible name. */ + label: string; + query: string; + onQueryChange: (query: string) => void; + /** "2/7", "No matches", or empty while the query is too short. */ + counterText: string; + hasMatches: boolean; + onNext: () => void; + onPrevious: () => void; + onClose: () => void; +}; + +/** + * The find control shared by "Find in conversation" and "Find on this page": + * a field, a match counter, and previous / next / close. Enter moves to the + * next match, Shift+Enter to the previous one, Escape closes. + */ +export function FindField(props: FindFieldProps) { + return ( +
+ + props.onQueryChange(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (event.shiftKey) { + props.onPrevious(); + } else { + props.onNext(); + } + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + props.onClose(); + } + }} + className="h-7 w-48 bg-transparent px-1 text-sm text-dls-text outline-none placeholder:text-dls-secondary sm:h-8 sm:w-56" + placeholder={props.label} + aria-label={props.label} + /> + + {props.counterText} + + + event.preventDefault()} + onClick={props.onPrevious} + > + + + } + /> + Previous match (⇧↵) + + + event.preventDefault()} + onClick={props.onNext} + > + + + } + /> + Next match (↵) + + + event.preventDefault()} + onClick={props.onClose} + > + + + } + /> + Close (Esc) + +
+ ); +} diff --git a/apps/app/src/react-app/domains/session/status/use-desktop-feature.ts b/apps/app/src/react-app/domains/session/status/use-desktop-feature.ts index 77937e6e42..ba7df8adf6 100644 --- a/apps/app/src/react-app/domains/session/status/use-desktop-feature.ts +++ b/apps/app/src/react-app/domains/session/status/use-desktop-feature.ts @@ -8,7 +8,7 @@ import { useAutomationsDenContext } from "@/react-app/domains/automations/use-au const FEATURE_STALE_MS = 30_000 -type DesktopFeatureKey = "remoteComputers" | "followUpSuggestions" | "questionMinimize" | "importOpencodeConnections" | "lowStorageWarning" +type DesktopFeatureKey = "remoteComputers" | "followUpSuggestions" | "questionMinimize" | "importOpencodeConnections" | "lowStorageWarning" | "settingsFind" /** * Reads one desktop feature from the registry. Signed in, it follows the diff --git a/apps/app/src/react-app/domains/session/surface/find-bar.tsx b/apps/app/src/react-app/domains/session/surface/find-bar.tsx index 8c2a355420..4b19882de4 100644 --- a/apps/app/src/react-app/domains/session/surface/find-bar.tsx +++ b/apps/app/src/react-app/domains/session/surface/find-bar.tsx @@ -1,9 +1,6 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState, type RefObject } from "react"; -import { ChevronDown, ChevronUp, Search, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; +import { FindField } from "@/components/find-field"; import { useSessionFindStore } from "./find-store"; import { SEARCH_HIGHLIGHT_SELECTOR } from "./text-highlights"; import { SESSION_SCROLL_NAVIGATION_EVENT } from "./scroll-controller"; @@ -275,92 +272,17 @@ export function SessionFindBar({ return (
-
- - setQuery(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - if (event.shiftKey) { - jumpToPrevious(); - } else { - jumpToNext(); - } - return; - } - - if (event.key === "Escape") { - event.preventDefault(); - closeFind(); - } - }} - className="h-7 w-48 bg-transparent px-1 text-sm text-dls-text outline-none placeholder:text-dls-secondary sm:h-8 sm:w-56" - placeholder="Find in conversation" - aria-label="Find in conversation" - /> - - {counterText} - - - event.preventDefault()} - onClick={jumpToPrevious} - > - - - } - /> - Previous match (⇧↵) - - - event.preventDefault()} - onClick={jumpToNext} - > - - - } - /> - Next match (↵) - - - event.preventDefault()} - onClick={closeFind} - > - - - } - /> - Close (Esc) - -
+ 0} + onNext={jumpToNext} + onPrevious={jumpToPrevious} + onClose={closeFind} + />
); } diff --git a/apps/app/src/react-app/domains/settings/find/find-highlights.ts b/apps/app/src/react-app/domains/settings/find/find-highlights.ts new file mode 100644 index 0000000000..bbdf10d478 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/find/find-highlights.ts @@ -0,0 +1,32 @@ +// Names used by `::highlight()` in `app/index.css`. +const MATCHES = "settings-find"; +const CURRENT_MATCH = "settings-find-current"; + +function highlightRegistry() { + if (typeof CSS === "undefined" || typeof Highlight === "undefined") return null; + return CSS.highlights ?? null; +} + +/** Paints every match, and the one being visited more strongly. */ +export function paintFindHighlights(ranges: Range[], current: Range | null) { + const registry = highlightRegistry(); + if (!registry) return; + if (ranges.length === 0) { + clearFindHighlights(); + return; + } + registry.set(MATCHES, new Highlight(...ranges)); + if (!current) { + registry.delete(CURRENT_MATCH); + return; + } + const highlight = new Highlight(current); + highlight.priority = 1; + registry.set(CURRENT_MATCH, highlight); +} + +export function clearFindHighlights() { + const registry = highlightRegistry(); + registry?.delete(MATCHES); + registry?.delete(CURRENT_MATCH); +} diff --git a/apps/app/src/react-app/domains/settings/find/find-matches.ts b/apps/app/src/react-app/domains/settings/find/find-matches.ts new file mode 100644 index 0000000000..59d8172116 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/find/find-matches.ts @@ -0,0 +1,67 @@ +/** Shorter queries match most of a page and are not searched. */ +export const FIND_MIN_QUERY_LENGTH = 2; + +/** More matches than this stop being useful to step through. */ +const FIND_MAX_MATCHES = 1000; + +/** Text that is on the page but not shown to someone reading it. */ +const SKIPPED = "script, style, .sr-only"; + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export type TextMatch = { start: number; end: number }; + +/** Every occurrence of `query` in `text`, ignoring case, left to right without overlap. */ +export function findTextMatches(text: string, query: string): TextMatch[] { + const needle = query.trim(); + if (!needle || !text) return []; + const matches: TextMatch[] = []; + for (const match of text.matchAll(new RegExp(escapeRegExp(needle), "giu"))) { + matches.push({ start: match.index, end: match.index + match[0].length }); + } + return matches; +} + +function isShown(element: Element) { + return typeof element.checkVisibility === "function" ? element.checkVisibility({ visibilityProperty: true }) : true; +} + +/** + * Ranges for every match of `query` in the text under `root`, in reading + * order, including text scrolled out of view. Nothing in the page is changed: + * the ranges are painted with the CSS Custom Highlight API, so React keeps + * owning every node. + */ +export function collectFindRanges( + root: Node, + query: string, + shown: (element: Element) => boolean = isShown, +): Range[] { + const needle = query.trim(); + if (needle.length < FIND_MIN_QUERY_LENGTH) return []; + const document = root.ownerDocument; + if (!document) return []; + + const ranges: Range[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node && ranges.length < FIND_MAX_MATCHES; node = walker.nextNode()) { + const matches = findTextMatches(node.nodeValue ?? "", needle); + if (matches.length === 0) continue; + const parent = node.parentElement; + if (!parent || parent.closest(SKIPPED) || !shown(parent)) continue; + for (const match of matches) { + const range = document.createRange(); + range.setStart(node, match.start); + range.setEnd(node, match.end); + ranges.push(range); + } + } + return ranges.slice(0, FIND_MAX_MATCHES); +} + +/** The match to land on after moving `step` from `index`, wrapping at both ends. */ +export function wrappedMatchIndex(index: number, step: number, total: number) { + if (total <= 0) return 0; + const remainder = (index + step) % total; + return remainder < 0 ? remainder + total : remainder; +} diff --git a/apps/app/src/react-app/domains/settings/find/settings-find-store.ts b/apps/app/src/react-app/domains/settings/find/settings-find-store.ts new file mode 100644 index 0000000000..9083edd737 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/find/settings-find-store.ts @@ -0,0 +1,25 @@ +import { create } from "zustand"; + +import { markFeatureUsed } from "@/app/lib/den"; + +type SettingsFindStore = { + open: boolean; + /** Changes each time find is asked for, to put the cursor back in the field. */ + focusNonce: number; + openFind: () => void; + closeFind: () => void; +}; + +/** + * Whether find is showing on the open settings page. One store so the + * shortcut, the header button and the command palette open the same thing. + */ +export const useSettingsFindStore = create((set) => ({ + open: false, + focusNonce: 0, + openFind: () => { + markFeatureUsed("settingsFind"); + set((state) => ({ open: true, focusNonce: state.focusNonce + 1 })); + }, + closeFind: () => set((state) => (state.open ? { open: false } : state)), +})); diff --git a/apps/app/src/react-app/domains/settings/find/settings-find.tsx b/apps/app/src/react-app/domains/settings/find/settings-find.tsx new file mode 100644 index 0000000000..d4821f5b08 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/find/settings-find.tsx @@ -0,0 +1,181 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useEffectEvent, useRef, useState, type RefObject } from "react"; +import { TextSearch } from "lucide-react"; + +import { FindField } from "@/components/find-field"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { isMacPlatform } from "@/app/utils"; + +import { clearFindHighlights, paintFindHighlights } from "./find-highlights"; +import { FIND_MIN_QUERY_LENGTH, collectFindRanges, wrappedMatchIndex } from "./find-matches"; +import { useSettingsFindStore } from "./settings-find-store"; + +export const SETTINGS_FIND_LABEL = "Find on this page"; +const DEBOUNCE_MS = 150; +const MUTATION_DEBOUNCE_MS = 100; + +/** + * Open state for find on a settings page, and the Cmd/Ctrl+F that opens it. + * Settings shown beside a chat share that shortcut with "Find in + * conversation", so there it is taken only while focus is inside `scopeRef`. + */ +export function useSettingsFind(input: { + enabled: boolean; + scopeRef: RefObject; + besideChat: boolean; +}) { + const open = useSettingsFindStore((state) => state.open); + const focusNonce = useSettingsFindStore((state) => state.focusNonce); + const openFind = useSettingsFindStore((state) => state.openFind); + const closeFind = useSettingsFindStore((state) => state.closeFind); + + const handleShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!input.enabled) return; + const mod = isMacPlatform() ? event.metaKey : event.ctrlKey; + if (!mod || event.shiftKey || event.altKey || event.key?.toLowerCase() !== "f") return; + if (input.besideChat && !input.scopeRef.current?.contains(document.activeElement)) return; + event.preventDefault(); + event.stopPropagation(); + openFind(); + }); + + useEffect(() => { + const handler = (event: KeyboardEvent) => handleShortcut(event); + // Capture, so the page's find wins over the conversation's when both are mounted. + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, []); + + // Leaving settings closes find, so it is not waiting open on the next visit. + useEffect(() => closeFind, [closeFind]); + + return { enabled: input.enabled, open: input.enabled && open, focusNonce, openFind, closeFind }; +} + +export function SettingsFindButton(props: { onOpen: () => void }) { + return ( + + + + + } + /> + {SETTINGS_FIND_LABEL} ({isMacPlatform() ? "⌘F" : "Ctrl+F"}) + + ); +} + +type SettingsFindBarProps = { + /** The settings page being searched; the bar itself sits outside it. */ + contentRef: RefObject; + /** Changes each time find is asked for, to put the cursor back in the field. */ + focusNonce: number; + onClose: () => void; +}; + +/** + * Finds text on the open settings page, including what is scrolled out of + * view: highlights every match, counts them and steps through them. + */ +export function SettingsFindBar(props: SettingsFindBarProps) { + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [appliedQuery, setAppliedQuery] = useState(""); + const [ranges, setRanges] = useState([]); + const [activeIndex, setActiveIndex] = useState(0); + const rangesRef = useRef([]); + const activeIndexRef = useRef(0); + + const show = useCallback((nextRanges: Range[], nextIndex: number, scroll: boolean) => { + const current = nextRanges[nextIndex] ?? null; + rangesRef.current = nextRanges; + activeIndexRef.current = current ? nextIndex : 0; + setRanges(nextRanges); + setActiveIndex(current ? nextIndex : 0); + paintFindHighlights(nextRanges, current); + if (scroll) current?.startContainer.parentElement?.scrollIntoView({ block: "center" }); + }, []); + + const collect = useEffectEvent((fromStart: boolean) => { + const root = props.contentRef.current; + const nextRanges = root ? collectFindRanges(root, appliedQuery) : []; + // A new query starts at its first match; a page that changed under the + // same query keeps the reader's place. + const nextIndex = fromStart ? 0 : Math.min(activeIndexRef.current, Math.max(0, nextRanges.length - 1)); + show(nextRanges, nextIndex, fromStart); + }); + + const move = useCallback((step: number) => { + const total = rangesRef.current.length; + if (total === 0) return; + show(rangesRef.current, wrappedMatchIndex(activeIndexRef.current, step, total), true); + }, [show]); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => window.cancelAnimationFrame(frame); + }, [props.focusNonce]); + + useEffect(() => { + const timer = window.setTimeout(() => setAppliedQuery(query.trim()), DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [query]); + + useEffect(() => { + collect(true); + }, [appliedQuery]); + + // Another settings page, or content that loads late, is searched again. + useEffect(() => { + const root = props.contentRef.current; + if (!root || appliedQuery.length < FIND_MIN_QUERY_LENGTH) return; + let timer: number | undefined; + const observer = new MutationObserver(() => { + window.clearTimeout(timer); + timer = window.setTimeout(() => collect(false), MUTATION_DEBOUNCE_MS); + }); + observer.observe(root, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + window.clearTimeout(timer); + }; + }, [appliedQuery, props.contentRef]); + + useEffect(() => clearFindHighlights, []); + + const counterText = appliedQuery.length < FIND_MIN_QUERY_LENGTH + ? "" + : ranges.length === 0 + ? "No matches" + : `${activeIndex + 1}/${ranges.length}`; + + return ( +
+ 0} + onNext={() => move(1)} + onPrevious={() => move(-1)} + onClose={props.onClose} + /> +
+ ); +} diff --git a/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx b/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx index d49667bc84..8885fd1d19 100644 --- a/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx +++ b/apps/app/src/react-app/domains/settings/shell/settings-shell.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource react */ import type * as React from "react"; +import { useRef } from "react"; import { DesktopUpdateButton } from "../state/desktop-updater-provider"; import { ChevronDown, X } from "lucide-react"; @@ -21,6 +22,7 @@ import { import { t } from "../../../../i18n"; import { NotificationBell } from "../../../shell/notification-center"; import type { SettingsTab } from "../../../../app/types"; +import { SettingsFindBar, SettingsFindButton, useSettingsFind } from "../find/settings-find"; import { SettingsPage, SettingsBetaBadge, @@ -48,14 +50,32 @@ export type SettingsShellProps = SettingsPageFrameProps & { modalSlot?: React.ReactNode; footer?: React.ReactNode; compact?: boolean; + /** Offers find on the open page: Cmd/Ctrl+F and a button in the header. */ + findEnabled?: boolean; }; export function SettingsShell(props: SettingsShellProps) { const title = getSettingsTabLabel(props.activeTab); + const shellRef = useRef(null); + const pageRef = useRef(null); + const find = useSettingsFind({ + enabled: props.findEnabled === true, + scopeRef: shellRef, + besideChat: props.compact === true, + }); + // The bar floats over the page it searches and is not part of it. + const page = ( +
+
+ {props.children} +
+ {find.open ? : null} +
+ ); if (props.compact) { return ( -
+
+ {find.enabled ? : null}