Skip to content

Commit 9e9f71f

Browse files
authored
Add release notes pop-up (#1801)
- Depends on #1794 - Partially addresses #491 ## Checklist - [x] I have added [tests](https://www.cursorless.org/docs/contributing/test-case-recorder/) - [-] I have updated the [docs](https://github.com/cursorless-dev/cursorless/tree/main/docs) and [cheatsheet](https://github.com/cursorless-dev/cursorless/tree/main/cursorless-talon/src/cheatsheet) - [-] I have not broken the cheatsheet
1 parent e93692d commit 9e9f71f

File tree

9 files changed

+386
-13
lines changed

9 files changed

+386
-13
lines changed

packages/common/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
"@types/js-yaml": "^4.0.2",
2020
"@types/lodash": "4.14.181",
2121
"@types/mocha": "^8.0.4",
22+
"@types/sinon": "^10.0.2",
2223
"js-yaml": "^4.1.0",
23-
"mocha": "^10.2.0"
24+
"mocha": "^10.2.0",
25+
"sinon": "^11.1.1"
2426
},
2527
"types": "./out/index.d.ts",
2628
"exports": {

packages/cursorless-vscode/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1074,7 +1074,8 @@
10741074
"fast-xml-parser": "^4.2.5",
10751075
"fs-extra": "11.1.0",
10761076
"glob": "^7.1.7",
1077-
"mocha": "^10.2.0"
1077+
"mocha": "^10.2.0",
1078+
"sinon": "^11.1.1"
10781079
},
10791080
"dependencies": {
10801081
"@cursorless/common": "workspace:*",
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import * as sinon from "sinon";
2+
import { MessageType, Messages, asyncSafety } from "@cursorless/common";
3+
import type { ExtensionContext, Uri } from "vscode";
4+
import { ReleaseNotes, VERSION_KEY, WHATS_NEW } from "./ReleaseNotes";
5+
import { VscodeApi } from "@cursorless/vscode-common";
6+
7+
interface Input {
8+
/** Whether the VSCode window is focused */
9+
isFocused: boolean;
10+
storedVersion: string | undefined;
11+
currentVersion: string;
12+
/** `true` if they pressed `What's new?` */
13+
pressedButton?: boolean;
14+
}
15+
16+
interface Output {
17+
/** The new version added to storage, or `false` if none added */
18+
storedVersion: string | false;
19+
showedMessage: boolean;
20+
openedUrl?: boolean;
21+
}
22+
23+
interface TestCase {
24+
input: Input;
25+
expectedOutput: Output;
26+
}
27+
28+
const testCases: TestCase[] = [
29+
{
30+
input: {
31+
isFocused: false,
32+
storedVersion: undefined,
33+
currentVersion: "0.28.0",
34+
},
35+
expectedOutput: {
36+
storedVersion: "0.28.0",
37+
showedMessage: false,
38+
},
39+
},
40+
{
41+
input: {
42+
isFocused: true,
43+
storedVersion: undefined,
44+
currentVersion: "0.28.0",
45+
},
46+
expectedOutput: {
47+
storedVersion: "0.28.0",
48+
showedMessage: false,
49+
},
50+
},
51+
{
52+
input: {
53+
isFocused: false,
54+
storedVersion: "0.28.0",
55+
currentVersion: "0.28.0",
56+
},
57+
expectedOutput: {
58+
storedVersion: false,
59+
showedMessage: false,
60+
},
61+
},
62+
{
63+
input: {
64+
isFocused: true,
65+
storedVersion: "0.28.0",
66+
currentVersion: "0.28.0",
67+
},
68+
expectedOutput: {
69+
storedVersion: false,
70+
showedMessage: false,
71+
},
72+
},
73+
{
74+
input: {
75+
isFocused: false,
76+
storedVersion: "0.28.0",
77+
currentVersion: "0.28.10",
78+
},
79+
expectedOutput: {
80+
storedVersion: false,
81+
showedMessage: false,
82+
},
83+
},
84+
{
85+
input: {
86+
isFocused: true,
87+
storedVersion: "0.28.0",
88+
currentVersion: "0.28.10",
89+
},
90+
expectedOutput: {
91+
storedVersion: false,
92+
showedMessage: false,
93+
},
94+
},
95+
{
96+
input: {
97+
isFocused: false,
98+
storedVersion: "0.28.0",
99+
currentVersion: "0.26.0",
100+
},
101+
expectedOutput: {
102+
storedVersion: false,
103+
showedMessage: false,
104+
},
105+
},
106+
{
107+
input: {
108+
isFocused: true,
109+
storedVersion: "0.28.0",
110+
currentVersion: "0.26.0",
111+
},
112+
expectedOutput: {
113+
storedVersion: false,
114+
showedMessage: false,
115+
},
116+
},
117+
{
118+
input: {
119+
isFocused: false,
120+
storedVersion: "0.28.0",
121+
currentVersion: "0.29.10",
122+
},
123+
expectedOutput: {
124+
storedVersion: false,
125+
showedMessage: false,
126+
},
127+
},
128+
{
129+
input: {
130+
isFocused: true,
131+
storedVersion: "0.28.0",
132+
currentVersion: "0.29.10",
133+
},
134+
expectedOutput: {
135+
storedVersion: "0.29.0",
136+
showedMessage: true,
137+
},
138+
},
139+
{
140+
input: {
141+
isFocused: true,
142+
storedVersion: "0.28.0",
143+
currentVersion: "0.29.10",
144+
pressedButton: true,
145+
},
146+
expectedOutput: {
147+
storedVersion: "0.29.0",
148+
showedMessage: true,
149+
openedUrl: true,
150+
},
151+
},
152+
];
153+
154+
suite("release notes", async function () {
155+
teardown(() => {
156+
sinon.restore();
157+
});
158+
159+
testCases.forEach(({ input, expectedOutput }) => {
160+
test(
161+
getTestName(input),
162+
asyncSafety(() => runTest(input, expectedOutput)),
163+
);
164+
});
165+
});
166+
167+
function getTestName(input: Input) {
168+
const nameComponents = [
169+
input.isFocused ? "focused" : "unfocused",
170+
String(input.storedVersion),
171+
input.currentVersion,
172+
];
173+
174+
if (input.pressedButton) {
175+
nameComponents.push("pressed");
176+
}
177+
178+
return nameComponents.join(" ");
179+
}
180+
181+
async function runTest(input: Input, expectedOutput: Output) {
182+
const {
183+
extensionContext,
184+
messages,
185+
openExternal,
186+
update,
187+
showMessage,
188+
vscodeApi,
189+
} = await getFakes(input);
190+
191+
await new ReleaseNotes(vscodeApi, extensionContext, messages).maybeShow();
192+
193+
if (expectedOutput.storedVersion === false) {
194+
sinon.assert.notCalled(update);
195+
} else {
196+
sinon.assert.calledOnceWithExactly(
197+
update,
198+
VERSION_KEY,
199+
expectedOutput.storedVersion,
200+
);
201+
}
202+
203+
if (expectedOutput.showedMessage) {
204+
sinon.assert.calledOnceWithExactly(
205+
showMessage,
206+
sinon.match.any,
207+
"releaseNotes",
208+
sinon.match.any,
209+
sinon.match.any,
210+
);
211+
212+
if (expectedOutput.openedUrl) {
213+
sinon.assert.calledOnce(openExternal);
214+
} else {
215+
sinon.assert.notCalled(openExternal);
216+
}
217+
} else {
218+
sinon.assert.notCalled(showMessage);
219+
}
220+
}
221+
222+
async function getFakes(input: Input) {
223+
const openExternal = sinon.fake.resolves<[Uri], Promise<boolean>>(true);
224+
const vscodeApi = {
225+
window: {
226+
state: {
227+
focused: input.isFocused,
228+
},
229+
},
230+
env: {
231+
openExternal,
232+
},
233+
} as unknown as VscodeApi;
234+
235+
const update = sinon.fake<[string, string], Promise<void>>();
236+
const extensionContext = {
237+
globalState: {
238+
get() {
239+
return input.storedVersion;
240+
},
241+
update,
242+
},
243+
extension: {
244+
packageJSON: {
245+
version: input.currentVersion,
246+
},
247+
},
248+
} as unknown as ExtensionContext;
249+
250+
const showMessage = sinon.fake.resolves<
251+
[MessageType, string, string, ...string[]],
252+
Promise<string | undefined>
253+
>(input.pressedButton ? WHATS_NEW : undefined);
254+
const messages: Messages = {
255+
showMessage,
256+
};
257+
258+
return {
259+
extensionContext,
260+
messages,
261+
openExternal,
262+
update,
263+
showMessage,
264+
vscodeApi,
265+
};
266+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { ExtensionContext } from "vscode";
2+
import { Messages, showInfo } from "@cursorless/common";
3+
import * as semver from "semver";
4+
import { VscodeApi } from "@cursorless/vscode-common";
5+
import { URI } from "vscode-uri";
6+
7+
/**
8+
* The key to use in global storage to detect when Cursorless version number has
9+
* increased, so we can show release notes.
10+
*/
11+
export const VERSION_KEY = "version";
12+
13+
export const WHATS_NEW = "What's new?";
14+
15+
function roundDown(version: string) {
16+
const { major, minor } = semver.parse(version)!;
17+
return `${major}.${minor}.0`;
18+
}
19+
20+
/**
21+
* Responsible for showing a message to the users when Cursorless has new
22+
* release notes available.
23+
*/
24+
export class ReleaseNotes {
25+
constructor(
26+
private vscodeApi: VscodeApi,
27+
private extensionContext: ExtensionContext,
28+
private messages: Messages,
29+
) {}
30+
31+
/**
32+
* Shows a message to the users if Cursorless has new release notes available
33+
* @returns A promise that resolves when the release notes have been shown
34+
*/
35+
async maybeShow() {
36+
// Round down because we just use the patch number to enforce monotone
37+
// version numbers during CD
38+
const currentVersion = roundDown(
39+
getCursorlessVersion(this.extensionContext),
40+
);
41+
42+
const storedVersion =
43+
this.extensionContext.globalState.get<string>(VERSION_KEY);
44+
45+
if (storedVersion == null) {
46+
// This is their initial install; note down initial install version, but
47+
// don't show release notes
48+
await this.extensionContext.globalState.update(
49+
VERSION_KEY,
50+
currentVersion,
51+
);
52+
return;
53+
}
54+
55+
if (
56+
// Don't show it in all the windows
57+
!this.vscodeApi.window.state.focused ||
58+
// Don't show it if they've seen this version before
59+
!semver.lt(storedVersion, currentVersion)
60+
) {
61+
return;
62+
}
63+
64+
await this.extensionContext.globalState.update(VERSION_KEY, currentVersion);
65+
66+
const result = await showInfo(
67+
this.messages,
68+
"releaseNotes",
69+
`Cursorless version ${currentVersion} has been released!`,
70+
WHATS_NEW,
71+
);
72+
73+
if (result === WHATS_NEW) {
74+
await this.vscodeApi.env.openExternal(
75+
URI.parse(
76+
`https://cursorless.org/docs/user/release-notes/${currentVersion}/`,
77+
),
78+
);
79+
}
80+
}
81+
}
82+
83+
function getCursorlessVersion(extensionContext: ExtensionContext): string {
84+
return extensionContext.extension.packageJSON.version;
85+
}

0 commit comments

Comments
 (0)