diff --git a/CHANGELOG.md b/CHANGELOG.md index af7ee7f1..98f74961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,26 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ## [Unreleased] +### Added + +- **A first connect now points you at the basics instead of the raw kernel.** A newly-connected user landed among kernel classes with no signpost to browsing, searching, or a workspace. A **GemStone - Start Here** button now appears in the status bar on connect; clicking it opens a quick pick of *Browse a class*, *Search your code*, *Open a workspace*, and *Take the tour*. It's unobtrusive, so it stays put rather than nagging — dismiss it for good with the quick pick's **Hide the Start Here button** entry (and bring it back with **GemStone: Reset Getting Started**). The same quick pick is always available from the Command Palette as **GemStone: Start Here**. The Get Started walkthrough also gained **Browse classes** and **Search your code** steps, which it previously skipped. ([#468](https://github.com/GemTalk/Jasper/issues/468)) +- **A one-time hint explains how to keep multiple methods open in the Explorer.** The Methods pane opens a method in a single reusable preview tab, so single-clicking another method replaces it — a first-time user reads that as the method being lost, and doesn't see how to view two at once. The first time a click is about to replace a previewed method, a one-time toast points out that double-clicking it (or its **Keep Method Open** button) keeps it open while you browse others. The pin button and the method-row tooltip now say the same thing: the button was retitled from **Pin Method** to **Keep Method Open (Pin)**, and the tooltip spells out preview-vs-keep. ([#468](https://github.com/GemTalk/Jasper/issues/468)) +- **The breadcrumb over a method source is now a live class navigator.** A `gemstone://` method editor shows a `Dictionary › Class › instance/class › category › selector` breadcrumb above the source, but its dropdowns were empty and clicking a crumb did nothing. Each crumb now drills into the stone: a dictionary lists its classes, a class lists its `instance`/`class` sides plus its `definition`, a side lists its method categories, and a category lists its selectors — so you can reach another method of the same class, or open the class definition, without leaving the editor. ([#468](https://github.com/GemTalk/Jasper/issues/468)) +- **GemStone Go Back / Forward buttons retrace the methods you've viewed.** Because methods open in a single reusable preview tab, VS Code's own Go Back couldn't step through them — its history only tracks distinct/pinned tabs, so a first-time user drilling through methods had no way back. Two title-bar buttons on GemStone editors walk a dedicated history of the `gemstone://` editors you've visited and reopen each in the preview tab; visiting a new method drops the forward trail, as a browser does. ([#468](https://github.com/GemTalk/Jasper/issues/468)) +- **Deleting now looks for what still uses the thing first.** Removing a method, a class, an instance variable or a class variable used to pop the same confirmation whatever the target, and check nothing — you found out afterwards, from a doesNotUnderstand or a failed recompile, that something still needed it. Each of the four now scans first: senders for a method, references and subclasses for a class, accessing methods for either kind of variable. When nothing references it the deletion just happens and is reported in a notification, so the click is one step instead of two; when something does, the confirmation says how many and which — one line per referencing class, the class named once however many of its methods are involved — and **Show References…** lists them for browsing before you decide whether to remove it anyway. Opening one of those references abandons the deletion rather than re-raising the question — you asked to go read that method, and a modal whose default action is destructive should not follow you there; closing the list without opening anything brings the question back, since that is still deciding rather than going somewhere. References that go away *with* the target don't count: a recursive method's send of its own selector, or a doomed subclass's use of the class being removed. Nor does removing an **override** raise a question — every send that resolved there resolves to the inherited implementation instead, so the notification says where they now resolve rather than pretending nothing referenced it, and the whole-image sender scan is skipped entirely. A scan that cannot answer falls back to asking. The variable scans match the way the refactoring engine does — bytecode access for an instance variable, binding identity for a class variable — so a name in a comment, or a same-named global, is not a reference; none of it needs the server plugin installed. A method scan asks about the selector image-wide, which is what the image can answer, so a dispatch through `perform:` is invisible to it. Every scan sweeps the method environments you have configured, runs under a progress notification because it walks the image, and hedges its count rather than stating it as fact when it comes back at the row cap. ([#433](https://github.com/GemTalk/Jasper/issues/433)) +- **Remove Class Variable.** The Explorer could add and rename a class variable but never remove one. The trash can on a class-variable row (and its context-menu entry) now does, guarded like every other delete. Like adding one it is lightweight — a class variable is not part of instance layout, so nothing is reshaped, no class version is created, and no refactoring engine is involved — and the removal is refused server-side for a variable a class only inherits, so the query and MCP paths cannot take one off the wrong class either. Nothing is committed until you commit the session. ([#433](https://github.com/GemTalk/Jasper/issues/433)) + ### Changed - **The two variable removals now show the trash can, like every other Explorer delete.** Removing an instance variable or a class variable used the `$(remove)` minus sign while removing a method, a class or a dictionary used the trash can — so the variable rows read as "take out of this list" rather than "delete this", a different gesture from the rows right above them. All five now match. ([#433](https://github.com/GemTalk/Jasper/issues/433)) +### Removed + +- **The GemStone Explorer's Open Editors pane is gone; a status-bar button replaces it.** As the topmost pane it appeared the instant you opened your first editor, which reshuffled the sidebar and scrolled your class selection out of view — and it spent a pane's worth of height duplicating what the editor tabs already show. Your open editors are just editor tabs now, and a left status-bar button (**GemStone: Close All GemStone Editors**, also in the Command Palette) tallies them and closes them all in one click. ([#468](https://github.com/GemTalk/Jasper/issues/468)) + ### Fixed +- **The senders/implementors hover no longer vanishes when one of its queries fails.** Resting on a selector runs `implementorsOf` to list the classes and `sendersOf` for the count; only the senders call was guarded, so a single thrown query — a busy session, or a stone without the browser/RB plugin loaded — rejected the whole hover and silently showed nothing, including the senders count that had already succeeded. The implementors lookup is now guarded the same way, so the hover degrades to what it can show instead of disappearing. ([#432](https://github.com/GemTalk/Jasper/issues/432), [#468](https://github.com/GemTalk/Jasper/issues/468)) - **An instance variable could not be removed from a class with no subclasses.** The Explorer's Remove Instance Variable action was gated on the row's context value being `explorerIvar`, but a class without subclasses builds its instance-variable rows as `explorerIvarNoSubs` — so on a leaf class the row offered no delete at all, inline or in the context menu. It now matches both, as the rename and push-up actions on the same row already did. ([#433](https://github.com/GemTalk/Jasper/issues/433)) - **A reference scan could miss methods outside environment 0, and then report that nothing referenced the target.** Scanning for references to a class built its `ClassOrganizer` without an environment, so it searched environment 0 whatever it was asked for; the two variable scans enumerated `selectors`, which likewise lists environment 0 only. On a stone with `gemstone.maxEnvironment` above 0 that made a real reference invisible — and safe delete would then take the silent path and announce that nothing referenced it. Found methods also now carry the environment they were found in, so a selector implemented in two environments counts as two methods and each opens the document it actually lives in — on both paths: picking a result switches an already-open System Browser to that environment, where it used to open whatever environment the browser happened to be showing, and with no browser open the method is opened directly there. That last part fixes the same latent bug in Senders, Implementors, hierarchy implementors and References, which folded two environments' methods into one row and opened results as environment 0; all of them now share one fold that counts the environment as part of a method's identity. ([#433](https://github.com/GemTalk/Jasper/issues/433)) - **A reference count could hit the scan's row cap and still be reported as exact.** The scan returns at most 500 rows per query, and the confirmation is supposed to say "At least 500 … (the list below is not complete)" rather than state a number that may really be thousands. Whether the cap was hit was worked out from the count the dialog was handed — after the references that go away with the target had already been dropped — so a capped scan of 500 that lost a recursive method's own send arrived as 499, and the warning silently disappeared from the one dialog that most needed it. The cap is now observed on the raw scan, per environment, and travels with the result: exclusions can no longer hide it, and several environments summing past 500 without any one query filling up is correctly reported as a complete list. ([#433](https://github.com/GemTalk/Jasper/issues/433)) @@ -17,11 +31,6 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i - **The delete confirmation never said which method environment anything was in.** A class can implement the same selector in more than one environment, and those are different methods — but the reference list named a receiver once, so two of them collapsed into a single line and the count said "1 method" for what was really two. References outside environment 0 are now labelled (`Account [env 1] >> #balance`) and get a line of their own, and environment 0 stays unlabelled so a stone that never raised `gemstone.maxEnvironment` reads exactly as before. Removing a method whose selector is also implemented in another environment now says so and says that only the environment-0 method is going, on the confirmation and on the notification alike — the Methods pane shows one row however many environments implement a selector, so without it a removal appears to take the selector off the class while an implementation is still standing. ([#433](https://github.com/GemTalk/Jasper/issues/433)) - **Removing a method could hide a sender that survives it.** A method's send of its own selector goes away with it, so it is discounted — but the match ignored the environment, and a class can implement the same selector on the same side in two environments. Deleting the one the Methods pane acts on crossed off the other environment's method as if it were the removed method's own recursion, understating the references. ([#433](https://github.com/GemTalk/Jasper/issues/433)) -### Added - -- **Deleting now looks for what still uses the thing first.** Removing a method, a class, an instance variable or a class variable used to pop the same confirmation whatever the target, and check nothing — you found out afterwards, from a doesNotUnderstand or a failed recompile, that something still needed it. Each of the four now scans first: senders for a method, references and subclasses for a class, accessing methods for either kind of variable. When nothing references it the deletion just happens and is reported in a notification, so the click is one step instead of two; when something does, the confirmation says how many and which — one line per referencing class, the class named once however many of its methods are involved — and **Show References…** lists them for browsing before you decide whether to remove it anyway. Opening one of those references abandons the deletion rather than re-raising the question — you asked to go read that method, and a modal whose default action is destructive should not follow you there; closing the list without opening anything brings the question back, since that is still deciding rather than going somewhere. References that go away *with* the target don't count: a recursive method's send of its own selector, or a doomed subclass's use of the class being removed. Nor does removing an **override** raise a question — every send that resolved there resolves to the inherited implementation instead, so the notification says where they now resolve rather than pretending nothing referenced it, and the whole-image sender scan is skipped entirely. A scan that cannot answer falls back to asking. The variable scans match the way the refactoring engine does — bytecode access for an instance variable, binding identity for a class variable — so a name in a comment, or a same-named global, is not a reference; none of it needs the server plugin installed. A method scan asks about the selector image-wide, which is what the image can answer, so a dispatch through `perform:` is invisible to it. Every scan sweeps the method environments you have configured, runs under a progress notification because it walks the image, and hedges its count rather than stating it as fact when it comes back at the row cap. ([#433](https://github.com/GemTalk/Jasper/issues/433)) -- **Remove Class Variable.** The Explorer could add and rename a class variable but never remove one. The trash can on a class-variable row (and its context-menu entry) now does, guarded like every other delete. Like adding one it is lightweight — a class variable is not part of instance layout, so nothing is reshaped, no class version is created, and no refactoring engine is involved — and the removal is refused server-side for a variable a class only inherits, so the query and MCP paths cannot take one off the wrong class either. Nothing is committed until you commit the session. ([#433](https://github.com/GemTalk/Jasper/issues/433)) - ## [1.8.13] - 2026-08-20 A follow-up release for **GemStone Search**: correctness fixes for multi-session and multi-environment use, matching and debounce repairs found by a review pass over the feature, one naming pass, and the senders/implementors counts moving off the method source. diff --git a/README.md b/README.md index fa0181ba..502fd413 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ If you already have a GemStone server running on another machine (or locally), y 1. Install the extension from the VS Code Marketplace or Open VSX (links above). 2. Open the **GemStone** sidebar (gem icon in the activity bar). -3. Click the **+** button in the **Logins** section to create a new login. -4. Fill in the connection details: GemStone version, host, stone name, NetLDI, and credentials. -5. Click **Login** to connect. +3. In **Logins & Sessions**, click **Add a Login** to open the login editor. +4. Fill in the connection details, top to bottom: GemStone version, gem host, stone name, NetLDI (service name or port), and your GemStone user/password. **Host User** and **Host Password** are optional — supply them only when the remote NetLDI requires host authentication; leave them blank for a local stone or a guest-mode NetLDI. (Stuck? Click **Help me login** in the login editor for per-field guidance.) +5. Click **Save**, then click the saved login to connect. A "Connecting…" notification reports success or failure, and the status bar (bottom right) shows the active session — or turns red, click-to-explain, if the connection fails. The first time you log in with a given GemStone version, Jasper needs the native GCI library (`libgcits`) for that version: @@ -166,9 +166,9 @@ Long-running expressions show a progress notification with soft-break and hard-b ### GemStone Explorer -The **GemStone Explorer** is the primary way to browse and edit code, and the view to reach for first. It lives in its own activity-bar container as a set of linked panes — **Dictionaries**, **Class Categories**, **Classes**, **Hierarchy**, and **Methods** — plus **Open Editors** for what you have open. +The **GemStone Explorer** is the primary way to browse and edit code, and the view to reach for first. It lives in its own activity-bar container as a set of linked panes — **Dictionaries**, **Class Categories**, **Classes**, **Hierarchy**, and **Methods**. Your open editors appear as ordinary editor tabs; a status-bar button tallies them and closes them all at once (**GemStone: Close All GemStone Editors**). -Selecting down the panes narrows what the next one shows. Click a method to open its source; **Cmd+S** (Ctrl+S) compiles it back to GemStone. Class definitions and comments are editable the same way. +Selecting down the panes narrows what the next one shows. Click a method to open its source; **Cmd+S** (Ctrl+S) compiles it back to GemStone. Class definitions and comments are editable the same way. A single click previews a method in one reusable tab, so clicking another replaces it — double-click a method (or use **Keep Method Open**) to keep it open while you browse others. Beyond browsing, the Explorer is where the code-changing operations live: diff --git a/client/src/__tests__/explorerOpenEditors.test.ts b/client/src/__tests__/explorerOpenEditors.test.ts deleted file mode 100644 index 8ecb6b39..00000000 --- a/client/src/__tests__/explorerOpenEditors.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -vi.mock('vscode', () => import('../__mocks__/vscode.js')); -import { DirtyDecorationProvider } from '../explorerOpenEditors'; -import { Uri, TabInputText, window } from '../__mocks__/vscode'; - -const SOURCE = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; - -function openTab(uriString: string, isDirty: boolean): void { - const uri = Uri.parse(uriString); - window.tabGroups.all = [{ tabs: [{ input: new TabInputText(uri), isDirty }] }]; -} - -describe('DirtyDecorationProvider', () => { - afterEach(() => { - window.tabGroups.all = []; - }); - - it('marks a gemstone editor with unsaved changes with an unsaved-changes dot', () => { - openTab(SOURCE, true); - - const decoration = new DirtyDecorationProvider().provideFileDecoration(Uri.parse(SOURCE)); - - expect(decoration?.badge).toBe('●'); - expect(decoration?.tooltip).toBe('Unsaved changes'); - }); - - it('leaves a saved editor undecorated', () => { - openTab(SOURCE, false); - - const decoration = new DirtyDecorationProvider().provideFileDecoration(Uri.parse(SOURCE)); - - expect(decoration).toBeUndefined(); - }); - - it('never decorates a non-gemstone resource', () => { - openTab(SOURCE, true); - - const decoration = new DirtyDecorationProvider().provideFileDecoration( - Uri.parse('file:///tmp/x.st'), - ); - - expect(decoration).toBeUndefined(); - }); -}); diff --git a/client/src/__tests__/explorerOpenEditorsLabel.test.ts b/client/src/__tests__/explorerOpenEditorsLabel.test.ts deleted file mode 100644 index cdf5926b..00000000 --- a/client/src/__tests__/explorerOpenEditorsLabel.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { classifyGemstoneUri } from '../explorerOpenEditorsLabel'; -import type { ParsedUri } from '../gemstoneFileSystemProvider'; - -function methodUri(over: Partial = {}): ParsedUri { - return { - kind: 'method', - sessionId: 1, - dictName: 'Globals', - className: 'Array', - isMeta: false, - category: 'accessing', - selector: 'at:', - environmentId: 0, - ...over, - }; -} - -describe('classifyGemstoneUri', () => { - it('labels an instance method as Class>>selector under the method group', () => { - expect(classifyGemstoneUri(methodUri())).toEqual({ kind: 'method', label: 'Array>>at:' }); - }); - - it('marks the class side of a method with a "(class)" receiver', () => { - const entry = classifyGemstoneUri(methodUri({ isMeta: true, selector: 'new' })); - - expect(entry?.label).toBe('Array (class)>>new'); - }); - - it('appends a "(base)" suffix for the persistent base source of an override', () => { - const entry = classifyGemstoneUri(methodUri({ base: true })); - - expect(entry?.label).toBe('Array>>at: (base)'); - }); - - it('omits the read-only override-diff comparison view', () => { - expect(classifyGemstoneUri(methodUri({ diffView: true }))).toBeUndefined(); - }); - - it('labels a class definition by its class name under the class group', () => { - const parsed = { - kind: 'definition', - sessionId: 1, - dictName: 'Globals', - className: 'Array', - } as ParsedUri; - - expect(classifyGemstoneUri(parsed)).toEqual({ kind: 'class', label: 'Array' }); - }); - - it('labels a class comment by its class name under the comment group', () => { - const parsed = { - kind: 'comment', - sessionId: 1, - dictName: 'Globals', - className: 'Array', - } as ParsedUri; - - expect(classifyGemstoneUri(parsed)).toEqual({ kind: 'comment', label: 'Array' }); - }); - - it('omits the new-class template', () => { - const parsed = { kind: 'new-class', sessionId: 1, dictName: 'UserGlobals' } as ParsedUri; - - expect(classifyGemstoneUri(parsed)).toBeUndefined(); - }); - - it('omits the new-method template', () => { - const parsed = methodUri(); - const asNewMethod = { ...parsed, kind: 'new-method' } as ParsedUri; - - expect(classifyGemstoneUri(asNewMethod)).toBeUndefined(); - }); -}); diff --git a/client/src/__tests__/explorerOpenMethod.test.ts b/client/src/__tests__/explorerOpenMethod.test.ts index 04335605..e2ac7035 100644 --- a/client/src/__tests__/explorerOpenMethod.test.ts +++ b/client/src/__tests__/explorerOpenMethod.test.ts @@ -6,7 +6,7 @@ vi.mock('vscode', () => import('../__mocks__/vscode.js')); vi.mock('../browserQueries', () => ({})); import type * as vscode from 'vscode'; -import { ExplorerController, MethodItem } from '../gemstoneExplorer'; +import { ExplorerController, MethodItem, shouldHintKeepMethodsOpen } from '../gemstoneExplorer'; import type { ExplorerTestResult } from '../gemstoneExplorer'; import { Uri, window, commands, workspace, languages } from '../__mocks__/vscode'; import type { SessionManager, ActiveSession } from '../sessionManager'; @@ -34,6 +34,26 @@ function makeController(): ExplorerController { return controllerFor(SESSION); } +// A controller wired with a fake global-storage memento, backed by `store`, so the +// one-time "keep methods open" hint can be exercised. +function controllerWithGlobalState(store: Record): ExplorerController { + const sessionManager = { getSelectedSession: () => SESSION } as unknown as SessionManager; + const memento = { + get: (k: string) => store[k], + update: (k: string, v: unknown) => { + store[k] = v; + return Promise.resolve(); + }, + } as unknown as vscode.Memento; + const ctl = new ExplorerController(sessionManager, undefined, undefined, memento); + ctl.state.dictName = 'UserGlobals'; + ctl.state.dictIndex = 1; + ctl.state.className = 'Array'; + return ctl; +} + +const HINT_KEY = 'gemstone.explorer.keepMethodsOpenHintShown'; + function info(over: Partial = {}): SelectorInfo { return { selector: 'at:', category: 'accessing', overrideBits: 0, sessionBit: 0, ...over }; } @@ -157,6 +177,55 @@ describe('ExplorerController.openMethod', () => { }); }); +describe('keep-methods-open hint', () => { + const showInfo = window.showInformationMessage as ReturnType; + + it('fires only when a different method replaces a previewed one, and only once', () => { + expect(shouldHintKeepMethodsOpen(undefined, 'a', false)).toBe(false); + expect(shouldHintKeepMethodsOpen('a', 'a', false)).toBe(false); + expect(shouldHintKeepMethodsOpen('a', 'b', false)).toBe(true); + expect(shouldHintKeepMethodsOpen('a', 'b', true)).toBe(false); + }); + + it('stays quiet on the first previewed method', async () => { + const ctl = controllerWithGlobalState({}); + + await ctl.openMethod(methodItem({ selector: 'at:' }), 'preview'); + + expect(showInfo).not.toHaveBeenCalled(); + }); + + it('explains once when a second, different method replaces the first', async () => { + const store: Record = {}; + const ctl = controllerWithGlobalState(store); + + await ctl.openMethod(methodItem({ selector: 'at:' }), 'preview'); + await ctl.openMethod(methodItem({ selector: 'size' }), 'preview'); + await ctl.openMethod(methodItem({ selector: 'first' }), 'preview'); + + expect(showInfo).toHaveBeenCalledTimes(1); + expect(store[HINT_KEY]).toBe(true); + }); + + it('does not fire when it has been shown in a previous session', async () => { + const ctl = controllerWithGlobalState({ [HINT_KEY]: true }); + + await ctl.openMethod(methodItem({ selector: 'at:' }), 'preview'); + await ctl.openMethod(methodItem({ selector: 'size' }), 'preview'); + + expect(showInfo).not.toHaveBeenCalled(); + }); + + it('does not fire when a pin or keep open replaces the preview (only single-click preview does)', async () => { + const ctl = controllerWithGlobalState({}); + + await ctl.openMethod(methodItem({ selector: 'at:' }), 'preview'); + await ctl.openMethod(methodItem({ selector: 'size' }), 'pin'); + + expect(showInfo).not.toHaveBeenCalled(); + }); +}); + describe('MethodItem click wiring', () => { it('routes each click to the double-click hook, carrying the node', () => { const node = methodItem(); @@ -279,7 +348,7 @@ describe('a click in the Testing view', () => { function ctl(): ExplorerController { const sessionManager = { getSelectedSession: () => SESSION } as unknown as SessionManager; - const c = new ExplorerController(sessionManager, undefined, undefined, { + const c = new ExplorerController(sessionManager, undefined, undefined, undefined, { isTestClass: () => true, isTestItemUri: (uri) => uri.toString() === TEST_URI, resultFor: () => undefined, @@ -369,7 +438,7 @@ describe('a click in the Testing view', () => { describe('ExplorerController.isTestSelector', () => { function ctlFor(testClasses: string[]): ExplorerController { const sessionManager = { getSelectedSession: () => SESSION } as unknown as SessionManager; - const ctl = new ExplorerController(sessionManager, undefined, undefined, { + const ctl = new ExplorerController(sessionManager, undefined, undefined, undefined, { isTestClass: (_dictName: string, className: string) => testClasses.includes(className), isTestItemUri: () => false, resultFor: () => undefined, @@ -422,7 +491,7 @@ describe('ExplorerController.isTestSelector', () => { AnnouncerTest: { outcome: 'failed' }, 'AnnouncerTest/testAnnounceClass': { outcome: 'passed', stale: true }, }; - const ctl = new ExplorerController(sessionManager, undefined, undefined, { + const ctl = new ExplorerController(sessionManager, undefined, undefined, undefined, { isTestClass: (_d: string, c: string) => c === 'AnnouncerTest', isTestItemUri: () => false, resultFor: (_d: string, c: string, sel?: string) => @@ -447,7 +516,7 @@ describe('ExplorerController.isTestSelector', () => { it('marks a running row so its run button can be swapped for a stop button', () => { const sessionManager = { getSelectedSession: () => SESSION } as unknown as SessionManager; - const ctl = new ExplorerController(sessionManager, undefined, undefined, { + const ctl = new ExplorerController(sessionManager, undefined, undefined, undefined, { isTestClass: () => true, isTestItemUri: () => false, resultFor: () => ({ outcome: 'running', stoppable: true }), @@ -469,7 +538,7 @@ describe('ExplorerController.isTestSelector', () => { // The debugger owns the gem; its own Terminate ends the test. A ■ of ours // would be a button that does nothing. const sessionManager = { getSelectedSession: () => SESSION } as unknown as SessionManager; - const ctl = new ExplorerController(sessionManager, undefined, undefined, { + const ctl = new ExplorerController(sessionManager, undefined, undefined, undefined, { isTestClass: () => true, isTestItemUri: () => false, resultFor: () => ({ outcome: 'running', stoppable: false }), @@ -490,7 +559,7 @@ describe('ExplorerController.isTestSelector', () => { it('says on the row when an outcome predates the code it described', () => { const sessionManager = { getSelectedSession: () => SESSION } as unknown as SessionManager; function rowFor(stale: boolean) { - const c = new ExplorerController(sessionManager, undefined, undefined, { + const c = new ExplorerController(sessionManager, undefined, undefined, undefined, { isTestClass: () => true, isTestItemUri: () => false, resultFor: () => ({ outcome: 'passed' as const, stale }), diff --git a/client/src/__tests__/explorerViewRegistration.test.ts b/client/src/__tests__/explorerViewRegistration.test.ts index 4c84bcf1..81023c02 100644 --- a/client/src/__tests__/explorerViewRegistration.test.ts +++ b/client/src/__tests__/explorerViewRegistration.test.ts @@ -2,51 +2,21 @@ import { describe, it, expect } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; -// Most GemStone Explorer panes are created eagerly with `vscode.window.createTreeView` +// GemStone Explorer panes are created eagerly with `vscode.window.createTreeView` // (gemstoneExplorer.ts). VS Code registers a contributed view only once its `when` // clause is satisfied, and createTreeView throws "No view is registered with id: // " for a view whose `when` is still false. So a createTreeView-backed view -// must NOT be gated on a context key that is false at activation/login. -// -// The Open Editors pane is the exception: it hides when no gemstone:// editor is -// open, so it IS gated on `gemstone.explorerHasOpenEditors` (false at login). That -// is safe ONLY because explorerOpenEditors.ts registers it with -// `registerTreeDataProvider`, which tolerates a hidden view, rather than -// createTreeView. These tests pin that split so neither half regresses (an empty -// pane always showing, or the login crash shipped in 1.8.1 coming back). +// must NOT be gated on a context key that is false at activation/login — that was +// the login crash shipped in 1.8.1. These tests pin that so it can't come back. const pkgPath = path.resolve(__dirname, '..', '..', '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); const explorerViews: Array<{ id: string; when?: string }> = pkg.contributes.views.gemstoneExplorer; -const OPEN_EDITORS = 'gemstoneExplorerOpenEditors'; - describe('GemStone Explorer views are registrable when created', () => { - it('registers an Open Editors pane', () => { - const ids = explorerViews.map((v) => v.id); - - expect(ids).toContain(OPEN_EDITORS); - }); - - it('hides the Open Editors pane when no editor is open (gated on active + content)', () => { - const openEditors = explorerViews.find((v) => v.id === OPEN_EDITORS); - - expect(openEditors?.when).toBe('gemstone.explorerActive && gemstone.explorerHasOpenEditors'); + it.each(explorerViews)('does not gate $id on a content key that is false at login', (view) => { + expect(view.when ?? '').not.toContain('explorerHasOpenEditors'); }); - - it('registers the content-gated Open Editors pane with registerTreeDataProvider, not createTreeView', () => { - const src = fs.readFileSync(path.resolve(__dirname, '..', 'explorerOpenEditors.ts'), 'utf-8'); - - expect(src).toContain('registerTreeDataProvider(VIEW_ID'); - expect(src).not.toContain('window.createTreeView('); - }); - - it.each(explorerViews.filter((v) => v.id !== OPEN_EDITORS))( - 'does not gate $id (a createTreeView pane) on a content key that is false at login', - (view) => { - expect(view.when ?? '').not.toContain('explorerHasOpenEditors'); - }, - ); }); // The Class Hierarchy pane starts collapsed so it doesn't crowd out the other diff --git a/client/src/__tests__/gemstoneFileSystemProvider.test.ts b/client/src/__tests__/gemstoneFileSystemProvider.test.ts index 0713c47c..46893ef8 100644 --- a/client/src/__tests__/gemstoneFileSystemProvider.test.ts +++ b/client/src/__tests__/gemstoneFileSystemProvider.test.ts @@ -22,6 +22,16 @@ vi.mock('../browserQueries', () => ({ compileClassDefinition: vi.fn(), setClassComment: vi.fn(), canClassBeWritten: vi.fn(() => true), + // Listing queries used by readDirectory (the breadcrumb drill-down). + getDictionaryNames: vi.fn(() => ['UserGlobals', 'Globals']), + getClassNames: vi.fn(() => ['Array', 'OrderedCollection']), + getMethodCategories: vi.fn(() => ['accessing', 'testing']), + getMethodList: vi.fn(() => [ + { isMeta: false, category: 'accessing', selector: 'at:' }, + { isMeta: false, category: 'accessing', selector: 'at:put:' }, + { isMeta: false, category: 'testing', selector: 'isEmpty' }, + { isMeta: true, category: 'instance creation', selector: 'new' }, + ]), })); // Keep the real gciLog but spy logInfo so the recategorize soft-failure log is observable. @@ -49,6 +59,7 @@ import { installStaleGemstoneTabReaper, escapeSelectorSlashes, parseUri, + parseDirUri, parseMethodUri, listOpenGemstoneTabs, } from '../gemstoneFileSystemProvider'; @@ -137,6 +148,151 @@ describe('GemStoneFileSystemProvider', () => { }); }); + describe('parseDirUri (breadcrumb drill-down classification)', () => { + it('classifies the session root as the dictionary list', () => { + expect(parseDirUri(Uri.parse('gemstone://1/'))?.kind).toBe('root'); + expect(parseDirUri(Uri.parse('gemstone://1'))?.kind).toBe('root'); + }); + + it('classifies a dictionary, a class, a side, and a category', () => { + expect(parseDirUri(Uri.parse('gemstone://1/Globals'))).toMatchObject({ + kind: 'dict', + dictName: 'Globals', + }); + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array'))).toMatchObject({ + kind: 'class', + className: 'Array', + }); + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array/instance'))).toMatchObject({ + kind: 'side', + isMeta: false, + }); + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array/class'))).toMatchObject({ + kind: 'side', + isMeta: true, + }); + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array/instance/accessing'))).toMatchObject( + { + kind: 'category', + category: 'accessing', + isMeta: false, + }, + ); + }); + + it('carries the ?dict index into deeper levels for scoped lookups', () => { + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array?dict=7'))).toMatchObject({ + kind: 'class', + className: 'Array', + dictIndex: 7, + }); + }); + + it('does NOT classify real files as directories', () => { + // method, definition, comment, new-class, new-method are files parseUri owns + expect( + parseDirUri(Uri.parse('gemstone://1/Globals/Array/instance/accessing/at%3A')), + ).toBeNull(); + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array/definition'))).toBeNull(); + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array/comment'))).toBeNull(); + expect(parseDirUri(Uri.parse('gemstone://1/UserGlobals/new-class'))).toBeNull(); + expect( + parseDirUri(Uri.parse('gemstone://1/Globals/Array/instance/accessing/new-method')), + ).toBeNull(); + // The 5-segment definition/comment display variants stay files too. + expect(parseDirUri(Uri.parse('gemstone://1/Globals/Array/definition/Array'))).toBeNull(); + }); + + it('ignores non-gemstone schemes', () => { + expect(parseDirUri(Uri.parse('file:///Globals/Array'))).toBeNull(); + }); + }); + + describe('stat (directory levels)', () => { + it('reports a read-only Directory for an intermediate breadcrumb URI', () => { + const stat = provider.stat(Uri.parse('gemstone://1/Globals/Array/instance')); + expect(stat.type).toBe(2); // FileType.Directory + expect(stat.permissions).toBe(FilePermission.Readonly); + }); + + it('still reports a File for a method URI', () => { + const stat = provider.stat(Uri.parse('gemstone://1/Globals/Array/instance/accessing/at%3A')); + expect(stat.type).toBe(1); // FileType.File + }); + }); + + describe('readDirectory (breadcrumb drill-down listing)', () => { + it('lists dictionaries at the root', () => { + const entries = provider.readDirectory(Uri.parse('gemstone://1/')); + expect(entries).toEqual([ + ['UserGlobals', 2], + ['Globals', 2], + ]); + }); + + it('lists classes in a dictionary', () => { + const entries = provider.readDirectory(Uri.parse('gemstone://1/Globals')); + expect(entries).toEqual([ + ['Array', 2], + ['OrderedCollection', 2], + ]); + }); + + it('lists the two sides plus the class definition under a class', () => { + const entries = provider.readDirectory(Uri.parse('gemstone://1/Globals/Array')); + expect(entries).toEqual([ + ['instance', 2], + ['class', 2], + ['definition', 1], + ]); + }); + + it('lists method categories under a side', () => { + const entries = provider.readDirectory(Uri.parse('gemstone://1/Globals/Array/instance')); + expect(entries).toEqual([ + ['accessing', 2], + ['testing', 2], + ]); + }); + + it('lists only the selectors of the matching side and category', () => { + const entries = provider.readDirectory( + Uri.parse('gemstone://1/Globals/Array/instance/accessing'), + ); + expect(entries).toEqual([ + ['at:', 1], + ['at:put:', 1], + ]); + }); + + it('escapes slashes in binary selectors so they survive the URI path', () => { + vi.mocked(queries.getMethodList).mockReturnValueOnce([ + { isMeta: false, category: 'arithmetic', selector: '/' }, + ]); + const entries = provider.readDirectory( + Uri.parse('gemstone://1/Globals/Number/instance/arithmetic'), + ); + expect(entries).toEqual([[escapeSelectorSlashes('/'), 1]]); + expect(entries[0][0]).not.toContain('/'); + }); + + it('returns an empty listing when a query throws (no broken breadcrumb)', () => { + vi.mocked(queries.getClassNames).mockImplementationOnce(() => { + throw new BrowserQueryError('session busy'); + }); + expect(provider.readDirectory(Uri.parse('gemstone://1/Globals'))).toEqual([]); + }); + + it('returns an empty listing for a dead session without reaping tabs', () => { + const mgr = { + getSessions: vi.fn(() => []), + getSession: vi.fn(() => undefined), + } as unknown as SessionManager; + const p = new GemStoneFileSystemProvider(mgr); + expect(p.readDirectory(Uri.parse('gemstone://99/Globals'))).toEqual([]); + }); + }); + describe('readFile', () => { it('reads a method source', () => { const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/at%3A'); diff --git a/client/src/__tests__/gemstoneHoverProvider.test.ts b/client/src/__tests__/gemstoneHoverProvider.test.ts index a8996555..623fdaa5 100644 --- a/client/src/__tests__/gemstoneHoverProvider.test.ts +++ b/client/src/__tests__/gemstoneHoverProvider.test.ts @@ -124,6 +124,23 @@ describe('GemStoneHoverProvider', () => { expect(md.value).toContain('0 implementors](command:gemstone.implementorsOfSelector?'); }); + it('survives implementorsOf throwing — degrades to senders-only, does not kill the hover', async () => { + // A thrown GCI query (busy session, browser/RB plugin absent) must not reject + // the whole hover and silently show nothing. + mockImplementorsOf.mockImplementation(() => { + throw new Error('session busy'); + }); + mockSendersOf.mockReturnValue(Array.from({ length: 4 }, () => ({}) as never)); + const resolver: SelectorResolver = { getSelector: vi.fn(async () => 'size') }; + const provider = new GemStoneHoverProvider(makeSessionManager(true), resolver); + const result = await provider.provideHover(makeDocument('self size'), pos(0, 5)); + + expect(result).not.toBeNull(); + const md = result!.contents as unknown as MarkdownString; + expect(md.value).toContain('4 senders](command:gemstone.sendersOfSelector?'); + expect(md.value).toContain('0 implementors](command:gemstone.implementorsOfSelector?'); + }); + it('shows singular "implementor" for one result', async () => { mockImplementorsOf.mockReturnValue([ { diff --git a/client/src/__tests__/gemstoneNavigationHistory.test.ts b/client/src/__tests__/gemstoneNavigationHistory.test.ts new file mode 100644 index 00000000..3d27d659 --- /dev/null +++ b/client/src/__tests__/gemstoneNavigationHistory.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +import { Uri } from '../__mocks__/vscode'; +import { GemstoneNavigationHistory } from '../gemstoneNavigationHistory'; + +const M1 = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; +const M2 = 'gemstone://1/Globals/Array/instance/accessing/at%3Aput%3A'; +const M3 = 'gemstone://1/Globals/OrderedCollection/instance/adding/add%3A'; + +describe('GemstoneNavigationHistory', () => { + let opened: string[]; + let openResult: boolean; + let history: GemstoneNavigationHistory; + + // Simulate VS Code reopening the URI and firing its activation echo, which the + // real onDidChangeActiveTextEditor subscription feeds back into record(). + function openImpl(uri: Uri): Promise { + opened.push(uri.toString()); + if (openResult) history.record(uri); + return Promise.resolve(openResult); + } + + beforeEach(() => { + opened = []; + openResult = true; + history = new GemstoneNavigationHistory(openImpl); + }); + + it('starts unable to go back or forward', () => { + expect(history.canGoBack()).toBe(false); + expect(history.canGoForward()).toBe(false); + }); + + it('ignores non-gemstone editors', () => { + history.record(Uri.parse('file:///tmp/foo.ts')); + expect(history.canGoBack()).toBe(false); + }); + + it('does not record consecutive duplicates', async () => { + history.record(Uri.parse(M1)); + history.record(Uri.parse(M1)); + expect(history.canGoBack()).toBe(false); + }); + + it('goes back and forward through the preview history', async () => { + history.record(Uri.parse(M1)); + history.record(Uri.parse(M2)); + history.record(Uri.parse(M3)); + + expect(history.canGoBack()).toBe(true); + expect(history.canGoForward()).toBe(false); + + await history.back(); + expect(opened).toEqual([M2]); + expect(history.canGoForward()).toBe(true); + + await history.back(); + expect(opened).toEqual([M2, M1]); + expect(history.canGoBack()).toBe(false); + + await history.forward(); + expect(opened).toEqual([M2, M1, M2]); + }); + + it('does not re-record its own back/forward navigation (no stack corruption)', async () => { + history.record(Uri.parse(M1)); + history.record(Uri.parse(M2)); + await history.back(); // reopens M1; the echo record(M1) must be ignored + // Forward must still be available — a corrupted stack would have truncated it. + expect(history.canGoForward()).toBe(true); + await history.forward(); + expect(opened).toEqual([M1, M2]); + }); + + it('truncates forward history when a new editor is visited after going back', async () => { + history.record(Uri.parse(M1)); + history.record(Uri.parse(M2)); + await history.back(); // now at M1, M2 is ahead + history.record(Uri.parse(M3)); // new branch — M2 is dropped + expect(history.canGoForward()).toBe(false); + await history.back(); + expect(opened).toEqual([M1, M1]); // reopened M1 for the back-nav, then M1 again + }); + + it('is a no-op at the ends', async () => { + history.record(Uri.parse(M1)); + await history.back(); + await history.forward(); + expect(opened).toEqual([]); + }); + + it('drops a stale entry that fails to reopen and keeps the cursor put', async () => { + history.record(Uri.parse(M1)); + history.record(Uri.parse(M2)); + history.record(Uri.parse(M3)); // cursor at M3 + openResult = false; // M2 can't be reopened (e.g. dead session) + await history.back(); + expect(opened).toEqual([M2]); // attempted M2 + // M2 pruned; a second back now reaches M1. + openResult = true; + await history.back(); + expect(opened).toEqual([M2, M1]); + }); +}); diff --git a/client/src/__tests__/loginEditorPanel.test.ts b/client/src/__tests__/loginEditorPanel.test.ts index 180eb742..0df159f7 100644 --- a/client/src/__tests__/loginEditorPanel.test.ts +++ b/client/src/__tests__/loginEditorPanel.test.ts @@ -294,7 +294,17 @@ describe('LoginEditorPanel', () => { it('renders a hint about leaving the password blank to be prompted', async () => { await LoginEditorPanel.show(storage, secretsArg, treeProvider); const panel = window.createWebviewPanel.mock.results[0].value; - expect(panel.webview.html).toContain('Leave password blank to be prompted on each login'); + expect(panel.webview.html).toContain('Leave it blank to be prompted on each login'); + }); + + it('renders the "Help me login" toggle with per-field help', async () => { + await LoginEditorPanel.show(storage, secretsArg, treeProvider); + const html = window.createWebviewPanel.mock.results[0].value.webview.html; + expect(html).toContain('id="helpToggle"'); + expect(html).toContain('Help me login'); + expect(html).toContain('class="field-help"'); + // The Host User/Password guidance — the field this help exists for. + expect(html).toContain('requires host authentication'); }); it('pre-fills password from SecretStorage when editing a keychain-backed login', async () => { diff --git a/client/src/__tests__/openEditorsStatusBar.test.ts b/client/src/__tests__/openEditorsStatusBar.test.ts new file mode 100644 index 00000000..fe69db1e --- /dev/null +++ b/client/src/__tests__/openEditorsStatusBar.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +vi.mock('vscode', () => import('../__mocks__/vscode.js')); +import { registerOpenEditorsStatusBar } from '../openEditorsStatusBar'; +import { Uri, TabInputText, window, commands } from '../__mocks__/vscode'; +import type * as vscode from 'vscode'; + +const CLOSE_ALL_COMMAND = 'gemstone.explorer.closeAllOpenEditors'; + +function openTabs(...uriStrings: string[]): void { + window.tabGroups.all = [ + { tabs: uriStrings.map((s) => ({ input: new TabInputText(Uri.parse(s)), isDirty: false })) }, + ]; +} + +function fakeContext(): vscode.ExtensionContext { + return { subscriptions: [] } as unknown as vscode.ExtensionContext; +} + +// The most recently created status-bar item — the one this module registers. +function statusItem() { + return vi.mocked(window.createStatusBarItem).mock.results.at(-1)!.value as { + text: string; + tooltip?: string; + command?: string; + show: ReturnType; + hide: ReturnType; + }; +} + +// The callback registered for the close-all command. +function closeAllHandler(): () => void { + const call = vi + .mocked(commands.registerCommand) + .mock.calls.find(([id]) => id === CLOSE_ALL_COMMAND); + return call![1] as () => void; +} + +describe('Close All GemStone Editors status-bar button', () => { + afterEach(() => { + window.tabGroups.all = []; + vi.clearAllMocks(); + }); + + it('shows a count and runs close-all when GemStone editors are open', () => { + openTabs('gemstone://1/Globals/Array/instance/accessing/at%3A'); + + registerOpenEditorsStatusBar(fakeContext()); + + const item = statusItem(); + expect(item.command).toBe(CLOSE_ALL_COMMAND); + expect(item.text).toContain('Close 1 GemStone editor'); + expect(item.show).toHaveBeenCalled(); + }); + + it('pluralizes the label for more than one editor', () => { + openTabs( + 'gemstone://1/Globals/Array/instance/accessing/at%3A', + 'gemstone://1/Globals/Array/instance/accessing/size', + ); + + registerOpenEditorsStatusBar(fakeContext()); + + expect(statusItem().text).toContain('Close 2 GemStone editors'); + }); + + it('counts a document split across editor groups once', () => { + const source = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; + window.tabGroups.all = [ + { tabs: [{ input: new TabInputText(Uri.parse(source)), isDirty: false }] }, + { tabs: [{ input: new TabInputText(Uri.parse(source)), isDirty: false }] }, + ]; + + registerOpenEditorsStatusBar(fakeContext()); + + expect(statusItem().text).toContain('1 GemStone editor'); + }); + + it('hides itself when no GemStone editor is open', () => { + openTabs(); + + registerOpenEditorsStatusBar(fakeContext()); + + expect(statusItem().hide).toHaveBeenCalled(); + expect(statusItem().show).not.toHaveBeenCalled(); + }); + + it('closes every open GemStone editor tab when invoked', () => { + openTabs( + 'gemstone://1/Globals/Array/instance/accessing/at%3A', + 'gemstone://1/Globals/Array/instance/accessing/size', + ); + registerOpenEditorsStatusBar(fakeContext()); + + closeAllHandler()(); + + const closed = vi.mocked(window.tabGroups.close).mock.calls.at(-1)![0] as unknown[]; + expect(closed).toHaveLength(2); + }); +}); diff --git a/client/src/__tests__/optionalSupportOffer.test.ts b/client/src/__tests__/optionalSupportOffer.test.ts index 073b6463..0b86ce0f 100644 --- a/client/src/__tests__/optionalSupportOffer.test.ts +++ b/client/src/__tests__/optionalSupportOffer.test.ts @@ -4,7 +4,9 @@ const mocks = vi.hoisted(() => { const config: Record = {}; return { config, - showInformationMessage: vi.fn<(...a: unknown[]) => Promise>(() => + // Resolves a MessageItem (the modal's chosen button) or a string/undefined + // (toasts, dismissal), so the return type is intentionally wide. + showInformationMessage: vi.fn<(...a: unknown[]) => Promise>(() => Promise.resolve(undefined), ), showWarningMessage: vi.fn<(...a: unknown[]) => Promise>(() => @@ -79,14 +81,21 @@ function baseSession(overrides: Partial = {}): ActiveSession { const getSelectedSession = vi.fn<() => ActiveSession | undefined>(); const sessionManager = { getSelectedSession } as unknown as SessionManager; -function answer(button: string | undefined) { - mocks.showInformationMessage.mockResolvedValue(button); +// The offer now passes MessageItem objects (so it can set isCloseAffordance on +// "Not Now"), and compares the resolved choice by identity — so resolve the actual +// item the modal was shown with, matched by title, not a bare string. +function answer(title: string | undefined) { + mocks.showInformationMessage.mockImplementation((...args: unknown[]) => { + const items = args.slice(2) as Array<{ title: string }>; + const match = title === undefined ? undefined : items.find((i) => i.title === title); + return Promise.resolve(match); + }); } -/** Button labels the modal was shown with (its variadic items). */ +/** Button labels the modal was shown with (the titles of its variadic items). */ function shownButtons(): string[] { const call = mocks.showInformationMessage.mock.calls[0]; - return call ? (call.slice(2) as string[]) : []; + return call ? (call.slice(2) as Array<{ title: string }>).map((i) => i.title) : []; } beforeEach(() => { @@ -160,12 +169,27 @@ describe('maybeOfferServerSupport', () => { expect(mocks.executeCommand).toHaveBeenCalledWith('gemstone.explorer.refresh'); }); - it('offers one modal with Install, Always, and Never', async () => { + it('offers one modal with Install, Not Now, Always, and Never', async () => { answer('Install'); await maybeOfferServerSupport(baseSession(), sessionManager, EXTENSION_PATH); - expect(shownButtons()).toEqual(['Install', 'Always', 'Never']); + expect(shownButtons()).toEqual(['Install', 'Not Now', 'Always', 'Never']); + }); + + it('marks "Not Now" as the modal close affordance so Escape declines without touching the setting', async () => { + answer('Not Now'); + + await maybeOfferServerSupport(baseSession(), sessionManager, EXTENSION_PATH); + + const items = mocks.showInformationMessage.mock.calls[0].slice(2) as Array<{ + title: string; + isCloseAffordance?: boolean; + }>; + expect(items.find((i) => i.title === 'Not Now')?.isCloseAffordance).toBe(true); + expect(mocks.installEI).not.toHaveBeenCalled(); + expect(mocks.installRB).not.toHaveBeenCalled(); + expect(mocks.update).not.toHaveBeenCalled(); }); it('installs both interactively when the user clicks Install, leaving the setting unchanged', async () => { diff --git a/client/src/__tests__/startHere.test.ts b/client/src/__tests__/startHere.test.ts new file mode 100644 index 00000000..5b9ec090 --- /dev/null +++ b/client/src/__tests__/startHere.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +import * as vscode from 'vscode'; +import { + startHereItems, + showStartHereMenu, + registerStartHere, + resetStartHere, + StartHereStatusBar, + START_HERE_RETIRED_KEY, +} from '../startHere'; + +type FakeContext = ConstructorParameters[0]; + +// Minimal ExtensionContext stand-in backed by a plain key/value store, enough for the +// globalState get/update the retirement gate uses. +function makeContext(initial: Record = {}): { + context: FakeContext; + store: Record; +} { + const store: Record = { ...initial }; + const context = { + globalState: { + get: (key: string, def?: unknown) => (key in store ? store[key] : def), + update: async (key: string, value: unknown) => { + store[key] = value; + }, + }, + } as unknown as FakeContext; + return { context, store }; +} + +const STATUS_COMMAND = 'gemstone.startHere.fromStatusBar'; + +describe('startHereItems', () => { + it('offers browse, search, workspace, and the tour, each dispatching a real command', () => { + const commands = startHereItems().map((i) => i.command); + expect(commands).toEqual([ + 'gemstone.findClass', + 'gemstone.search', + 'gemstone.openWorkspace', + 'gemstone.openWalkthrough', + ]); + for (const item of startHereItems()) { + expect(item.label.length).toBeGreaterThan(0); + expect(item.detail.length).toBeGreaterThan(0); + } + }); +}); + +describe('showStartHereMenu', () => { + beforeEach(() => vi.clearAllMocks()); + + it('runs the picked item’s command', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ + command: 'gemstone.search', + } as never); + await showStartHereMenu(); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('gemstone.search'); + }); + + it('does nothing when the menu is dismissed', async () => { + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined); + await showStartHereMenu(); + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); +}); + +describe('StartHereStatusBar', () => { + beforeEach(() => vi.clearAllMocks()); + + function setup(initial: Record = {}) { + const { context, store } = makeContext(initial); + const bar = new StartHereStatusBar(context); + const disposables = bar.register(); + const item = vi.mocked(vscode.window.createStatusBarItem).mock.results.at(-1)!.value; + return { bar, item, store, disposables }; + } + + it('shows on connect when not retired', () => { + const { bar, item } = setup(); + bar.showForConnection(); + expect(item.show).toHaveBeenCalledTimes(1); + }); + + it('does not show on connect once retired', () => { + const { bar, item } = setup({ [START_HERE_RETIRED_KEY]: true }); + bar.showForConnection(); + expect(item.show).not.toHaveBeenCalled(); + }); + + it('hides on disconnection without retiring', () => { + const { bar, item, store } = setup(); + bar.hideForDisconnection(); + expect(item.hide).toHaveBeenCalledTimes(1); + expect(store[START_HERE_RETIRED_KEY]).toBeUndefined(); + }); + + function clickButton() { + const handler = vi + .mocked(vscode.commands.registerCommand) + .mock.calls.find((c) => c[0] === STATUS_COMMAND)?.[1] as () => Promise; + expect(handler).toBeDefined(); + return handler(); + } + + it('opens the hub on click and offers a Hide entry', async () => { + setup(); + let offered: Array<{ command: string }> = []; + vi.mocked(vscode.window.showQuickPick).mockImplementationOnce(async (items: unknown) => { + offered = items as Array<{ command: string }>; + return undefined; + }); + await clickButton(); + expect(vscode.window.showQuickPick).toHaveBeenCalledTimes(1); + expect(offered.some((i) => i.command === '__startHere.hide')).toBe(true); + }); + + it('does NOT hide when the hub is dismissed (clicked away)', async () => { + const { item, store } = setup(); + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined); + await clickButton(); + expect(store[START_HERE_RETIRED_KEY]).toBeUndefined(); + expect(item.hide).not.toHaveBeenCalled(); + }); + + it('hides persistently only when the Hide entry is chosen', async () => { + const { item, store } = setup(); + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({ + command: '__startHere.hide', + } as never); + await clickButton(); + expect(store[START_HERE_RETIRED_KEY]).toBe(true); + expect(item.hide).toHaveBeenCalled(); + // A command is never dispatched for the Hide action. + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); + + it('does not offer the Hide entry from the plain Command Palette menu', async () => { + let offered: Array<{ command: string }> = []; + vi.mocked(vscode.window.showQuickPick).mockImplementationOnce(async (items: unknown) => { + offered = items as Array<{ command: string }>; + return undefined; + }); + await showStartHereMenu(); + expect(offered.some((i) => i.command === '__startHere.hide')).toBe(false); + }); +}); + +describe('resetStartHere', () => { + it('un-retires the button so it shows again on the next connect', async () => { + const { context, store } = makeContext({ [START_HERE_RETIRED_KEY]: true }); + await resetStartHere(context); + expect(store[START_HERE_RETIRED_KEY]).toBeUndefined(); + }); +}); + +describe('registerStartHere', () => { + beforeEach(() => vi.clearAllMocks()); + + it('registers the gemstone.startHere command', () => { + registerStartHere(); + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + 'gemstone.startHere', + expect.any(Function), + ); + }); +}); diff --git a/client/src/__tests__/walkthroughContent.test.ts b/client/src/__tests__/walkthroughContent.test.ts index 42f654a0..c0eaeaee 100644 --- a/client/src/__tests__/walkthroughContent.test.ts +++ b/client/src/__tests__/walkthroughContent.test.ts @@ -93,11 +93,12 @@ describe('Getting Started walkthrough content', () => { expect(databasesWelcome?.contents).toContain('command:gemstone.createDatabase'); }); - // The walkthrough auto-opens on activation (startup), not on connecting or on - // revealing a view — so the Reset command's title must describe the real - // trigger rather than the stale "on Next Connect". - it('describes the reset command by its real trigger (startup)', () => { - expect(resetCommand?.title.toLowerCase()).toContain('startup'); - expect(resetCommand?.title.toLowerCase()).not.toContain('connect'); + // Reset re-arms two first-run surfaces: the walkthrough (auto-opens on startup) + // and the Start Here status-bar button (shows on connect). The title names both + // so the user knows what comes back, rather than describing a single trigger. + it('names both surfaces the reset command restores', () => { + const title = resetCommand?.title.toLowerCase(); + expect(title).toContain('walkthrough'); + expect(title).toContain('start here'); }); }); diff --git a/client/src/activeEditorDecoration.ts b/client/src/activeEditorDecoration.ts index 9ad2a010..65646b5f 100644 --- a/client/src/activeEditorDecoration.ts +++ b/client/src/activeEditorDecoration.ts @@ -1,15 +1,14 @@ import * as vscode from 'vscode'; // Tints the tree row whose gemstone:// source is the CURRENTLY ACTIVE editor — -// the matching method in the Methods pane and the matching row in the Open Editors -// pane — so you can see at a glance which method/class the front editor is. A -// tree's own selection goes muted grey once focus moves into the editor, leaving no -// strong link back to the row; this decoration is focus-independent. +// the matching method in the Methods pane — so you can see at a glance which +// method/class the front editor is. A tree's own selection goes muted grey once +// focus moves into the editor, leaving no strong link back to the row; this +// decoration is focus-independent. // -// Colour only (no badge), so it composes with the unsaved-changes dot -// (DirtyDecorationProvider) rather than competing for the single badge slot. Uses -// the same FileDecoration mechanism the git/SCM views use for row badges (each row -// carries a resourceUri). +// Colour only (no badge), so it composes with any row badge rather than competing +// for the single badge slot. Uses the same FileDecoration mechanism the git/SCM +// views use for row badges (each row carries a resourceUri). export class ActiveEditorDecorationProvider implements vscode.FileDecorationProvider { private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChangeFileDecorations = this._onDidChange.event; diff --git a/client/src/explorerOpenEditors.ts b/client/src/explorerOpenEditors.ts deleted file mode 100644 index aad94c96..00000000 --- a/client/src/explorerOpenEditors.ts +++ /dev/null @@ -1,199 +0,0 @@ -import * as vscode from 'vscode'; -import { parseUri, listOpenGemstoneTabs } from './gemstoneFileSystemProvider'; -import { classifyGemstoneUri, OpenEditorKind } from './explorerOpenEditorsLabel'; - -// The Open Editors pane: a live mirror of the currently-open gemstone:// source -// editors, shown as the FIRST (top) pane of the GemStone Explorer container. -// There is no pinning or persistence — a row appears when its editor opens and -// disappears when it closes. Entries are split into two groups: class definition -// editors ("Classes") and method source editors ("Methods"). Clicking a row -// focuses that editor. When no gemstone editors are open the pane shows no rows. -// -// The view is hidden entirely when no gemstone editors are open: its `when` is -// `gemstone.explorerActive && gemstone.explorerHasOpenEditors`, and we drive the -// `explorerHasOpenEditors` context key from the open-tab count (initially and on -// every tab change). We use `registerTreeDataProvider` (NOT `createTreeView`) -// because the latter throws "No view is registered with id: …" while the view's -// `when` is false, whereas `registerTreeDataProvider` simply binds the provider -// and tolerates the view being currently hidden — so an empty pane never shows. - -const VIEW_ID = 'gemstoneExplorerOpenEditors'; -const REVEAL_COMMAND = 'gemstone.explorer.revealOpenEditor'; -const CLOSE_COMMAND = 'gemstone.explorer.closeOpenEditor'; -const CLOSE_ALL_COMMAND = 'gemstone.explorer.closeAllOpenEditors'; -const ITEM_CONTEXT = 'explorerOpenEditorItem'; - -// Group headers, in display order. Only non-empty groups are shown. -const GROUPS: { kind: OpenEditorKind; label: string; icon: string }[] = [ - { kind: 'class', label: 'Classes', icon: 'symbol-class' }, - { kind: 'comment', label: 'Comments', icon: 'book' }, - { kind: 'method', label: 'Methods', icon: 'symbol-method' }, -]; - -class GroupItem extends vscode.TreeItem { - constructor( - readonly kind: OpenEditorKind, - label: string, - ) { - super(label, vscode.TreeItemCollapsibleState.Expanded); - this.id = `group:${kind}`; - this.contextValue = 'explorerOpenEditorGroup'; - } -} - -class EditorItem extends vscode.TreeItem { - constructor( - label: string, - readonly uri: vscode.Uri, - icon: string, - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.id = uri.toString(); - this.resourceUri = uri; - this.iconPath = new vscode.ThemeIcon(icon); - this.tooltip = uri.toString(); - this.contextValue = ITEM_CONTEXT; - this.command = { command: REVEAL_COMMAND, title: 'Reveal Open Editor', arguments: [uri] }; - } -} - -interface Entry { - kind: OpenEditorKind; - label: string; - uri: vscode.Uri; -} - -// Every open gemstone:// source tab, classified and de-duplicated by URI (the -// same document split across editor groups yields one row). -function openEntries(): Entry[] { - const seen = new Set(); - const out: Entry[] = []; - for (const { uri } of listOpenGemstoneTabs()) { - const key = uri.toString(); - if (seen.has(key)) continue; - seen.add(key); - let entry: { kind: OpenEditorKind; label: string } | undefined; - try { - entry = classifyGemstoneUri(parseUri(uri)); - } catch { - entry = undefined; - } // unrecognized URI shape → skip - if (entry) out.push({ ...entry, uri }); - } - return out; -} - -class OpenEditorsProvider implements vscode.TreeDataProvider { - private readonly _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - refresh(): void { - this._onDidChangeTreeData.fire(); - } - getTreeItem(element: vscode.TreeItem): vscode.TreeItem { - return element; - } - - getChildren(element?: vscode.TreeItem): vscode.TreeItem[] { - const entries = openEntries(); - if (!element) { - // Top level: one header per non-empty group (Classes, Comments, Methods). - return GROUPS.filter((g) => entries.some((e) => e.kind === g.kind)).map( - (g) => new GroupItem(g.kind, g.label), - ); - } - if (element instanceof GroupItem) { - const icon = GROUPS.find((g) => g.kind === element.kind)!.icon; - return entries - .filter((e) => e.kind === element.kind) - .sort((a, b) => a.label.localeCompare(b.label)) - .map((e) => new EditorItem(e.label, e.uri, icon)); - } - return []; - } -} - -// A gemstone:// URI is "dirty" when any open tab for it has unsaved edits. -function isDirtyUri(uri: vscode.Uri): boolean { - const key = uri.toString(); - return listOpenGemstoneTabs().some((t) => t.uri.toString() === key && t.tab.isDirty); -} - -// Marks unsaved rows in the Open Editors pane with a small dot, mirroring the -// unsaved-dot VS Code paints on the editor tab. Uses the same FileDecoration -// mechanism the git/SCM views use for row badges (each row carries a -// resourceUri). No color, so it reads as a neutral dot and doesn't tint the -// label. Scoped to gemstone:// so it never touches other resources. -export class DirtyDecorationProvider implements vscode.FileDecorationProvider { - private readonly _onDidChange = new vscode.EventEmitter(); - readonly onDidChangeFileDecorations = this._onDidChange.event; - - // Dirty state isn't encoded in the URI, so VS Code caches per-URI decorations - // until we tell it they may have changed. Fire only the open gemstone source - // URIs rather than `undefined` (which would invalidate every decoration in - // the workbench, git badges included) on each tab change. - refresh(): void { - this._onDidChange.fire(listOpenGemstoneTabs().map((t) => t.uri)); - } - - provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { - if (uri.scheme !== 'gemstone') return undefined; - if (!isDirtyUri(uri)) return undefined; - return { badge: '●', tooltip: 'Unsaved changes', propagate: false }; - } -} - -// Close the open editor tab(s) for one URI (the same document may be split -// across editor groups). -async function closeEditor(uri: vscode.Uri): Promise { - const key = uri.toString(); - const tabs = listOpenGemstoneTabs() - .filter((t) => t.uri.toString() === key) - .map((t) => t.tab); - if (tabs.length) await vscode.window.tabGroups.close(tabs); -} - -// Close every open gemstone:// source editor at once. -async function closeAllEditors(): Promise { - const tabs = listOpenGemstoneTabs().map((t) => t.tab); - if (tabs.length) await vscode.window.tabGroups.close(tabs); -} - -// Keep the `explorerHasOpenEditors` context key in sync with whether any -// gemstone:// source editor is open, so the view's `when` hides an empty pane. -function syncHasOpenEditorsContext(): void { - void vscode.commands.executeCommand( - 'setContext', - 'gemstone.explorerHasOpenEditors', - openEntries().length > 0, - ); -} - -export function registerExplorerOpenEditors(context: vscode.ExtensionContext): void { - const provider = new OpenEditorsProvider(); - const decorations = new DirtyDecorationProvider(); - syncHasOpenEditorsContext(); - - context.subscriptions.push( - vscode.window.registerTreeDataProvider(VIEW_ID, provider), - vscode.window.registerFileDecorationProvider(decorations), - // A tab opening, closing, or changing its dirty state rebuilds the pane's - // rows, refreshes the unsaved-dot decorations, and re-evaluates whether the - // pane should be shown at all. - vscode.window.tabGroups.onDidChangeTabs(() => { - provider.refresh(); - decorations.refresh(); - syncHasOpenEditorsContext(); - }), - vscode.commands.registerCommand(REVEAL_COMMAND, (uri?: vscode.Uri) => { - if (uri instanceof vscode.Uri) { - void vscode.window.showTextDocument(uri, { preview: false, preserveFocus: false }); - } - }), - // Row inline close button — the argument is the clicked EditorItem. - vscode.commands.registerCommand(CLOSE_COMMAND, (item?: { uri?: vscode.Uri }) => { - if (item?.uri instanceof vscode.Uri) void closeEditor(item.uri); - }), - // View-title "Close All Editors". - vscode.commands.registerCommand(CLOSE_ALL_COMMAND, () => void closeAllEditors()), - ); -} diff --git a/client/src/explorerOpenEditorsLabel.ts b/client/src/explorerOpenEditorsLabel.ts deleted file mode 100644 index d8696dda..00000000 --- a/client/src/explorerOpenEditorsLabel.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ParsedUri } from './gemstoneFileSystemProvider'; - -// The kinds of GemStone editor the Open Editors pane groups by. -export type OpenEditorKind = 'class' | 'comment' | 'method'; - -export interface OpenEditorEntry { - kind: OpenEditorKind; - label: string; -} - -// Pure. Classifies an open gemstone:// source tab for the Open Editors pane: -// a class-definition editor ('class', labelled by class name) or a method -// source editor ('method', labelled `Class>>selector` — `Class (class)>>…` for -// the class side, with a " (base)" suffix when it shows the persistent base -// source) or a class comment editor ('comment', labelled by class name). -// Returns undefined for tabs that are not browsable — the new-class / new-method -// templates or the read-only override-diff comparison view — so the pane omits -// them. -export function classifyGemstoneUri(parsed: ParsedUri): OpenEditorEntry | undefined { - switch (parsed.kind) { - case 'method': { - if (parsed.diffView) return undefined; // read-only comparison view - const receiver = parsed.isMeta ? `${parsed.className} (class)` : parsed.className; - const suffix = parsed.base ? ' (base)' : ''; - return { kind: 'method', label: `${receiver}>>${parsed.selector}${suffix}` }; - } - case 'definition': - return { kind: 'class', label: parsed.className }; - case 'comment': - return { kind: 'comment', label: parsed.className }; - case 'new-class': - case 'new-method': - return undefined; - } -} diff --git a/client/src/extension.ts b/client/src/extension.ts index 13e12bd2..a95346ed 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -90,6 +90,7 @@ import { refreshRefactoringSupportAvailable } from './refactoring/refactoringAva import { supportsEnhancedInspector } from './enhancedInspector/enhancedInspectorInstall'; import { DebuggerPanel } from './debuggerPanel'; import { InlineValuesCodeLensProvider } from './inlineValuesCodeLens'; +import { GemstoneNavigationHistory } from './gemstoneNavigationHistory'; import { GemStoneFileSystemProvider, MethodCompiledEvent, @@ -100,6 +101,7 @@ import { parseUri, } from './gemstoneFileSystemProvider'; import { openWorkspace } from './workspace'; +import { registerStartHere, StartHereStatusBar, resetStartHere } from './startHere'; import { openTutorialNotebook } from './tutorialNotebook'; import { GemStoneDebugSession } from './gemstoneDebugSession'; import { InspectorTreeProvider, InspectorNode } from './inspectorTreeProvider'; @@ -1034,6 +1036,68 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(sessionManager.onDidChangeSelection(() => updateStatusBar())); updateStatusBar(); + // ── Status Bar: Connect Feedback (left) ──────────────── + // A dedicated left-aligned item carries the connecting/failed states — that is + // where the user's eyes already are during a connect. It is separate from the + // right-hand Active Session item, which stays the calm persistent state. + // • connecting: a spinner while the attempt (which may start the stone) runs. + // • success: the spinner is cleared, the GemStone Explorer is revealed, and a + // green ✅ banner flashes at the top of it for a few seconds (see the + // explorer's showConnectedBanner). The status bar cannot render green, and a + // webview flash was far too large — the banner is unobtrusive and theme-safe. + // • failure: the item turns red and becomes a click-through to the failure + // reason, since the toast that first reported it may already be gone. It + // persists until the next attempt. + const connectStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + context.subscriptions.push(connectStatusItem); + let lastLoginError: string | undefined; + + // A "Start Here" status-bar button pointing a new user at the basics (browse a + // class, search, open a workspace, take the tour). Shown on connect below; stays + // until the user hides it from its own menu (issue #468, item 10). + const startHereStatusBar = new StartHereStatusBar(context); + context.subscriptions.push( + ...startHereStatusBar.register(), + // When the last session goes away, hide the button until the next connect. + sessionManager.onDidRemoveSession(() => { + if (sessionManager.getSessions().length === 0) startHereStatusBar.hideForDisconnection(); + }), + ); + + function showConnecting(stone: string): void { + lastLoginError = undefined; + connectStatusItem.color = undefined; + connectStatusItem.backgroundColor = undefined; + connectStatusItem.command = undefined; + connectStatusItem.text = `$(sync~spin) GemStone: connecting to ${stone}…`; + connectStatusItem.tooltip = undefined; + connectStatusItem.show(); + } + + function flashConnected(stone: string): void { + lastLoginError = undefined; + connectStatusItem.hide(); + void vscode.commands.executeCommand('workbench.view.extension.gemstoneExplorer'); + explorer.showConnectedBanner(stone); + startHereStatusBar.showForConnection(); + } + + function showLoginError(message: string): void { + lastLoginError = message; + connectStatusItem.color = undefined; + connectStatusItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + connectStatusItem.command = 'gemstone.showLastLoginError'; + connectStatusItem.text = '$(error) GemStone: login failed'; + connectStatusItem.tooltip = 'Click to see why the connection failed'; + connectStatusItem.show(); + } + + context.subscriptions.push( + vscode.commands.registerCommand('gemstone.showLastLoginError', () => { + void vscode.window.showErrorMessage(lastLoginError ?? 'No recent GemStone login error.'); + }), + ); + // Drive the `gemstone.enhancedInspectorSupported` context key off the selected // session's version, so the "Install Enhanced Inspector Support" command is // only offered where it can actually work (see package.json commandPalette @@ -1285,6 +1349,27 @@ export function activate(context: vscode.ExtensionContext) { } }; + // Back/Forward history for gemstone:// editors (drives the title-bar arrows). + // Reopens as a preview so it reuses the single method tab, matching the flow it + // retraces; returns false when the URI can't be shown so its entry is pruned. + const gsHistory = new GemstoneNavigationHistory(async (uri) => { + try { + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc, { preview: true }); + return true; + } catch { + return false; + } + }); + if (vscode.window.activeTextEditor) { + gsHistory.record(vscode.window.activeTextEditor.document.uri); + } + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) gsHistory.record(editor.document.uri); + }), + ); + // ── Commands ─────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand( @@ -1318,6 +1403,15 @@ export function activate(context: vscode.ExtensionContext) { }, ), + // Thin wrappers so editor-history Back/Forward can appear as title-bar icon + // buttons on gemstone:// editors (a menu entry needs an icon our own command + // supplies). They walk gsHistory — our own view history — rather than VS + // Code's built-in Go Back/Forward, because a method opened in the reusable + // preview tab isn't recorded by the built-in history (that only tracks + // pinned/distinct tabs), so a first-time user couldn't get back. + vscode.commands.registerCommand('gemstone.navigateBack', () => gsHistory.back()), + vscode.commands.registerCommand('gemstone.navigateForward', () => gsHistory.forward()), + vscode.commands.registerCommand('gemstone.addLogin', () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises -- FIXME: unhandled floating promise; needs investigation to decide await vs. void vs. .catch before this rule is enabled repo-wide LoginEditorPanel.show(storage, context.secrets, treeProvider, undefined, sysadminStorage); @@ -1377,6 +1471,8 @@ export function activate(context: vscode.ExtensionContext) { await openWorkspace(); }), + registerStartHere(), + vscode.commands.registerCommand('gemstone.openTutorial', async () => { await openTutorialNotebook(); }), @@ -1438,9 +1534,13 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand('gemstone.resetGettingStarted', async () => { await context.globalState.update(GETTING_STARTED_SEEN_KEY, undefined); + // Also un-retire the "Start Here" status-bar button, so one reset restores + // every first-run onboarding surface. + await resetStartHere(context); const openNow = 'Open Walkthrough Now'; const choice = await vscode.window.showInformationMessage( - 'Getting Started reset — the walkthrough will open automatically the next time VS Code starts.', + 'Getting Started reset — the walkthrough will open automatically the next time VS Code starts, ' + + 'and the "Start Here" button will show again on your next connect.', openNow, ); if (choice === openNow) { @@ -1630,13 +1730,14 @@ export function activate(context: vscode.ExtensionContext) { // it back to `string | undefined` inside the async closure below. const resolvedGciPath = gciPath; treeProvider.setConnecting(item.login, true); - const connectingStatus = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 0, - ); - connectingStatus.text = `$(sync~spin) GemStone: connecting to ${login.stone}…`; - connectingStatus.show(); - + // Drive the left-hand connect-status item through this attempt. showConnecting + // also clears any leftover red "login failed" state from a prior attempt. + showConnecting(login.stone); + + // Captured across the recovery flow so the failure feedback (toast + red + // status bar) can report the reason even when the retry path swallowed the + // original throw. + let failureMessage: string | undefined; let session: ActiveSession | undefined; try { session = await vscode.window.withProgress( @@ -1658,6 +1759,7 @@ export function activate(context: vscode.ExtensionContext) { // the case, offers to start it, and reports the outcome — // including re-showing this error untouched when it cannot help. const msg = e instanceof Error ? e.message : String(e); + failureMessage = `Login failed: ${msg}`; let recovered: ActiveSession | undefined; await maybeStartDatabaseAndRetry(login, `Login failed: ${msg}`, { getDatabases: () => sysadminStorage.getDatabases(), @@ -1672,7 +1774,10 @@ export function activate(context: vscode.ExtensionContext) { await setAutoStartMode(mode); }, confirm: confirmStartDatabase, - showError: (m) => vscode.window.showErrorMessage(m), + showError: (m) => { + failureMessage = m; + vscode.window.showErrorMessage(m); + }, report: (m) => progress.report({ message: m }), retryLogin: async () => { recovered = await sessionManager.loginAsync(login, resolvedGciPath); @@ -1689,16 +1794,23 @@ export function activate(context: vscode.ExtensionContext) { // that rejects shows only "command failed", with nothing about which // login or why. const msg = e instanceof Error ? e.message : String(e); - vscode.window.showErrorMessage(`Login failed: ${msg}`); + failureMessage = `Login failed: ${msg}`; + vscode.window.showErrorMessage(failureMessage); + showLoginError(failureMessage); return; } finally { treeProvider.setConnecting(item.login, false); - connectingStatus.dispose(); + // The connect-status item is not cleared here: the outcome code below + // (flashConnected / showLoginError) sets its final connected/failed state. } // Undefined when the login failed and the recovery flow could not (or - // was not allowed to) rescue it. It has already reported why. - if (!session) return; + // was not allowed to) rescue it. It has already shown a toast; mirror that + // in the status bar so the reason survives after the toast dismisses. + if (!session) { + showLoginError(failureMessage ?? 'Login failed'); + return; + } refreshEnhancedInspectorAvailable(session); refreshRefactoringSupportAvailable(session); @@ -1707,6 +1819,7 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.showInformationMessage( `Connected to ${login.stone} (${session.stoneVersion}) on ${login.gem_host} as ${login.gs_user}`, ); + flashConnected(login.stone); // eslint-disable-next-line @typescript-eslint/no-floating-promises -- FIXME: unhandled floating promise; needs investigation to decide await vs. void vs. .catch before this rule is enabled repo-wide exportManager.exportSession(session, true); // We no longer auto-open a workspace on every connect (it left a dirty, @@ -1716,6 +1829,9 @@ export function activate(context: vscode.ExtensionContext) { // user connects rather than after. The workspace stays available via the // gemstone.openWorkspace command and the Logins & Sessions welcome view. + // The "Start Here" status-bar button (shown from flashConnected above) points + // a new user at the basics; see StartHereStatusBar (issue #468, item 10). + // Offer the optional server-side supports this stone lacks (Enhanced // Inspector + refactoring engine) as one bundle, per // gemstone.serverSupport.autoInstall: `always` installs silently, `ask` diff --git a/client/src/gemstoneExplorer.ts b/client/src/gemstoneExplorer.ts index f9ae94d1..ea5f613b 100644 --- a/client/src/gemstoneExplorer.ts +++ b/client/src/gemstoneExplorer.ts @@ -26,7 +26,7 @@ import { } from './explorerMethodFilter'; import { DoubleClickDetector } from './explorerDoubleClick'; import { categoryChildNodes, categoryParentPath, categoryMatches } from './explorerCategories'; -import { registerExplorerOpenEditors } from './explorerOpenEditors'; +import { registerOpenEditorsStatusBar } from './openEditorsStatusBar'; import { SourceEditorPlacement } from './sourceEditorPlacement'; import { generateAndSaveGrailStub } from './grailStubGenerator'; import { @@ -199,8 +199,8 @@ export async function openGemstoneDocument( // A set of interconnected navigation panes that cascade left-to-right: // Dictionaries → Class Categories → Classes → Methods (side ▸ category ▸ sel) // Selecting a method opens its source in an editor; the ↗ inline action (or -// right-click ▸ Open to the Side) opens it in a balanced editor group. The -// Open Editors pane mirrors the currently-open source editors. +// right-click ▸ Open to the Side) opens it in a balanced editor group. A +// status-bar button tallies the open source editors and closes them all at once. // // The panes live in their own `gemstoneExplorer` sidebar container. All four share // one controller that holds the cascade state, the current dictionary's @@ -446,7 +446,9 @@ export class MethodItem extends vscode.TreeItem { const arg = encodeURIComponent(JSON.stringify([{ selector: info.selector, isMeta }])); const cmd = (id: string) => `command:gemstone.explorer.${id}?${arg}`; - const lines = ['Click to open · $(pin) pins it to the side']; + const lines = [ + 'Single-click previews (one reusable tab) · double-click or $(pin) keeps it open', + ]; lines.push(`[Implementors](${cmd('implementorsOf')}) · [Senders](${cmd('sendersOf')})`); if (info.overrideBits & 1) { lines.push( @@ -648,6 +650,19 @@ interface ExplorerViews { method: vscode.TreeView; } +// Whether to fire the one-time "how to keep methods open" hint. It fires the first +// time a single-click preview REPLACES a different previously previewed method — +// the moment the reused preview tab makes a first method appear to be lost. Not on +// the very first open (nothing has been replaced yet), not when re-opening the same +// method, and never once it has been shown. +export function shouldHintKeepMethodsOpen( + prevKey: string | undefined, + key: string, + alreadyShown: boolean, +): boolean { + return !alreadyShown && prevKey !== undefined && prevKey !== key; +} + // ── Controller ─────────────────────────────────────────────────────────────── /** The last-known outcome of one test class or test method, as the Explorer needs it. */ @@ -860,6 +875,8 @@ export class ExplorerController { /** Called once per class removed by Remove Class, so views holding a cached class corpus (GemStone * Search) can drop it. Per class, not per command: the delete takes the whole subtree. */ private readonly onClassRemoved?: (sessionId: number, className: string) => void, + /** Extension global storage, used only to fire the one-time "how to keep methods open" hint. */ + private readonly globalState?: vscode.Memento, /** Test affordances on class/method rows. Absent in tests that don't exercise them, * and before the SUnit controller exists. */ private readonly sunit?: ExplorerSunitHooks, @@ -3847,11 +3864,39 @@ export class ExplorerController { // permanent one (focus stays in the tree so type-to-filter / arrow-nav keep // working); the 📌 action pins a real tab so methods can be compared. await openGemstoneDocument(doc, mode, this.placement); + // The first time a single click is about to REPLACE a previously previewed + // method (the exact moment a first-time user watches their method disappear), + // explain once how to keep methods open. + if (mode === 'preview') this.maybeHintKeepMethodsOpen(`${node.isMeta}:${node.info.selector}`); // Under an active ivar filter, highlight the filtered ivar in the just-opened // source (it may not be the active editor, so refresh all visible editors). this.refreshIvarHighlights(); } + // The key (`isMeta:selector`) of the last method opened as a preview, so we can + // detect when a new single click is about to replace it. + private lastPreviewedKey?: string; + private static readonly KEEP_METHODS_HINT_KEY = 'gemstone.explorer.keepMethodsOpenHintShown'; + + // A first-time user single-clicks a method, then single-clicks another and the + // first vanishes — the preview tab is reused, and it isn't obvious the method is + // still reachable or how to keep both open (issue #468). Fire a one-time toast at + // exactly that moment: the second, different preview open. It names both gestures + // that keep a method open (double-click, or the Keep Method Open button). + private maybeHintKeepMethodsOpen(key: string): void { + const prev = this.lastPreviewedKey; + this.lastPreviewedKey = key; + if (!this.globalState) return; + const alreadyShown = !!this.globalState.get(ExplorerController.KEEP_METHODS_HINT_KEY); + if (!shouldHintKeepMethodsOpen(prev, key, alreadyShown)) return; + void this.globalState.update(ExplorerController.KEEP_METHODS_HINT_KEY, true); + void vscode.window.showInformationMessage( + 'Methods open in a single reusable preview tab, so clicking another method replaces the last. ' + + 'Double-click a method — or use its 📌 Keep Method Open button — to keep it open while you browse others.', + 'Got it', + ); + } + // Every environment the user has asked to see, so a scan covers the same ground the // Senders / Implementors commands do. private environmentsToScan(): number[] { @@ -5719,6 +5764,9 @@ export interface ExplorerHandle { onMethodCompiled(sessionId: number, className: string): void; onClassCompiled(sessionId: number, className: string, dictName?: string): void; onSessionAborted(sessionId: number): void; + /** Flash a green ✅ connection-success banner atop the Dictionaries view for a + * few seconds (called after a successful login). */ + showConnectedBanner(stone: string): void; /** Claim an about-to-happen open so it navigates the panes; see * ExplorerController.markAttributedOpen. */ markAttributedOpen(uri: vscode.Uri): void; @@ -5747,7 +5795,13 @@ export function registerGemStoneExplorer( // built after this one. sunit?: ExplorerSunitHooks, ): ExplorerHandle { - const ctl = new ExplorerController(sessionManager, onSymbolListChanged, onClassRemoved, sunit); + const ctl = new ExplorerController( + sessionManager, + onSymbolListChanged, + onClassRemoved, + context.globalState, + sunit, + ); // A run starting or finishing changes what these rows should say, so repaint the // three panes that carry test affordances. Cheap — the providers rebuild rows from @@ -5762,9 +5816,10 @@ export function registerGemStoneExplorer( ); } - // The Open Editors pane (last in the container) mirrors the open gemstone:// - // source editors; it is session-independent, so it registers on its own. - registerExplorerOpenEditors(context); + // A status-bar "Close All GemStone Editors" button, tallying the open + // gemstone:// source editors; it is session-independent, so it registers on + // its own. + registerOpenEditorsStatusBar(context); // Gate the downstream panes (and swap the Dictionaries welcome) on whether a // session is available to browse. @@ -6390,11 +6445,30 @@ export function registerGemStoneExplorer( ivarHighlightDecoration, ); + // A green connection-success banner at the top of the Dictionaries view, shown + // briefly after a login. The ✅ emoji renders green in every theme (including + // High Contrast), and TreeView.message sits above the tree without stealing space + // or focus — unlike a status-bar color (which can't be green) or a webview panel + // (which is far too large for a transient flash). + const CONNECTED_BANNER_MS = 5000; + let connectedBannerTimer: ReturnType | undefined; + function showConnectedBanner(stone: string): void { + if (connectedBannerTimer) clearTimeout(connectedBannerTimer); + const message = `✅ Connected to ${stone}`; + dictView.message = message; + connectedBannerTimer = setTimeout(() => { + connectedBannerTimer = undefined; + // Only clear our own banner — a newer message (or another connect) wins. + if (dictView.message === message) dictView.message = undefined; + }, CONNECTED_BANNER_MS); + } + return { onMethodCompiled: (sessionId, className) => ctl.onExternalMethodCompiled(sessionId, className), onClassCompiled: (sessionId, className, dictName) => ctl.onExternalClassCompiled(sessionId, className, dictName), onSessionAborted: (sessionId) => ctl.onSessionAborted(sessionId), + showConnectedBanner, markAttributedOpen: (uri) => ctl.markAttributedOpen(uri), clearAttributedOpen: (uri) => ctl.clearAttributedOpen(uri), revealDocument: (uri) => ctl.revealDocument(uri), diff --git a/client/src/gemstoneFileSystemProvider.ts b/client/src/gemstoneFileSystemProvider.ts index 6415ad86..11e56b6b 100644 --- a/client/src/gemstoneFileSystemProvider.ts +++ b/client/src/gemstoneFileSystemProvider.ts @@ -165,6 +165,95 @@ export function parseUri(uri: vscode.Uri): ParsedUri { throw vscode.FileSystemError.FileNotFound(uri); } +// ── Directory URIs (native editor breadcrumb drill-down) ────── +// The breadcrumb over an open gemstone:// method treats each ancestor path +// segment as a folder. VS Code fills a crumb's dropdown by calling readDirectory +// on the matching URI, so classifying these partial paths as directories — and +// listing their children from the stone — turns the otherwise inert breadcrumb +// into a live class-browser drill-down: +// / → dictionaries +// /{dict} → classes in the dictionary +// /{dict}/{Class} → instance / class (+ the class definition) +// /{dict}/{Class}/{side} → method categories on that side +// /{dict}/{Class}/{side}/{category} → selectors (each opens the method) +// Two native limits are inherent, not bugs: picking a crumb entry opens it in the +// current editor group (VS Code's breadcrumb can't be redirected to a pinned side +// tab), and the child URI VS Code builds drops the ?dict/?env query, so navigation +// resolves by dictionary name at the base environment. +type DirLevel = + | { kind: 'root'; sessionId: number } + | { kind: 'dict'; sessionId: number; dictName: string; dictIndex?: number } + | { kind: 'class'; sessionId: number; dictName: string; className: string; dictIndex?: number } + | { + kind: 'side'; + sessionId: number; + dictName: string; + className: string; + isMeta: boolean; + dictIndex?: number; + } + | { + kind: 'category'; + sessionId: number; + dictName: string; + className: string; + isMeta: boolean; + category: string; + dictIndex?: number; + }; + +// The two method-side segments — the only 4th/5th path segments that mark a +// browsable folder. Everything else at that depth (definition/comment/new-method) +// is a real file that parseUri owns. +const SIDE_SEGMENTS = ['instance', 'class']; + +// Classify a gemstone:// URI as a browsable directory level, or null when it is a +// real file (method/definition/comment/new-*) or another scheme. Pure path +// shape — no stone round-trip — so stat stays cheap on every breadcrumb render. +export function parseDirUri(uri: vscode.Uri): DirLevel | null { + if (uri.scheme !== 'gemstone') return null; + const sessionId = parseInt(uri.authority, 10); + if (Number.isNaN(sessionId)) return null; + const parts = uri.path.split('/').map(decodeURIComponent); + // parts[0] is '' (leading /). A bare authority or a lone '/' is the root. + const dictMatch = uri.query?.match(/(?:^|&)dict=(\d+)(?:&|$)/); + const dictIndex = dictMatch ? parseInt(dictMatch[1], 10) : undefined; + + if (parts.length <= 1 || (parts.length === 2 && parts[1] === '')) { + return { kind: 'root', sessionId }; + } + if (parts.length === 2) { + return { kind: 'dict', sessionId, dictName: parts[1], dictIndex }; + } + if (parts.length === 3) { + if (parts[2] === 'new-class') return null; // a file, not a folder + return { kind: 'class', sessionId, dictName: parts[1], className: parts[2], dictIndex }; + } + if (parts.length === 4 && SIDE_SEGMENTS.includes(parts[3])) { + return { + kind: 'side', + sessionId, + dictName: parts[1], + className: parts[2], + isMeta: parts[3] === 'class', + dictIndex, + }; + } + if (parts.length === 5 && SIDE_SEGMENTS.includes(parts[3])) { + return { + kind: 'category', + sessionId, + dictName: parts[1], + className: parts[2], + isMeta: parts[3] === 'class', + category: parts[4], + dictIndex, + }; + } + // definition/comment (4 or 5 segments), method/new-method (6+) — real files. + return null; +} + // A saved method's coordinates, recovered from its gemstone:// source URI. export interface MethodUriRef { sessionId: number; @@ -469,6 +558,17 @@ export class GemStoneFileSystemProvider implements vscode.FileSystemProvider { stat(uri: vscode.Uri): vscode.FileStat { logInfo(`[FS] stat ${uri.toString()}`); + // An intermediate segment of a method URI is a browsable folder (the native + // breadcrumb drill-down); classify by shape only — no stone round-trip. + if (parseDirUri(uri)) { + return { + type: vscode.FileType.Directory, + ctime: 0, + mtime: 0, + size: 0, + permissions: vscode.FilePermission.Readonly, + }; + } const stat: vscode.FileStat = { type: vscode.FileType.File, ctime: 0, @@ -493,7 +593,50 @@ export class GemStoneFileSystemProvider implements vscode.FileSystemProvider { return stat; } - readDirectory(): [string, vscode.FileType][] { + // List a directory level's children for the native editor breadcrumb (see + // parseDirUri). Runs stone queries lazily — only when a crumb dropdown is + // opened — and degrades to an empty list on any query error so a hiccup yields + // an empty dropdown rather than a broken breadcrumb. + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const dir = parseDirUri(uri); + if (!dir) return []; + // Resolve the session directly (not getSession, which reaps stale tabs) — + // browsing a breadcrumb must never close editors as a side effect. + const session = this.sessionManager.getSessions().find((s) => s.id === dir.sessionId); + if (!session) return []; + const { File, Directory } = vscode.FileType; + try { + switch (dir.kind) { + case 'root': + return queries + .getDictionaryNames(session) + .map((n): [string, vscode.FileType] => [n, Directory]); + case 'dict': + return queries + .getClassNames(session, dir.dictIndex ?? dir.dictName) + .map((n): [string, vscode.FileType] => [n, Directory]); + case 'class': + return [ + ['instance', Directory], + ['class', Directory], + ['definition', File], + ]; + case 'side': + return queries + .getMethodCategories(session, dir.className, dir.isMeta, dir.dictIndex ?? dir.dictName) + .map((c): [string, vscode.FileType] => [c, Directory]); + case 'category': + return queries + .getMethodList(session, dir.className) + .filter((m) => m.isMeta === dir.isMeta && m.category === dir.category) + .map((m): [string, vscode.FileType] => [escapeSelectorSlashes(m.selector), File]); + } + } catch (e) { + logInfo( + `[FS] readDirectory ${uri.toString()} → ${e instanceof Error ? e.message : String(e)}`, + ); + return []; + } return []; } diff --git a/client/src/gemstoneHoverProvider.ts b/client/src/gemstoneHoverProvider.ts index e21ae586..edf60a66 100644 --- a/client/src/gemstoneHoverProvider.ts +++ b/client/src/gemstoneHoverProvider.ts @@ -56,7 +56,15 @@ export class GemStoneHoverProvider implements vscode.HoverProvider { if (selector) { const env = vscode.workspace.getConfiguration('gemstone').get('maxEnvironment', 0); - const results = queries.implementorsOf(session, selector, env); + // A thrown query (busy session, browser/RB plugin absent) must not reject + // the whole hover — that silently shows nothing. Degrade to no implementors, + // mirroring the sendersOf guard below. + let results: ReturnType; + try { + results = queries.implementorsOf(session, selector, env); + } catch { + results = []; + } // Senders count (cached — sendersOf is costly and a hover fires easily). const sKey = `${selector}|${session.id}|${env}`; diff --git a/client/src/gemstoneNavigationHistory.ts b/client/src/gemstoneNavigationHistory.ts new file mode 100644 index 00000000..63003a90 --- /dev/null +++ b/client/src/gemstoneNavigationHistory.ts @@ -0,0 +1,78 @@ +import * as vscode from 'vscode'; + +// A Back/Forward history of the gemstone:// editors the user has viewed, so the +// title-bar arrows can retrace steps even when every method opens in the SAME +// reusable preview tab. VS Code's own navigation history doesn't record those +// same-tab preview swaps as distinct locations — it only works once tabs are +// pinned/distinct — so a first-time user single-clicking through methods can't +// get back. This models a browser's history: a linear stack with a cursor, where +// navigating to a new editor truncates any forward entries. +// +// The class owns only the stack logic; the caller injects `open` (reopen a URI) +// so it stays unit-testable without the VS Code window. +export class GemstoneNavigationHistory { + private readonly history: string[] = []; + private cursor = -1; + // The URI our own back()/forward() is about to activate. Reopening it fires an + // onDidChangeActiveTextEditor echo; matching it here consumes that echo so it + // isn't recorded as a fresh navigation (which would corrupt the stack). A + // one-shot compare sidesteps the timing races a boolean "suppress" flag has — + // the activation event can arrive after the open() promise resolves. + private expected: string | null = null; + + // `open` reopens a URI and resolves true on success, false when it can't be + // shown (dead session, deleted method) so the stale entry can be dropped. + constructor(private readonly open: (uri: vscode.Uri) => Promise) {} + + // Record that a gemstone:// editor became active. No-ops for other schemes, for + // the echo of our own back/forward, and for a repeat of the current entry. + record(uri: vscode.Uri): void { + if (uri.scheme !== 'gemstone') return; + const key = uri.toString(); + if (this.expected === key) { + this.expected = null; + return; + } + this.expected = null; + if (this.cursor >= 0 && this.history[this.cursor] === key) return; + this.history.length = this.cursor + 1; // drop any forward history + this.history.push(key); + this.cursor = this.history.length - 1; + } + + canGoBack(): boolean { + return this.cursor > 0; + } + + canGoForward(): boolean { + return this.cursor >= 0 && this.cursor < this.history.length - 1; + } + + async back(): Promise { + if (this.canGoBack()) await this.step(this.cursor - 1); + } + + async forward(): Promise { + if (this.canGoForward()) await this.step(this.cursor + 1); + } + + private async step(target: number): Promise { + const key = this.history[target]; + this.expected = key; + let ok = false; + try { + ok = await this.open(vscode.Uri.parse(key)); + } catch { + ok = false; + } + if (ok) { + this.cursor = target; + return; + } + // Couldn't reopen: drop the stale entry and keep the cursor on the current + // editor, so a second press tries the next one along instead of getting stuck. + this.expected = null; + this.history.splice(target, 1); + if (target < this.cursor) this.cursor--; + } +} diff --git a/client/src/loginEditorPanel.ts b/client/src/loginEditorPanel.ts index 4a19bcd8..9e2c0057 100644 --- a/client/src/loginEditorPanel.ts +++ b/client/src/loginEditorPanel.ts @@ -145,6 +145,16 @@ export class LoginEditorPanel { readOnly: this.readOnly, }); break; + case 'openDocs': + // GemStone System Administration Guide — "Logging in Gem Sessions": the + // authoritative reference for login parameters, NRS syntax, and how the + // NetLDI starts the Gem. + void vscode.env.openExternal( + vscode.Uri.parse( + 'https://downloads.gemtalksystems.com/docs/GemStone64/3.4.x/GS64-SysAdminGuide-3.4/1-Introduction.htm#pgfId-1621117', + ), + ); + break; } }, null, @@ -293,6 +303,42 @@ export class LoginEditorPanel { opacity: 0.7; margin-top: 4px; } + a.doc-link { + color: var(--vscode-textLink-foreground); + cursor: pointer; + text-decoration: none; + } + a.doc-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; + } + .help-row { + margin: 4px 0 8px; + } + /* Per-field help, revealed by the "Help me login" toggle. The left accent bar + visually ties each note to the field above it. */ + .field-help { + display: none; + font-size: 0.9em; + opacity: 0.85; + margin-top: 4px; + padding-left: 8px; + border-left: 2px solid var(--vscode-focusBorder); + } + body.help-on .field-help { + display: block; + } + .help-intro { + display: none; + padding: 8px 12px; + margin-bottom: 12px; + border-radius: 2px; + background: var(--vscode-inputValidation-infoBackground, var(--vscode-editorWidget-background)); + border: 1px solid var(--vscode-inputValidation-infoBorder, var(--vscode-focusBorder)); + } + body.help-on .help-intro { + display: block; + } .banner { display: none; padding: 8px 12px; @@ -306,6 +352,18 @@ export class LoginEditorPanel {

