From 51ba6777fc7bab43da4091e4a42220960836ca23 Mon Sep 17 00:00:00 2001
From: Henning Dieterichs <hdieterichs@microsoft.com>
Date: Wed, 6 Sep 2023 19:37:00 +0200
Subject: [PATCH] Improves playground

---
 website/package.json                          |   2 +-
 website/src/shared.ts                         |   2 +-
 .../website/pages/playground/BisectModel.ts   | 161 +++++
 .../website/pages/playground/LocationModel.ts | 211 +++++++
 .../pages/playground/PlaygroundModel.ts       | 556 ++----------------
 .../pages/playground/PlaygroundPage.tsx       |   5 +-
 .../playground/PlaygroundPageContent.tsx      | 158 +++--
 .../src/website/pages/playground/Preview.tsx  |  68 ++-
 .../src/website/pages/playground/Source.ts    | 107 ++++
 website/src/website/pages/playground/utils.ts |  29 +
 website/src/website/style.scss                |   3 +
 website/yarn.lock                             |   8 +-
 12 files changed, 734 insertions(+), 576 deletions(-)
 create mode 100644 website/src/website/pages/playground/BisectModel.ts
 create mode 100644 website/src/website/pages/playground/LocationModel.ts
 create mode 100644 website/src/website/pages/playground/Source.ts
 create mode 100644 website/src/website/pages/playground/utils.ts

diff --git a/website/package.json b/website/package.json
index 99b36bd3..657513c6 100644
--- a/website/package.json
+++ b/website/package.json
@@ -24,7 +24,7 @@
 		"mini-css-extract-plugin": "^2.6.1",
 		"mobx": "^5.15.4",
 		"mobx-react": "^6.2.2",
-		"monaco-editor": "^0.41.0",
+		"monaco-editor": "^0.42.0-dev-20230906",
 		"react": "^17.0.2",
 		"react-bootstrap": "^2.4.0",
 		"react-dom": "^17.0.2",
diff --git a/website/src/shared.ts b/website/src/shared.ts
index 807b30b7..0165733f 100644
--- a/website/src/shared.ts
+++ b/website/src/shared.ts
@@ -32,6 +32,6 @@ export interface IPlaygroundProject {
 }
 
 export interface IPreviewState extends IPlaygroundProject {
-	key: number;
+	reloadKey: number;
 	monacoSetup: IMonacoSetup;
 }
