Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,5 @@ jobs:
- name: Test
working-directory: shell/src-tauri
run: cargo test
- name: Shell input tests
run: node --test shell/tests/*.test.cjs
127 changes: 127 additions & 0 deletions shell/tests/composition.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Run with node --test shell/tests/*.test.cjs. No browser or model needed.
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const { test } = require('node:test');
const vm = require('node:vm');

// Exercise the real search functions and input bindings. Only the DOM, clock and
// helper IPC boundary are replaced; requests and rendered replies are observable.
function field(view) {
const source = readFileSync(join(__dirname, '../ui', `${view}.js`), 'utf8');
const listeners = new Map(), timers = new Map(), requests = [], visible = [], errors = [];
const query = { value: '', addEventListener: (type, fn) => listeners.set(type, fn) };
const elements = new Map([['query', query]]);
let nextTimer = 0;
const context = vm.createContext({
$query: query, $: (id) => {
if (!elements.has(id)) elements.set(id, {});
return elements.get(id);
},
$kinds: {}, MOD: 'Ctrl+', TYPING_PAUSE: 120, FIELD_HEIGHT: 56, LIMIT: 120, FILTERS: [], FILTER_OF_KIND: {},
closeBig() {}, renderBar() {}, renderContent() {}, renderStatus() { errors.push('error'); },
render() { visible.push('render'); }, rows: () => [],
show(reply) { visible.push(reply); }, failed(error) { errors.push(error); },
setTimeout(fn) { const id = ++nextTimer; timers.set(id, fn); return id; },
clearTimeout(id) { timers.delete(id); },
ask(request) {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
requests.push({ ...request, resolve, reject });
if (request.op !== 'search') resolve({});
return promise;
},
});
vm.runInContext(source.slice(source.indexOf('const state = {'), source.indexOf('// MARK: Searching')), context);
const start = source.indexOf(view === 'panel' ? 'function queryChanged(' : 'function isCode(');
const end = source.indexOf(view === 'panel' ? '\nfunction show(' : '\n/// Every result shown');
vm.runInContext(source.slice(start, end), context);
const events = source.indexOf('$query.addEventListener("input"');
vm.runInContext(source.slice(events, source.indexOf('document.addEventListener("keydown"', events)), context);
return {
query, requests, visible, errors,
event(type, options = {}) { listeners.get(type)?.({ type, ...options }); },
input(text, options) { query.value = text; this.event('input', options); },
flush() { const pending = [...timers.values()]; timers.clear(); for (const fn of pending) fn(); },
programmatic(text) { query.value = text; vm.runInContext('queryChanged()', context); },
handedOver(text) { query.value = text; context.initialQuery = text; vm.runInContext('state.query = initialQuery; run()', context); },
submitted() { return vm.runInContext('state.query', context); },
};
}

test('window: an initial search handed over by the panel can be cleared', () => {
const f = field('window');
f.handedOver('dog');
f.input(''); f.flush();
assert.equal(f.submitted(), '');
assert.equal(f.requests.filter(r => r.op === 'search').length, 0);
});

const settle = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); };
for (const view of ['panel', 'window']) {
test(`${view}: composition does not search intermediate Japanese text`, () => {
const f = field(view);
f.event('compositionstart');
f.input('とう', { isComposing: true }); f.flush();
f.input('とうきょう', { isComposing: true }); f.flush();
assert.equal(f.requests.length, 0);
assert.equal(f.query.value, 'とうきょう');
});

for (const finalInputFirst of [false, true]) {
test(`${view}: committed text searches once (final input first: ${finalInputFirst})`, () => {
const f = field(view);
f.event('compositionstart');
f.input('とうきょう', { isComposing: true });
f.query.value = '東京';
if (finalInputFirst) f.event('input', { isComposing: false });
f.event('compositionend');
if (!finalInputFirst) f.event('input', { isComposing: false });
f.flush();
assert.deepEqual(f.requests.filter(r => r.op === 'embed_ahead').map(r => r.q), ['東京']);
assert.deepEqual(f.requests.filter(r => r.op === 'search' && r.meaning).map(r => r.q), ['東京']);
});
}

test(`${view}: composition cancels the previous debounce`, () => {
const f = field(view);
f.input('old');
const before = f.requests.length;
f.event('compositionstart'); f.flush();
assert.equal(f.requests.length, before);
});

for (const fails of [false, true]) {
test(`${view}: late ${fails ? 'errors' : 'results'} do not interrupt composition`, async () => {
const f = field(view);
f.input('old'); f.flush();
const pending = f.requests.filter(r => r.op === 'search');
const before = f.visible.length;
f.event('compositionstart');
for (const r of pending) {
if (fails) r.reject(new Error('old request failed'));
else r.resolve({ hits: [], strong: 0, meaning: true });
}
await settle();
assert.equal(f.visible.length, before);
assert.equal(f.errors.length, 0);
});
}

test(`${view}: cancelled composition resumes the previous query`, () => {
const f = field(view);
f.input('dog');
f.event('compositionstart'); f.input('ど', { isComposing: true });
f.query.value = 'dog'; f.event('compositionend'); f.event('input'); f.flush();
assert.deepEqual(f.requests.filter(r => r.op === 'search' && r.meaning).map(r => r.q), ['dog']);
});

test(`${view}: ordinary typing and programmatic searches still work`, () => {
const f = field(view);
f.input('dog'); f.flush();
f.programmatic('dog'); f.flush();
assert.deepEqual(f.requests.filter(r => r.op === 'search' && r.meaning).map(r => r.q), ['dog', 'dog']);
f.input(''); f.flush();
assert.equal(f.requests.filter(r => r.op === 'search' && r.meaning).length, 2);
});
}
22 changes: 18 additions & 4 deletions shell/ui/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const state = {
generation: 0,
finalGeneration: -1, // the generation whose meaning results are showing: late keyword results don't replace them
timer: null,
composing: false,
input: "", // last submitted field value: the input after compositionend is redundant
phase: "empty", // empty | words | meaning | wordsOnly | codeHint
hasCode: true, // code search has folders (the state's hasCode)
text: "", // what was searched (without "code:")
Expand All @@ -45,7 +47,10 @@ let debugPending = false;

// MARK: Searching

function queryChanged() {
function queryChanged(event) {
if (state.composing || event?.isComposing) return;
if (event?.type === "input" && $query.value === state.input) return;
state.input = $query.value;
closeBig();
const generation = ++state.generation;
clearTimeout(state.timer);
Expand All @@ -64,7 +69,7 @@ function queryChanged() {
.then((reply) => {
if (generation === state.generation && state.finalGeneration !== generation) show(reply, text, "words");
})
.catch(failed);
.catch((error) => { if (generation === state.generation) failed(error); });
// The meaning is embedded now, so its vector is ready when typing pauses: the pause is the whole wait.
ask({ op: "embed_ahead", q: text, code }).catch(() => {});
state.timer = setTimeout(() => {
Expand All @@ -75,7 +80,7 @@ function queryChanged() {
show(reply, text, reply.meaning ? "meaning" : "wordsOnly");
if (debugPending) report();
})
.catch(failed);
.catch((error) => { if (generation === state.generation) failed(error); });
}, TYPING_PAUSE);
}

Expand Down Expand Up @@ -431,9 +436,18 @@ function message(title, text) {
// MARK: Keys and events

$query.addEventListener("input", queryChanged);
$query.addEventListener("compositionstart", () => {
state.composing = true;
clearTimeout(state.timer);
++state.generation; // replies for the previous query must not interrupt conversion
});
$query.addEventListener("compositionend", () => {
state.composing = false;
queryChanged();
});

document.addEventListener("keydown", (event) => {
if (event.isComposing) return; // an input method is composing: its keys
if (state.composing || event.isComposing) return; // an input method is composing: its keys
const modifier = isMac ? event.metaKey : event.ctrlKey;
switch (event.key) {
case "Escape":
Expand Down
18 changes: 16 additions & 2 deletions shell/ui/window.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const state = {
navigated: false, // the selection was moved by the arrow keys or a click since the last edit: ← → move it then
generation: 0,
timer: null,
composing: false,
error: null,
info: { folders: [], hotkey: null, hasCode: false },
app: {}, // indexing and the model, from the shell (status.rs): line, progress, caveat, counts
Expand All @@ -62,6 +63,7 @@ function isCode() {
/// The query or the filter changed: after a short pause in typing, the search (and the same search of one kind, for a
/// filter), all at once. The query's meaning is embedded on every keystroke, so the pause is the whole wait.
function run() {
if (state.composing) return;
const generation = ++state.generation;
clearTimeout(state.timer);
state.navigated = false;
Expand Down Expand Up @@ -111,7 +113,10 @@ function setQuery(text) {
queryChanged();
}

function queryChanged() {
function queryChanged(event) {
if (state.composing || event?.isComposing) return;
// compositionend already submitted this value; ignore its trailing input event.
if (event?.type === "input" && $query.value === state.query) return;
closeBig();
state.query = $query.value;
$("clear").hidden = !state.query;
Expand Down Expand Up @@ -953,6 +958,15 @@ window.addEventListener("blur", hideMenu);
// MARK: Keys and events

$query.addEventListener("input", queryChanged);
$query.addEventListener("compositionstart", () => {
state.composing = true;
clearTimeout(state.timer);
++state.generation; // replies for the previous query must not interrupt conversion
});
$query.addEventListener("compositionend", () => {
state.composing = false;
queryChanged();
});
// A click back in the field: ← → move the cursor again (after picking a result, they moved the selection).
$query.addEventListener("mousedown", () => { state.navigated = false; });
$("clear").onmousedown = (event) => event.preventDefault();
Expand Down Expand Up @@ -980,7 +994,7 @@ function updatePreviewButton() {
}

document.addEventListener("keydown", (event) => {
if (event.isComposing) return; // an input method is composing: its keys
if (state.composing || event.isComposing) return; // an input method is composing: its keys
const modifier = isMac ? event.metaKey : event.ctrlKey;
const plain = !event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey;
if (!$menu.hidden && event.key === "Escape") {
Expand Down