From b036b07c17bf4b76baac6cf8a43225d040360780 Mon Sep 17 00:00:00 2001 From: NA <221035391+Nagi-Inaba@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:11:59 +0000 Subject: [PATCH] Defer shell searches until IME composition is committed --- .github/workflows/ci.yml | 2 + shell/tests/composition.test.cjs | 127 +++++++++++++++++++++++++++++++ shell/ui/panel.js | 22 +++++- shell/ui/window.js | 18 ++++- 4 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 shell/tests/composition.test.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 470777cb..b1504bf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/shell/tests/composition.test.cjs b/shell/tests/composition.test.cjs new file mode 100644 index 00000000..3c5d8a6b --- /dev/null +++ b/shell/tests/composition.test.cjs @@ -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); + }); +} diff --git a/shell/ui/panel.js b/shell/ui/panel.js index d6e415ab..a928069e 100644 --- a/shell/ui/panel.js +++ b/shell/ui/panel.js @@ -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:") @@ -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); @@ -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(() => { @@ -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); } @@ -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": diff --git a/shell/ui/window.js b/shell/ui/window.js index db28c086..5cf22828 100644 --- a/shell/ui/window.js +++ b/shell/ui/window.js @@ -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 @@ -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; @@ -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; @@ -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(); @@ -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") {