From 4a94eb9ba74156500a01177b1869232e7adcf335 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 04:08:32 +0200 Subject: [PATCH] fix(tabs): lower-case search text without the browser locale (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodemanTabSearch lower-cased the query and each row with toLocaleLowerCase(), i.e. the browser's locale. Under a Turkish or Azeri locale "API Review" lowers to "apı review", so a search for "api" missed it, and the sidebar filter box, which used locale-independent toLowerCase() before #580, changed matching with it. Both now use toLowerCase(), like every other frontend search. Test: a vm context of its own whose toLocaleLowerCase behaves like the Turkish locale (the prototype is that context's alone) checks that "API" still normalizes to "api" and that "API Review" matches. The test file's overview also lists the pins the #580 fixes added. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 6 ++++-- test/tab-rail-search.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 6c8de2e7..ea38016b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1023,11 +1023,13 @@ function tabClusterNameSplit(name, label) { /** * Session-list search: the vertical rail's search box and the sidebar's filter * box. Trimmed, case-insensitive substring; a whitespace-only query is no query. + * Lower-cased with toLowerCase(), never toLocaleLowerCase(): under a Turkish or + * Azeri browser locale "API" lowers to "apı" and a search for "api" would miss it. * @param {unknown} query * @returns {string} the needle, '' when there is nothing to search for */ function tabSearchNeedle(query) { - return typeof query === 'string' ? query.trim().toLocaleLowerCase() : ''; + return typeof query === 'string' ? query.trim().toLowerCase() : ''; } /** @@ -1056,7 +1058,7 @@ function filterTabSearchRows(rows, query) { for (const row of Array.isArray(rows) ? rows : []) { const hasSection = row.section !== null && row.section !== undefined; if (hasSection && !counts.has(row.section)) counts.set(row.section, 0); - const text = typeof row.text === 'string' ? row.text.toLocaleLowerCase() : ''; + const text = typeof row.text === 'string' ? row.text.toLowerCase() : ''; const matches = !needle || text.includes(needle); if (!matches && row.keep !== true) { hidden.add(row.key); diff --git a/test/tab-rail-search.test.ts b/test/tab-rail-search.test.ts index 3dfdf12f..a27ee9e7 100644 --- a/test/tab-rail-search.test.ts +++ b/test/tab-rail-search.test.ts @@ -16,7 +16,15 @@ * - Only the grouped rail is a tree: hidden rows and the headers of hidden * groups leave the roving walk and the posinset/setsize count. * - The rail matches the NAME (a web tab's title); the sidebar keeps matching - * name + working directory. + * name + working directory. Lower-casing never follows the browser locale + * (a Turkish locale lowers "API" to "apı"). + * - A session row with a tab alert is never hidden, and keeps its group or + * case box on screen (the owner's call on #580). + * - Escape in a box that holds text clears the search and closes nothing else: + * the global key handler runs first (capture phase) and must route it. + * - A change in what shows redraws the connector lines, which are anchored to + * row positions; an unchanged re-apply does not. + * - The sidebar hides a case box its filter emptied, like the rail. * - The input is labelled, sits at the top of #tabRail, and every new string * reads in zh-CN. * @@ -126,6 +134,24 @@ describe('CodemanTabSearch (pure)', () => { expect(result.counts.get('g')).toBe(0); expect(search.filter(null as never, 'x').matchCount).toBe(0); }); + + it('lower-cases without the browser locale, so a Turkish locale still finds "API"', () => { + // A context of its own, whose locale-aware lower-casing behaves like the + // Turkish locale (I -> dotless i). The prototypes are this context's alone. + const context = vm.createContext({ window: {}, globalThis: {} }); + vm.runInContext( + "String.prototype.toLocaleLowerCase = function () { return String(this).replace(/I/g, '\\u0131').toLowerCase(); };", + context + ); + vm.runInContext(read('constants.js'), context, { filename: 'constants.js' }); + const turkish = (context.window as { CodemanTabSearch: TabSearch }).CodemanTabSearch; + expect(vm.runInContext("'API'.toLocaleLowerCase()", context)).toBe('apı'); + + expect(turkish.needle('API')).toBe('api'); + const result = turkish.filter([{ key: 'r', text: 'API Review', section: null }], 'api'); + expect(result.hidden.size).toBe(0); + expect(result.matchCount).toBe(1); + }); }); // ─── The rail, driven through the shipping CodemanApp ────────────────────────