const units: [Intl.RelativeTimeFormatUnit, number][] = [ ['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60], ]; /** When a message was sent: the time today, with the date before it when from an earlier day. */ export function stamp(ms: number, now = Date.now()): string { if (!ms) return ''; const date = new Date(ms); const current = new Date(now); const time = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).toLowerCase(); if (date.toDateString() === current.toDateString()) return time; const day = date .toLocaleDateString([], { month: 'short', day: 'numeric', ...(date.getFullYear() !== current.getFullYear() ? { year: 'numeric' } : {}), }) .toLowerCase(); return `${day}, ${time}`; } /** "4m", "2h", "3d": short enough for a list row. */ export function ago(iso: string | null, now = Date.now()): string { if (!iso) return ''; const seconds = Math.max(0, (now - Date.parse(iso)) / 1000); if (seconds < 45) return 'now'; for (const [unit, size] of units) { if (seconds >= size) return `${Math.floor(seconds / size)}${unit === 'month' ? 'mo' : unit[0]}`; } return `${Math.round(seconds / 60)}m`; } /** The workspace's last folder, with home shown as ~ and a remote host before it. */ export function place(workspace: string): string { const remote = /^(?:[^/@]+@)?([^/:~][^/:]*):(\/.*)$/.exec(workspace); if (remote) return `${remote[1]}:${place(remote[2])}`; const trimmed = workspace.replace(/\/+$/, ''); if (/^\/(Users|home)\/[^/]+$/.test(trimmed)) return '~'; return trimmed.split('/').pop() || trimmed; } /** The full path with the home folder shortened to ~. */ export function homePath(workspace: string): string { return workspace.replace(/^\/(Users|home)\/[^/]+(?=\/|$)/, '~'); } export type MatchRank = [number, number, number]; export function compareRank(a: MatchRank, b: MatchRank): number { return b[0] - a[0] || b[1] - a[1] || b[2] - a[2]; } function lowerScalars(s: string): string[] { return Array.from(s, (ch) => Array.from(ch.toLowerCase())[0]); } export function searchKey(s: string): string { return lowerScalars(s).join('').replace(/[\p{White_Space}\-_.:/]+/gu, ''); } /** Splits a query into lowercase separator-folded search words. */ export function searchWords(query: string): string[] { return query.split(/\p{White_Space}+/u).map(searchKey).filter(Boolean); } type Match = { rank: MatchRank; hits: number[] }; type Token = { start: number; text: string[] }; function candidate(tier: number, bonus: number, score: number, hits: number[]): Match { return { rank: [tier, bonus, score], hits }; } function betterScore(a: Match | null, b: Match | null): Match | null { return b && (!a || b.rank[2] > a.rank[2]) ? b : a; } function prefixLength(text: string[], query: string[], at = 0): number { let n = 0; while (n < text.length && at + n < query.length && text[n] === query[at + n]) n++; return n; } function matchWord(word: string, rawField: string): Match | null { const query = Array.from(word); const field = lowerScalars(rawField); const tokens: Token[] = []; field.forEach((ch, i) => { if (!/[\p{L}\p{N}\p{M}]/u.test(ch) && !query.includes(ch)) return; const last = tokens[tokens.length - 1]; if (last && last.start + last.text.length === i) last.text.push(ch); else tokens.push({ start: i, text: [ch] }); }); if (!tokens.length) return null; const range = (start: number, length = query.length) => Array.from({ length }, (_, i) => start + i); const folded = tokens.flatMap((t) => t.text); const foldedHits = tokens.flatMap((t) => range(t.start, t.text.length)); const spaceWords = rawField.split(/\p{White_Space}+/u).filter(Boolean).length; if (prefixLength(folded, query) === query.length) { return candidate(folded.length === query.length ? 5 : 4, 2, 100, foldedHits.slice(0, query.length)); } for (const [wi, { start, text }] of tokens.entries()) { if (prefixLength(text, query) === query.length) { return candidate(4, wi === 0 ? 2 : 1, (text.length === query.length ? 95 : 85) - wi, range(start)); } } if (spaceWords <= 2 && query.length >= 2 && tokens.length >= 2) { const maxSkip = spaceWords === 1 && tokens.length <= 5 ? 1 : 0; const chain = (startTi: number, qi: number, ti: number, skips: number): Match | null => { if (qi === query.length) return candidate(3, 0, 78 - startTi * 2 - skips * 8, []); if (ti >= tokens.length) return null; const { start, text } = tokens[ti]; let best: Match | null = null; // Keep the first successful prefix length, then compare the skipped path. for (let n = prefixLength(text, query, qi); n >= 1; n--) { const rest = chain(startTi, qi + n, ti + 1, skips); if (rest) { best = candidate(3, 0, rest.rank[2] + (n - 1) * 3, range(start, n).concat(rest.hits)); break; } } return skips < maxSkip ? betterScore(best, chain(startTi, qi, ti + 1, skips + 1)) : best; }; let best: Match | null = null; for (let st = 0; st < tokens.length; st++) { if (tokens[st].text[0] === query[0]) best = betterScore(best, chain(st, 0, st, 0)); } if (best) { best.rank[1] = best.hits[0] === tokens[0].start ? 1 : 0; return best; } } for (const { start, text } of tokens) { const token = text.join(''); const at = token.indexOf(word); if (at >= 0) { const hit = start + Array.from(token.slice(0, at)).length; return candidate(2, 0, 65 - Math.min(hit, 40), range(hit)); } } let best: Match | null = null; if (query.length >= 2) { for (const [wi, { start, text }] of tokens.entries()) { if (text[0] !== query[0] || text.length < query.length) continue; const hits: number[] = []; let pos = 0; for (const ch of query) { const at = text.indexOf(ch, pos); if (at < 0) break; hits.push(start + at); pos = at + 1; } if (hits.length === query.length) { const gaps = pos - query.length; best = betterScore(best, candidate(1, 0, 55 - gaps * 3 - wi, hits)); } } } return best; } /** * Ranks a row's fields against the search words (mirroring `cli/internal/tui/model_search.go`): * every word must match at least one field; hits are original UTF-16 offsets in fields[0]. */ export function matchFields(words: string[], ...fields: string[]): Match | null { if (!words.length) return { rank: [0, 0, 0], hits: [] }; const whole = words.join(''); const exactWhole = whole && fields.some((f) => whole === searchKey(f)) ? 1 : 0; let tierSum = 0; let scoreSum = 0; const hitSet = new Set(); for (const word of words) { let best: Match | null = null; for (const [i, field] of fields.entries()) { const m = matchWord(word, field); if (!m) continue; m.rank[2] -= i * 2; if (i !== 0) m.hits = []; if (!best || compareRank(m.rank, best.rank) < 0) best = m; } if (!best) return null; tierSum += best.rank[0] * 4 + best.rank[1]; scoreSum += best.rank[2]; for (const h of best.hits) hitSet.add(h); } const hits: number[] = []; let offset = 0; for (const [i, ch] of Array.from(fields[0] ?? '').entries()) { if (hitSet.has(i)) hits.push(offset); offset += ch.length; } return { rank: [exactWhole, tierSum, scoreSum], hits }; } /** Splits `text` into plain and highlighted segments from matched character indices. */ export function splitSegments(text: string, hits: Set | number[]): { text: string; hit: boolean }[] { const set = hits instanceof Set ? hits : new Set(hits); if (!set.size || !text.length) return [{ text, hit: false }]; const out: { text: string; hit: boolean }[] = []; let start = 0; let marked = set.has(0); let offset = 0; for (const ch of text) { const m = set.has(offset); if (m !== marked) { out.push({ text: text.slice(start, offset), hit: marked }); start = offset; marked = m; } offset += ch.length; } out.push({ text: text.slice(start), hit: marked }); return out; } /** Whether `query` matches `fields` under the unified matcher. */ export function matches(query: string, ...fields: string[]): boolean { return matchFields(searchWords(query), ...fields) !== null; } /** The entries that fit the query, best first; ties keep their order. */ export function rank(entries: T[], query: string, fields: (entry: T) => string | string[]): T[] { const words = searchWords(query); if (!words.length) return [...entries]; return entries .map((entry) => { const f = fields(entry); const list = Array.isArray(f) ? f : [f]; return { entry, matched: matchFields(words, ...list) }; }) .filter((item): item is { entry: T; matched: Match } => item.matched !== null) .sort((a, b) => compareRank(a.matched.rank, b.matched.rank)) .map(({ entry }) => entry); }