Search suggestions (#85)
The motivation for building search suggestions is two-fold: (1) to make the zoekt query language more approachable by presenting all available options to the user, and (2) make it easier for power-users to craft complex queries.
The meat-n-potatoes of this change are concentrated in searchBar.tsx and searchSuggestionBox.tsx. The suggestions box works by maintaining a state-machine of "modes". By default, the box is in the refine mode, where suggestions for different prefixes (e.g., repo:, lang:, etc.) are suggested to the user. When one of these prefixes is matched, the state-machine transitions to the corresponding mode (e.g., repository, language, etc.) and surfaces suggestions for that mode (if any).
The query is split up into parts by spaces " " (e.g., 'test repo:hello' -> ['test', 'repo:hello']). See splitQuery. The part that has the cursor over it is considered the active part. We evaluate which mode the state machine is in based on the active part. When a suggestion is clicked, we only modify the active part of the query.
Three modes are currently missing suggestion data: file (file names), revision (branch / tag names), and symbol (symbol names). In future PRs, we will need to introduce endpoints into the backend to allow the frontend to fetch this data and surface it as suggestions..
2024-11-23 02:50:13 +00:00
|
|
|
import { expect, test } from 'vitest'
|
|
|
|
|
import { completeSuggestion, splitQuery } from './searchSuggestionsBox'
|
|
|
|
|
|
|
|
|
|
test('splitQuery returns a single element when the query is empty', () => {
|
|
|
|
|
const { queryParts, cursorIndex } = splitQuery('', 0);
|
|
|
|
|
expect(cursorIndex).toEqual(0);
|
|
|
|
|
expect(queryParts).toEqual(['']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery splits on spaces', () => {
|
|
|
|
|
const query = String.raw`repo:^github\.com/example/example$ world`;
|
|
|
|
|
const { queryParts, cursorIndex } = splitQuery(query, 0);
|
|
|
|
|
expect(queryParts).toEqual(query.split(" "));
|
|
|
|
|
expect(cursorIndex).toEqual(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery groups parts that are in the same quote capture group into a single part', () => {
|
|
|
|
|
const part1 = 'lang:"1C Enterprise"';
|
|
|
|
|
const part2 = "hello";
|
|
|
|
|
const { queryParts, cursorIndex } = splitQuery(`${part1} ${part2}`, 12);
|
|
|
|
|
expect(queryParts).toEqual([part1, part2]);
|
|
|
|
|
expect(cursorIndex).toEqual(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery does not support nested quote capture groups', () => {
|
|
|
|
|
const { queryParts } = splitQuery('lang:"My language "with quotes"" hello', 0);
|
|
|
|
|
expect(queryParts).toEqual(['lang:"My language "with', 'quotes""', 'hello']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery groups all parts together when a quote capture group is not closed', () => {
|
|
|
|
|
const query = '"hello asdf ok'
|
|
|
|
|
const { queryParts, cursorIndex } = splitQuery(query, 0);
|
|
|
|
|
expect(queryParts).toEqual([query]);
|
|
|
|
|
expect(cursorIndex).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-16 18:59:01 +00:00
|
|
|
test('splitQuery correctly locates the cursor index given the cursor position (1)', () => {
|
Search suggestions (#85)
The motivation for building search suggestions is two-fold: (1) to make the zoekt query language more approachable by presenting all available options to the user, and (2) make it easier for power-users to craft complex queries.
The meat-n-potatoes of this change are concentrated in searchBar.tsx and searchSuggestionBox.tsx. The suggestions box works by maintaining a state-machine of "modes". By default, the box is in the refine mode, where suggestions for different prefixes (e.g., repo:, lang:, etc.) are suggested to the user. When one of these prefixes is matched, the state-machine transitions to the corresponding mode (e.g., repository, language, etc.) and surfaces suggestions for that mode (if any).
The query is split up into parts by spaces " " (e.g., 'test repo:hello' -> ['test', 'repo:hello']). See splitQuery. The part that has the cursor over it is considered the active part. We evaluate which mode the state machine is in based on the active part. When a suggestion is clicked, we only modify the active part of the query.
Three modes are currently missing suggestion data: file (file names), revision (branch / tag names), and symbol (symbol names). In future PRs, we will need to introduce endpoints into the backend to allow the frontend to fetch this data and surface it as suggestions..
2024-11-23 02:50:13 +00:00
|
|
|
const query = 'foo bar "fizz buzz"';
|
|
|
|
|
|
|
|
|
|
const { queryParts: parts1, cursorIndex: index1 } = splitQuery(query, 0);
|
|
|
|
|
expect(parts1).toEqual(['foo', 'bar', '"fizz buzz"']);
|
|
|
|
|
expect(parts1[index1]).toBe('foo');
|
|
|
|
|
|
|
|
|
|
const { queryParts: parts2, cursorIndex: index2 } = splitQuery(query, 6);
|
|
|
|
|
expect(parts2).toEqual(['foo', 'bar', '"fizz buzz"']);
|
|
|
|
|
expect(parts2[index2]).toBe('bar');
|
|
|
|
|
|
|
|
|
|
const { queryParts: parts3, cursorIndex: index3 } = splitQuery(query, 15);
|
|
|
|
|
expect(parts3).toEqual(['foo', 'bar', '"fizz buzz"']);
|
|
|
|
|
expect(parts3[index3]).toBe('"fizz buzz"');
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-16 18:59:01 +00:00
|
|
|
test('splitQuery correctly locates the cursor index given the cursor position (2)', () => {
|
Search suggestions (#85)
The motivation for building search suggestions is two-fold: (1) to make the zoekt query language more approachable by presenting all available options to the user, and (2) make it easier for power-users to craft complex queries.
The meat-n-potatoes of this change are concentrated in searchBar.tsx and searchSuggestionBox.tsx. The suggestions box works by maintaining a state-machine of "modes". By default, the box is in the refine mode, where suggestions for different prefixes (e.g., repo:, lang:, etc.) are suggested to the user. When one of these prefixes is matched, the state-machine transitions to the corresponding mode (e.g., repository, language, etc.) and surfaces suggestions for that mode (if any).
The query is split up into parts by spaces " " (e.g., 'test repo:hello' -> ['test', 'repo:hello']). See splitQuery. The part that has the cursor over it is considered the active part. We evaluate which mode the state machine is in based on the active part. When a suggestion is clicked, we only modify the active part of the query.
Three modes are currently missing suggestion data: file (file names), revision (branch / tag names), and symbol (symbol names). In future PRs, we will need to introduce endpoints into the backend to allow the frontend to fetch this data and surface it as suggestions..
2024-11-23 02:50:13 +00:00
|
|
|
const query = 'a b';
|
|
|
|
|
expect(splitQuery(query, 0).cursorIndex).toBe(0);
|
|
|
|
|
expect(splitQuery(query, 1).cursorIndex).toBe(0);
|
|
|
|
|
expect(splitQuery(query, 2).cursorIndex).toBe(1);
|
|
|
|
|
expect(splitQuery(query, 3).cursorIndex).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery can handle multiple spaces adjacent', () => {
|
|
|
|
|
expect(splitQuery("a b ", 0).queryParts).toEqual(['a', '', '', 'b', '', '']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery locates the cursor index to the last query part when the cursor position is at the end of the query', () => {
|
|
|
|
|
const query = "as df";
|
|
|
|
|
const cursorPos = query.length;
|
|
|
|
|
const { queryParts, cursorIndex } = splitQuery(query, cursorPos);
|
|
|
|
|
expect(cursorIndex).toBe(queryParts.length - 1);
|
|
|
|
|
expect(queryParts[cursorIndex]).toBe("df");
|
|
|
|
|
expect(queryParts).toEqual(['as', 'df']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('splitQuery sets the cursor index to 0 when the cursor position is out of bounds', () => {
|
|
|
|
|
const query = "hello world";
|
|
|
|
|
const cursorPos = query.length + 1;
|
|
|
|
|
const { queryParts, cursorIndex } = splitQuery(query, cursorPos);
|
|
|
|
|
expect(cursorIndex).toBe(0);
|
|
|
|
|
expect(queryParts[cursorIndex]).toBe("hello");
|
|
|
|
|
expect(queryParts).toEqual(['hello', 'world']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestion can complete a empty query', () => {
|
|
|
|
|
const suggestionQuery = ``;
|
|
|
|
|
const query = ``;
|
|
|
|
|
const suggestion = "hello";
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery,
|
|
|
|
|
suggestion,
|
|
|
|
|
trailingSpace: false,
|
|
|
|
|
regexEscaped: false,
|
|
|
|
|
cursorPosition: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedNewQuery = String.raw`hello`;
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe(newQuery.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestion can complete with a empty suggestion query', () => {
|
|
|
|
|
const suggestionQuery = ``;
|
|
|
|
|
const query = `case:`;
|
|
|
|
|
const suggestion = "auto";
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery,
|
|
|
|
|
suggestion,
|
|
|
|
|
trailingSpace: false,
|
|
|
|
|
regexEscaped: false,
|
|
|
|
|
cursorPosition: query.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedNewQuery = `case:auto`;
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe(newQuery.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestion inserts a trailing space when trailingSpace is true and the completion is at the end of the query', () => {
|
|
|
|
|
const suggestionQuery = 'a';
|
|
|
|
|
const part1 = String.raw`lang:Go`;
|
|
|
|
|
const part2 = String.raw`case:${suggestionQuery}`;
|
|
|
|
|
const query = `${part1} ${part2}`
|
|
|
|
|
const suggestion = 'auto';
|
|
|
|
|
const cursorPosition = query.length;
|
|
|
|
|
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery,
|
|
|
|
|
suggestion,
|
|
|
|
|
trailingSpace: true,
|
|
|
|
|
regexEscaped: false,
|
|
|
|
|
cursorPosition,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedPart2 = `case:auto`
|
|
|
|
|
const expectedNewQuery = `${part1} ${expectedPart2} `;
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe(newQuery.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestion does not insert a trailing space when trailingSpace is true and the completion is not at the end of the query', () => {
|
|
|
|
|
const suggestionQuery = 'G';
|
|
|
|
|
const part1 = String.raw`lang:${suggestionQuery}`;
|
|
|
|
|
const part2 = String.raw`case:auto`;
|
|
|
|
|
const query = `${part1} ${part2}`
|
|
|
|
|
const suggestion = 'Go';
|
|
|
|
|
const cursorPosition = part1.length;
|
|
|
|
|
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery,
|
|
|
|
|
suggestion,
|
|
|
|
|
trailingSpace: true,
|
|
|
|
|
regexEscaped: false,
|
|
|
|
|
cursorPosition,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedPart1 = `lang:Go`
|
|
|
|
|
const expectedNewQuery = `${expectedPart1} ${part2}`; // Notice no trailing space
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe(expectedPart1.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestion wraps suggestions in quotes when the suggestion contains a space and regexEscaped is false', () => {
|
|
|
|
|
const suggestionQuery = `m`;
|
|
|
|
|
const query = `lang:${suggestionQuery}`;
|
|
|
|
|
const suggestion = `my language`;
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery,
|
|
|
|
|
suggestion,
|
|
|
|
|
trailingSpace: false,
|
|
|
|
|
regexEscaped: false,
|
|
|
|
|
cursorPosition: query.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedNewQuery = `lang:"my language"`;
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe(newQuery.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestion completes on query parts that are inbetween other parts', () => {
|
|
|
|
|
const part1 = String.raw`repo:^github\.com/sourcebot\x2ddev/sourcebot$`;
|
|
|
|
|
const suggestionQuery = 'Type';
|
|
|
|
|
const part2 = String.raw`lang:${suggestionQuery}`;
|
|
|
|
|
const part3 = String.raw`case:auto`;
|
|
|
|
|
const query = `${part1} ${part2} ${part3}`;
|
|
|
|
|
const suggestion = 'TypeScript';
|
|
|
|
|
const cursorPosition = ([part1, part2].join(" ").length);
|
|
|
|
|
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery,
|
|
|
|
|
suggestion,
|
|
|
|
|
trailingSpace: false,
|
|
|
|
|
regexEscaped: false,
|
|
|
|
|
cursorPosition,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedPart2 = "lang:TypeScript";
|
|
|
|
|
const expectedNewQuery = String.raw`${part1} ${expectedPart2} ${part3}`;
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe([part1, expectedPart2].join(" ").length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('completeSuggestions regex escapes suggestions when regexEscaped is true', () => {
|
|
|
|
|
const query = "repo:github";
|
|
|
|
|
const { newQuery, newCursorPosition } = completeSuggestion({
|
|
|
|
|
query,
|
|
|
|
|
suggestionQuery: "github",
|
|
|
|
|
suggestion: "github.com/sourcebot-dev/sourcebot",
|
|
|
|
|
trailingSpace: true,
|
|
|
|
|
regexEscaped: true,
|
|
|
|
|
cursorPosition: query.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const expectedNewQuery = String.raw`repo:^github\.com/sourcebot\x2ddev/sourcebot$ `;
|
|
|
|
|
expect(newQuery).toEqual(expectedNewQuery);
|
|
|
|
|
expect(newCursorPosition).toBe(newQuery.length);
|
|
|
|
|
});
|