Skip to content

Commit eff341d

Browse files
ericwingerclaude
andcommitted
Guide new users after first connect (#468)
A newly-connected user was dropped into the raw kernel with no signpost to browsing, searching, or opening a workspace. Add two unobtrusive, self-erasing entry points to the basics: - A "GemStone - Start Here" status-bar button, shown on connect, that opens a quick pick of Browse a class / Search your code / Open a workspace / Take the tour. It stays put (non-invasive for power users) and is removed only via its own "Hide the Start Here button" entry; Reset Getting Started brings it back. The same quick pick is always available from the palette as GemStone: Start Here. Implemented in a testable module (client/src/startHere.ts). - Two new Get Started walkthrough steps, "Browse classes" and "Search your code", which the walkthrough previously skipped despite teaching evaluate and inspect. Reset Getting Started now re-arms both the walkthrough and the button, and its title reflects that. CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb30b9e commit eff341d

8 files changed

Lines changed: 404 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i
66

77
### Added
88

9+
- **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))
910
- **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))
1011
- **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))
1112
- **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))
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
vi.mock('vscode', () => import('../__mocks__/vscode.js'));
4+
5+
import * as vscode from 'vscode';
6+
import {
7+
startHereItems,
8+
showStartHereMenu,
9+
registerStartHere,
10+
resetStartHere,
11+
StartHereStatusBar,
12+
START_HERE_RETIRED_KEY,
13+
} from '../startHere';
14+
15+
type FakeContext = ConstructorParameters<typeof StartHereStatusBar>[0];
16+
17+
// Minimal ExtensionContext stand-in backed by a plain key/value store, enough for the
18+
// globalState get/update the retirement gate uses.
19+
function makeContext(initial: Record<string, unknown> = {}): {
20+
context: FakeContext;
21+
store: Record<string, unknown>;
22+
} {
23+
const store: Record<string, unknown> = { ...initial };
24+
const context = {
25+
globalState: {
26+
get: (key: string, def?: unknown) => (key in store ? store[key] : def),
27+
update: async (key: string, value: unknown) => {
28+
store[key] = value;
29+
},
30+
},
31+
} as unknown as FakeContext;
32+
return { context, store };
33+
}
34+
35+
const STATUS_COMMAND = 'gemstone.startHere.fromStatusBar';
36+
37+
describe('startHereItems', () => {
38+
it('offers browse, search, workspace, and the tour, each dispatching a real command', () => {
39+
const commands = startHereItems().map((i) => i.command);
40+
expect(commands).toEqual([
41+
'gemstone.findClass',
42+
'gemstone.search',
43+
'gemstone.openWorkspace',
44+
'gemstone.openWalkthrough',
45+
]);
46+
for (const item of startHereItems()) {
47+
expect(item.label.length).toBeGreaterThan(0);
48+
expect(item.detail.length).toBeGreaterThan(0);
49+
}
50+
});
51+
});
52+
53+
describe('showStartHereMenu', () => {
54+
beforeEach(() => vi.clearAllMocks());
55+
56+
it('runs the picked item’s command', async () => {
57+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({
58+
command: 'gemstone.search',
59+
} as never);
60+
await showStartHereMenu();
61+
expect(vscode.commands.executeCommand).toHaveBeenCalledWith('gemstone.search');
62+
});
63+
64+
it('does nothing when the menu is dismissed', async () => {
65+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined);
66+
await showStartHereMenu();
67+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
68+
});
69+
});
70+
71+
describe('StartHereStatusBar', () => {
72+
beforeEach(() => vi.clearAllMocks());
73+
74+
function setup(initial: Record<string, unknown> = {}) {
75+
const { context, store } = makeContext(initial);
76+
const bar = new StartHereStatusBar(context);
77+
const disposables = bar.register();
78+
const item = vi.mocked(vscode.window.createStatusBarItem).mock.results.at(-1)!.value;
79+
return { bar, item, store, disposables };
80+
}
81+
82+
it('shows on connect when not retired', () => {
83+
const { bar, item } = setup();
84+
bar.showForConnection();
85+
expect(item.show).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it('does not show on connect once retired', () => {
89+
const { bar, item } = setup({ [START_HERE_RETIRED_KEY]: true });
90+
bar.showForConnection();
91+
expect(item.show).not.toHaveBeenCalled();
92+
});
93+
94+
it('hides on disconnection without retiring', () => {
95+
const { bar, item, store } = setup();
96+
bar.hideForDisconnection();
97+
expect(item.hide).toHaveBeenCalledTimes(1);
98+
expect(store[START_HERE_RETIRED_KEY]).toBeUndefined();
99+
});
100+
101+
function clickButton() {
102+
const handler = vi
103+
.mocked(vscode.commands.registerCommand)
104+
.mock.calls.find((c) => c[0] === STATUS_COMMAND)?.[1] as () => Promise<void>;
105+
expect(handler).toBeDefined();
106+
return handler();
107+
}
108+
109+
it('opens the hub on click and offers a Hide entry', async () => {
110+
setup();
111+
let offered: Array<{ command: string }> = [];
112+
vi.mocked(vscode.window.showQuickPick).mockImplementationOnce(async (items: unknown) => {
113+
offered = items as Array<{ command: string }>;
114+
return undefined;
115+
});
116+
await clickButton();
117+
expect(vscode.window.showQuickPick).toHaveBeenCalledTimes(1);
118+
expect(offered.some((i) => i.command === '__startHere.hide')).toBe(true);
119+
});
120+
121+
it('does NOT hide when the hub is dismissed (clicked away)', async () => {
122+
const { item, store } = setup();
123+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined);
124+
await clickButton();
125+
expect(store[START_HERE_RETIRED_KEY]).toBeUndefined();
126+
expect(item.hide).not.toHaveBeenCalled();
127+
});
128+
129+
it('hides persistently only when the Hide entry is chosen', async () => {
130+
const { item, store } = setup();
131+
vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce({
132+
command: '__startHere.hide',
133+
} as never);
134+
await clickButton();
135+
expect(store[START_HERE_RETIRED_KEY]).toBe(true);
136+
expect(item.hide).toHaveBeenCalled();
137+
// A command is never dispatched for the Hide action.
138+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
139+
});
140+
141+
it('does not offer the Hide entry from the plain Command Palette menu', async () => {
142+
let offered: Array<{ command: string }> = [];
143+
vi.mocked(vscode.window.showQuickPick).mockImplementationOnce(async (items: unknown) => {
144+
offered = items as Array<{ command: string }>;
145+
return undefined;
146+
});
147+
await showStartHereMenu();
148+
expect(offered.some((i) => i.command === '__startHere.hide')).toBe(false);
149+
});
150+
});
151+
152+
describe('resetStartHere', () => {
153+
it('un-retires the button so it shows again on the next connect', async () => {
154+
const { context, store } = makeContext({ [START_HERE_RETIRED_KEY]: true });
155+
await resetStartHere(context);
156+
expect(store[START_HERE_RETIRED_KEY]).toBeUndefined();
157+
});
158+
});
159+
160+
describe('registerStartHere', () => {
161+
beforeEach(() => vi.clearAllMocks());
162+
163+
it('registers the gemstone.startHere command', () => {
164+
registerStartHere();
165+
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
166+
'gemstone.startHere',
167+
expect.any(Function),
168+
);
169+
});
170+
});

