diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f1545b..6288917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.91 + +- Fix: **a space after an operator's colon now works.** `title: ci time` was read as a bare `title:` with no value, reported as unreadable, and then searched as the two plain words `ci` and `time` — 113 sessions instead of the 7 that `title:ci time` returns. The space is what people type, and a bare `title:` meant nothing before, so for a plain word the rule only changes queries that were already being reported as errors. Applies to every operator (`pr:`, `has:`, `is:`, `after:`, `before:` and all six field terms), and a quoted phrase still works after the space (`title: "two words"`). **A token that is already a search term is never taken** — another operator or a PR reference — so `title: is:live` and `title: #137` keep searching for the live filter and for PR 137 respectively, each with its `title:` reported, rather than silently losing the term inside a field value. **Quotes are the escape hatch**, identically with and without the space: `title: "is:live"` and `title:"is:live"` both mean a title containing that text. When a taken value turns out to be unusable the warning now shows both halves (`ignored: after: soon`) instead of a bare `after:` + ## 1.0.90 - Fix: the **`match …` markers are outlined rather than filled.** They carried the same solid amber as the highlighted words, which was right while a row had at most one of them; [#152](https://github.com/grimmerk/codev/pull/152) made a row carry up to four (`match #N` plus `match path`, `match assistant`, and `match recap` or `match reply`), and at that count the labels competed with the text they point at and could not be told apart from it. They are now amber-outlined pills, the shape every other badge on the row already uses (PR, account, terminal), so solid amber means exactly one thing: text the query matched. The `id 4ed7505a` marker follows, keeping the corner radius of the badges beside it on that line. No row is taller for it: the border is drawn on an inline element, which costs no line height diff --git a/README.md b/README.md index 55e0ee9..574636d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Bare words search everything; **operators aim the query** (the `?` chip beside t | `after:7d` `after:2026-09-01` `after:today` `before:…` | by the session's last activity (`Nh` `Nd` `Nw`, a date, `today`, `yesterday`) | | `#137` `pr:137` `owner/repo#137` `owner/repo/pull/137` `https://github.com/owner/repo/pull/137` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). Three levels of strictness; see *Finding a pull request* below | -Every term must hold. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. +Every term must hold. A space after the colon is fine — `title: ci` reads the same as `title:ci` — unless the next token is already a search term on its own, i.e. another operator or a PR reference: `title: is:live` and `title: #137` stay two terms rather than quietly becoming a title, and the `title:` is reported. Quote it to mean it literally (`title: "is:live"`), with or without the space. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. A result also says **why and when**: the `match #N` line steps through a session's prompt hits, up to 20 of them (`‹ 2/12 ›`) and unfolds (`▸`) the prompt before and after the hit; a `by match` chip orders results by when the match happened rather than by the session's last activity; and when the matching field is not on the row — the project path, something the assistant said, a recap the row is not showing — a `match path` / `match assistant` / `match recap` / `match reply` line names it. Fields that are on the row (title, branch, project name, PR badge, first/last prompt) already carry the highlight and add no line. diff --git a/package.json b/package.json index 5b59980..9508b40 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.90", + "version": "1.0.91", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/session-search.test.ts b/src/session-search.test.ts index ba78a3d..4844019 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -475,6 +475,78 @@ describe('parseQuery', () => { expect(q.words).toEqual(['ok']); }); + it('reads a space after the colon as part of the operator', () => { + const q = parseQuery('title: ci time', now); + expect(q.fields).toEqual([{ field: 'title', value: 'ci' }]); + expect(q.words).toEqual(['time']); + expect(q.ignored).toEqual([]); + // Identical to the no-space form, which is the whole point. + expect(q).toEqual(parseQuery('title:ci time', now)); + }); + + it('takes a quoted phrase after the space, for every operator there is', () => { + expect(parseQuery('title: "two words" rest', now).fields).toEqual([ + { field: 'title', value: 'two words' }, + ]); + expect(parseQuery('pr: 147', now).prRefs).toEqual([ + { number: 147, strict: true }, + ]); + expect(parseQuery('has: pr', now).has).toEqual(['pr']); + expect(parseQuery('is: live', now).is).toEqual(['live']); + expect(parseQuery('after: 7d', now).after).toBe(now - 7 * 24 * 3600 * 1000); + expect(parseQuery('before: 3d', now).before).toBe( + now - 3 * 24 * 3600 * 1000, + ); + // Every scoped field, so a change to SCOPED_FIELDS cannot silently leave + // one of them out of the rule. + const scoped = [ + 'title', + 'branch', + 'msg', + 'project', + 'account', + 'recap', + ] as const; + for (const field of scoped) { + expect(parseQuery(`${field}: x`, now).fields).toEqual([ + { field, value: 'x' }, + ]); + } + }); + + it('never takes a token that is already a search term, and reports what was typed', () => { + const q = parseQuery('title: is:live', now); + expect(q.fields).toEqual([]); + expect(q.is).toEqual(['live']); + expect(q.ignored).toEqual(['title:']); + // A PR reference is a search term too: absorbing it would delete the + // search silently, since any non-empty value is a legal field value. + const pr = parseQuery('title: #137', now); + expect(pr.fields).toEqual([]); + expect(pr.prRefs).toEqual([{ number: 137 }]); + expect(pr.ignored).toEqual(['title:']); + // Nothing to take at the end of the query — someone still typing. + expect(parseQuery('ci title:', now).ignored).toEqual(['title:']); + // When the taken token is unusable, the report shows both halves. + expect(parseQuery('after: soon', now).ignored).toEqual(['after: soon']); + // An unknown key stays a bare word and takes nothing. + const unknown = parseQuery('error: ci', now); + expect(unknown.words).toEqual(['error:', 'ci']); + expect(unknown.ignored).toEqual([]); + }); + + it('quotes are the escape hatch, with or without the space', () => { + for (const literal of ['is:live', '#137', 'pr:9']) { + const spaced = parseQuery(`title: "${literal}"`, now); + expect(spaced.fields).toEqual([{ field: 'title', value: literal }]); + expect(spaced.is).toEqual([]); + expect(spaced.prRefs).toEqual([]); + expect(spaced.ignored).toEqual([]); + // The no-space form has always meant this; the two must not diverge. + expect(spaced).toEqual(parseQuery(`title:"${literal}"`, now)); + } + }); + it('keeps a leading-zero hash as a bare word rather than reading it as a PR', () => { const q = parseQuery('#012', now); expect(q.prRefs).toEqual([]); diff --git a/src/session-search.ts b/src/session-search.ts index 7b19a06..59a5b8d 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -108,31 +108,49 @@ export const isEmptyQuery = (q: ParsedQuery): boolean => q.after === undefined && q.before === undefined; +interface QueryToken { + text: string; + /** + * Some part of this token was inside double quotes. The quotes are gone by + * the time anything reads the token, but whether they were there is the + * language's "treat this as literal text" mark, which the operator rule in + * `parseQuery` needs: `title: "is:live"` is a title, not a live filter. + */ + quoted: boolean; +} + /** * Split on whitespace, honouring double quotes: `title:"foo bar"` is one * token with the quotes removed (`title:foo bar`). An unterminated quote runs * to the end of the query, which is what someone still typing expects. */ -export const tokenizeQuery = (query: string): string[] => { - const out: string[] = []; +const tokenizeQueryDetailed = (query: string): QueryToken[] => { + const out: QueryToken[] = []; let cur = ''; + let inQuotes = false; let quoted = false; for (const ch of query) { if (ch === '"') { - quoted = !quoted; + inQuotes = !inQuotes; + quoted = true; continue; } - if (!quoted && /\s/.test(ch)) { - if (cur) out.push(cur); + if (!inQuotes && /\s/.test(ch)) { + if (cur) out.push({ text: cur, quoted }); cur = ''; + quoted = false; continue; } cur += ch; } - if (cur) out.push(cur); + if (cur) out.push({ text: cur, quoted }); return out; }; +/** The tokens alone, for callers that do not care how they were written. */ +export const tokenizeQuery = (query: string): string[] => + tokenizeQueryDetailed(query).map((t) => t.text); + // A GitHub owner is alphanumerics and hyphens only — no dot — which is what // keeps `example.com/o/pull/1` from reading as owner `example.com`. const OWNER = '[a-z0-9-]+'; @@ -223,14 +241,61 @@ export const parseQueryDate = (value: string, now: number): number | null => { return null; }; +/** Every `key:` the parser understands, for the space-after-colon rule below. */ +const OPERATOR_KEYS: ReadonlySet = new Set([ + ...SCOPED_FIELDS, + 'pr', + 'has', + 'is', + 'after', + 'before', +]); + +/** Is this token itself an operator (`is:live`), rather than a plain value? */ +const isOperatorToken = (token: string): boolean => { + const colon = token.indexOf(':'); + return colon > 0 && OPERATOR_KEYS.has(token.slice(0, colon)); +}; + +/** + * May this token become the value of the operator before it? + * + * A QUOTED token always may: quotes are the language's mark for literal text, + * so `title: "is:live"` is a title of `is:live`, matching what the no-space + * `title:"is:live"` has always done. + * + * An unquoted one may not when it is already a search term in its own right — + * another operator, or a PR reference. Those meant something before this rule + * existed, and absorbing them would silently delete a term the user asked for: + * `title: #137` used to search for PR 137 (and report `title:` as unusable), + * so it still does, rather than quietly becoming a title of `#137`. + */ +const isTakeableValue = (token: QueryToken): boolean => { + if (token.quoted) return true; + const lower = token.text.toLowerCase(); + return !isOperatorToken(lower) && !parsePrRef(lower); +}; + /** * Parse the search box. Everything is lowercased; a token with an unknown * `key:` prefix (`error:`, `12:30`, a URL that is not a PR) stays a bare * word, so the operators cost nothing to queries that do not use them. + * + * A space after the colon is allowed: `title: ci` reads as `title:ci`. People + * type the space (it is how a sentence works, and how most search boxes read), + * and a bare `title:` meant nothing before — it was reported as an unreadable + * value — so for a plain word the rule only changes queries that were already + * errors. Which token may be taken is `isTakeableValue`: quoted text always, + * and anything that is not itself a search term (an operator, a PR reference) + * otherwise. So `title: is:live` and `title: #137` keep doing what they did, + * each with its `title:` reported, and `title: "is:live"` is the way to ask + * for that literal title — the same escape hatch as `title:"is:live"`. */ export const parseQuery = (query: string, now = Date.now()): ParsedQuery => { const q = emptyQuery(); - for (const raw of tokenizeQuery(query)) { + const tokens = tokenizeQueryDetailed(query); + for (let i = 0; i < tokens.length; i++) { + const raw = tokens[i].text; const token = raw.toLowerCase(); const pr = parsePrRef(token); if (pr) { @@ -239,7 +304,19 @@ export const parseQuery = (query: string, now = Date.now()): ParsedQuery => { } const colon = token.indexOf(':'); const key = colon > 0 ? token.slice(0, colon) : ''; - const value = colon > 0 ? token.slice(colon + 1) : ''; + let value = colon > 0 ? token.slice(colon + 1) : ''; + // What the warning line shows when the value turns out to be unusable: + // both tokens when the next one was taken, so `after: soon` is reported + // as the user typed it rather than as a bare `after:`. + let shown = raw; + if (key && !value && OPERATOR_KEYS.has(key)) { + const next = tokens[i + 1]; + if (next !== undefined && isTakeableValue(next)) { + value = next.text.toLowerCase(); + shown = `${raw} ${next.text}`; + i++; + } + } if (key === 'pr') { // `pr:147`, `pr:o/r#147`, `pr:` — a number alone is allowed here // because the key already says what it is. @@ -247,20 +324,20 @@ export const parseQuery = (query: string, now = Date.now()): ParsedQuery => { ? { number: Number(value), strict: true } : parsePrRef(value); if (ref) q.prRefs.push(ref); - else q.ignored.push(raw); + else q.ignored.push(shown); } else if (SCOPED_FIELDS.has(key)) { if (value) q.fields.push({ field: key as ScopedField, value }); - else q.ignored.push(raw); + else q.ignored.push(shown); } else if (key === 'has') { if (HAS_VALUES.has(value)) q.has.push(value); - else q.ignored.push(raw); + else q.ignored.push(shown); } else if (key === 'is') { const v = IS_ALIASES[value] ?? value; if (IS_VALUES.has(v)) q.is.push(v); - else q.ignored.push(raw); + else q.ignored.push(shown); } else if (key === 'after' || key === 'before') { const t = parseQueryDate(value, now); - if (t === null) q.ignored.push(raw); + if (t === null) q.ignored.push(shown); else if (key === 'after') q.after = Math.max(q.after ?? -Infinity, t); else q.before = Math.min(q.before ?? Infinity, t); } else if (token) {