From 9f785840501f8901e10118dcee626b8e8f70f606 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 14:29:31 -0400 Subject: [PATCH 1/4] Square off the filter chips and tighten the search bar --- src/webview/components/dex-filter-bar.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/webview/components/dex-filter-bar.ts b/src/webview/components/dex-filter-bar.ts index c8393de..47a6935 100644 --- a/src/webview/components/dex-filter-bar.ts +++ b/src/webview/components/dex-filter-bar.ts @@ -36,7 +36,7 @@ export class DexFilterBar extends LitElement { as conditions accumulate, and the row under the caret is the one that matters. */ max-height: 52px; overflow-y: auto; - padding: 2px 6px; + padding: 1px 4px; box-sizing: border-box; border: 1px solid var(--dex-border-color, #d0d0d0); border-radius: 3px; @@ -56,8 +56,8 @@ export class DexFilterBar extends LitElement { align-items: baseline; gap: 3px; max-width: 100%; - padding: 1px 2px 1px 6px; - border-radius: 9px; + padding: 1px 2px 1px 5px; + border-radius: 3px; background: var(--dex-bg-badge, rgba(128, 128, 128, 0.18)); white-space: nowrap; } @@ -98,7 +98,7 @@ export class DexFilterBar extends LitElement { height: 14px; padding: 0; border: none; - border-radius: 7px; + border-radius: 2px; background: none; color: var(--dex-color-text-secondary, #666); font: inherit; From a38917c50a85d3b087d3ef355fc693eb48384ebd Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 14:35:58 -0400 Subject: [PATCH 2/4] Read a multi-word header label as one filter prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A space split the label, so `Data Type:double` parsed as the bare word `Data` ANDed with `Type:double` — a stray condition the user never typed, with a chip that looked correct. The tokenizer now recognizes a header label containing a space when an operator follows it, so the prefix can be typed the way the header spells it. Quoting it still works, and the same words with no operator after them are still two words to search for. --- README.md | 2 +- src/webview/rowFilter.ts | 62 +++++++++++++++++++++++++++++++++++---- test/rowFilter.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8dc7786..3d27173 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It adds a native experience for Simulink file types — a **Simulink Data Explor - **Live two-way sync (textual `.sldd`)** — because a textual (JSON) `.sldd` is backed by its JSON text document, edits in the table and edits in the JSON text editor update each other instantly, and there is a single shared undo history across both views. - **Properties panel** — a selection-following webview that shows the full properties of the entry selected in the table. It lives in its own view container and can be docked in the secondary sidebar. - **Variable Editor for matrix values** — a value with two or more dimensions stays a short descriptor in its cell (`<2x3x2 double>`) with a grid glyph beside it; clicking the glyph opens the whole array in a floating spreadsheet-style grid, laid out the way MATLAB displays it. Anything above rank 2 gets a `(:,:,k)` page selector to step through its trailing dimensions. Available from both the table and the Properties panel; view-only. -- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header — `Name:gain`, `"Data Type"=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`; quote anything containing a space. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. +- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header exactly as the header spells it — `Name:gain`, `Data Type=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`; quote a *value* that contains a space (`Name:"my var"`), but a header's own space needs no quoting. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. > Quoting now only groups words: `value:"5"` matches any value *containing* 5. To ask for exactly 5, use `Value=5`. - **Usage column, both directions** — a dictionary entry, MAT variable, or model-workspace variable lists the blocks that read it, qualified by the model they are in; a block's row shows which of its parameters resolved where (`Gain=Kp (params.sldd)`). Either link navigates to the other side. Resolution follows MATLAB: the mask parameters of the masked subsystems a block sits inside come first, then the model workspace, then the linked data dictionary and any dictionary it references, then linked MAT-files — so a `Gain = g1` inside a mask reads as the mask's own `g1` (`Gain=g1 (MulAdd)`), and the value that mask parameter was given is credited to the masked block. - **Block paths in the table** — where a model's blocks share a name, each row's Name shows the subsystem it lives in (`Gain (Controller)`), and hovering a block in the Usage column shows that block's full path. diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index fdd49c6..25065a9 100644 --- a/src/webview/rowFilter.ts +++ b/src/webview/rowFilter.ts @@ -130,6 +130,31 @@ function unquote(s: string): string { const OP_CHARS = new Set([':', '=', '<', '>', '!', '~']); +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// One sticky alternation of every header label that CONTAINS A SPACE, so the +// tokenizer can keep such a label in one token. Whitespace splits tokens, and a +// user types the prefix off the header they are reading — which says `Data Type`, +// not `"Data Type"`. Without this, `Data Type:double` parsed as the bare word +// `Data` AND `Type:double`: two conditions ANDed, one of them a word the user +// never meant to search for, so the query returned less than it should have while +// showing a chip that looked right. +// +// Longest first, or `Last Modified By:ww` would match `Last Modified` and leave +// `By:ww` behind. The lookahead is what keeps this from stealing ordinary text: +// the label only counts as a prefix when an operator follows it immediately, so +// `Data Type` on its own stays two words to search for. +function buildMultiWordLabelRe(labelMap: Map): RegExp | null { + const labels = [...labelMap.keys()].filter((l) => /\s/.test(l)).sort((a, b) => b.length - a.length); + if (labels.length === 0) return null; + // `\s+` between the words: the label came off a header, so a double space there + // is a typo rather than a different question. + const alts = labels.map((l) => l.trim().split(/\s+/).map(escapeRe).join('\\s+')); + return new RegExp(`(?:${alts.join('|')})(?=[:=<>!~])`, 'iy'); +} + interface OpHit { /** Where the prefix ends, i.e. the operator's first character. */ prefixEnd: number; @@ -219,12 +244,39 @@ export function parseFilterExpression( if (term) terms.push({ column, text: term }); }; - for (const m of text.matchAll(/(?:[^\s"]+|"[^"]*")+/g)) { - const raw = m[0]; - const start = m.index; - const end = start + raw.length; + const multiWordLabelRe = buildMultiWordLabelRe(labelMap); + // Chunks up front rather than a streaming matchAll: a multi-word label spans a + // whitespace boundary, so this loop sometimes has to swallow the NEXT chunk too. + const chunks = [...text.matchAll(/(?:[^\s"]+|"[^"]*")+/g)].map((m) => ({ + raw: m[0], + start: m.index, + end: m.index + m[0].length, + })); + + for (let ci = 0; ci < chunks.length; ci++) { + let { raw, start, end } = chunks[ci]; + if (multiWordLabelRe) { + multiWordLabelRe.lastIndex = start; + const label = multiWordLabelRe.exec(text); + if (label) { + // The operator sits in a later chunk (the label's own space ended this + // one). Extend the token to the end of THAT chunk, so the value comes with + // it and the recorded span still covers exactly what the chip removes. + const opAt = start + label[0].length; + const opChunk = chunks.findIndex((c, i) => i >= ci && c.start <= opAt && opAt < c.end); + if (opChunk !== -1) { + end = chunks[opChunk].end; + raw = text.slice(start, end); + ci = opChunk; + } + } + } const hit = findOperator(raw); - const column = hit ? resolveColumn(unquote(raw.slice(0, hit.prefixEnd)).toLowerCase(), labelMap, vocabulary) : null; + // Whitespace in the prefix is collapsed before resolving, because the label + // scanner above accepts `Data Type:x` and the label map holds one space. The + // two have to agree or a tolerated typo resolves to no column at all. + const prefix = hit ? unquote(raw.slice(0, hit.prefixEnd)).toLowerCase().replace(/\s+/g, ' ') : ''; + const column = hit ? resolveColumn(prefix, labelMap, vocabulary) : null; // No operator, or a prefix that names no column: the whole token is text, // colon included. `constructor:` is ordinary text a user may well look for. diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts index da5a702..bb04d72 100644 --- a/test/rowFilter.test.ts +++ b/test/rowFilter.test.ts @@ -286,6 +286,69 @@ describe('the operator scanner', () => { }); }); +// A user types the prefix off the header they are looking at, and that header says +// `Data Type`, not `"Data Type"`. Without this the space split the label in two and +// `Data Type:double` silently became `Data` AND `Type:double` — two conditions, one +// of them a stray word, which reads as the filter being broken. +describe('a header label with a space, unquoted', () => { + const VOCAB = { + labels: { Name: 'Name', Value: 'Value', DataType: 'Data Type', lastModified: 'Last Modified', lastModifiedBy: 'Last Modified By' }, + keys: [...COLUMNS, 'lastModified', 'lastModifiedBy'], + }; + + it('reads the whole label as the prefix', () => { + const { tokens } = parseFilterExpression('Data Type:double', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', op: 'contains', value: 'double', raw: 'Data Type:double' }); + }); + + it('works for every operator, and case-insensitively', () => { + for (const [text, op] of [['Data Type=double', '='], ['data type!=double', '!='], ['DATA TYPE:double', 'contains']] as const) { + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', op }); + } + }); + + it('prefers the longest label, so Last Modified By is not Last Modified + By', () => { + const { tokens } = parseFilterExpression('Last Modified By:ww', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'lastModifiedBy', value: 'ww' }); + }); + + it('keeps a quoted value together after an unquoted label', () => { + const { tokens } = parseFilterExpression('Data Type="fixed point"', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', op: '=', value: 'fixed point' }); + }); + + it('leaves the same words alone when no operator follows them', () => { + const { tokens } = parseFilterExpression('Data Type', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(2); + expect(tokens.map((t) => t.column)).toEqual([null, null]); + expect(tokens.map((t) => t.value)).toEqual(['Data', 'Type']); + }); + + it('records one span covering the label, so its chip removes in one go', () => { + const text = 'abc Data Type:double Value>1'; + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens.map((t) => text.slice(t.start, t.end))).toEqual(['abc', 'Data Type:double', 'Value>1']); + expect(removeToken(text, tokens[1])).toBe('abc Value>1'); + }); + + it('tolerates a double space inside the label', () => { + const { tokens } = parseFilterExpression('Data Type:double', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: 'DataType', value: 'double' }); + }); + + it('is not fooled by a quoted phrase that happens to start with a label', () => { + const { tokens } = parseFilterExpression('"Data Type: double"', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + expect(tokens[0]).toMatchObject({ column: null, value: 'Data Type: double' }); + }); +}); + describe('the = rule', () => { const VOCAB = { labels: { Name: 'Name', Value: 'Value' }, keys: COLUMNS }; const match = (text: string, r: Row) => From 8e969d38942f0d394619a34aceaab7ff8d01fb4c Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 15:01:34 -0400 Subject: [PATCH 3/4] Read a spaced condition as one condition, not three words `data type: double` split on whitespace into `data`, `type:` and `double`, so the query silently ANDed two words the user never meant to search for onto a condition that had lost its value. `Value > 5` had the same fault. Whitespace inside a condition is now insignificant: one scanner reads across the seams, and a condition's chip spans the whole thing so its `x` removes every part of it. This only applies once the prefix resolves to a real column of the table, which is what keeps `a > b` and `Name gain` ordinary text. The cost, pinned in a test: `Unit= abc` now means Unit equals abc rather than "no Unit, and abc somewhere". Asking for empty cells still works -- leave nothing after the operator. --- README.md | 2 +- src/webview/rowFilter.ts | 233 +++++++++++++++++++++++++++------------ test/rowFilter.test.ts | 108 +++++++++++++++++- 3 files changed, 271 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 3d27173..5e8d71b 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It adds a native experience for Simulink file types — a **Simulink Data Explor - **Live two-way sync (textual `.sldd`)** — because a textual (JSON) `.sldd` is backed by its JSON text document, edits in the table and edits in the JSON text editor update each other instantly, and there is a single shared undo history across both views. - **Properties panel** — a selection-following webview that shows the full properties of the entry selected in the table. It lives in its own view container and can be docked in the secondary sidebar. - **Variable Editor for matrix values** — a value with two or more dimensions stays a short descriptor in its cell (`<2x3x2 double>`) with a grid glyph beside it; clicking the glyph opens the whole array in a floating spreadsheet-style grid, laid out the way MATLAB displays it. Anything above rank 2 gets a `(:,:,k)` page selector to step through its trailing dimensions. Available from both the table and the Properties panel; view-only. -- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header exactly as the header spells it — `Name:gain`, `Data Type=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`; quote a *value* that contains a space (`Name:"my var"`), but a header's own space needs no quoting. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. +- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header exactly as the header spells it — `Name:gain`, `Data Type=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`, and spaces around one are ignored, so `Data Type: double` and `Value > 5` each read as a single condition. Quote a *value* that contains a space (`Name:"my var"`); a header's own space needs no quoting. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. > Quoting now only groups words: `value:"5"` matches any value *containing* 5. To ask for exactly 5, use `Value=5`. - **Usage column, both directions** — a dictionary entry, MAT variable, or model-workspace variable lists the blocks that read it, qualified by the model they are in; a block's row shows which of its parameters resolved where (`Gain=Kp (params.sldd)`). Either link navigates to the other side. Resolution follows MATLAB: the mask parameters of the masked subsystems a block sits inside come first, then the model workspace, then the linked data dictionary and any dictionary it references, then linked MAT-files — so a `Gain = g1` inside a mask reads as the mask's own `g1` (`Gain=g1 (MulAdd)`), and the value that mask parameter was given is credited to the masked block. - **Block paths in the table** — where a model's blocks share a name, each row's Name shows the subsystem it lives in (`Gain (Controller)`), and hovering a block in the Usage column shows that block's full path. diff --git a/src/webview/rowFilter.ts b/src/webview/rowFilter.ts index 25065a9..1e6d634 100644 --- a/src/webview/rowFilter.ts +++ b/src/webview/rowFilter.ts @@ -134,25 +134,121 @@ function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -// One sticky alternation of every header label that CONTAINS A SPACE, so the -// tokenizer can keep such a label in one token. Whitespace splits tokens, and a -// user types the prefix off the header they are reading — which says `Data Type`, -// not `"Data Type"`. Without this, `Data Type:double` parsed as the bare word -// `Data` AND `Type:double`: two conditions ANDed, one of them a word the user -// never meant to search for, so the query returned less than it should have while -// showing a chip that looked right. +// Whitespace splits tokens, which made a condition unwritable the way a person +// writes one: `Data Type: double` split into three pieces, of which `Data` and +// `double` became stray words ANDed onto the query. So whitespace inside a +// condition — around the operator, and between the words of a multi-word header +// label — is insignificant. The one thing holding that open: it applies ONLY once +// the prefix has resolved to a real column of THIS table. `a > b` names no column, +// so it stays three ordinary words to search for. // -// Longest first, or `Last Modified By:ww` would match `Last Modified` and leave -// `By:ww` behind. The lookahead is what keeps this from stealing ordinary text: -// the label only counts as a prefix when an operator follows it immediately, so -// `Data Type` on its own stays two words to search for. -function buildMultiWordLabelRe(labelMap: Map): RegExp | null { - const labels = [...labelMap.keys()].filter((l) => /\s/.test(l)).sort((a, b) => b.length - a.length); - if (labels.length === 0) return null; - // `\s+` between the words: the label came off a header, so a double space there - // is a typo rather than a different question. - const alts = labels.map((l) => l.trim().split(/\s+/).map(escapeRe).join('\\s+')); - return new RegExp(`(?:${alts.join('|')})(?=[:=<>!~])`, 'iy'); +// One sticky alternation of every prefix this table understands: each header label +// (its spaces relaxed to `\s+`, since the label came off a header and a double +// space there is a typo rather than a different question) and each legacy alias. +// Longest first, or `Last Modified By` matches as `Last Modified` and strands `By`. +function buildPrefixRe(labelMap: Map): RegExp { + const names = [...labelMap.keys(), ...SUBSTRING_FILTER_COLUMNS.keys(), 'value']; + const alts = [...new Set(names)] + .sort((a, b) => b.length - a.length) + .map((l) => l.trim().split(/\s+/).map(escapeRe).join('\\s+')); + return new RegExp(`(?:${alts.join('|')})`, 'iy'); +} + +function skipWs(text: string, pos: number): number { + let i = pos; + while (i < text.length && /\s/.test(text[i])) i++; + return i; +} + +// Where a value ends: the next whitespace outside quotes, so `Data Type: "fixed +// point"` keeps its value whole. +function valueEnd(text: string, pos: number): number { + let i = pos; + while (i < text.length) { + const ch = text[i]; + if (/\s/.test(ch)) break; + if (ch === '"') { + // Only a BALANCED pair groups. An unclosed quote ends the value here, which + // is what the chunk tokenizer does with one too — the two have to agree, or + // `value:"5` means one thing when this path reads it and another when the + // chunk path does. + const close = text.indexOf('"', i + 1); + if (close === -1) break; + i = close + 1; + continue; + } + i++; + } + return i; +} + +// Reads an operator at `pos`, or null when there is none. A lone `!` or `~` is not +// one — `Name!abc` is text — and that is what stops this from claiming every +// punctuation mark as syntax. +function readOperator(text: string, pos: number): { op: FilterOp; end: number } | null { + const two = text.slice(pos, pos + 2); + if (two === '!=' || two === '~=') return { op: '!=', end: pos + 2 }; + if (two === '>=' || two === '<=') return { op: two as FilterOp, end: pos + 2 }; + const ch = text[pos]; + if (ch === '=') return { op: '=', end: pos + 1 }; + if (ch === '>' || ch === '<') return { op: ch as FilterOp, end: pos + 1 }; + if (ch !== ':') return null; + // `:` is contains — unless an operator follows it, which is the legacy + // `value:>10` spelling, now accepted on every column and across a space. To + // search for the literal text `>10`, quote it: `Description:">10"`. + const after = skipWs(text, pos + 1); + const legacy = /^(>=|<=|!=|~=|=|>|<)/.exec(text.slice(after)); + if (legacy) { + const g = legacy[1]; + return { op: g === '~=' ? '!=' : (g as FilterOp), end: after + g.length }; + } + return { op: 'contains', end: pos + 1 }; +} + +interface ConditionHit { + column: string; + op: FilterOp; + value: string; + /** End of the whole condition in the source text — the chip's span ends here. */ + end: number; +} + +// Reads ` ` at `pos`, with optional whitespace at each seam. +// Null unless the prefix resolves to a column of this table AND an operator +// follows, which is what keeps ordinary text out. +function readCondition( + text: string, + pos: number, + prefixRe: RegExp, + labelMap: Map, + vocab: ColumnVocabulary | undefined, + crossWhitespaceForValue: boolean, +): ConditionHit | null { + prefixRe.lastIndex = pos; + const prefix = prefixRe.exec(text); + if (!prefix) return null; + const column = resolveColumn(prefix[0].toLowerCase().replace(/\s+/g, ' '), labelMap, vocab); + if (!column) return null; + + const opHit = readOperator(text, skipWs(text, pos + prefix[0].length)); + if (!opHit) return null; + + let start = opHit.end; + let end = valueEnd(text, start); + if (end === start) { + // Nothing flush against the operator. The value is the next word — unless that + // word is itself a condition, in which case this one has an empty value and + // means it: `Unit= Value>5` asks for entries with no Unit whose Value is over 5, + // and must not read as `Unit=Value>5`. + const next = skipWs(text, start); + const nextIsCondition = + next < text.length && readCondition(text, next, prefixRe, labelMap, vocab, false) !== null; + if (crossWhitespaceForValue && next > start && !nextIsCondition) { + start = next; + end = valueEnd(text, next); + } + } + return { column, op: opHit.op, value: unquote(text.slice(start, end)), end }; } interface OpHit { @@ -244,9 +340,41 @@ export function parseFilterExpression( if (term) terms.push({ column, text: term }); }; - const multiWordLabelRe = buildMultiWordLabelRe(labelMap); - // Chunks up front rather than a streaming matchAll: a multi-word label spans a - // whitespace boundary, so this loop sometimes has to swallow the NEXT chunk too. + // One column-scoped condition, however it was spelled. Both paths below end here, + // so what `Data Type: double` and `"Data Type":double` mean cannot drift apart. + const emitColumn = (raw: string, start: number, end: number, column: string, op: FilterOp, value: string): void => { + const label = vocabulary?.labels?.[column] ?? column; + const token: FilterToken = { raw, start, end, column, columnLabel: label, op, value }; + tokens.push(token); + + if (op === 'contains') { + const lower = value.toLowerCase(); + addTerm(column, lower); + predicates.push((row) => getCellText(row, column).toLowerCase().includes(lower)); + } else if (op === '=' || op === '!=') { + // `=` highlights (its value IS in the cell); `!=` cannot — nothing matched. + if (op === '=') addTerm(column, value.toLowerCase()); + const want = op === '='; + predicates.push((row) => valuesEqual(getCellText(row, column), value) === want); + } else { + // A bound that is not a number contributes NO predicate — a half-typed + // `Value>` must not blank the table. Surfaced on the chip instead. + const bound = parseFloat(value); + if (!Number.isFinite(bound)) { + token.warning = 'non-numeric-bound'; + return; + } + predicates.push((row) => { + const n = parseFloat(getCellText(row, column)); + if (!Number.isFinite(n)) return false; + return op === '>' ? n > bound : op === '<' ? n < bound : op === '>=' ? n >= bound : n <= bound; + }); + } + }; + + const prefixRe = buildPrefixRe(labelMap); + // Chunks up front rather than a streaming matchAll: a condition may span several + // of them, so this loop sometimes has to swallow the ones that follow. const chunks = [...text.matchAll(/(?:[^\s"]+|"[^"]*")+/g)].map((m) => ({ raw: m[0], start: m.index, @@ -254,27 +382,22 @@ export function parseFilterExpression( })); for (let ci = 0; ci < chunks.length; ci++) { - let { raw, start, end } = chunks[ci]; - if (multiWordLabelRe) { - multiWordLabelRe.lastIndex = start; - const label = multiWordLabelRe.exec(text); - if (label) { - // The operator sits in a later chunk (the label's own space ended this - // one). Extend the token to the end of THAT chunk, so the value comes with - // it and the recorded span still covers exactly what the chip removes. - const opAt = start + label[0].length; - const opChunk = chunks.findIndex((c, i) => i >= ci && c.start <= opAt && opAt < c.end); - if (opChunk !== -1) { - end = chunks[opChunk].end; - raw = text.slice(start, end); - ci = opChunk; - } - } + const { start } = chunks[ci]; + + // A condition first, reading across whitespace. Its span ends where the + // condition ends, so the chip's `×` removes every piece of it and nothing else. + const cond = readCondition(text, start, prefixRe, labelMap, vocabulary, true); + if (cond) { + emitColumn(text.slice(start, cond.end), start, cond.end, cond.column, cond.op, cond.value); + while (ci + 1 < chunks.length && chunks[ci + 1].start < cond.end) ci++; + continue; } + + // Otherwise the chunk stands alone. Still needed for the quoted prefix form + // (`"Data Type":double`, which no bare label matches) and for a prefix that + // looks like a column but names none — the `unknown-column` warning. + const { raw, end } = chunks[ci]; const hit = findOperator(raw); - // Whitespace in the prefix is collapsed before resolving, because the label - // scanner above accepts `Data Type:x` and the label map holds one space. The - // two have to agree or a tolerated typo resolves to no column at all. const prefix = hit ? unquote(raw.slice(0, hit.prefixEnd)).toLowerCase().replace(/\s+/g, ' ') : ''; const column = hit ? resolveColumn(prefix, labelMap, vocabulary) : null; @@ -298,35 +421,7 @@ export function parseFilterExpression( continue; } - const value = unquote(raw.slice(hit.valueStart)); - const label = vocabulary?.labels?.[column] ?? column; - const token: FilterToken = { raw, start, end, column, columnLabel: label, op: hit.op, value }; - tokens.push(token); - - if (hit.op === 'contains') { - const lower = value.toLowerCase(); - addTerm(column, lower); - predicates.push((row) => getCellText(row, column).toLowerCase().includes(lower)); - } else if (hit.op === '=' || hit.op === '!=') { - // `=` highlights (its value IS in the cell); `!=` cannot — nothing matched. - if (hit.op === '=') addTerm(column, value.toLowerCase()); - const want = hit.op === '='; - predicates.push((row) => valuesEqual(getCellText(row, column), value) === want); - } else { - // A bound that is not a number contributes NO predicate — a half-typed - // `Value>` must not blank the table. Surfaced on the chip instead. - const bound = parseFloat(value); - if (!Number.isFinite(bound)) { - token.warning = 'non-numeric-bound'; - continue; - } - const op = hit.op; - predicates.push((row) => { - const n = parseFloat(getCellText(row, column)); - if (!Number.isFinite(n)) return false; - return op === '>' ? n > bound : op === '<' ? n < bound : op === '>=' ? n >= bound : n <= bound; - }); - } + emitColumn(raw, start, end, column, hit.op, unquote(raw.slice(hit.valueStart))); } return { tokens, predicates, terms }; diff --git a/test/rowFilter.test.ts b/test/rowFilter.test.ts index bb04d72..e245f5d 100644 --- a/test/rowFilter.test.ts +++ b/test/rowFilter.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import { parseFilterExpression, filterRows, formatToken, removeToken, SUBSTRING_FILTER_COLUMNS, + type ColumnVocabulary, } from '../src/webview/rowFilter.js'; interface Row { @@ -33,8 +34,13 @@ function row(id: string, parent: string | null, extra: Partial = {}): Row { // Runs the whole pipeline `filterRows` exercises: parse + filter, returning just // the surviving ids in original order — the shape most of these tests care about. -function ids(rows: Row[], text: string, stickyRowIds: Set = new Set()): string[] { - return filterRows(rows, text, COLUMNS, getCellText, stickyRowIds).map((r) => r.ID); +function ids( + rows: Row[], + text: string, + stickyRowIds: Set = new Set(), + vocabulary?: ColumnVocabulary, +): string[] { + return filterRows(rows, text, COLUMNS, getCellText, stickyRowIds, vocabulary).map((r) => r.ID); } describe('parseFilterExpression', () => { @@ -349,6 +355,104 @@ describe('a header label with a space, unquoted', () => { }); }); +// `Value > 5` and `Name: abc` are how a person writes a condition. Whitespace around +// the operator used to split one condition into two or three junk terms that matched +// nothing. It is insignificant now — but ONLY once the prefix has resolved to a real +// column, which is what keeps `a > b` ordinary text. +describe('whitespace around the operator', () => { + const VOCAB = { + labels: { Name: 'Name', Value: 'Value', DataType: 'Data Type', Unit: 'Unit' }, + keys: [...COLUMNS, 'Unit'], + }; + const one = (text: string) => { + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(1); + return tokens[0]; + }; + + it('accepts a space after the colon, multi-word label included', () => { + expect(one('data type: double')).toMatchObject({ column: 'DataType', op: 'contains', value: 'double' }); + expect(one('Name: abc')).toMatchObject({ column: 'Name', op: 'contains', value: 'abc' }); + }); + + it('accepts spaces on both sides of any operator', () => { + expect(one('Value > 5')).toMatchObject({ column: 'Value', op: '>', value: '5' }); + expect(one('Value >= 5')).toMatchObject({ column: 'Value', op: '>=', value: '5' }); + expect(one('Name != abc')).toMatchObject({ column: 'Name', op: '!=', value: 'abc' }); + expect(one('Data Type = double')).toMatchObject({ column: 'DataType', op: '=', value: 'double' }); + expect(one('Value< 5')).toMatchObject({ column: 'Value', op: '<', value: '5' }); + expect(one('Value :5')).toMatchObject({ column: 'Value', op: 'contains', value: '5' }); + }); + + it('reads the legacy colon-then-operator form across a space too', () => { + expect(one('Value: >10')).toMatchObject({ op: '>', value: '10' }); + expect(one('Value: > 10')).toMatchObject({ op: '>', value: '10' }); + }); + + it('takes a quoted value from after the space', () => { + expect(one('Data Type: "fixed point"')).toMatchObject({ column: 'DataType', value: 'fixed point' }); + }); + + it('does not swallow the NEXT condition as a value', () => { + const { tokens } = parseFilterExpression('Unit= Value>5', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(2); + expect(tokens[0]).toMatchObject({ column: 'Unit', op: '=', value: '' }); + expect(tokens[1]).toMatchObject({ column: 'Value', op: '>', value: '5' }); + }); + + it('still reads a trailing operator as an empty value, which asks for empty cells', () => { + expect(one('Unit=')).toMatchObject({ column: 'Unit', op: '=', value: '' }); + expect(one('Unit= ')).toMatchObject({ column: 'Unit', op: '=', value: '' }); + }); + + it('leaves an operator between two non-columns as ordinary text', () => { + const { tokens } = parseFilterExpression('a > b', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(3); + expect(tokens.map((t) => t.column)).toEqual([null, null, null]); + }); + + it('leaves a column name followed by an ordinary word alone', () => { + const { tokens } = parseFilterExpression('Name gain', COLUMNS, getCellText, VOCAB); + expect(tokens).toHaveLength(2); + expect(tokens.map((t) => t.value)).toEqual(['Name', 'gain']); + }); + + it('spans the whole condition, so the chip removes every part of it', () => { + const text = 'abc Data Type: double Value>1'; + const { tokens } = parseFilterExpression(text, COLUMNS, getCellText, VOCAB); + expect(tokens.map((t) => text.slice(t.start, t.end))).toEqual(['abc', 'Data Type: double', 'Value>1']); + expect(removeToken(text, tokens[1])).toBe('abc Value>1'); + }); + + it('filters the rows it says it does', () => { + const rows = [ + row('a', null, { Name: 'gain', Value: '10', DataType: 'double' }), + row('b', null, { Name: 'other', Value: '2', DataType: 'single' }), + ]; + expect(ids(rows, 'Value > 5', new Set(), VOCAB)).toEqual(['a']); + expect(ids(rows, 'data type: single', new Set(), VOCAB)).toEqual(['b']); + }); + + it('reads every spelling of one condition the same way', () => { + // Two code paths now: one that scans a bare label across whitespace, and the + // per-chunk one that still handles a QUOTED prefix (which no bare label can + // match). Pin the invariant BETWEEN them — a user who quotes, spaces, or does + // neither is asking the same question and must get the same answer. + const want = { column: 'DataType', op: '=' as const, value: 'double' }; + for (const text of ['Data Type=double', 'Data Type = double', 'Data Type =double', '"Data Type"=double']) { + expect(one(text), text).toMatchObject(want); + } + }); + + it('takes the word after the operator as the value, so Unit= abc is not two conditions', () => { + // The cost of crossing whitespace: `Unit= abc` used to mean "no Unit, and abc + // somewhere". It now means Unit equals abc, which is what the spacing looks + // like. Asking for empty cells still works — leave nothing after the operator. + expect(one('Unit= abc')).toMatchObject({ column: 'Unit', op: '=', value: 'abc' }); + expect(one('Unit=')).toMatchObject({ column: 'Unit', op: '=', value: '' }); + }); +}); + describe('the = rule', () => { const VOCAB = { labels: { Name: 'Name', Value: 'Value' }, keys: COLUMNS }; const match = (text: string, r: Row) => From b200cc2eb73af6b7eda510fd51417d02d3ec5f84 Mon Sep 17 00:00:00 2001 From: Wei Wang Date: Thu, 17 Sep 2026 15:10:03 -0400 Subject: [PATCH 4/4] Clear the whole search from one x at the right of the box Escape clears the box, but only for someone who knows that; with a mouse, emptying a search meant removing each chip in turn. A x at the right end of the bar now drops the applied filter and whatever was half-typed in one press, and hands focus back to the input so the next search can just be typed. It is absent while the box is empty -- a x with nothing to clear sits exactly where the eye looks for one that does something. Clearing a pending tail alone proposes nothing, so "abandon what I was typing" stays a local edit no consumer hears about, the way Escape's first press already worked. --- README.md | 2 +- src/webview/components/dex-filter-bar.ts | 55 +++++++++++++++++++++- test/filterBar.test.ts | 58 ++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5e8d71b..6958296 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ It adds a native experience for Simulink file types — a **Simulink Data Explor - **Live two-way sync (textual `.sldd`)** — because a textual (JSON) `.sldd` is backed by its JSON text document, edits in the table and edits in the JSON text editor update each other instantly, and there is a single shared undo history across both views. - **Properties panel** — a selection-following webview that shows the full properties of the entry selected in the table. It lives in its own view container and can be docked in the secondary sidebar. - **Variable Editor for matrix values** — a value with two or more dimensions stays a short descriptor in its cell (`<2x3x2 double>`) with a grid glyph beside it; clicking the glyph opens the whole array in a floating spreadsheet-style grid, laid out the way MATLAB displays it. Anything above rank 2 gets a `(:,:,k)` page selector to step through its trailing dimensions. Available from both the table and the Properties panel; view-only. -- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`. Scope a condition to one column by naming that column's header exactly as the header spells it — `Name:gain`, `Data Type=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`, and spaces around one are ignored, so `Data Type: double` and `Value > 5` each read as a single condition. Quote a *value* that contains a space (`Name:"my var"`); a header's own space needs no quoting. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. +- **Search** — filter entries with the table's built-in filter bar. Type a word and press Enter; each condition becomes a chip you can remove with its `×`, and the `×` at the right end of the box clears the whole search. Scope a condition to one column by naming that column's header exactly as the header spells it — `Name:gain`, `Data Type=double`, `Value>10` — or right-click any column header to build the same thing from a popup, which shows you the text it writes. The operators are `:` (contains), `=`, `!=` (also `~=`), `>`, `<`, `>=` and `<=`, and spaces around one are ignored, so `Data Type: double` and `Value > 5` each read as a single condition. Quote a *value* that contains a space (`Name:"my var"`); a header's own space needs no quoting. Or search across every data source in the workspace with **Data Explorer: Search Data Source Entries** (Ctrl/Cmd+Alt+E), which lists each match with the file it comes from. A model's blocks are listed one hit per block, qualified by the subsystem the block sits in — so the several blocks named `Gain` a model may hold stay distinguishable, and the subsystem name is searchable too. > Quoting now only groups words: `value:"5"` matches any value *containing* 5. To ask for exactly 5, use `Value=5`. - **Usage column, both directions** — a dictionary entry, MAT variable, or model-workspace variable lists the blocks that read it, qualified by the model they are in; a block's row shows which of its parameters resolved where (`Gain=Kp (params.sldd)`). Either link navigates to the other side. Resolution follows MATLAB: the mask parameters of the masked subsystems a block sits inside come first, then the model workspace, then the linked data dictionary and any dictionary it references, then linked MAT-files — so a `Gain = g1` inside a mask reads as the mask's own `g1` (`Gain=g1 (MulAdd)`), and the value that mask parameter was given is credited to the masked block. - **Block paths in the table** — where a model's blocks share a name, each row's Name shows the subsystem it lives in (`Gain (Controller)`), and hovering a block in the Usage column shows that block's full path. diff --git a/src/webview/components/dex-filter-bar.ts b/src/webview/components/dex-filter-bar.ts index 47a6935..087e356 100644 --- a/src/webview/components/dex-filter-bar.ts +++ b/src/webview/components/dex-filter-bar.ts @@ -124,6 +124,31 @@ export class DexFilterBar extends LitElement { font: inherit; outline: none; } + /* Clear everything. Sized and shaped like a chip's own × because it does the + same kind of thing, one row up: this removes the whole query, that one + condition. Sits after the input, which flexes, so it lands at the right end. */ + .clear-all { + flex: 0 0 auto; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 2px; + background: none; + color: var(--dex-color-text-secondary, #666); + font: inherit; + font-size: 13px; + line-height: 1; + cursor: pointer; + outline: none; + } + .clear-all:hover { + background: var(--dex-bg-hover, #e8e8e8); + color: var(--dex-color-text, inherit); + } + .clear-all:focus-visible { + outline: 1px solid var(--dex-color-accent, #0078d4); + } /* Without this, Enter-to-filter reads as a search box that stopped working. */ .pending-hint { flex: 0 0 auto; @@ -146,7 +171,8 @@ export class DexFilterBar extends LitElement { .chip.warning { border: 2px solid Highlight !important; } - .chip-remove:focus-visible { + .chip-remove:focus-visible, + .clear-all:focus-visible { outline: 2px solid Highlight !important; } } @@ -182,6 +208,19 @@ export class DexFilterBar extends LitElement { this._propose(this.text ? `${this.text} ${tail}` : tail); } + // Everything at once, which is the one thing Escape cannot do in a single press: + // it clears the tail first and the filter second, deliberately, so a half-typed + // word can be abandoned without losing an applied search. A × is aimed, not + // typed, so it means all of it. Proposing only when something IS applied keeps + // "abandon what I was typing" a local edit that no consumer hears about. + private _clearAll(): void { + const hadFilter = this.text !== ''; + this._tail = ''; + if (hadFilter) this._propose(''); + // The next thing the user does is type, so leave the caret where they left it. + this._input?.focus(); + } + private _removeAt(index: number): void { const token = this.tokens[index]; if (token) this._propose(removeToken(this.text, token)); @@ -272,6 +311,20 @@ export class DexFilterBar extends LitElement { @blur=${() => this.classList.remove('focused')} /> ${this._tail.trim() ? html`⏎ to filter` : nothing} + ${this.text || this._tail + ? html`` + : nothing} `; } } diff --git a/test/filterBar.test.ts b/test/filterBar.test.ts index 6a3e5b3..2636ad1 100644 --- a/test/filterBar.test.ts +++ b/test/filterBar.test.ts @@ -107,6 +107,64 @@ describe('dex-filter-bar', () => { }); }); +describe('dex-filter-bar clear-all', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + const clearAll = (el: DexFilterBar) => el.shadowRoot!.querySelector('.clear-all') as HTMLButtonElement | null; + + it('offers nothing to clear when there is nothing to clear', async () => { + // A × on an empty box is a control that does nothing, sitting where the eye + // looks for one that does. + expect(clearAll(await bar())).toBeNull(); + }); + + it('appears once anything is in the box, applied or still being typed', async () => { + expect(clearAll(await bar('abc'))).not.toBeNull(); + const el = await bar(); + await type(el, 'ga'); + expect(clearAll(el)).not.toBeNull(); + }); + + it('clears the applied filter and the pending tail in one click', async () => { + const el = await bar('abc Name:gain'); + const seen = applied(el); + await type(el, 'Value>1'); + clearAll(el)!.click(); + await el.updateComplete; + expect(seen).toEqual(['']); + expect(input(el).value).toBe(''); + }); + + it('leaves the caret in the box, ready for the next search', async () => { + const el = await bar('abc'); + clearAll(el)!.click(); + await el.updateComplete; + expect(el.shadowRoot!.activeElement).toBe(input(el)); + }); + + it('is a labelled button, so it is reachable without a mouse', async () => { + const el = await bar('abc'); + const button = clearAll(el)!; + expect(button.tagName).toBe('BUTTON'); + expect(button.getAttribute('aria-label')).toBe('Clear search'); + }); + + it('proposes nothing when only a tail was pending — there is no filter to replace', async () => { + // Clearing an uncommitted tail is a local edit. Proposing '' would look the + // same here and would clear an applied filter that a later Escape should have + // kept, so the two cases stay distinct. + const el = await bar(); + const seen = applied(el); + await type(el, 'ga'); + clearAll(el)!.click(); + await el.updateComplete; + expect(seen).toEqual([]); + expect(input(el).value).toBe(''); + }); +}); + describe('dex-filter-bar keyboard', () => { beforeEach(() => { document.body.innerHTML = '';