diff --git a/website/src/website/pages/playground/BisectModel.ts b/website/src/website/pages/playground/BisectModel.ts
new file mode 100644
index 00000000..a2f400d9
--- /dev/null
+++ b/website/src/website/pages/playground/BisectModel.ts
@@ -0,0 +1,161 @@
+import { action, ObservableMap } from "mobx";
+import {
+	getNpmVersions,
+	getNpmVersionsSync,
+	getVsCodeCommitId,
+} from "./getNpmVersionsSync";
+import { PlaygroundModel } from "./PlaygroundModel";
+import { findLastIndex } from "./utils";
+
+export class BisectModel {
+	private readonly map = new ObservableMap<string, boolean>();
+
+	constructor(private readonly model: PlaygroundModel) {}
+
+	public getState(version: string): boolean | undefined {
+		return this.map.get(version);
+	}
+
+	public get isActive() {
+		return [...this.map.values()].some((e) => e !== undefined);
+	}
+
+	public reset(): void {
+		this.map.clear();
+	}
+
+	public async toggleState(version: string, state: boolean): Promise<void> {
+		const currentState = this.getState(version);
+		await this.setState(
+			version,
+			currentState === state ? undefined : state
+		);
+	}
+
+	@action
+	public async setState(
+		version: string,
+		state: boolean | undefined
+	): Promise<void> {
+		if (state === undefined) {
+			this.map.delete(version);
+		} else {
+			this.map.set(version, state);
+		}
+
+		const nextVersion = await this.getNextVersion();
+		if (!nextVersion) {
+			return;
+		}
+		this.model.settings.setSettings({
+			...this.model.settings.settings,
+			npmVersion: nextVersion,
+		});
+	}
+
+	private get versions() {
+		return getNpmVersionsSync(undefined);
+	}
+
+	private get indexOfLastBadVersion() {
+		return findLastIndex(this.versions, (v) => this.map.get(v) === false);
+	}
+	private get indexOfFirstGoodVersion() {
+		return this.versions.findIndex((v) => this.map.get(v) === true);
+	}
+
+	public get steps() {
+		const indexOfFirstGoodVersion = this.indexOfFirstGoodVersion;
+		const indexOfLastBadVersion = this.indexOfLastBadVersion;
+
+		if (indexOfFirstGoodVersion === -1 && indexOfLastBadVersion === -1) {
+			return -1;
+		}
+		if (indexOfFirstGoodVersion === -1) {
+			return Math.ceil(
+				Math.log2(this.versions.length - indexOfLastBadVersion)
+			);
+		} else if (indexOfLastBadVersion === -1) {
+			return Math.ceil(Math.log2(indexOfFirstGoodVersion + 1));
+		} else {
+			return Math.ceil(
+				Math.log2(indexOfFirstGoodVersion - indexOfLastBadVersion)
+			);
+		}
+	}
+
+	public get isFinished() {
+		if (
+			this.indexOfFirstGoodVersion !== -1 &&
+			this.indexOfLastBadVersion + 1 === this.indexOfFirstGoodVersion
+		) {
+			return true;
+		}
+		return false;
+	}
+
+	public async openGithub() {
+		const versions = await getNpmVersions();
+		const indexOfFirstGoodVersion =
+			this.indexOfFirstGoodVersion === -1
+				? versions.length - 1
+				: this.indexOfFirstGoodVersion;
+		const indexOfLastBadVersion =
+			this.indexOfLastBadVersion === -1 ? 0 : this.indexOfLastBadVersion;
+		const goodCommitId = await getVsCodeCommitId(
+			versions[indexOfFirstGoodVersion]
+		);
+		const badCommitId = await getVsCodeCommitId(
+			versions[indexOfLastBadVersion]
+		);
+		window.open(
+			`https://github.com/microsoft/vscode/compare/${goodCommitId}...${badCommitId}`,
+			"_blank"
+		);
+	}
+
+	private async getNextVersion(): Promise<string | undefined> {
+		const versions = await getNpmVersions();
+
+		const indexOfFirstGoodVersion = this.indexOfFirstGoodVersion;
+		const indexOfLastBadVersion = this.indexOfLastBadVersion;
+
+		if (
+			indexOfFirstGoodVersion !== -1 &&
+			indexOfLastBadVersion + 1 === indexOfFirstGoodVersion
+		) {
+			// Finished
+			return;
+		}
+
+		if (indexOfLastBadVersion === -1 && indexOfFirstGoodVersion === -1) {
+			return versions[0];
+		}
+		if (indexOfLastBadVersion === -1) {
+			// try first (newest) version that hasn't been tested
+			const indexOfFirstUntestedVersion = versions.findIndex(
+				(v) => this.map.get(v) === undefined
+			);
+			if (indexOfFirstUntestedVersion === -1) {
+				return undefined;
+			}
+			return versions[indexOfFirstUntestedVersion];
+		}
+
+		if (indexOfFirstGoodVersion === -1) {
+			/*// exponential back off, might be good for recent regressions, but ruins step counter
+			const candidate = Math.min(
+				indexOfLastBadVersion * 2 + 1,
+				versions.length - 1
+			);*/
+			const candidate = Math.floor(
+				(indexOfLastBadVersion + versions.length) / 2
+			);
+			return versions[candidate];
+		}
+
+		return versions[
+			Math.floor((indexOfLastBadVersion + indexOfFirstGoodVersion) / 2)
+		];
+	}
+}
diff --git a/website/src/website/pages/playground/LocationModel.ts b/website/src/website/pages/playground/LocationModel.ts
new file mode 100644
index 00000000..abc96ad1
--- /dev/null
+++ b/website/src/website/pages/playground/LocationModel.ts
@@ -0,0 +1,211 @@
+import { action, observable } from "mobx";
+import { IPlaygroundProject } from "../../../shared";
+import { monacoEditorVersion } from "../../monacoEditorVersion";
+import { LzmaCompressor } from "../../utils/lzmaCompressor";
+import {
+	HistoryController,
+	IHistoryModel,
+	ILocation,
+} from "../../utils/ObservableHistory";
+import { debouncedComputed, Disposable } from "../../utils/utils";
+import { getPlaygroundExamples, PlaygroundExample } from "./playgroundExamples";
+import { Source } from "./Source";
+import { PlaygroundModel } from "./PlaygroundModel";
+import { projectEquals } from "./utils";
+
+export class LocationModel implements IHistoryModel {
+	public readonly dispose = Disposable.fn();
+
+	private readonly compressor = new LzmaCompressor<IPlaygroundProject>();
+
+	private cachedState:
+		| { state: IPlaygroundProject; hash: string }
+		| undefined = undefined;
+
+	@observable private _sourceOverride: Source | undefined;
+	get sourceOverride(): Source | undefined {
+		return this._sourceOverride;
+	}
+
+	@observable private _compareWith: Source | undefined;
+	get compareWith(): Source | undefined {
+		return this._compareWith;
+	}
+
+	/**
+	 * This is used to control replace/push state.
+	 * Replace is used if the history id does not change.
+	 */
+	@observable historyId: number = 0;
+
+	constructor(private readonly model: PlaygroundModel) {
+		this.dispose.track(
+			new HistoryController((initialLocation) => {
+				this.updateLocation(initialLocation);
+				return this;
+			})
+		);
+	}
+
+	get location(): ILocation {
+		const source = this._sourceOverride || this.sourceFromSettings;
+		return {
+			hashValue: this.computedHashValue.value || this.cachedState?.hash,
+			searchParams: {
+				source: source?.sourceToString(),
+				sourceLanguages: source?.sourceLanguagesToString(),
+				compareWith: this._compareWith?.sourceToString(),
+			},
+		};
+	}
+
+	@action
+	updateLocation(currentLocation: ILocation): void {
+		const hashValue = currentLocation.hashValue;
+		const sourceStr = currentLocation.searchParams.source;
+		const sourceLanguages = currentLocation.searchParams.sourceLanguages;
+		const source =
+			sourceStr || sourceLanguages
+				? Source.parse(sourceStr, sourceLanguages)
+				: undefined;
+
+		if (this.sourceFromSettings?.equals(source)) {
+			this._sourceOverride = undefined;
+		} else {
+			this._sourceOverride = source;
+		}
+
+		const compareWithStr = currentLocation.searchParams.compareWith;
+		const compareWith = compareWithStr
+			? Source.parse(compareWithStr, undefined)
+			: undefined;
+		this._compareWith = compareWith;
+
+		function findExample(hashValue: string): PlaygroundExample | undefined {
+			if (hashValue.startsWith("example-")) {
+				hashValue = hashValue.substring("example-".length);
+			}
+			return getPlaygroundExamples()
+				.flatMap((e) => e.examples)
+				.find((e) => e.id === hashValue);
+		}
+
+		let example: PlaygroundExample | undefined;
+
+		if (!hashValue) {
+			this.model.selectedExample = getPlaygroundExamples()[0].examples[0];
+		} else if ((example = findExample(hashValue))) {
+			this.model.selectedExample = example;
+		} else {
+			let p: IPlaygroundProject | undefined = undefined;
+			if (this.cachedState?.hash === hashValue) {
+				p = this.cachedState.state;
+			}
+			if (!p) {
+				try {
+					p =
+						this.compressor.decodeData<IPlaygroundProject>(
+							hashValue
+						);
+				} catch (e) {
+					console.log("Could not deserialize from hash value", e);
+				}
+			}
+			if (p) {
+				this.cachedState = { state: p, hash: hashValue };
+				this.model.setState(p);
+			}
+		}
+	}
+
+	private readonly computedHashValue = debouncedComputed(
+		500,
+		() => ({
+			state: this.model.playgroundProject,
+			selectedExampleProject: this.model.selectedExampleProject,
+		}),
+		({ state, selectedExampleProject }) => {
+			if (
+				selectedExampleProject &&
+				projectEquals(state, selectedExampleProject.project)
+			) {
+				return "example-" + selectedExampleProject.example.id;
+			}
+			if (
+				this.cachedState &&
+				projectEquals(this.cachedState.state, state)
+			) {
+				return this.cachedState.hash;
+			}
+			return this.compressor.encodeData(state);
+		}
+	);
+
+	private get sourceFromSettings(): Source | undefined {
+		const settings = this.model.settings.settings;
+		if (settings.monacoSource === "npm") {
+			return new Source(settings.npmVersion, undefined, undefined);
+		} else if (
+			settings.monacoSource === "independent" &&
+			((settings.coreSource === "url" &&
+				(settings.languagesSource === "latest" ||
+					settings.languagesSource === "url")) ||
+				(settings.coreSource === "latest" &&
+					settings.languagesSource === "url"))
+		) {
+			return new Source(
+				undefined,
+				settings.coreSource === "url" ? settings.coreUrl : undefined,
+				settings.languagesSource === "latest"
+					? undefined
+					: settings.languagesUrl
+			);
+		} else if (settings.monacoSource === "latest") {
+			return new Source(monacoEditorVersion, undefined, undefined);
+		}
+		return undefined;
+	}
+
+	@action
+	exitCompare(): void {
+		this._compareWith = undefined;
+		this.historyId++;
+	}
+
+	@action
+	disableSourceOverride(): void {
+		this._sourceOverride = undefined;
+		this.historyId++;
+	}
+
+	@action
+	compareWithLatestDev(): void {
+		this._compareWith = Source.useLatestDev();
+		this.historyId++;
+	}
+
+	@action
+	saveCompareWith(): void {
+		if (this._compareWith) {
+			this.model.settings.setSettings({
+				...this.model.settings.settings,
+				...this._compareWith.toPartialSettings(),
+			});
+			this.historyId++;
+			this._compareWith = undefined;
+			this._sourceOverride = undefined;
+		}
+	}
+
+	@action
+	saveSourceOverride(): void {
+		if (this._sourceOverride) {
+			this.model.settings.setSettings({
+				...this.model.settings.settings,
+				...this._sourceOverride.toPartialSettings(),
+			});
+			this.historyId++;
+			this._sourceOverride = undefined;
+		}
+	}
+}
diff --git a/website/src/website/pages/playground/PlaygroundModel.ts b/website/src/website/pages/playground/PlaygroundModel.ts
index db067c66..46ea286c 100644
--- a/website/src/website/pages/playground/PlaygroundModel.ts
+++ b/website/src/website/pages/playground/PlaygroundModel.ts
@@ -8,7 +8,6 @@ import {
 	autorun,
 	computed,
 	observable,
-	ObservableMap,
 	reaction,
 	runInAction,
 } from "mobx";
