Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/app/src/app/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
116 changes: 116 additions & 0 deletions apps/app/src/components/find-field.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | null>;
/** 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 (
<div className="flex items-center gap-1 rounded-xl border border-dls-border bg-dls-surface/95 px-1.5 py-1 shadow-(--dls-card-shadow) backdrop-blur-md">
<Search className="ml-1 size-3.5 shrink-0 text-dls-secondary" />
<input
ref={props.inputRef}
value={props.query}
onChange={(event) => 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}
/>
<span className={cn(
"min-w-14 text-right text-xs tabular-nums text-muted-foreground",
props.counterText === "No matches" && "min-w-20",
)} aria-live="polite">
{props.counterText}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Previous match"
disabled={!props.hasMatches}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onPrevious}
>
<ChevronUp />
</Button>
}
/>
<TooltipContent>Previous match (⇧↵)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Next match"
disabled={!props.hasMatches}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onNext}
>
<ChevronDown />
</Button>
}
/>
<TooltipContent>Next match (↵)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Close find"
onMouseDown={(event) => event.preventDefault()}
onClick={props.onClose}
>
<X />
</Button>
}
/>
<TooltipContent>Close (Esc)</TooltipContent>
</Tooltip>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 12 additions & 90 deletions apps/app/src/react-app/domains/session/surface/find-bar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -275,92 +272,17 @@ export function SessionFindBar({

return (
<div className="absolute top-2 right-3 z-30 sm:right-5">
<div className="flex items-center gap-1 rounded-xl border border-dls-border bg-dls-surface/95 px-1.5 py-1 shadow-(--dls-card-shadow) backdrop-blur-md">
<Search className="ml-1 size-3.5 shrink-0 text-dls-secondary" />
<input
ref={inputRef}
value={query}
onChange={(event) => 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"
/>
<span className={cn(
"min-w-14 text-right text-xs tabular-nums text-muted-foreground",
counterText === "No matches" && "min-w-20",
)} aria-live="polite">
{counterText}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Previous match"
disabled={totalMatches === 0}
onMouseDown={(event) => event.preventDefault()}
onClick={jumpToPrevious}
>
<ChevronUp />
</Button>
}
/>
<TooltipContent>Previous match (⇧↵)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Next match"
disabled={totalMatches === 0}
onMouseDown={(event) => event.preventDefault()}
onClick={jumpToNext}
>
<ChevronDown />
</Button>
}
/>
<TooltipContent>Next match (↵)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Close find"
onMouseDown={(event) => event.preventDefault()}
onClick={closeFind}
>
<X />
</Button>
}
/>
<TooltipContent>Close (Esc)</TooltipContent>
</Tooltip>
</div>
<FindField
inputRef={inputRef}
label="Find in conversation"
query={query}
onQueryChange={setQuery}
counterText={counterText}
hasMatches={totalMatches > 0}
onNext={jumpToNext}
onPrevious={jumpToPrevious}
onClose={closeFind}
/>
</div>
);
}
32 changes: 32 additions & 0 deletions apps/app/src/react-app/domains/settings/find/find-highlights.ts
Original file line number Diff line number Diff line change
@@ -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);
}
67 changes: 67 additions & 0 deletions apps/app/src/react-app/domains/settings/find/find-matches.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<SettingsFindStore>((set) => ({
open: false,
focusNonce: 0,
openFind: () => {
markFeatureUsed("settingsFind");
set((state) => ({ open: true, focusNonce: state.focusNonce + 1 }));
},
closeFind: () => set((state) => (state.open ? { open: false } : state)),
}));
Loading
Loading