Skip to content

GemStone Search: rank the selector scan, reset on a session switch, add a refresh - #520

Merged
ericwinger merged 7 commits into
mainfrom
eric/issue517-search-refresh
Aug 31, 2026
Merged

GemStone Search: rank the selector scan, reset on a session switch, add a refresh#520
ericwinger merged 7 commits into
mainfrom
eric/issue517-search-refresh

Conversation

@ericwinger

Copy link
Copy Markdown
Member

Closes #517. Three defects in GemStone Search, all of them the panel answering out of state that is no longer true.

1. Typing at: never returned Array>>at:

The Methods scope asks the stone for a bounded number of matching selectors so searching-as-you-type stays fast. The stone collected the first limit matches it walked into — dictionary-hash order, nothing to do with relevance. Measured on a 3.6.2 base image: 1142 selectors contain at: and only 31 are at:, so with a slice of 80 the exact implementors sat far past the cutoff and were never sent. The slice filled with instVarAt:put: and floatAt:put: from a couple of incidental classes.

buildSelectorSearchCode now sorts matches into tiers — the selector is the term, starts with it, merely contains it — and returns them best-first, each tier capped. What a cut-off drops is now the least relevant tail. The walk no longer short-circuits on a match count (a better-tier hit can be anywhere in the image); the one early exit left is a full exact tier, where nothing later can displace a row. Cost for at:: ~27 ms against ~2 ms — and a full walk was already the price of every precise term, which never reached the old cutoff at all.

One trap worth knowing about if you touch that query. GciLibrary.execute compiles our source with Utf8 as its string class, so every literal in it is a Utf8 while sel asString answers a String. Comparing the two raises ArgumentError 2718, Unicode argument disallowed in String comparison and takes the whole search down. The tier test therefore uses integer sizes plus includesString: and no string = anywhere; a unit test guards against a "clearer" rewrite. The live-stone integration test caught this — the unit tests could not.

2. Making another session active did nothing to the search

The docked panel rebuilt its engine only when something else happened to ask it to (a reveal, a settings change, the next keystroke); an open Spotter never rebuilt at all. Until then both kept answering — and opening rows — out of the session the user had left.

Both hosts now listen to SessionManager.onDidChangeSelection. Two decisions worth recording:

  • The webview is wiped, not just the engine. Dropping the engine is enough for a settings change, because the rows on screen are still true. Here they are not: they came from the old session, they still look live, and activating one would open a document against the session that is now current.
  • The wipe is not deferred while hidden, though the re-prime still is. The visible gate exists to keep image-wide GCI executes off a background path, and that still holds for re-priming. It does not hold for clearing the screen: a reveal cannot un-show stale rows.

The Spotter is re-pointed in place rather than closed and reopened — it is an editor tab the user put there, possibly pinned, and the tab, its pin and its HTML are all session-independent. show() for a different session takes the same path, which replaced its old dispose-and-recreate branch. Logging out of the last session is the same event with nothing to bind to: both hosts reset and say Log in to a GemStone session to search.

3. Nothing could pick up code created by executing it

A subclass: evaluated in a workspace, a method compiled by a compileMethod: doit, a global assigned in a doit — none of them announce anything the panel can listen for, so short of a commit or abort the cached class/dictionary/global/category corpora stayed stale with no upper bound.

A in the search bar (both surfaces), in the panel title bar, and gemstone.search.refresh in the palette reloads every corpus and re-runs the current term. It ignores the visibility gate the sync hooks use — the user asked for it now — and clears any deferred sync so one click is one walk.

It is a separate engine call (refresh) rather than the button wired to resync, and the difference is the pivot: resync deliberately leaves a references list alone, which made the ⟳ a dead button for anyone reading one. refresh re-asks the stone who references the row the pivot was taken from, keeps whatever filter is typed into it, and leaves the pivot if that row has been deleted.

Also fixed, found while testing: collapsing the panel disposes the view, and the fresh webview a reopen creates was never sent its config message (empty tab row, zero debounce until the first search refilled it).

Testing

npm test green: 6641 client + 322 server + 92 mcp. New coverage:

  • methodsRelevance.integration.test.ts — live stone, both releases: the bounded scan returns the exact implementors of at: including Array>>at:, they lead the slice, and a substring-only term still comes through (the tiers rank, they must not filter).
  • searchSelectors.test.ts — tier caps, best-first order, the exact-tier-only early exit, and the no-string-= guard.
  • omniSearchViewProvider.test.ts — session switch wipes + rebinds (visible and hidden), no-op on re-selecting the same session, logout resets, refresh reloads once when a hidden sync was also owed, refresh builds nothing for a view never instantiated, config re-sent on reopen.
  • omniSearchPanel.test.ts — the Spotter rebinds in place, ignores a re-selection of its own session, resets on logout, and builds nothing when closed.
  • omniEngine.test.tsrefresh vs resync over a pivot, pivot filter preserved across a re-fetch, pivot left when its target is gone.
  • omniRefreshAndReset.test.ts — the ⟳ posts refresh and keeps the typed term; reset clears query, rows, preview, breadcrumb, error, references chip, and returns the scope to All.

Manually verified on a live 3.7.5 stone against a fixture (a class with exact/prefix targets plus 250 substring decoys, above the server ceiling): ranking A1–A5, refresh B1–B6, session switching C1–C5, and no-regression checks D1–D5.

🤖 Generated with Claude Code

ericwinger and others added 3 commits August 27, 2026 15:41
…dd a refresh

Three defects in one feature, all of them the search answering out of state
that is no longer true.