@@ -18,22 +17,10 @@ import {
 	waitForLoadedMonaco,
 } from "../../../monaco-loader";
 import { IPlaygroundProject, IPreviewState } from "../../../shared";
-import { monacoEditorVersion } from "../../monacoEditorVersion";
 import { Debouncer } from "../../utils/Debouncer";
-import { LzmaCompressor } from "../../utils/lzmaCompressor";
-import {
-	HistoryController,
-	IHistoryModel,
-	ILocation,
-} from "../../utils/ObservableHistory";
 import { ObservablePromise } from "../../utils/ObservablePromise";
-import { debouncedComputed, Disposable } from "../../utils/utils";
-import {
-	getNpmVersions,
-	getNpmVersionsSync,
-	getVsCodeCommitId,
-} from "./getNpmVersionsSync";
-import { getPlaygroundExamples, PlaygroundExample } from "./playgroundExamples";
+import { Disposable } from "../../utils/utils";
+import { PlaygroundExample } from "./playgroundExamples";
 import {
 	getDefaultSettings,
 	JsonString,
@@ -41,6 +28,8 @@ import {
 	SettingsModel,
 	toLoaderConfig,
 } from "./SettingsModel";
+import { BisectModel } from "./BisectModel";
+import { LocationModel } from "./LocationModel";
 
 export class PlaygroundModel {
 	public readonly dispose = Disposable.fn();
@@ -58,16 +47,18 @@ export class PlaygroundModel {
 	@observable
 	public reloadKey = 0;
 
-	public readonly serializer = new StateUrlSerializer(this);
+	public readonly historyModel = new LocationModel(this);
 
 	public reload(): void {
 		this.reloadKey++;
 	}
 
-	private readonly _previewHandlers = new Set<IPreviewHandler>();
+	public get previewShouldBeFullScreen(): boolean {
+		return this.settings.previewFullScreen;
+	}
 
 	private _wasEverNonFullScreen = false;
-	public get wasEverNonFullScreen() {
+	public get wasEverNonFullScreen(): boolean {
 		if (this._wasEverNonFullScreen) {
 			return true;
 		}
@@ -79,7 +70,7 @@ export class PlaygroundModel {
 
 	@computed.struct
 	get monacoSetup(): IMonacoSetup {
-		const sourceOverride = this.serializer.sourceOverride;
+		const sourceOverride = this.historyModel.sourceOverride;
 		if (sourceOverride) {
 			return toLoaderConfig({
 				...getDefaultSettings(),
@@ -105,10 +96,33 @@ export class PlaygroundModel {
 		return {
 			...this.playgroundProject,
 			monacoSetup: this.monacoSetup,
-			key: this.reloadKey,
+			reloadKey: this.reloadKey,
 		};
 	}
 
+	@observable.ref
+	private _previewState: IPreviewState | undefined = undefined;
+
+	public readonly getPreviewState = (): IPreviewState | undefined => {
+		return this._previewState;
+	};
+
+	public readonly getCompareWithPreviewState = ():
+		| IPreviewState
+		| undefined => {
+		const previewState = this.getPreviewState();
+		if (!previewState) {
+			return undefined;
+		}
+		return {
+			...previewState,
+			monacoSetup: toLoaderConfig({
+				...getDefaultSettings(),
+				...this.historyModel.compareWith!.toPartialSettings(),
+			}),
+		};
+	};
+
 	@observable
 	public settingsDialogModel: SettingsDialogModel | undefined = undefined;
 
@@ -134,6 +148,7 @@ export class PlaygroundModel {
 						example: value,
 						project: p,
 					};
+					this.reloadKey++;
 					this.setState(p);
 				});
 			});
@@ -146,37 +161,37 @@ export class PlaygroundModel {
 	public isDirty = false;
 
 	constructor() {
-		let lastState = this.state;
+		let lastState: IPreviewState | undefined = undefined;
 
 		this.dispose.track({
 			dispose: reaction(
 				() => ({ state: this.state }),
-				({ state }) => {
+				() => {
+					const state = this.state;
 					if (!this.settings.autoReload) {
 						if (
-							JSON.stringify(state.monacoSetup) ===
-								JSON.stringify(lastState.monacoSetup) &&
-							state.key === lastState.key
+							(!lastState ||
+								JSON.stringify(state.monacoSetup) ===
+									JSON.stringify(lastState.monacoSetup)) &&
+							state.reloadKey === (lastState?.reloadKey ?? 0)
 						) {
 							this.isDirty = true;
 							return;
 						}
 					}
-					const action = () => {
+					const updatePreviewState = () => {
 						this.isDirty = false;
-						lastState = state;
-						for (const handler of this._previewHandlers) {
-							handler.handlePreview(state);
-						}
+						this._previewState = state;
+						lastState = this._previewState;
 					};
 
-					if (state.key !== lastState.key) {
-						action(); // sync update
+					if (state.reloadKey !== lastState?.reloadKey) {
+						updatePreviewState();
 					} else {
-						this.debouncer.run(action);
+						this.debouncer.run(updatePreviewState);
 					}
 				},
-				{ name: "update preview" }
+				{ name: "update preview", fireImmediately: true }
 			),
 		});
 
@@ -284,21 +299,13 @@ export class PlaygroundModel {
 		this.css = state.css;
 	}
 
-	public setPreviewHandler(handler: IPreviewHandler): monaco.IDisposable {
-		this._previewHandlers.add(handler);
-		handler.handlePreview(this.state);
-		return {
-			dispose: () => {
-				this._previewHandlers.delete(handler);
-			},
-		};
-	}
-
 	public readonly bisectModel = new BisectModel(this);
-}
 
-export interface IPreviewHandler {
-	handlePreview(state: IPreviewState): void;
+	@action
+	compareWithLatestDev(): void {
+		this.settings.previewFullScreen = true;
+		this.historyModel.compareWithLatestDev();
+	}
 }
 
 export class SettingsDialogModel {
@@ -316,458 +323,3 @@ export class SettingsDialogModel {
 		this.settings = Object.assign({}, settings);
 	}
 }
-
-function projectEquals(
-	project1: IPlaygroundProject,
-	project2: IPlaygroundProject
-): boolean {
-	return (
-		normalizeLineEnding(project1.css) ===
-			normalizeLineEnding(project2.css) &&
-		normalizeLineEnding(project1.html) ===
-			normalizeLineEnding(project2.html) &&
-		normalizeLineEnding(project1.js) === normalizeLineEnding(project2.js)
-	);
-}
-
-function normalizeLineEnding(str: string): string {
-	return str.replace(/\r\n/g, "\n");
-}
-
-class StateUrlSerializer implements IHistoryModel {
-	public readonly dispose = Disposable.fn();
-
-	private readonly compressor = new LzmaCompressor<IPlaygroundProject>();
-
-	private cachedState:
-		| { state: IPlaygroundProject; hash: string }
-		| undefined = undefined;
-
-	private readonly computedHashValue = debouncedComputed(
-		500,
-		() => ({
-			state: this.model.playgroundProject,
-			selectedExampleProject: this.model.selectedExampleProject,
-		}),
-		({ state, selectedExampleProject }) => {
-			if (
-				selectedExampleProject &&
-				projectEquals(state, selectedExampleProject.project)
-			) {
-				return "example-" + selectedExampleProject.example.id;
-			}
-			if (
-				this.cachedState &&
-				projectEquals(this.cachedState.state, state)
-			) {
-				return this.cachedState.hash;
-			}
-			return this.compressor.encodeData(state);
-		}
-	);
-
-	private get sourceFromSettings(): Source | undefined {
-		const settings = this.model.settings.settings;
-		if (settings.monacoSource === "npm") {
-			return new Source(settings.npmVersion, undefined, undefined);
-		} else if (
-			settings.monacoSource === "independent" &&
-			((settings.coreSource === "url" &&
-				(settings.languagesSource === "latest" ||
-					settings.languagesSource === "url")) ||
-				(settings.coreSource === "latest" &&
-					settings.languagesSource === "url"))
-		) {
-			return new Source(
-				undefined,
-				settings.coreSource === "url" ? settings.coreUrl : undefined,
-				settings.languagesSource === "latest"
-					? undefined
-					: settings.languagesUrl
-			);
-		} else if (settings.monacoSource === "latest") {
-			return new Source(monacoEditorVersion, undefined, undefined);
-		}
-		return undefined;
-	}
-
-	@observable
-	private _sourceOverride: Source | undefined;
-
-	get sourceOverride(): Source | undefined {
-		return this._sourceOverride;
-	}
-
-	@action
-	disableSourceOverride(): void {
-		this._sourceOverride = undefined;
-		this.historyId++;
-	}
-
-	@action
-	useLatestDev(): void {
-		this._sourceOverride = undefined;
-		this.model.settings.setSettings({
-			...this.model.settings.settings,
-			...Source.useLatestDev().toPartialSettings(),
-		});
-		this.historyId++;
-	}
-
-	@action
-	saveSourceOverride(): void {
-		if (this._sourceOverride) {
-			this.model.settings.setSettings({
-				...this.model.settings.settings,
-				...this._sourceOverride.toPartialSettings(),
-			});
-			this.historyId++;
-			this._sourceOverride = undefined;
-		}
-	}
-
-	/**
-	 * This is used to control replace/push state.
-	 * Replace is used if the history id does not change.
-	 */
-	@observable historyId: number = 0;
-
-	get location(): ILocation {
-		const source = this._sourceOverride || this.sourceFromSettings;
-		return {
-			hashValue: this.computedHashValue.value || this.cachedState?.hash,
-			searchParams: {
-				source: source?.sourceToString(),
-				sourceLanguages: source?.sourceLanguagesToString(),
-			},
-		};
-	}
-
-	@action
-	updateLocation(currentLocation: ILocation): void {
-		const hashValue = currentLocation.hashValue;
-		const sourceStr = currentLocation.searchParams.source;
-		const sourceLanguages = currentLocation.searchParams.sourceLanguages;
-		const source =
-			sourceStr || sourceLanguages
-				? Source.parse(sourceStr, sourceLanguages)
-				: undefined;
-
-		if (this.sourceFromSettings?.equals(source)) {
-			this._sourceOverride = undefined;
-		} else {
-			this._sourceOverride = source;
-		}
-
-		function findExample(hashValue: string): PlaygroundExample | undefined {
-			if (hashValue.startsWith("example-")) {
-				hashValue = hashValue.substring("example-".length);
-			}
-			return getPlaygroundExamples()
-				.flatMap((e) => e.examples)
-				.find((e) => e.id === hashValue);
-		}
-
-		let example: PlaygroundExample | undefined;
-
-		if (!hashValue) {
-			this.model.selectedExample = getPlaygroundExamples()[0].examples[0];
-		} else if ((example = findExample(hashValue))) {
-			this.model.selectedExample = example;
-		} else {
-			let p: IPlaygroundProject | undefined = undefined;
-			if (this.cachedState?.hash === hashValue) {
-				p = this.cachedState.state;
-			}
-			if (!p) {
-				try {
-					p =
-						this.compressor.decodeData<IPlaygroundProject>(
-							hashValue
-						);
-				} catch (e) {
-					console.log("Could not deserialize from hash value", e);
-				}
-			}
-			if (p) {
-				this.cachedState = { state: p, hash: hashValue };
-				this.model.setState(p);
-			}
-		}
-	}
-
-	private readonly historyController = this.dispose.track(
-		new HistoryController((initialLocation) => {
-			this.updateLocation(initialLocation);
-			return this;
-		})
-	);
-
-	constructor(private readonly model: PlaygroundModel) {}
-}
-
-class BisectModel {
-	private readonly map = new ObservableMap<string, boolean>();
-
-	constructor(private readonly model: PlaygroundModel) {}
-
-	public getState(version: string): boolean | undefined {
-		return this.map.get(version);
-	}
-
-	public get isActive() {
-		return [...this.map.values()].some((e) => e !== undefined);
-	}
-
-	public reset(): void {
-		this.map.clear();
-	}
-
-	public async toggleState(version: string, state: boolean): Promise<void> {
-		const currentState = this.getState(version);
-		await this.setState(
-			version,
-			currentState === state ? undefined : state
-		);
-	}
-
-	@action
-	public async setState(
-		version: string,
-		state: boolean | undefined
-	): Promise<void> {
-		if (state === undefined) {
-			this.map.delete(version);
-		} else {
-			this.map.set(version, state);
-		}
-
-		const nextVersion = await this.getNextVersion();
-		if (!nextVersion) {
-			return;
-		}
-		this.model.settings.setSettings({
-			...this.model.settings.settings,
-			npmVersion: nextVersion,
-		});
-	}
-
-	private get versions() {
-		return getNpmVersionsSync(undefined);
-	}
-
-	private get indexOfLastBadVersion() {
-		return findLastIndex(this.versions, (v) => this.map.get(v) === false);
-	}
-	private get indexOfFirstGoodVersion() {
-		return this.versions.findIndex((v) => this.map.get(v) === true);
-	}
-
-	public get steps() {
-		const indexOfFirstGoodVersion = this.indexOfFirstGoodVersion;
-		const indexOfLastBadVersion = this.indexOfLastBadVersion;
-
-		if (indexOfFirstGoodVersion === -1 && indexOfLastBadVersion === -1) {
-			return -1;
-		}
-		if (indexOfFirstGoodVersion === -1) {
-			return Math.ceil(
-				Math.log2(this.versions.length - indexOfLastBadVersion)
-			);
-		} else if (indexOfLastBadVersion === -1) {
-			return Math.ceil(Math.log2(indexOfFirstGoodVersion + 1));
-		} else {
-			return Math.ceil(
-				Math.log2(indexOfFirstGoodVersion - indexOfLastBadVersion)
-			);
-		}
-	}
-
-	public get isFinished() {
-		if (
-			this.indexOfFirstGoodVersion !== -1 &&
-			this.indexOfLastBadVersion + 1 === this.indexOfFirstGoodVersion
-		) {
-			return true;
-		}
-		return false;
-	}
-
-	public async openGithub() {
-		const versions = await getNpmVersions();
-		const indexOfFirstGoodVersion =
-			this.indexOfFirstGoodVersion === -1
-				? versions.length - 1
-				: this.indexOfFirstGoodVersion;
-		const indexOfLastBadVersion =
-			this.indexOfLastBadVersion === -1 ? 0 : this.indexOfLastBadVersion;
-		const goodCommitId = await getVsCodeCommitId(
-			versions[indexOfFirstGoodVersion]
-		);
-		const badCommitId = await getVsCodeCommitId(
-			versions[indexOfLastBadVersion]
-		);
-		window.open(
-			`https://github.com/microsoft/vscode/compare/${goodCommitId}...${badCommitId}`,
-			"_blank"
-		);
-	}
-
-	private async getNextVersion(): Promise<string | undefined> {
-		const versions = await getNpmVersions();
-
-		const indexOfFirstGoodVersion = this.indexOfFirstGoodVersion;
-		const indexOfLastBadVersion = this.indexOfLastBadVersion;
-
-		if (
-			indexOfFirstGoodVersion !== -1 &&
-			indexOfLastBadVersion + 1 === indexOfFirstGoodVersion
-		) {
-			// Finished
-			return;
-		}
-
-		if (indexOfLastBadVersion === -1 && indexOfFirstGoodVersion === -1) {
-			return versions[0];
-		}
-		if (indexOfLastBadVersion === -1) {
-			// try first (newest) version that hasn't been tested
-			const indexOfFirstUntestedVersion = versions.findIndex(
-				(v) => this.map.get(v) === undefined
-			);
-			if (indexOfFirstUntestedVersion === -1) {
-				return undefined;
-			}
-			return versions[indexOfFirstUntestedVersion];
-		}
-
-		if (indexOfFirstGoodVersion === -1) {
-			/*// exponential back off, might be good for recent regressions, but ruins step counter
-			const candidate = Math.min(
-				indexOfLastBadVersion * 2 + 1,
-				versions.length - 1
-			);*/
-			const candidate = Math.floor(
-				(indexOfLastBadVersion + versions.length) / 2
-			);
-			return versions[candidate];
-		}
-
-		return versions[
-			Math.floor((indexOfLastBadVersion + indexOfFirstGoodVersion) / 2)
-		];
-	}
-}
-
-function findLastIndex<T>(
-	array: T[],
-	predicate: (value: T) => boolean
-): number {
-	for (let i = array.length - 1; i >= 0; i--) {
-		if (predicate(array[i])) {
-			return i;
-		}
-	}
-	return -1;
-}
-
-class Source {
-	public static useLatestDev(sourceLanguagesStr?: string): Source {
-		// Assume the versions are already loaded
-		const versions = getNpmVersionsSync(undefined);
-		const version = versions.find((v) => v.indexOf("-dev-") !== -1);
-		return new Source(version, undefined, sourceLanguagesStr);
-	}
-
-	public static useLatest(sourceLanguagesStr?: string): Source {
-		return new Source(monacoEditorVersion, undefined, sourceLanguagesStr);
-	}
-
-	public static parse(
-		sourceStr: string | undefined,
-		sourceLanguagesStr: string | undefined
-	): Source {
-		if (sourceStr === "latest-dev") {
-			return Source.useLatestDev(sourceLanguagesStr);
-		}
-		if (sourceStr === "latest") {
-			return Source.useLatest(sourceLanguagesStr);
-		}
-
-		if (sourceStr && sourceStr.startsWith("v")) {
-			return new Source(
-				sourceStr.substring(1),
-				undefined,
-				sourceLanguagesStr
-			);
-		}
-		return new Source(undefined, sourceStr, sourceLanguagesStr);
-	}
-
-	public equals(other: Source | undefined): boolean {
-		if (!other) {
-			return false;
-		}
-		return other.toString() === this.toString();
-	}
-
-	constructor(
-		public readonly version: string | undefined,
-		public readonly url: string | undefined,
-		public readonly sourceLanguagesStr: string | undefined
-	) {
-		if (
-			version === undefined &&
-			url === undefined &&
-			sourceLanguagesStr === undefined
-		) {
-			throw new Error("one parameter must be defined");
-		}
-	}
-
-	sourceToString(): string | undefined {
-		if (this.url) {
-			return this.url;
-		}
-		if (this.version) {
-			return `v${this.version}`;
-		}
-		return undefined;
-	}
-
-	sourceLanguagesToString(): string | undefined {
-		return this.sourceLanguagesStr;
-	}
-
-	toString() {
-		return `${this.sourceToString()};${this.sourceLanguagesToString()}`;
-	}
-
-	public toPartialSettings(): Partial<Settings> {
-		const languagesSettings: Partial<Settings> = {
-			languagesSource:
-				this.sourceLanguagesStr === undefined ? "latest" : "url",
-			languagesUrl: this.sourceLanguagesStr,
-		};
-
-		if (this.version) {
-			return {
-				monacoSource: "npm",
-				npmVersion: this.version,
-			};
-		} else if (this.url) {
-			return {
-				monacoSource: "independent",
-				coreSource: "url",
-				coreUrl: this.url,
-				...languagesSettings,
-			};
-		} else {
-			return {
-				monacoSource: "independent",
-				coreSource: "latest",
-				...languagesSettings,
-			};
-		}
-	}
-}
diff --git a/website/src/website/pages/playground/PlaygroundPage.tsx b/website/src/website/pages/playground/PlaygroundPage.tsx
index fc6f6fbf..fccfa6f1 100644
--- a/website/src/website/pages/playground/PlaygroundPage.tsx
+++ b/website/src/website/pages/playground/PlaygroundPage.tsx
@@ -7,9 +7,10 @@ import { withLoader } from "../../components/Loader";
 import { getNpmVersions } from "./getNpmVersionsSync";
 
 @withLoader(async () => {
+	const search = new URLSearchParams(window.location.search);
 	if (
-		new URLSearchParams(window.location.search).get("source") ===
-		"latest-dev"
+		search.get("source") === "latest-dev" ||
+		search.get("compareWith") === "latest-dev"
 	) {
 		// So that the source class can resolve that value
 		await getNpmVersions();
diff --git a/website/src/website/pages/playground/PlaygroundPageContent.tsx b/website/src/website/pages/playground/PlaygroundPageContent.tsx
index ac5129cf..4681b0fb 100644
--- a/website/src/website/pages/playground/PlaygroundPageContent.tsx
+++ b/website/src/website/pages/playground/PlaygroundPageContent.tsx
@@ -19,6 +19,7 @@ import { Preview } from "./Preview";
 import { SettingsDialog } from "./SettingsDialog";
 import { getNpmVersionsSync } from "./getNpmVersionsSync";
 import { PlaygroundExample, getPlaygroundExamples } from "./playgroundExamples";
+import { getDefaultSettings, toLoaderConfig } from "./SettingsModel";
 
 @hotComponent(module)
 @observer
@@ -41,7 +42,7 @@ export class PlaygroundPageContent extends React.Component<
 							<Col
 								md
 								className={
-									model.settings.previewFullScreen
+									model.previewShouldBeFullScreen
 										? "d-none"
 										: ""
 								}
@@ -118,15 +119,24 @@ export class PlaygroundPageContent extends React.Component<
 								</Vertical>
 							</Col>
 						)}
-						<Col md>
+						<Col
+							md
+							style={{ display: "flex", flexDirection: "column" }}
+						>
 							<LabeledEditor
-								label="Preview"
+								label={`Preview${
+									model.historyModel.compareWith &&
+									model.historyModel.sourceOverride
+										? " " +
+										  model.historyModel.sourceOverride.toString()
+										: ""
+								}:`}
 								titleBarItems={
 									<div
 										style={{ marginLeft: "auto" }}
 										className="d-flex gap-2 align-items-center"
 									>
-										{model.settings.previewFullScreen || (
+										{model.previewShouldBeFullScreen || (
 											<FormCheck
 												label="Auto-Reload"
 												className="text-nowrap"
@@ -177,64 +187,116 @@ export class PlaygroundPageContent extends React.Component<
 											}
 										/>
 
-										{model.serializer.sourceOverride ? (
+										{!model.historyModel.compareWith ? (
+											model.historyModel
+												.sourceOverride ? (
+												<ButtonGroup>
+													<button
+														type="button"
+														className="btn btn-primary"
+														onClick={() =>
+															model.historyModel.disableSourceOverride()
+														}
+													>
+														Disable{" "}
+														{model.historyModel
+															.sourceOverride
+															.version ??
+															"url"}{" "}
+														override
+													</button>
+													<button
+														type="button"
+														className="btn btn-secondary"
+														onClick={() =>
+															model.compareWithLatestDev()
+														}
+													>
+														Compare with latest dev
+													</button>
+													<button
+														type="button"
+														className="btn btn-secondary"
+														onClick={() =>
+															model.historyModel.saveSourceOverride()
+														}
+													>
+														Save
+													</button>
+												</ButtonGroup>
+											) : (
+												<>
+													<VersionSelector
+														model={model}
+													/>
+
+													<button
+														type="button"
+														className="btn btn-light settings bi-gear"
+														style={{
+															fontSize: 20,
+															padding: "0px 4px",
+														}}
+														onClick={() =>
+															model.showSettingsDialog()
+														}
+													/>
+												</>
+											)
+										) : (
 											<ButtonGroup>
 												<button
 													type="button"
 													className="btn btn-primary"
 													onClick={() =>
-														model.serializer.disableSourceOverride()
-													}
-												>
-													Disable{" "}
-													{model.serializer
-														.sourceOverride
-														.version ?? "url"}{" "}
-													override
-												</button>
-												<button
-													type="button"
-													className="btn btn-secondary"
-													onClick={() =>
-														model.serializer.useLatestDev()
-													}
-												>
-													Use latest dev
-												</button>
-												<button
-													type="button"
-													className="btn btn-secondary"
-													onClick={() =>
-														model.serializer.saveSourceOverride()
+														model.historyModel.exitCompare()
 													}
 												>
-													Save
+													Exit Compare
 												</button>
 											</ButtonGroup>
-										) : (
-											<>
-												<VersionSelector
-													model={model}
-												/>
-
-												<button
-													type="button"
-													className="btn btn-light settings bi-gear"
-													style={{
-														fontSize: 20,
-														padding: "0px 4px",
-													}}
-													onClick={() =>
-														model.showSettingsDialog()
-													}
-												/>
-											</>
 										)}
 									</div>
 								}
 							>
-								<Preview model={model} />
+								<Preview
+									model={model}
+									getPreviewState={model.getPreviewState}
+								/>
 							</LabeledEditor>
+							{model.historyModel.compareWith && (
+								<>
+									<div style={{ height: "10px" }} />
+									<LabeledEditor
+										label={`Preview ${model.historyModel.compareWith.toString()}:`}
+										titleBarItems={
+											<div
+												style={{ marginLeft: "auto" }}
+												className="d-flex gap-2 align-items-center"
+											>
+												<ButtonGroup>
+													<button
+														type="button"
+														className="btn btn-primary"
+														onClick={() =>
+															model.historyModel.saveCompareWith()
+														}
+													>
+														Save
+													</button>
+												</ButtonGroup>
+											</div>
+										}
+									>
+										<Preview
+											model={model}
+											getPreviewState={
+												model.getCompareWithPreviewState
+											}
+										/>
+									</LabeledEditor>
+								</>
+							)}
 						</Col>
 					</Row>
 				</div>
diff --git a/website/src/website/pages/playground/Preview.tsx b/website/src/website/pages/playground/Preview.tsx
index 07ee853c..538b8d80 100644
--- a/website/src/website/pages/playground/Preview.tsx
+++ b/website/src/website/pages/playground/Preview.tsx
@@ -1,27 +1,53 @@
 import * as React from "react";
-import { IPreviewHandler, PlaygroundModel } from "./PlaygroundModel";
+import { PlaygroundModel } from "./PlaygroundModel";
 import { observer } from "mobx-react";
-import { observable } from "mobx";
+import { autorun, observable, reaction } from "mobx";
 import {
 	IMessageFromRunner,
 	IMessageToRunner,
 	IPreviewState,
 } from "../../../shared";
+import { Button } from "react-bootstrap";
 
 @observer
-export class Preview
-	extends React.Component<{ model: PlaygroundModel }>
-	implements IPreviewHandler
-{
+export class Preview extends React.Component<{
+	model: PlaygroundModel;
+	getPreviewState: () => IPreviewState | undefined;
+}> {
 	private disposables: monaco.IDisposable[] = [];
-	@observable
-	private counter = 0;
-	private currentState: IPreviewState | undefined;
+	@observable private counter = 0;
+	@observable.ref private currentState: IPreviewState | undefined;
 	private iframe: HTMLIFrameElement | null = null;
 
 	render() {
 		return (
 			<div className="preview">
+				{this.currentState ? null : (
+					<div
+						style={{
+							width: "100%",
+							height: "100%",
+							display: "flex",
+							justifyContent: "center",
+							alignItems: "center",
+						}}
+					>
+						<div>
+							Load{" "}
+							<Button
+								type="button"
+								className={
+									"btn settings bi-arrow-clockwise btn-primary"
+								}
+								style={{
+									fontSize: 20,
+									padding: "0px 4px",
+								}}
+								onClick={() => this.props.model.reload()}
+							/>
+						</div>
+					</div>
+				)}
 				<iframe
 					className="full-iframe"
 					key={this.counter}
@@ -66,27 +92,33 @@ export class Preview
 	};
 
 	componentDidMount() {
-		this.disposables.push(this.props.model.setPreviewHandler(this));
+		this.disposables.push({
+			dispose: reaction(
+				() => this.props.getPreviewState(),
+				(state) => {
+					if (state) {
+						console.log("handlePreview", state);
+						this.handlePreview(state);
+					}
+				},
+				{ fireImmediately: true }
+			),
+		});
 	}
 
 	componentWillUnmount() {
 		this.disposables.forEach((d) => d.dispose());
 	}
 
-	handlePreview(state: IPreviewState): void {
+	private handlePreview(state: IPreviewState): void {
 		if (
 			JSON.stringify({ ...state, css: "" }) ===
 			JSON.stringify({ ...this.currentState, css: "" })
 		) {
 			// only css changed
 			this.iframe?.contentWindow!.postMessage(
-				{
-					kind: "update-css",
-					css: state.css,
-				} as IMessageToRunner,
-				{
-					targetOrigin: "*",
-				}
+				{ kind: "update-css", css: state.css } as IMessageToRunner,
+				{ targetOrigin: "*" }
 			);
 			this.currentState = state;
 		} else {
diff --git a/website/src/website/pages/playground/Source.ts b/website/src/website/pages/playground/Source.ts
new file mode 100644
index 00000000..299a1011
--- /dev/null
+++ b/website/src/website/pages/playground/Source.ts
@@ -0,0 +1,107 @@
+import { monacoEditorVersion } from "../../monacoEditorVersion";
+import { getNpmVersionsSync } from "./getNpmVersionsSync";
+import { Settings } from "./SettingsModel";
+
+export class Source {
+	public static useLatestDev(sourceLanguagesStr?: string): Source {
+		// Assume the versions are already loaded
+		const versions = getNpmVersionsSync(undefined);
+		const version = versions.find((v) => v.indexOf("-dev-") !== -1);
+		return new Source(version, undefined, sourceLanguagesStr);
+	}
+
+	public static useLatest(sourceLanguagesStr?: string): Source {
+		return new Source(monacoEditorVersion, undefined, sourceLanguagesStr);
+	}
+
+	public static parse(
+		sourceStr: string | undefined,
+		sourceLanguagesStr: string | undefined
+	): Source {
+		if (sourceStr === "latest-dev") {
+			return Source.useLatestDev(sourceLanguagesStr);
+		}
+		if (sourceStr === "latest") {
+			return Source.useLatest(sourceLanguagesStr);
+		}
+
+		if (sourceStr && sourceStr.startsWith("v")) {
+			return new Source(
+				sourceStr.substring(1),
+				undefined,
+				sourceLanguagesStr
+			);
+		}
+		return new Source(undefined, sourceStr, sourceLanguagesStr);
+	}
+
+	public equals(other: Source | undefined): boolean {
+		if (!other) {
+			return false;
+		}
+		return other.toString() === this.toString();
+	}
+
+	constructor(
+		public readonly version: string | undefined,
+		public readonly url: string | undefined,
+		public readonly sourceLanguagesStr: string | undefined
+	) {
+		if (
+			version === undefined &&
+			url === undefined &&
+			sourceLanguagesStr === undefined
+		) {
+			throw new Error("one parameter must be defined");
+		}
+	}
+
+	sourceToString(): string | undefined {
+		if (this.url) {
+			return this.url;
+		}
+		if (this.version) {
+			return `v${this.version}`;
+		}
+		return undefined;
+	}
+
+	sourceLanguagesToString(): string | undefined {
+		return this.sourceLanguagesStr;
+	}
+
+	toString() {
+		const sourceLangToStr = this.sourceLanguagesToString();
+		return `${this.sourceToString()}${
+			sourceLangToStr ? `;${sourceLangToStr}` : ""
+		}`;
+	}
+
+	public toPartialSettings(): Partial<Settings> {
+		const languagesSettings: Partial<Settings> = {
+			languagesSource:
+				this.sourceLanguagesStr === undefined ? "latest" : "url",
+			languagesUrl: this.sourceLanguagesStr,
+		};
+
+		if (this.version) {
+			return {
+				monacoSource: "npm",
+				npmVersion: this.version,
+			};
+		} else if (this.url) {
+			return {
+				monacoSource: "independent",
+				coreSource: "url",
+				coreUrl: this.url,
+				...languagesSettings,
+			};
+		} else {
+			return {
+				monacoSource: "independent",
+				coreSource: "latest",
+				...languagesSettings,
+			};
+		}
+	}
+}
diff --git a/website/src/website/pages/playground/utils.ts b/website/src/website/pages/playground/utils.ts
new file mode 100644
index 00000000..29d9fd10
--- /dev/null
+++ b/website/src/website/pages/playground/utils.ts
@@ -0,0 +1,29 @@
+import { normalizeLineEnding } from "./utils";
+import { IPlaygroundProject } from "../../../shared";
+
+export function findLastIndex<T>(
+	array: T[],
+	predicate: (value: T) => boolean
+): number {
+	for (let i = array.length - 1; i >= 0; i--) {
+		if (predicate(array[i])) {
+			return i;
+		}
+	}
+	return -1;
+}
+export function projectEquals(
+	project1: IPlaygroundProject,
+	project2: IPlaygroundProject
+): boolean {
+	return (
+		normalizeLineEnding(project1.css) ===
+			normalizeLineEnding(project2.css) &&
+		normalizeLineEnding(project1.html) ===
+			normalizeLineEnding(project2.html) &&
+		normalizeLineEnding(project1.js) === normalizeLineEnding(project2.js)
+	);
+}
+export function normalizeLineEnding(str: string): string {
+	return str.replace(/\r\n/g, "\n");
+}
diff --git a/website/src/website/style.scss b/website/src/website/style.scss
index 5b0c0c31..59fe9b60 100644
--- a/website/src/website/style.scss
+++ b/website/src/website/style.scss
@@ -74,6 +74,9 @@ body,
 
 .monaco-editor {
 	position: absolute !important;
+	a {
+		text-decoration: none;
+	}
 }
 
 button.settings {
diff --git a/website/yarn.lock b/website/yarn.lock
index 33f833dc..84682341 100644
--- a/website/yarn.lock
+++ b/website/yarn.lock
@@ -2147,10 +2147,10 @@ mobx@^5.15.4:
   resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.7.tgz#b9a5f2b6251f5d96980d13c78e9b5d8d4ce22665"
   integrity sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==
 
-monaco-editor@^0.41.0:
-  version "0.41.0"
-  resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.41.0.tgz#2ba31e5af7e3ae93ac5d7467ec2772ef9b3d967f"
-  integrity sha512-1o4olnZJsiLmv5pwLEAmzHTE/5geLKQ07BrGxlF4Ri/AXAc2yyDGZwHjiTqD8D/ROKUZmwMA28A+yEowLNOEcA==
+monaco-editor@^0.42.0-dev-20230906:
+  version "0.42.0-dev-20230906"
+  resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.42.0-dev-20230906.tgz#612a41fcbed662d3873a94ad5f558e6893da2c7d"
+  integrity sha512-UICbxxHu0jYovjOKcwSJkmnJbokiSefro1wDqVJ4OpyzXmS0dYZol+lYPJLIdfb0oUtUTf8840VMAPo5jC+B1Q==
 
 mrmime@^1.0.0:
   version "1.0.1"