mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): trigger #channel/@mention autocomplete after opening brackets
detectPrefixQuery required the trigger char to sit at start-of-string or
after whitespace, so typing `(#channel` (or `[@name`, `{#chan`) glued to
an opening bracket never opened the autocomplete — the user had to add a
space after the bracket. Allow opening brackets ([{ as a word boundary in
both the fast-path regex and the multi-word backward scan, kept in sync via
a single isBoundaryChar definition. Fixes 7796a4f4.
Adds detectPrefixQuery.test.mjs (first test on this helper): bracket
boundaries for # and @, single- and multi-word paths, and negatives
(prefix glued to a word char still does not trigger).
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
parent
1d8d006aaf
commit
f51807ac8d
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { detectPrefixQuery } from "./detectPrefixQuery.ts";
|
||||
|
||||
const CHANNELS = ["buzz-bugs", "buzz dev", "general"];
|
||||
const PEOPLE = ["alice", "bob jones"];
|
||||
|
||||
// Helper: detect at end-of-string (the usual cursor position while typing).
|
||||
const at = (prefix, text, names) =>
|
||||
detectPrefixQuery(prefix, text, text.length, names);
|
||||
|
||||
// ── Existing behavior: start-of-string and after whitespace still trigger ─────
|
||||
|
||||
test("triggers at start of string", () => {
|
||||
assert.deepEqual(at("#", "#buzz", CHANNELS), {
|
||||
query: "buzz",
|
||||
startIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("triggers after whitespace", () => {
|
||||
assert.deepEqual(at("#", "go to #buzz", CHANNELS), {
|
||||
query: "buzz",
|
||||
startIndex: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("does NOT trigger when glued to a word character", () => {
|
||||
assert.equal(at("#", "foo#buzz", CHANNELS), null);
|
||||
assert.equal(at("@", "email@bob", PEOPLE), null);
|
||||
});
|
||||
|
||||
// ── The bug fix: opening brackets count as a boundary ─────────────────────────
|
||||
|
||||
test("7796a4f4: ( before # triggers channel query", () => {
|
||||
assert.deepEqual(at("#", "(#buzz", CHANNELS), {
|
||||
query: "buzz",
|
||||
startIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("7796a4f4: [ and { before # also trigger", () => {
|
||||
assert.deepEqual(at("#", "[#buzz", CHANNELS), {
|
||||
query: "buzz",
|
||||
startIndex: 1,
|
||||
});
|
||||
assert.deepEqual(at("#", "{#buzz", CHANNELS), {
|
||||
query: "buzz",
|
||||
startIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("7796a4f4: ( before @ triggers mention query", () => {
|
||||
assert.deepEqual(at("@", "(@alice", PEOPLE), {
|
||||
query: "alice",
|
||||
startIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("bracket mid-sentence (word before the bracket) still triggers", () => {
|
||||
// The char immediately before the prefix is `(`, which is the boundary —
|
||||
// what precedes the bracket is irrelevant.
|
||||
assert.deepEqual(at("#", "see also(#general", CHANNELS), {
|
||||
query: "general",
|
||||
startIndex: 9,
|
||||
});
|
||||
});
|
||||
|
||||
test("nested brackets: (( before # triggers, startIndex at the prefix", () => {
|
||||
assert.deepEqual(at("#", "((#buzz", CHANNELS), {
|
||||
query: "buzz",
|
||||
startIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multi-word path: bracket boundary works for space-containing names ─────────
|
||||
|
||||
test("multi-word channel name after ( resolves via multi-word path", () => {
|
||||
assert.deepEqual(at("#", "(#buzz de", CHANNELS), {
|
||||
query: "buzz de",
|
||||
startIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("multi-word person name after ( resolves via multi-word path", () => {
|
||||
assert.deepEqual(at("@", "ping (@bob jo", PEOPLE), {
|
||||
query: "bob jo",
|
||||
startIndex: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("multi-word: glued-to-word prefix still rejected", () => {
|
||||
// `x#buzz de` — `#` preceded by `x`, no boundary → no query even though
|
||||
// "buzz de" is a prefix of a known channel.
|
||||
assert.equal(at("#", "x#buzz de", CHANNELS), null);
|
||||
});
|
||||
|
||||
// ── Empty / no-match guards unchanged ─────────────────────────────────────────
|
||||
|
||||
test("bare prefix after ( yields empty single-word query, not multi-word", () => {
|
||||
// Fast path matches with empty query (dropdown shows all) — consistent with
|
||||
// bare `#` at start.
|
||||
assert.deepEqual(at("#", "(#", CHANNELS), { query: "", startIndex: 1 });
|
||||
});
|
||||
|
||||
test("no prefix present → null", () => {
|
||||
assert.equal(at("#", "just text", CHANNELS), null);
|
||||
});
|
||||
@@ -21,9 +21,17 @@ export function detectPrefixQuery(
|
||||
): { query: string; startIndex: number } | null {
|
||||
const beforeCursor = value.slice(0, cursorPosition);
|
||||
|
||||
// A prefix only triggers a query when it opens a "word" — i.e. it sits at the
|
||||
// start of the text or right after whitespace or an opening bracket. Opening
|
||||
// brackets are included so `(#channel`, `[@name`, `{#chan` all autocomplete
|
||||
// the same as ` #channel` (previously only whitespace/start counted, so a
|
||||
// prefix glued to a `(` never fired). Keep the two detection paths below in
|
||||
// sync with this single definition of "boundary before the prefix".
|
||||
const isBoundaryChar = (ch: string) => /[\s([{]/.test(ch);
|
||||
|
||||
// Fast path: single-word query (no spaces after the prefix)
|
||||
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const simplePattern = new RegExp(`(?:^|[\\s])${escapedPrefix}([^\\s]*)$`);
|
||||
const simplePattern = new RegExp(`(?:^|[\\s([{])${escapedPrefix}([^\\s]*)$`);
|
||||
const simpleMatch = beforeCursor.match(simplePattern);
|
||||
if (simpleMatch) {
|
||||
const query = simpleMatch[1];
|
||||
@@ -37,8 +45,8 @@ export function detectPrefixQuery(
|
||||
for (let i = beforeCursor.length - 1; i >= scanStart; i--) {
|
||||
const ch = beforeCursor[i];
|
||||
if (ch === prefix) {
|
||||
// Ensure prefix is at start or preceded by whitespace
|
||||
if (i > 0 && !/\s/.test(beforeCursor[i - 1])) {
|
||||
// Ensure prefix is at start or preceded by whitespace/opening bracket
|
||||
if (i > 0 && !isBoundaryChar(beforeCursor[i - 1])) {
|
||||
continue;
|
||||
}
|
||||
const candidate = beforeCursor.slice(i + 1);
|
||||
|
||||
Reference in New Issue
Block a user