mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
* feat(app): rank, highlight, and timestamp Command Center results
Workspace and agent rows now show when they were last active, rank by
match quality and then recency, mark the query terms in every result
kind, and lead workspaces with their project icon.
Ties used to be broken by where the match landed in the string, so
"Deliver sidebar usage" beat "expose usage data" on sentence position
and recency almost never decided anything. Built-in rows now compare
only which fields matched and the matcher tier, then the most recent
row wins. The empty query follows the same rule, so it lists the most
recent workspaces and agents instead of alphabetical workspaces and
status-first agents.
Row and section heights live in list-metrics: a layout picks one row
inset and every height derives from it plus shared line boxes, which
the styles also read. The layout is chosen once at the top, so rows
never branch on it, and two-line rows are now vertically centered.
Desktop is denser (32/46); compact keeps 44pt touch rows (44/58).
findHighlightRanges takes the matcher's options so the marks match
what the list matched: the Command Center is not typo-tolerant, the
history list is.
* fix(app): keep the project icon out of Command Center row names
The fallback project icon draws the project's initial as text, which
became the first word of the row's accessible name ("C Command Center
..."), so screen readers announced it. The icon is decorative.
The scroll spec now reads the newest seeded workspace: the empty query
lists the most recent workspaces, so the first one seeded is no longer
among them.
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
import {
|
|
fuzzyPolicyForToken,
|
|
matchRanges,
|
|
scoreMatch,
|
|
tokenizeQuery,
|
|
type MatchRange,
|
|
type TextFieldsOptions,
|
|
} from "@getpaseo/protocol/search/text-match";
|
|
|
|
export interface HighlightSegment {
|
|
/** Offset into the source text; unique per segment, so it doubles as a key. */
|
|
start: number;
|
|
text: string;
|
|
marked: boolean;
|
|
}
|
|
|
|
/**
|
|
* Splits text into marked and unmarked runs. Ranges are clamped and skipped
|
|
* rather than trusted: they arrive over the wire, and a stale one must not slice
|
|
* a title into gibberish.
|
|
*/
|
|
export function toHighlightSegments(
|
|
text: string,
|
|
ranges: readonly MatchRange[],
|
|
): HighlightSegment[] {
|
|
const segments: HighlightSegment[] = [];
|
|
let cursor = 0;
|
|
for (const range of ranges) {
|
|
const start = Math.max(range.start, cursor);
|
|
const end = Math.min(start + range.length, text.length);
|
|
if (end <= start) continue;
|
|
if (start > cursor) {
|
|
segments.push({ start: cursor, text: text.slice(cursor, start), marked: false });
|
|
}
|
|
segments.push({ start, text: text.slice(start, end), marked: true });
|
|
cursor = end;
|
|
}
|
|
if (cursor < text.length) {
|
|
segments.push({ start: cursor, text: text.slice(cursor), marked: false });
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
/**
|
|
* Best-effort presentation of each query token in this field, independently of other fields.
|
|
* Pass the same options the list was matched with, so a mark never claims a match the list did
|
|
* not make.
|
|
*/
|
|
export function findHighlightRanges(
|
|
query: string,
|
|
text: string,
|
|
options: TextFieldsOptions = {},
|
|
): MatchRange[] {
|
|
const ranges = tokenizeQuery(query)
|
|
.flatMap((token) => {
|
|
const score = scoreMatch(token, text, {
|
|
fuzzy: options.typoTolerant ? fuzzyPolicyForToken(token) : null,
|
|
subsequence: options.subsequence,
|
|
});
|
|
return score ? matchRanges(token, text, score) : [];
|
|
})
|
|
.sort((left, right) => left.start - right.start);
|
|
const merged: MatchRange[] = [];
|
|
for (const range of ranges) {
|
|
const last = merged.at(-1);
|
|
if (last && range.start <= last.start + last.length) {
|
|
last.length = Math.max(last.length, range.start + range.length - last.start);
|
|
} else {
|
|
merged.push({ ...range });
|
|
}
|
|
}
|
|
return merged;
|
|
}
|