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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
72 changes: 72 additions & 0 deletions src/session-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
103 changes: 90 additions & 13 deletions src/session-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-]+';
Expand Down Expand Up @@ -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<string> = 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) {
Expand All @@ -239,28 +304,40 @@ 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:<url>` — a number alone is allowed here
// because the key already says what it is.
const ref = /^[1-9][0-9]*$/.test(value)
? { 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) {
Expand Down
Loading