client/src/__tests__/walkthroughContent.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ describe('Getting Started walkthrough content', () => {
9393
expect(databasesWelcome?.contents).toContain('command:gemstone.createDatabase');
9494
});
9595

96-
// The walkthrough auto-opens on activation (startup), not on connecting or on
97-
// revealing a view — so the Reset command's title must describe the real
98-
// trigger rather than the stale "on Next Connect".
99-
it('describes the reset command by its real trigger (startup)', () => {
100-
expect(resetCommand?.title.toLowerCase()).toContain('startup');
101-
expect(resetCommand?.title.toLowerCase()).not.toContain('connect');
96+
// Reset re-arms two first-run surfaces: the walkthrough (auto-opens on startup)
97+
// and the Start Here status-bar button (shows on connect). The title names both
98+
// so the user knows what comes back, rather than describing a single trigger.
99+
it('names both surfaces the reset command restores', () => {
100+
const title = resetCommand?.title.toLowerCase();
101+
expect(title).toContain('walkthrough');
102+
expect(title).toContain('start here');
102103
});
103104
});

client/src/extension.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
parseUri,
101101
} from './gemstoneFileSystemProvider';
102102
import { openWorkspace } from './workspace';
103+
import { registerStartHere, StartHereStatusBar, resetStartHere } from './startHere';
103104
import { openTutorialNotebook } from './tutorialNotebook';
104105
import { GemStoneDebugSession } from './gemstoneDebugSession';
105106
import { InspectorTreeProvider, InspectorNode } from './inspectorTreeProvider';
@@ -996,6 +997,18 @@ export function activate(context: vscode.ExtensionContext) {
996997
context.subscriptions.push(connectStatusItem);
997998
let lastLoginError: string | undefined;
998999

1000+
// A "Start Here" status-bar button pointing a new user at the basics (browse a
1001+
// class, search, open a workspace, take the tour). Shown on connect below; stays
1002+
// until the user hides it from its own menu (issue #468, item 10).
1003+
const startHereStatusBar = new StartHereStatusBar(context);
1004+
context.subscriptions.push(
1005+
...startHereStatusBar.register(),
1006+
// When the last session goes away, hide the button until the next connect.
1007+
sessionManager.onDidRemoveSession(() => {
1008+
if (sessionManager.getSessions().length === 0) startHereStatusBar.hideForDisconnection();
1009+
}),
1010+
);
1011+
9991012
function showConnecting(stone: string): void {
10001013
lastLoginError = undefined;
10011014
connectStatusItem.color = undefined;
@@ -1011,6 +1024,7 @@ export function activate(context: vscode.ExtensionContext) {
10111024
connectStatusItem.hide();
10121025
void vscode.commands.executeCommand('workbench.view.extension.gemstoneExplorer');
10131026
explorer.showConnectedBanner(stone);
1027+
startHereStatusBar.showForConnection();
10141028
}
10151029

10161030
function showLoginError(message: string): void {
@@ -1412,6 +1426,8 @@ export function activate(context: vscode.ExtensionContext) {
14121426
await openWorkspace();
14131427
}),
14141428

1429+
registerStartHere(),
1430+
14151431
vscode.commands.registerCommand('gemstone.openTutorial', async () => {
14161432
await openTutorialNotebook();
14171433
}),
@@ -1473,9 +1489,13 @@ export function activate(context: vscode.ExtensionContext) {
14731489

14741490
vscode.commands.registerCommand('gemstone.resetGettingStarted', async () => {
14751491
await context.globalState.update(GETTING_STARTED_SEEN_KEY, undefined);
1492+
// Also un-retire the "Start Here" status-bar button, so one reset restores
1493+
// every first-run onboarding surface.
1494+
await resetStartHere(context);
14761495
const openNow = 'Open Walkthrough Now';
14771496
const choice = await vscode.window.showInformationMessage(
1478-
'Getting Started reset — the walkthrough will open automatically the next time VS Code starts.',
1497+
'Getting Started reset — the walkthrough will open automatically the next time VS Code starts, ' +
1498+
'and the "Start Here" button will show again on your next connect.',
14791499
openNow,
14801500
);
14811501
if (choice === openNow) {
@@ -1764,6 +1784,9 @@ export function activate(context: vscode.ExtensionContext) {
17641784
// user connects rather than after. The workspace stays available via the
17651785
// gemstone.openWorkspace command and the Logins & Sessions welcome view.
17661786

1787+
// The "Start Here" status-bar button (shown from flashConnected above) points
1788+
// a new user at the basics; see StartHereStatusBar (issue #468, item 10).
1789+
17671790
// Offer the optional server-side supports this stone lacks (Enhanced
17681791
// Inspector + refactoring engine) as one bundle, per
17691792
// gemstone.serverSupport.autoInstall: `always` installs silently, `ask`

0 commit comments

Comments
 (0)