mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e3f34030f | ||
|
|
94cf07a822 | ||
|
|
8e29e79a2e |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { reconcileAutocompletePopupState, type AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
/**
|
||||
* Regression test for #3426: hovering autocomplete rows must not change the
|
||||
@@ -10,7 +11,7 @@ import { JSDOM } from "jsdom";
|
||||
* the hovered row and self-oscillating (violent mousemove jitter).
|
||||
*/
|
||||
|
||||
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async () => {
|
||||
test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost",
|
||||
@@ -194,6 +195,38 @@ test("autocomplete popup keeps a fixed geometry while hovering rows (#3426)", as
|
||||
}
|
||||
}
|
||||
|
||||
await t.test("edited snippet rows cannot be clicked before the provider refresh", async () => {
|
||||
let clicks = 0;
|
||||
const state: AutocompleteState = {
|
||||
suggestions: [{ text: "deploy", displayText: "deploy", source: "snippet", score: 2000,
|
||||
snippet: { id: "deploy", label: "deploy", command: "echo deploy" } }],
|
||||
selectedIndex: -1, popupVisible: true,
|
||||
popupAnchorViewport: { left: 100, top: 300, bottom: 316 },
|
||||
expandUpward: false, subDirPanels: [], subDirFocusLevel: -1,
|
||||
};
|
||||
const renderPopup = async (next: AutocompleteState) => {
|
||||
await act(async () => {
|
||||
root.render(<AutocompletePopup
|
||||
suggestions={next.suggestions} selectedIndex={next.selectedIndex}
|
||||
anchorViewport={next.popupAnchorViewport} visible={next.popupVisible}
|
||||
onSelect={() => { clicks++; }} containerRef={containerRef}
|
||||
/>);
|
||||
});
|
||||
};
|
||||
await renderPopup(state);
|
||||
const label = Array.from(rootNode.querySelectorAll<HTMLElement>("span"))
|
||||
.find((el) => el.textContent === "deploy");
|
||||
const row = label?.closest<HTMLElement>("div[style*='cursor']");
|
||||
assert.ok(row);
|
||||
await act(async () => {
|
||||
row.dispatchEvent(new window.MouseEvent("mousedown", { bubbles: true }));
|
||||
});
|
||||
assert.equal(clicks, 1, "an unselected fresh snippet is clickable");
|
||||
await renderPopup(reconcileAutocompletePopupState(state, "git pull xyz"));
|
||||
assert.equal(Boolean(rootNode.querySelector(".xterm-autocomplete-popup")), false);
|
||||
assert.equal(clicks, 1);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -406,16 +406,32 @@ export async function getCompletions(
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets: only at the command position (typing the command name).
|
||||
// Snippets: at the command position the whole typed line is the needle. Once
|
||||
// arguments are present, match against the word being typed so a snippet
|
||||
// (e.g. an email shortcut) stays reachable after a command like `git pull `
|
||||
// (#3622). Argument-position matching is prefix-only, needs at least two
|
||||
// typed characters, and yields when path completion owns the slot.
|
||||
// Push without the early seen-text skip: snippets score above history, so if
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
})) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
if (options.snippets && options.snippets.length > 0) {
|
||||
const atCommandPosition = ctx.wordIndex === 0;
|
||||
const argumentNeedle = normalizeHistoryPathPrefix(ctx.currentWord);
|
||||
const snippetArgumentMatch = !atCommandPosition
|
||||
&& !preferPathSuggestions
|
||||
&& argumentNeedle.trim().length >= 2;
|
||||
if (atCommandPosition || snippetArgumentMatch) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(
|
||||
atCommandPosition ? input : argumentNeedle,
|
||||
options.snippets,
|
||||
{
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
prefixOnly: !atCommandPosition,
|
||||
},
|
||||
)) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
|
||||
import { matchesSearchQuery } from "../../../lib/searchMatcher";
|
||||
import { matchesSearchQuery, matchesWordPrefixQuery } from "../../../lib/searchMatcher";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
@@ -30,7 +30,7 @@ function snippetAvailableForAutocomplete(
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string; hostGroup?: string } = {},
|
||||
options: { hostId?: string; hostGroup?: string; prefixOnly?: boolean } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
@@ -44,10 +44,16 @@ export function getSnippetSuggestions(
|
||||
const labelPrefix = label.startsWith(needle);
|
||||
// Literal prefix/substring first (cheap); fall back to shared smart matcher
|
||||
// so Chinese titles surface for pinyin / initials the same way host search does.
|
||||
const matches = labelPrefix
|
||||
|| label.includes(needle)
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesSearchQuery(needle, snippet.label, firstLine);
|
||||
// In prefixOnly mode (argument position), matches must start at a word
|
||||
// boundary to avoid flooding mid-argument popups; pinyin still applies.
|
||||
const matches = options.prefixOnly
|
||||
? labelPrefix
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesWordPrefixQuery(needle, snippet.label, firstLine)
|
||||
: labelPrefix
|
||||
|| label.includes(needle)
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesSearchQuery(needle, snippet.label, firstLine);
|
||||
if (!matches) continue;
|
||||
|
||||
out.push({
|
||||
|
||||
@@ -144,8 +144,8 @@ function isSubsequence(query: string, target: string): boolean {
|
||||
|
||||
/**
|
||||
* Remove history rows that no longer match the edited input while a debounced
|
||||
* provider refresh is pending. Other sources keep their provider-specific
|
||||
* matching semantics (snippet search, plugin replacement, etc.).
|
||||
* provider refresh is pending. Snippets must be re-queried for the new input
|
||||
* before they can be selected or executed. Other sources retain their matching.
|
||||
*/
|
||||
export function reconcileAutocompletePopupState(
|
||||
prev: AutocompleteState,
|
||||
@@ -158,6 +158,7 @@ export function reconcileAutocompletePopupState(
|
||||
const inputContext = parseCommandLine(input);
|
||||
const allowFuzzyHistory = input.length >= 2 && inputContext.wordIndex === 0;
|
||||
const suggestions = prev.suggestions.filter((suggestion) => {
|
||||
if (suggestion.source === "snippet") return false;
|
||||
if (suggestion.source !== "history") return true;
|
||||
const text = suggestion.text.toLowerCase();
|
||||
if (suggestion.historyMatch === "path-argument") {
|
||||
|
||||
@@ -14,8 +14,28 @@ test("getCompletions includes snippet suggestions at the command position", asyn
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets past the command position", async () => {
|
||||
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
|
||||
test("getCompletions surfaces prefix-matching snippets while typing an argument (#3622)", async () => {
|
||||
const out = await getCompletions("git pull dep", { snippets: [deploySnippet] });
|
||||
const snip = out.find((s) => s.source === "snippet");
|
||||
assert.ok(snip, "expected a snippet suggestion while typing an argument");
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets for mid-word argument matches", async () => {
|
||||
// Argument-position matching works on word boundaries only: "mai" sits
|
||||
// mid-word in "gmail" and must not surface the snippet.
|
||||
const substringSnippet: Snippet = { id: "gm", label: "gmail", command: "echo gmail" };
|
||||
const out = await getCompletions("git push mai", { snippets: [substringSnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets for single-character arguments", async () => {
|
||||
const out = await getCompletions("git pull d", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions yields argument-position snippets to path completion", async () => {
|
||||
const out = await getCompletions("cat dep", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,3 +111,31 @@ test("matches Chinese labels by pinyin and initials (smart suggest)", () => {
|
||||
assert.equal(getSnippetSuggestions("bushu", [s], {})[0]?.snippet?.id, "zh");
|
||||
assert.equal(getSnippetSuggestions("bsfw", [s], {})[0]?.snippet?.id, "zh");
|
||||
});
|
||||
|
||||
test("prefixOnly mode matches at word boundaries (argument position, #3622)", () => {
|
||||
const s = snip({ id: "mail", label: "set-email", command: "git config user.email a@b.c" });
|
||||
// First 3 letters of the label's last word still match while typing an
|
||||
// argument, e.g. after `git pull `.
|
||||
assert.equal(getSnippetSuggestions("ema", [s], { prefixOnly: true })[0]?.snippet?.id, "mail");
|
||||
// Mid-word substrings do not match.
|
||||
const gmail = snip({ id: "gm", label: "gmail", command: "echo gmail" });
|
||||
assert.deepEqual(getSnippetSuggestions("mai", [gmail], { prefixOnly: true }), []);
|
||||
});
|
||||
|
||||
test("prefixOnly mode matches Chinese labels via pinyin and initials", () => {
|
||||
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply" });
|
||||
assert.equal(getSnippetSuggestions("bush", [s], { prefixOnly: true })[0]?.snippet?.id, "zh");
|
||||
const mailbox = snip({ id: "yx", label: "邮箱", command: "user@example.com" });
|
||||
assert.equal(getSnippetSuggestions("yx", [mailbox], { prefixOnly: true })[0]?.snippet?.id, "yx");
|
||||
});
|
||||
|
||||
test("prefixOnly mode does not match option-prefixed needles (#3623)", () => {
|
||||
// The pinyin fallback must not strip the leading "--"/"-" punctuation:
|
||||
// "--ve" (from `git --ve`) would otherwise match "version", and "-d"
|
||||
// (from `git -d`) match "deploy" via a single typed letter, displacing
|
||||
// the real Fig option completions while typing flags.
|
||||
const version = snip({ id: "v", label: "version", command: "echo version" });
|
||||
const deploy = snip({ id: "d", label: "deploy", command: "kubectl apply" });
|
||||
assert.deepEqual(getSnippetSuggestions("--ve", [version], { prefixOnly: true }), []);
|
||||
assert.deepEqual(getSnippetSuggestions("-d", [deploy], { prefixOnly: true }), []);
|
||||
});
|
||||
@@ -177,7 +177,7 @@ test("path history follows the current argument instead of the whole line", () =
|
||||
assert.equal(changedArgument.popupVisible, false);
|
||||
});
|
||||
|
||||
test("provider-specific non-history rows remain until their debounced refresh", () => {
|
||||
test("snippet rows are removed synchronously while their next query is pending", () => {
|
||||
const snippet: CompletionSuggestion = {
|
||||
text: "deploy production",
|
||||
displayText: "deploy production",
|
||||
@@ -186,7 +186,8 @@ test("provider-specific non-history rows remain until their debounced refresh",
|
||||
};
|
||||
|
||||
const next = reconcileAutocompletePopupState(popupState([snippet]), "dp");
|
||||
assert.deepEqual(next.suggestions, [snippet]);
|
||||
assert.deepEqual(next.suggestions, []);
|
||||
assert.equal(next.popupVisible, false);
|
||||
assert.equal(next.selectedIndex, -1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import type { AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
|
||||
|
||||
const suggestion = (text: string) => ({
|
||||
@@ -348,3 +350,32 @@ test("subdir Escape does not arm Meta prefix", () => {
|
||||
assert.equal(context.escMetaPrefixUntilRef.current, 0);
|
||||
});
|
||||
|
||||
|
||||
for (const edited of [false, true]) {
|
||||
test(`snippet navigation before provider refresh: edited=${edited}`, () => {
|
||||
let runs = 0;
|
||||
const initial: AutocompleteState = {
|
||||
suggestions: [{
|
||||
text: "deploy", displayText: "deploy", source: "snippet", score: 2000,
|
||||
snippet: { id: "deploy", label: "deploy", command: "echo deploy" },
|
||||
}],
|
||||
selectedIndex: -1, popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false, subDirPanels: [], subDirFocusLevel: -1,
|
||||
};
|
||||
const stateRef = { current: edited ? reconcileAutocompletePopupState(initial, "git pull xyz") : initial };
|
||||
const { context } = createContext({
|
||||
stateRef,
|
||||
setState(update: AutocompleteState | ((prev: AutocompleteState) => AutocompleteState)) {
|
||||
stateRef.current = typeof update === "function" ? update(stateRef.current) : update;
|
||||
},
|
||||
acceptSnippet() { runs++; return true; },
|
||||
});
|
||||
// An unselected snippet does not execute on direct Enter.
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Enter"), context);
|
||||
assert.equal(runs, 0);
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("ArrowDown"), context);
|
||||
handleTerminalAutocompleteKeyEvent(keyEvent("Enter"), context);
|
||||
assert.equal(runs, edited ? 0 : 1);
|
||||
});
|
||||
}
|
||||
@@ -138,6 +138,45 @@ export function matchesSearchQuery(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-prefix matcher: the query must prefix-match a whole token (word) of one
|
||||
* of the fields — including that token's pinyin full/initials forms. Strictly
|
||||
* weaker than matchesSearchQuery's substring behavior; used where substring
|
||||
* noise is undesirable, e.g. snippet completions while typing an argument
|
||||
* (issue #3622). Dashes/underscores separate tokens, so "set-email" still
|
||||
* matches from the word "email".
|
||||
*/
|
||||
export function matchesWordPrefixQuery(
|
||||
query: string,
|
||||
...fields: Array<string | null | undefined>
|
||||
): boolean {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const words = fields
|
||||
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
|
||||
.flatMap((field) => normalizeText(field).split(SEARCH_SPLIT_REGEX))
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 0) return false;
|
||||
|
||||
// The compact/pinyin fallback must not discard the query's leading
|
||||
// punctuation: option-style needles like "--ve" or "-d" would otherwise
|
||||
// match words ("version", "deploy") that never literally start with them
|
||||
// and displace the real Fig option completions while typing flags.
|
||||
const compactQuery = /^[a-z0-9]/.test(normalizedQuery)
|
||||
? compactText(normalizedQuery)
|
||||
: "";
|
||||
return words.some((word) => {
|
||||
if (word.startsWith(normalizedQuery)) return true;
|
||||
if (!compactQuery) return false;
|
||||
const { full, initials } = getPinyinVariants(word);
|
||||
return (
|
||||
(full !== "" && full.startsWith(compactQuery))
|
||||
|| (initials !== "" && initials.startsWith(normalizedQuery))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Host search should avoid mixing label/group tokens with hostname/IP tokens.
|
||||
* Otherwise queries like "山东 6-1" can accidentally match:
|
||||
|
||||
Reference in new issue
Block a user