GemStone Login Parameters

+
+ +
+ +
+ Fill in the fields below to connect to a stone; each field's help explains what to + enter. Host User/Password are needed only for a remote stone whose NetLDI requires + host authentication. For a full explanation of how GemStone logins work — NRS + syntax, linked vs. RPC logins, and how the NetLDI starts your Gem — see GemStone's + Logging in Gem Sessions guide. +
+ @@ -313,21 +371,25 @@ export class LoginEditorPanel {
+
The GemStone version whose GCI client library Jasper uses. It must match the version of the stone you are connecting to.
+
The machine where your Gem process runs. Use localhost for a stone on this computer, or the remote host's name or IP address for a remote stone.
+
The name of the running stone (repository monitor) to log in to — for example, gs64stone.
-
Accepts a NetLDI service name (e.g. gs64ldi) or a port number (e.g. 50377), useful for remote stones.
+
The NetLDI network server that launches your Gem. Enter its service name (e.g. gs64ldi) or its port number (e.g. 50377). A port number is often easiest for a remote stone.
+
Your GemStone user — a UserProfile inside the repository, such as DataCurator. This is a GemStone account, not your operating-system login.
@@ -335,15 +397,16 @@ export class LoginEditorPanel {
-
Leave password blank to be prompted on each login.
+
The password for the GemStone user. Leave it blank to be prompted on each login, or check the box above to store it securely in your OS keychain.
- + - + +
The operating-system account on the Gem's host machine. Fill these in only when the remote NetLDI requires host authentication; leave them blank for a local stone or a guest-mode NetLDI. If left blank, your own OS user is used.
@@ -351,7 +414,7 @@ export class LoginEditorPanel {
-
Keeps a read-only .gemstone mirror in sync on login/commit. Turn off for slow or remote connections where the initial sync isn't worth it — server-side search still works.
+
Keeps a read-only .gemstone mirror in sync on login/commit. Turn off for slow or remote connections where the initial sync isn't worth it — server-side search still works.
@@ -364,6 +427,19 @@ export class LoginEditorPanel { const fields = ['version','gem_host','stone','gs_user','gs_password','netldi','host_user','host_password']; let originalLabel = null; + let helpOn = false; + function setHelp(on) { + helpOn = on; + document.body.classList.toggle('help-on', on); + const btn = document.getElementById('helpToggle'); + btn.textContent = on ? 'Hide help' : 'Help me login'; + btn.setAttribute('aria-expanded', String(on)); + } + document.getElementById('helpToggle').addEventListener('click', () => setHelp(!helpOn)); + document.getElementById('docsLink').addEventListener('click', () => { + vscode.postMessage({ command: 'openDocs' }); + }); + vscode.postMessage({ command: 'requestData' }); window.addEventListener('message', event => { @@ -406,6 +482,11 @@ export class LoginEditorPanel { } document.getElementById('readOnlyBanner').style.display = readOnly ? 'block' : 'none'; document.querySelector('.button-row').style.display = readOnly ? 'none' : 'flex'; + + // Default the help open for a brand-new login (the first-use case this is + // for); keep it closed when editing/viewing an existing one so it stays + // out of the way. The user can toggle it either way. + setHelp(!readOnly && !originalLabel); } }); diff --git a/client/src/openEditorsStatusBar.ts b/client/src/openEditorsStatusBar.ts new file mode 100644 index 00000000..824b603f --- /dev/null +++ b/client/src/openEditorsStatusBar.ts @@ -0,0 +1,56 @@ +import * as vscode from 'vscode'; +import { listOpenGemstoneTabs } from './gemstoneFileSystemProvider'; + +// A status-bar "Close All GemStone Editors" button. It replaces the former Open +// Editors Explorer pane: the open editors are already visible as editor tabs, so +// the pane's only unique value was one-click "close everything" — which a +// status-bar item preserves without stealing a pane's worth of height from the +// Explorer. The item shows a live count and closes every open gemstone:// source +// editor on click; it hides itself when nothing is open. The count is expected to +// grow beyond source editors as inspectors/debuggers join it, which is exactly +// where an always-visible tally earns its place over a tab strip. + +const CLOSE_ALL_COMMAND = 'gemstone.explorer.closeAllOpenEditors'; + +// Distinct open gemstone:// source URIs (one document split across editor groups +// counts once), matching what "close all" actually closes. +function openEditorUris(): vscode.Uri[] { + const seen = new Set(); + const out: vscode.Uri[] = []; + for (const { uri } of listOpenGemstoneTabs()) { + const key = uri.toString(); + if (seen.has(key)) continue; + seen.add(key); + out.push(uri); + } + return out; +} + +// Close every open gemstone:// source editor at once (all tabs, across groups). +async function closeAllEditors(): Promise { + const tabs = listOpenGemstoneTabs().map((t) => t.tab); + if (tabs.length) await vscode.window.tabGroups.close(tabs); +} + +export function registerOpenEditorsStatusBar(context: vscode.ExtensionContext): void { + const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + item.command = CLOSE_ALL_COMMAND; + + const refresh = () => { + const count = openEditorUris().length; + if (count === 0) { + item.hide(); + return; + } + item.text = `$(close-all) Close ${count} GemStone editor${count === 1 ? '' : 's'}`; + item.tooltip = 'Close all open GemStone editors'; + item.show(); + }; + refresh(); + + context.subscriptions.push( + item, + vscode.window.tabGroups.onDidChangeTabs(refresh), + vscode.commands.registerCommand(CLOSE_ALL_COMMAND, () => void closeAllEditors()), + ); +} diff --git a/client/src/optionalSupportOffer.ts b/client/src/optionalSupportOffer.ts index 8a2a2a4b..374074c2 100644 --- a/client/src/optionalSupportOffer.ts +++ b/client/src/optionalSupportOffer.ts @@ -7,9 +7,10 @@ * On connect (`maybeOfferServerSupport`), per that setting: * - `never` → do nothing. * - `always` → install whatever is missing, silently. - * - `ask` → show one modal (Install / Always / Never / dismiss) that installs + * - `ask` → show one modal (Install / Not Now / Always / Never) that installs * the missing supports, or none. "Always"/"Never" remember the - * choice; dismiss asks again next connect. + * choice; "Not Now" (also Escape / the window-close control) asks + * again next connect. * The Command Palette entry (`runInstallServerSupport`) installs/reinstalls every * support applicable to the stone's version. * @@ -189,15 +190,21 @@ export async function maybeOfferServerSupport( return; } - const INSTALL = 'Install'; - const ALWAYS = 'Always'; - const NEVER = 'Never'; + const INSTALL: vscode.MessageItem = { title: 'Install' }; + const ALWAYS: vscode.MessageItem = { title: 'Always' }; + const NEVER: vscode.MessageItem = { title: 'Never' }; + // "Not Now" IS the modal's close affordance (isCloseAffordance) — Escape and the + // window-close control resolve to it — so declining reads as an explicit, labeled + // choice instead of a generic "Cancel" that looks like it aborts the login. It + // declines this connect only: the setting stays at "ask", so the offer returns + // next time (that is why it is "Not Now" and not "Never"). + const NOT_NOW: vscode.MessageItem = { title: 'Not Now', isCloseAffordance: true }; const names = missing.map((f) => f.label).join(' and '); // Modal (not a toast): a one-time setup decision that is too easily missed as a - // notification. Buttons mirror the original Enhanced Inspector offer: - // Install / Always / Never, plus the modal's implicit Cancel ("not now"). + // notification. Order mirrors the choices' permanence: install once, decline once, + // then the two sticky "remember this" options. const choice = await vscode.window.showInformationMessage( - `Install optional GemStone support on "${base.login.stone}"?`, + `Install recommended GemStone support on "${base.login.stone}"?`, { modal: true, detail: @@ -207,6 +214,7 @@ export async function maybeOfferServerSupport( 'Choose "Always" or "Never" to remember your choice for stones without it.', }, INSTALL, + NOT_NOW, ALWAYS, NEVER, ); @@ -220,7 +228,7 @@ export async function maybeOfferServerSupport( if (choice === INSTALL || choice === ALWAYS) { await installFeatures(base, sessionManager, extensionPath, true, missing); } - // Cancelled/dismissed: leave the setting at "ask" and do nothing. + // "Not Now" / dismissed: leave the setting at "ask" and do nothing. } /** @@ -240,7 +248,7 @@ export async function runInstallServerSupport( const applicable = SERVER_SUPPORT_FEATURES.filter((f) => f.isApplicable(base)); if (applicable.length === 0) { vscode.window.showInformationMessage( - `No optional GemStone support applies to ${base.stoneVersion}.`, + `No recommended GemStone support applies to ${base.stoneVersion}.`, ); return; } @@ -283,7 +291,7 @@ export async function runUninstallServerSupport(sessionManager: SessionManager): const installed = installedFeatures(base, SERVER_SUPPORT_FEATURES); if (installed.length === 0) { vscode.window.showInformationMessage( - `No optional GemStone support is installed on "${base.login.stone}".`, + `No recommended GemStone support is installed on "${base.login.stone}".`, ); return; } @@ -293,7 +301,7 @@ export async function runUninstallServerSupport(sessionManager: SessionManager): // Modal (not a toast): a destructive, committed change the user must confirm. // Text mirrors the install offer so the two are recognizably a pair. const choice = await vscode.window.showWarningMessage( - `Uninstall optional GemStone support from "${base.login.stone}"?`, + `Uninstall recommended GemStone support from "${base.login.stone}"?`, { modal: true, detail: diff --git a/client/src/refactoring/extractMethodCommand.ts b/client/src/refactoring/extractMethodCommand.ts index cae827d5..1be41e10 100644 --- a/client/src/refactoring/extractMethodCommand.ts +++ b/client/src/refactoring/extractMethodCommand.ts @@ -209,8 +209,8 @@ export async function extractMethodCommand(sessions: SessionManager): Promise void | Promise): Promise { + const items = startHereItems(); + if (onHide) { + items.push({ + label: '$(eye-closed) Hide the Start Here button', + detail: 'Bring it back later with “GemStone: Reset Getting Started”', + command: HIDE_ACTION, + }); + } + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'New to Jasper? Start here…', + matchOnDetail: true, + }); + if (!picked) return; + if (picked.command === HIDE_ACTION) { + await onHide?.(); + return; + } + await vscode.commands.executeCommand(picked.command); +} + +// A left status-bar "Start Here" button for a newly-connected user. It shows on +// connect and stays put — it never auto-retires (it's unobtrusive enough to leave for +// a power user), so the only ways it goes away are the explicit "Hide the Start Here +// button" menu entry or losing the connection. A hide is persisted, and +// `gemstone.resetGettingStarted` brings it back. +export class StartHereStatusBar { + private readonly item: vscode.StatusBarItem; + + constructor(private readonly context: vscode.ExtensionContext) { + this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 90); + this.item.text = '$(mortar-board) GemStone - Start Here'; + // Purple foreground to stand out from the neutral status bar. `charts.purple` is + // a theme-defined color, so it stays a sensible purple in light and dark themes + // rather than a fixed hex that reads wrong in one of them. (Only the status-bar + // background is restricted to warning/error; the foreground color is free.) + this.item.color = new vscode.ThemeColor('charts.purple'); + this.item.tooltip = + 'New to Jasper? Browse a GemStone class, search your code, open a workspace, or take the tour.'; + this.item.command = START_HERE_STATUS_COMMAND; + } + + private get hidden(): boolean { + return !!this.context.globalState.get(START_HERE_RETIRED_KEY); + } + + // Persistently hide the button (the menu's explicit Hide action). Reversible via + // resetStartHere. + private async hidePermanently(): Promise { + this.item.hide(); + await this.context.globalState.update(START_HERE_RETIRED_KEY, true); + } + + // Show the button on connect, unless the user has hidden it. + showForConnection(): void { + if (!this.hidden) this.item.show(); + } + + // Hide when the last session goes away. Not persisted: a later reconnect shows it + // again (unless it was hidden explicitly). + hideForDisconnection(): void { + this.item.hide(); + } + + register(): vscode.Disposable[] { + return [ + this.item, + // Clicking the button just opens the hub — dismissing the hub leaves the button + // in place. It goes away only via the hub's explicit Hide entry. + vscode.commands.registerCommand(START_HERE_STATUS_COMMAND, () => + showStartHereMenu(() => this.hidePermanently()), + ), + ]; + } +} + +// Register the `gemstone.startHere` command (the hub, also reachable from the status +// bar button and the Command Palette). +export function registerStartHere(): vscode.Disposable { + return vscode.commands.registerCommand('gemstone.startHere', () => showStartHereMenu()); +} + +// Un-hide the Start Here button so it shows again on the next connect — folded into +// the `gemstone.resetGettingStarted` command so one reset re-arms every first-run +// surface. +export async function resetStartHere(context: vscode.ExtensionContext): Promise { + await context.globalState.update(START_HERE_RETIRED_KEY, undefined); +} diff --git a/client/src/systemBrowser.ts b/client/src/systemBrowser.ts index c11b1b6a..0f890c9b 100644 --- a/client/src/systemBrowser.ts +++ b/client/src/systemBrowser.ts @@ -2420,9 +2420,19 @@ export class SystemBrowser { background-color: var(--vscode-list-hoverBackground); } + /* Selection has to stay clearly visible in every theme, including High + Contrast. This is a webview, so it does NOT get VS Code's automatic + focused/unfocused list treatment or its High-Contrast selection outline, and + the activeSelection background alone reads faint (or empty) there — which is + why the selected class/method were nearly invisible on High Contrast. Draw a + full outline around the selected row in the focus color (which High-Contrast + themes render as a bold, high-contrast color) so selection always shows a + visible box in every theme, on top of the theme selection background. */ .column-list .item.selected { - background-color: var(--vscode-list-activeSelectionBackground); + background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-inactiveSelectionBackground)); color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-focusBorder, var(--vscode-list-focusOutline, #007acc)); + outline-offset: -1px; } .column-list .item.virtual { diff --git a/package.json b/package.json index 8d608768..c52d59c2 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,16 @@ "node": ">=22.15.1" }, "devEngines": { - "packageManager": { "name": "npm", "version": ">=11.16.0", "onFail": "error" }, - "runtime": { "name": "node", "version": ">=22.15.1", "onFail": "error" } + "packageManager": { + "name": "npm", + "version": ">=11.16.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": ">=22.15.1", + "onFail": "error" + } }, "icon": "resources/gemstone-icon.png", "categories": [ @@ -137,7 +145,10 @@ "properties": { "gemstone.omniSearch.ui": { "type": "string", - "enum": ["spotter", "panel"], + "enum": [ + "spotter", + "panel" + ], "enumDescriptions": [ "Editor-tab Spotter: a webview in an editor tab with labeled scope tabs, a source-preview pane, own match highlighting, and an always-on case indicator.", "Bottom-panel view (default): the same webview docked in the panel strip (next to Terminal/Output). Stays out of the way; results open in the editor area above it." @@ -147,7 +158,11 @@ }, "gemstone.omniSearch.matchMode": { "type": "string", - "enum": ["fuzzy", "substring", "prefix"], + "enum": [ + "fuzzy", + "substring", + "prefix" + ], "enumDescriptions": [ "Subsequence match: the typed characters appear in order (the Quick-Open feel).", "The typed text appears as a contiguous run.", @@ -237,7 +252,12 @@ "type": "array", "items": { "type": "string", - "enum": ["classes", "methods", "dictionaries", "globals"] + "enum": [ + "classes", + "methods", + "dictionaries", + "globals" + ] }, "default": [], "markdownDescription": "Scopes to leave out of the **All** search while keeping their own tab, so you can still search them on purpose. Useful for **methods**, which queries the stone on every keystroke. This is different from `#gemstone.omniSearch.categories#`, which removes a scope altogether (no tab, no search). Source, Literals and Class Categories are always outside All and so cannot be listed here. This is the value the panel **starts** with; the **Scopes** button in the panel changes it for the current session without changing this setting." @@ -406,12 +426,12 @@ "never" ], "enumDescriptions": [ - "When a stone lacks the optional support, show one Install / Always / Never prompt on connect.", - "Install the optional support automatically on connect (uses the SystemUser default password; a notification explains if it must be entered).", - "Never offer or install the optional support." + "When a stone lacks the recommended support, show one Install / Not Now / Always / Never prompt on connect.", + "Install the recommended support automatically on connect (uses the SystemUser default password; a notification explains if it must be entered).", + "Never offer or install the recommended support." ], "default": "ask", - "markdownDescription": "What Jasper does when you connect to a stone missing the optional server-side support — the **Enhanced Inspector** (GemStone 3.7.5+) and the **refactoring engine** (all releases), installed together: `ask` shows one Install / Always / Never prompt, `always` installs silently, `never` does nothing. Installing needs a SystemUser login and commits the supporting classes to the database.\n\n▶ [Install GemStone support now](command:gemstone.installServerSupport)\n\n▶ [Uninstall GemStone support](command:gemstone.uninstallServerSupport) — removes both from the stone (does nothing if neither is installed; also needs a SystemUser login and commits the removal)" + "markdownDescription": "What Jasper does when you connect to a stone missing the recommended server-side support — the **Enhanced Inspector** (GemStone 3.7.5+) and the **refactoring engine** (all releases), installed together: `ask` shows one Install / Not Now / Always / Never prompt, `always` installs silently, `never` does nothing. Installing needs a SystemUser login and commits the supporting classes to the database.\n\n▶ [Install GemStone support now](command:gemstone.installServerSupport)\n\n▶ [Uninstall GemStone support](command:gemstone.uninstallServerSupport) — removes both from the stone (does nothing if neither is installed; also needs a SystemUser login and commits the removal)" } } }, @@ -512,14 +532,6 @@ } ], "gemstoneExplorer": [ - { - "id": "gemstoneExplorerOpenEditors", - "name": "Open Editors", - "type": "tree", - "icon": "$(go-to-file)", - "when": "gemstone.explorerActive && gemstone.explorerHasOpenEditors", - "size": 1 - }, { "id": "gemstoneExplorerDicts", "name": "Dictionaries", @@ -741,10 +753,32 @@ "onCommand:gemstone.inspectIt" ] }, + { + "id": "browseClasses", + "title": "Browse classes", + "description": "Open the GemStone Explorer and click a class — its methods appear in the Methods pane below. Or jump straight to any class by name.\n[Find Class…](command:gemstone.findClass)", + "media": { + "markdown": "resources/walkthrough/browseClasses.md" + }, + "completionEvents": [ + "onCommand:gemstone.findClass" + ] + }, + { + "id": "search", + "title": "Search your code", + "description": "One box searches across classes, methods, and method source — the fastest way to find something when you don't know where it lives.\n[GemStone Search…](command:gemstone.search)", + "media": { + "markdown": "resources/walkthrough/search.md" + }, + "completionEvents": [ + "onCommand:gemstone.search" + ] + }, { "id": "serverSupport", - "title": "Install optional server support", - "description": "Jasper can install two optional server-side supports: the Enhanced Inspector (rich object views, GemStone 3.7.5+) and the refactoring engine (rename instance variable, and more). On connect it offers to install whatever a stone is missing; the `gemstone.serverSupport.autoInstall` setting (ask / always / never) controls that.\n[Install GemStone Support…](command:gemstone.installServerSupport)\n\n[Uninstall GemStone Support…](command:gemstone.uninstallServerSupport) removes both from the stone (does nothing if neither is installed).", + "title": "Install recommended server support", + "description": "Jasper can install two recommended server-side supports: the Enhanced Inspector (rich object views, GemStone 3.7.5+) and the refactoring engine (rename instance variable, and more). On connect it offers to install whatever a stone is missing; the `gemstone.serverSupport.autoInstall` setting (ask / always / never) controls that.\n[Install GemStone Support…](command:gemstone.installServerSupport)\n\n[Uninstall GemStone Support…](command:gemstone.uninstallServerSupport) removes both from the stone (does nothing if neither is installed).", "media": { "markdown": "resources/walkthrough/enhancedInspector.md" }, @@ -915,9 +949,14 @@ "category": "GemStone", "icon": "$(mortar-board)" }, + { + "command": "gemstone.startHere", + "title": "Start Here", + "category": "GemStone" + }, { "command": "gemstone.resetGettingStarted", - "title": "Reset Getting Started (Show Walkthrough on Next Startup)", + "title": "Reset Getting Started (Walkthrough + Start Here Button)", "category": "GemStone" }, { @@ -1138,6 +1177,18 @@ "category": "GemStone", "icon": "$(search)" }, + { + "command": "gemstone.navigateBack", + "title": "GemStone Go Back", + "category": "GemStone", + "icon": "$(arrow-left)" + }, + { + "command": "gemstone.navigateForward", + "title": "GemStone Go Forward", + "category": "GemStone", + "icon": "$(arrow-right)" + }, { "command": "gemstone.toggleSelectorBreakpoint", "title": "Toggle Selector Breakpoint", @@ -1168,17 +1219,6 @@ "category": "GemStone", "icon": "$(discard)" }, - { - "command": "gemstone.explorer.revealOpenEditor", - "title": "Reveal Open Editor", - "category": "GemStone" - }, - { - "command": "gemstone.explorer.closeOpenEditor", - "title": "Close Editor", - "category": "GemStone", - "icon": "$(close)" - }, { "command": "gemstone.explorer.closeAllOpenEditors", "title": "Close All GemStone Editors", @@ -1187,12 +1227,12 @@ }, { "command": "gemstone.explorer.openSelectedMethodToSide", - "title": "Pin Method", + "title": "Keep Method Open (Pin)", "category": "GemStone" }, { "command": "gemstone.explorer.openMethodToSide", - "title": "Pin Method", + "title": "Keep Method Open (Pin)", "category": "GemStone", "icon": "$(pin)" }, @@ -1892,6 +1932,14 @@ "command": "gemstone.explorer.classClicked", "when": "false" }, + { + "command": "gemstone.navigateBack", + "when": "false" + }, + { + "command": "gemstone.navigateForward", + "when": "false" + }, { "command": "gemstone.renameTemporary", "when": "false" @@ -2004,14 +2052,6 @@ "command": "gemstone.explorer.splitClass", "when": "false" }, - { - "command": "gemstone.explorer.revealOpenEditor", - "when": "false" - }, - { - "command": "gemstone.explorer.closeOpenEditor", - "when": "false" - }, { "command": "gemstone.explorer.openSelectedMethodToSide", "when": "false" @@ -2137,11 +2177,6 @@ "when": "view == gemstoneExplorerDicts", "group": "navigation@5" }, - { - "command": "gemstone.explorer.closeAllOpenEditors", - "when": "view == gemstoneExplorerOpenEditors", - "group": "navigation" - }, { "command": "gemstoneExplorerDicts.filter", "when": "view == gemstoneExplorerDicts", @@ -2394,11 +2429,6 @@ "when": "view == gemstoneExplorerMethods && viewItem =~ /\\.test$/", "group": "1_browse@0" }, - { - "command": "gemstone.explorer.closeOpenEditor", - "when": "view == gemstoneExplorerOpenEditors && viewItem == explorerOpenEditorItem", - "group": "inline@0" - }, { "command": "gemstone.explorer.implementorsOf", "when": "view == gemstoneExplorerMethods && viewItem =~ /^explorerMethod/", @@ -2903,6 +2933,18 @@ "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", "group": "3_gemstoneBreakpoints@0" } + ], + "editor/title": [ + { + "command": "gemstone.navigateBack", + "when": "resourceScheme == gemstone", + "group": "navigation@1" + }, + { + "command": "gemstone.navigateForward", + "when": "resourceScheme == gemstone", + "group": "navigation@2" + } ] }, "debuggers": [ diff --git a/resources/walkthrough/browseClasses.md b/resources/walkthrough/browseClasses.md new file mode 100644 index 00000000..c4393a63 --- /dev/null +++ b/resources/walkthrough/browseClasses.md @@ -0,0 +1,12 @@ +# Browse classes + +The **GemStone Explorer** — its own icon in the activity bar (the far-left strip) +— is the primary way to read and edit code. It has stacked panes: **Dictionaries**, +**Classes**, and **Methods**. + +Click a class in the **Classes** pane and its methods appear in the **Methods** +pane below — that pane is where you navigate between a class's methods. Click a +method to open its source. + +In a hurry? **Find Class…** — `Cmd/Ctrl + K` then `C` — jumps straight to any +class by name and reveals it in the Explorer, without scrolling the tree. diff --git a/resources/walkthrough/connect.md b/resources/walkthrough/connect.md index 17e91f69..c4051c19 100644 --- a/resources/walkthrough/connect.md +++ b/resources/walkthrough/connect.md @@ -5,13 +5,29 @@ Jasper talks to a running GemStone/S 64 stone through the GCI client library. If you ran **Quick Setup**, a **DataCurator** login is already waiting in the **Logins & Sessions** view — just click it to connect. -To reach a stone you set up yourself, or a remote one: +To reach a stone you set up yourself, or a remote one, follow these steps in the +order the login editor presents them: 1. Open the **GemStone** view in the Activity Bar (the GemStone icon on the left). -2. In **Logins & Sessions**, click **Add a Login** and enter your stone's host, - name, and credentials. -3. Click the login to connect. +2. In **Logins & Sessions**, click **Add a Login** to open the login editor. +3. Fill in the fields, top to bottom: + - **GemStone Version** — the version whose GCI client library to use. + - **Gem Host** — the machine the stone runs on (`localhost` for a local stone). + - **Stone** — the stone's name (e.g. `gs64stone`). + - **NetLDI (name or port)** — a NetLDI service name (e.g. `gs64ldi`) or a port + number (e.g. `50377`); a port is often easiest for a remote stone. + - **GemStone User** / **GemStone Password** — your GemStone credentials (e.g. + `DataCurator`). Leave the password blank to be prompted on each login. + - **Host User** / **Host Password** — *optional.* The OS account on the Gem's + host machine, required only when the remote NetLDI requires host + authentication. Leave blank for a local stone or a guest-mode NetLDI. +4. Click **Save**. +5. Back in **Logins & Sessions**, click the saved login (the plug) to connect. -Once connected, the session appears under its login row, and the editor commands -(Display It, Execute It, Inspect It) become available in any `gemstone-smalltalk` -document. +While connecting, a "Connecting to…" notification appears. On success it reports +**Connected**, the session appears under its login row, and the status bar (bottom +right) shows the active session. If the connection fails, the status bar turns red +— click it to see why. + +Once connected, the editor commands (Display It, Execute It, Inspect It) become +available in any `gemstone-smalltalk` document. diff --git a/resources/walkthrough/enhancedInspector.md b/resources/walkthrough/enhancedInspector.md index b9cb8bd6..65e00c7c 100644 --- a/resources/walkthrough/enhancedInspector.md +++ b/resources/walkthrough/enhancedInspector.md @@ -1,6 +1,6 @@ -# Install optional server support +# Install recommended server support -Jasper has two optional server-side supports that aren't part of a stock GemStone +Jasper has two recommended server-side supports that aren't part of a stock GemStone image, so each is installed once per stone: - **Enhanced Inspector** — replaces the plain list of instance variables with @@ -12,8 +12,8 @@ image, so each is installed once per stone: They install together as one bundle. When you connect to a stone that is missing them, Jasper's behavior follows the `gemstone.serverSupport.autoInstall` setting: -- **Ask on connect** — offer to install with one Install / Always / Never prompt - (the default). +- **Ask on connect** — offer to install with one Install / Not Now / Always / Never + prompt (the default). - **Always** — install automatically on connect. - **Never** — do nothing. diff --git a/resources/walkthrough/search.md b/resources/walkthrough/search.md new file mode 100644 index 00000000..88a991d1 --- /dev/null +++ b/resources/walkthrough/search.md @@ -0,0 +1,11 @@ +# Search your code + +**GemStone Search** — `Cmd/Ctrl + Shift + A` — is one box that searches across +classes, methods, method source, and more. Start typing and results group by +category; pick one to open it. + +It's the fastest way to answer "where is this?" when you don't yet know which +dictionary or class something lives in — no need to guess your way down the tree. + +You can also search from the **GemStone Search** view (its icon in the sidebar), +which keeps your results and a preview pane open while you explore.