chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
227
third_party/pi/packages/tui/test/editor.test.ts
vendored
227
third_party/pi/packages/tui/test/editor.test.ts
vendored
@@ -1,11 +1,14 @@
|
||||
import assert from "node:assert";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts";
|
||||
import { Editor, wordWrapLine } from "../src/components/editor.ts";
|
||||
import type { TUI } from "../src/tui.ts";
|
||||
import { FAKE_CURSOR_START, renderFakeCursor, type TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
|
||||
import { defaultEditorTheme } from "./test-themes.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
@@ -797,7 +800,7 @@ describe("Editor component", () => {
|
||||
}
|
||||
|
||||
// Verify content split correctly
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim());
|
||||
assert.strictEqual(contentLines.length, 2);
|
||||
assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns
|
||||
assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding)
|
||||
@@ -829,7 +832,7 @@ describe("Editor component", () => {
|
||||
|
||||
// The cursor (reverse video space) should be visible
|
||||
const contentLine = lines[1]!;
|
||||
assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor");
|
||||
assert.ok(contentLine.includes(FAKE_CURSOR_START), "Should have fake cursor");
|
||||
|
||||
// Line should still be correct width
|
||||
assert.strictEqual(visibleWidth(contentLine), width);
|
||||
@@ -860,7 +863,7 @@ describe("Editor component", () => {
|
||||
let lines = editor.render(width + paddingX);
|
||||
let contentLines = lines.slice(1, -1);
|
||||
assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap");
|
||||
assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line");
|
||||
assert.ok(contentLines[0]!.endsWith(renderFakeCursor(" ")), "Cursor should be at end of line");
|
||||
|
||||
// Type 1 more → text wraps to second line
|
||||
editor.handleInput("a");
|
||||
@@ -880,7 +883,7 @@ describe("Editor component", () => {
|
||||
const lines = editor.render(width);
|
||||
|
||||
// Get content lines (between borders)
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
|
||||
const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim());
|
||||
|
||||
// Should NOT break mid-word
|
||||
// Line 1 should end with a complete word
|
||||
@@ -2129,6 +2132,218 @@ describe("Editor component", () => {
|
||||
});
|
||||
|
||||
describe("Autocomplete", () => {
|
||||
it("triggers and debounces symbol completion after CJK punctuation", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
for (const before of [
|
||||
"查看,",
|
||||
"\u3000",
|
||||
...",.:;!?()[]{}“”‘’…—。、「」『』《》【】",
|
||||
"(",
|
||||
"see (",
|
||||
"`",
|
||||
"[",
|
||||
]) {
|
||||
for (const trigger of ["@", "#", "$", "-"]) {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const requests: string[] = [];
|
||||
editor.setAutocompleteProvider({
|
||||
triggerCharacters: ["$", "-"],
|
||||
getSuggestions: async (lines, cursorLine, cursorCol) => {
|
||||
requests.push(lines[cursorLine]!.slice(0, cursorCol));
|
||||
return null;
|
||||
},
|
||||
applyCompletion,
|
||||
});
|
||||
editor.setText(before);
|
||||
editor.handleInput(trigger);
|
||||
t.mock.timers.tick(19);
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, []);
|
||||
t.mock.timers.tick(1);
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, [before + trigger]);
|
||||
|
||||
editor.handleInput("r");
|
||||
editor.handleInput("e");
|
||||
t.mock.timers.tick(19);
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(requests.length, 1);
|
||||
t.mock.timers.tick(1);
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, [before + trigger, `${before}${trigger}re`]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not auto-trigger after CJK letters or for unprefixed paths", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
let requests = 0;
|
||||
editor.setAutocompleteProvider({
|
||||
getSuggestions: async () => {
|
||||
requests++;
|
||||
return null;
|
||||
},
|
||||
applyCompletion,
|
||||
});
|
||||
for (const text of [
|
||||
"user@example.com",
|
||||
"张三@example.com",
|
||||
"查看@src",
|
||||
"あ@src",
|
||||
"カ@src",
|
||||
"한@src",
|
||||
"ㄅ@src",
|
||||
"𠮷@src",
|
||||
"か\u3099@src",
|
||||
"禰\u{e0100}@src",
|
||||
"々@src",
|
||||
"A@src",
|
||||
"文档@备份",
|
||||
"prefix#123",
|
||||
"foo(@src",
|
||||
"问题#123",
|
||||
"查看,/path/",
|
||||
"查看,./文档/",
|
||||
"src/index.ts",
|
||||
"./文档/说明.md",
|
||||
"文档/说明.md",
|
||||
"查看src/index.ts",
|
||||
]) {
|
||||
editor.setText("");
|
||||
for (const char of text) editor.handleInput(char);
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(requests, 0, text);
|
||||
}
|
||||
});
|
||||
|
||||
it("requests path completion after CJK punctuation only on Tab", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const requests: Array<{ text: string; force: boolean | undefined }> = [];
|
||||
editor.setAutocompleteProvider({
|
||||
getSuggestions: async (lines, cursorLine, cursorCol, options) => {
|
||||
requests.push({ text: lines[cursorLine]!.slice(0, cursorCol), force: options.force });
|
||||
return null;
|
||||
},
|
||||
applyCompletion,
|
||||
});
|
||||
const text = "查看,/path/";
|
||||
for (const char of text) editor.handleInput(char);
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, []);
|
||||
editor.handleInput("\t");
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, [{ text, force: true }]);
|
||||
});
|
||||
|
||||
it("completes Chinese path prefixes after whitespace or CJK punctuation with Tab", async (t) => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "pi-editor-autocomplete-"));
|
||||
t.after(() => rmSync(baseDir, { recursive: true, force: true }));
|
||||
mkdirSync(join(baseDir, "文档"));
|
||||
writeFileSync(join(baseDir, "文档", "说明.md"), "text");
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([], baseDir));
|
||||
for (const separator of [" ", "\t", "\u3000", "\u00a0", ",", "。"]) {
|
||||
editor.setText(`查看${separator}`);
|
||||
const before = editor.getText();
|
||||
editor.handleInput("文");
|
||||
editor.handleInput("\t");
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.getText(), `${before}文档/`);
|
||||
editor.handleInput("说");
|
||||
editor.handleInput("\t");
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.getText(), `${before}文档/说明.md`);
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length });
|
||||
}
|
||||
});
|
||||
|
||||
it("ends unquoted trigger and debounce contexts at whitespace or CJK punctuation", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
for (const separator of [" ", "\u3000", ",", "。"]) {
|
||||
for (const trigger of ["@", "#", "$"]) {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const requests: string[] = [];
|
||||
const prefix = `${trigger}src`;
|
||||
editor.setAutocompleteProvider({
|
||||
triggerCharacters: ["$"],
|
||||
getSuggestions: async (lines, cursorLine, cursorCol) => {
|
||||
const text = lines[cursorLine]!.slice(0, cursorCol);
|
||||
requests.push(text);
|
||||
return text === prefix ? { prefix, items: [{ value: `${prefix}/`, label: "src/" }] } : null;
|
||||
},
|
||||
applyCompletion,
|
||||
});
|
||||
editor.setText(`${trigger}sr`);
|
||||
editor.handleInput("c");
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||
editor.handleInput(separator);
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, [prefix, prefix + separator]);
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||
editor.handleInput("文");
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.deepStrictEqual(requests, [prefix, prefix + separator]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("re-triggers CJK path completion after accepting directories and deleting", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const provider = new CombinedAutocompleteProvider([], process.cwd());
|
||||
for (const directory of ["文档", "我的 文档", "资料,归档"]) {
|
||||
const quoted = directory !== "文档";
|
||||
const initial = quoted ? `@"${directory.slice(0, 2)}` : "@文";
|
||||
const directoryValue = quoted ? `@"${directory}/"` : `@${directory}/`;
|
||||
const fileValue = quoted ? `@"${directory}/说明.md"` : `@${directory}/说明.md`;
|
||||
const filePrefix = quoted ? `@"${directory}/说` : `@${directory}/说`;
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.setAutocompleteProvider({
|
||||
getSuggestions: async (lines, cursorLine, cursorCol) => {
|
||||
const before = lines[cursorLine]!.slice(0, cursorCol);
|
||||
const prefix = before.slice(before.indexOf("@"));
|
||||
if (prefix === initial) {
|
||||
return { prefix, items: [{ value: directoryValue, label: `${directory}/` }] };
|
||||
}
|
||||
return prefix === filePrefix ? { prefix, items: [{ value: fileValue, label: "说明.md" }] } : null;
|
||||
},
|
||||
applyCompletion: (...args) => provider.applyCompletion(...args),
|
||||
});
|
||||
editor.setText(`查看:${initial}`);
|
||||
editor.handleInput("\t");
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.getText(), `查看:${directoryValue}`);
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||
|
||||
editor.handleInput("说");
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||
|
||||
for (const deletion of ["\x7f", "\x1b[3~"]) {
|
||||
editor.handleInput("错");
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||
if (deletion === "\x1b[3~") editor.handleInput("\x1b[D");
|
||||
editor.handleInput(deletion);
|
||||
t.mock.timers.tick(20);
|
||||
await flushAutocomplete();
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||
}
|
||||
|
||||
editor.handleInput("\t");
|
||||
assert.strictEqual(editor.getText(), `查看:${fileValue} `);
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-applies single force-file suggestion without showing menu", async () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user