Skip to content
Merged
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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Enter</kbd>; 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** (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Alt</kbd>+<kbd>E</kbd>), 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 <kbd>Enter</kbd>; 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** (<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Alt</kbd>+<kbd>E</kbd>), 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.
Expand Down
63 changes: 58 additions & 5 deletions src/webview/components/dex-filter-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -272,6 +311,20 @@ export class DexFilterBar extends LitElement {
@blur=${() => this.classList.remove('focused')}
/>
${this._tail.trim() ? html`<span class="pending-hint">⏎ to filter</span>` : nothing}
${this.text || this._tail
? html`<button
type="button"
class="clear-all"
aria-label="Clear search"
title="Clear search (Escape)"
@click=${(e: MouseEvent) => {
e.stopPropagation();
this._clearAll();
}}
>
×
</button>`
: nothing}
`;
}
}
Expand Down
215 changes: 181 additions & 34 deletions src/webview/rowFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,127 @@ function unquote(s: string): string {

const OP_CHARS = new Set([':', '=', '<', '>', '!', '~']);

function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// 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.
//
// 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<string, string>): 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 `<column> <op> <value>` 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<string, string>,
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 {
/** Where the prefix ends, i.e. the operator's first character. */
prefixEnd: number;
Expand Down Expand Up @@ -219,12 +340,66 @@ export function parseFilterExpression<T extends FilterableRow>(
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;
// 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,
end: m.index + m[0].length,
}));

for (let ci = 0; ci < chunks.length; ci++) {
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);
const column = hit ? resolveColumn(unquote(raw.slice(0, hit.prefixEnd)).toLowerCase(), labelMap, vocabulary) : null;
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.
Expand All @@ -246,35 +421,7 @@ export function parseFilterExpression<T extends FilterableRow>(
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 };
Expand Down
Loading