Typing `at:` returned no `Array>>at:`. The Methods scan asks the stone for a
bounded number of matching selectors, but the stone collected the FIRST ones it
walked into — dictionary-hash order, nothing to do with relevance — and with
1142 selectors containing `at:` on a 3.6.2 base image, the 31 classes that
implement `at:` itself sat far past a cutoff of 80 and were never sent. The scan
now sorts matches into tiers (the selector IS the term, STARTS WITH it, CONTAINS
it) and returns them best-first, so the cut-off drops the least relevant tail.
The walk no longer short-circuits on a match count, since a better-tier hit can
be anywhere in the image; the one early exit left is a full exact tier, where
nothing later can displace a row. Measured cost for `at:`: ~27 ms against ~2 ms,
and a full walk was already the price of every precise term.

The tier test deliberately uses no string `=`. GciLibrary.execute compiles our
source as Utf8, so a literal is a Utf8 while `sel asString` is a String, and
comparing them raises ArgumentError 2718 and takes the whole search down — found
by the live-stone integration test, not by reading.

Making another session active did nothing to the search. The docked panel
rebuilt its engine only when something else happened to ask it to; an open
Spotter never rebuilt at all. Until then both kept answering, and opening rows,
out of the session the user had left. Both hosts now listen to
onDidChangeSelection: the engine is rebound and the WEBVIEW is wiped, because
those rows still look live and activating one would open a document against the
session that is now current. The wipe is not deferred while hidden — a reveal
cannot un-show stale rows — though the costly re-prime still is. The Spotter is
re-pointed in place rather than closed, which also replaced show()'s old
dispose-and-recreate branch.

Nothing could pick up code created by EXECUTING it — a workspace `subclass:`, a
`compileMethod:` doit, a global assigned in a doit announce nothing the panel can
listen for, so short of a commit or abort the cached corpora stayed stale with no
upper bound. A refresh control (⟳ in the webview chrome, the panel title bar, and
`gemstone.search.refresh` in the palette) reloads every corpus and re-runs the
current term. It ignores the visibility gate the sync hooks use — the user asked
for it now — and clears any deferred sync rather than paying for both.

Closes #517

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two provider doc-comments listed a session sync as the only thing that drops
their cache. The refresh added in the previous commit does the same, through the
same reprime path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reconfigure a reopened panel

Two things found while testing the refresh.

Wiring the ⟳ to `resync` made it a dead button for anyone reading a senders
list. `resync` deliberately leaves a pivot alone — a commit is not a request to
disturb what you are reading — so the click reloaded every corpus silently and
left the stale senders on screen, with nothing to show it had done anything. The
engine now has a separate `refresh` for the explicit gesture: it re-asks the
stone who references the row the pivot was taken from, keeping whatever filter is
typed into it, and if that row is gone (its method or class deleted) it leaves the
pivot rather than keep showing senders of nothing. `resync` keeps its old
behaviour for the automatic commit/abort path.

Collapsing the panel disposes the view, and reopening it hands us a brand-new
webview with an empty tab row, no case flag and a zero debounce. The config push
lived in `ensureEngine`, which had nothing to rebuild on a reopen (the engine
outlived the view and still matched the session) and so pushed nothing. The
`ready` handler now takes responsibility when the engine did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread client/src/omniSearch/omniSearchPanel.ts Outdated
Comment thread client/src/omniSearch/omniSearchViewProvider.ts
Comment thread client/src/omniSearch/omniSearchPanel.ts Outdated
Comment thread client/src/omniSearch/__tests__/omniSearchPanel.test.ts Outdated
Comment thread client/src/omniSearch/__tests__/omniSearchViewProvider.test.ts Outdated
ericwinger and others added 4 commits August 31, 2026 10:48
…spinner

Review follow-ups on the session-reset and refresh work.

The Spotter's logout path wiped the webview but kept its engine, so the tab
still held the departed session's primed corpora and an `activate` closed over
its GCI handle — the next keystroke answered with rows out of a session that was
gone. It now drops the engine as well, and `onMessage` shows the "log in" notice
instead of searching, mirroring the docked host's `ensureEngine` gate. Rebinding
compares the engine as well as the session id, so logging back in under the same
id is not mistaken for a no-op.

A refresh that rejects no longer leaves the panel faded: both hosts catch around
the engine call and take the spinner off. Neither entry point (the palette
command, the view title-bar button) had a catch of its own.

`gemstone.search.refresh` on a COLLAPSED docked panel no longer pays three
image-wide GCI executes to post results to a disposed webview; it is remembered
and paid on the next reveal, where it also subsumes any deferred sync.

Tests: the panel's single lifecycle block becomes seven named cases with an
afterEach that disposes the singleton; the relevance thresholds are named
constants with the reasoning written down; the macrotask `settle()` helper is
hoisted to module scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…refresh

# Conflicts:
#	CHANGELOG.md
#	package.json
The comments read as though the Spotter is normally still on screen when the
last session goes. It is not: an unpinned Spotter disposes on focus-out, so
clicking away to log out closes it first, and the docked view has a
`gemstone.hasActiveSession` when-clause that takes it out of the panel entirely.
Only a PINNED Spotter tab survives to take this path — say so, rather than
leaving the next reader to work out why the code exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ericwinger
ericwinger added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 3fe7dd7 Aug 31, 2026
34 of 38 checks passed
@ericwinger
ericwinger deleted the eric/issue517-search-refresh branch August 31, 2026 18:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GemStone Search: reset on session switch, add a refresh, and fix missing Array>>at:

2 participants