Learn how to turn your VS Code extension into a Marquee widget
Marquee is a VS Code extension that brings a fully extensible homescreen right into your favorite IDE for minimal context switching. Also, it’s open source. What’s cool is that you can integrate your VS Code Extension onto our Marquee Widget ecosystem. While Marquee focuses on productivity tools, we also have room for more fun.
This tutorial will teach you how to turn a VS Code extension into a Marquee custom widget. This example will use the popular vscode-spotify
extension, giving it a custom widget for the Marquee dashboard. This is what we are going to build:
Just a bit of terminology before we begin:
-
Extension Host - this is where the extension runs (an integral part of VS Code). Most of your business logic will live here.
- Typically this file is named
extension.ts
- Typically this file is named
- Webview - the environment where the Marquee dashboard lives. A VS Code API which is essentially a browser env (iframe) inside VS Code.
- Extension Development Host - the debugger view. So we can see the widget in development.
Prerequisites
- VS Code
- Marquee
- vscode-spotify
If you want to follow along from a clean slate you can checkout:
https://github.com/stateful/vscode-spotify/tree/custom-widget-tutorial-start
💡 While you're at, we could use a thumbs up on the pending PR to make Spotify available to you in Marquee please: https://github.com/ShyykoSerhiy/vscode-spotify/pull/182
How to run
Run F5
to run debugger or Run and Debug
from VS Code’s command palette. Also, the Extension Development Host
does not refresh on save like how a normal browser would when working on a react app, so be sure to refresh the extension development host after every save. To refresh command + r / crl + r
or Reload Window
from the command palette.
Dependencies
In addition to the packages already installed, install these dependencies will be used to create our widget.
- tangle@2.1.0 - communication channel if we want to send information to be displayed from extension host to the webview. Tangle provides a better x-sandbox communication interface than stringing together postMessage/onMessage across multiple processes.
- lit@2.2.8 - used to create our webviews, marquee widgets are unconstrained to any frontend library or framework but ultimately must be exported as a web component. To keep things simple, we will use lit.html.
- fortawesome/free-solid-svg-icons (icon to show on our widget list).
- webpack@5.74.0 (dev dependency).
- webpack-cli@4.10.0 (dev dependency).
- npm-run-all@4.1.5 (dev dependency).
Extension Host
The Extension Host is responsible for running extensions as per the https://code.visualstudio.com/api/advanced-topics/extension-host VS Code docs.
The extension host will have anactivate
function that fires up the extension.
For vscode-spotify the extension host looks like this-
/src/extension.ts
import { ExtensionContext, window } from 'vscode';
...
import { getStore } from './store/store';
// This method is called when your extension is activated. Activation is
// controlled by the activation events defined in package.json.
export function activate(context: ExtensionContext) {
// This line of code will only be executed once when your extension is activated.
registerGlobalState(context.globalState);
getStore(context.globalState);
const spotifyStatus = new SpotifyStatus();
const controller = new SpotifyStatusController();
const playlistTreeView = window.createTreeView('vscode-spotify-playlists', { treeDataProvider: new TreePlaylistProvider() });
const albumTreeView = window.createTreeView('vscode-spotify-albums', { treeDataProvider: new TreeAlbumProvider() });
const treeTrackProvider = new TreeTrackProvider();
const trackTreeView = window.createTreeView('vscode-spotify-tracks', { treeDataProvider: treeTrackProvider });
treeTrackProvider.bindView(trackTreeView);
// Add to a list of disposables which are disposed when this extension is deactivated.
context.subscriptions.push(connectPlaylistTreeView(playlistTreeView));
context.subscriptions.push(connectAlbumTreeView(albumTreeView));
context.subscriptions.push(connectTrackTreeView(trackTreeView));
context.subscriptions.push(controller);
context.subscriptions.push(spotifyStatus);
context.subscriptions.push(playlistTreeView);
context.subscriptions.push(createCommands(SpoifyClientSingleton.spotifyClient));
}
We need to be able to create a channel that allows our extension host
to communicate with the webview.
Unlike working in a regular browser, webviews are essentially iFrames containing a subset of the browsers api, disallowing importing of files and other features. We will then use tangle
to bridge sending data from our extension host to our webview, which we create later.
The data we need to pass to our webview is inside the getStore
and getState
(we need to import this inside) function. Note that the extension host is running on a setTimeout that runs every second, updating the state.
The data returns the information we need to display such as:
- track
- playerState
- loginState
- isRunning
Setting tangle up in extension host:
To setup our marquee interface, we will return a marquee interface:
Note
: No code was removed, just using …
to focus on the newly added code.
...
import { Client } from 'tangle';
import { ILoginState, IPlayerState, ITrack } from './state/state';
import { getState } from "./store/store";
// This method is called when your extension is activated. Activation is
// controlled by the activation events defined in package.json.
export function activate(context: ExtensionContext) {
// This line of code will only be executed once when your extension is activated.
...
context.subscriptions.push(createCommands(SpoifyClientSingleton.spotifyClient));
return {
marquee: {
setup: (
tangle: Client<{
track: ITrack;
playerState: IPlayerState;
loginState: ILoginState | null;
isRunning: boolean
}>
) => {
return tangle.whenReady().then(() => {
getStore().subscribe(() => {
const { track, playerState, loginState, isRunning } = getState();
tangle.emit("isRunning", isRunning);
tangle.emit("loginState", loginState);
tangle.emit("track", track);
tangle.emit("playerState", playerState);
});
});
}
},
};
}
Explanation
- We are exporting a marquee interface with a setup function that emits after our extension activation event in the Marquee expects a custom widget.
-
tangle
allows type assertion allowing tangle to know what state we want toemit
or listen to. -
tangle.emit(eventName, payload)
is a way to broadcast our data to ourwebview
- Reference - https://www.npmjs.com/package/tangle
...
context.subscriptions.push(createCommands(SpoifyClientSingleton.spotifyClient));
return {
marquee: {
setup: (
tangle: Client<{
track: ITrack;
playerState: IPlayerState;
loginState: ILoginState | null;
isRunning: boolean
}>
) => {
return tangle.whenReady().then(() => {
getStore().subscribe(() => {
const { track, playerState, loginState, isRunning } = getState();
tangle.emit("isRunning", isRunning);
tangle.emit("loginState", loginState);
tangle.emit("track", track);
tangle.emit("playerState", playerState);
});
});
}
},
};
webpack
- Create a
webpack.config.ts
file in the root directory. - The output will be what we use as a reference to our
widget
. - This way the final javascript bundle will be only contain what’s required and minimal in size.
import * as path from "path";
import { Configuration } from "webpack";
const widgetConfig: Configuration = {
target: "node",
entry: path.resolve(__dirname, "src", "marquee/widget.ts"),
output: {
path: path.resolve(__dirname, "out", "marquee"),
filename: "widget.js",
libraryTarget: 'commonjs2'
},
devtool: "source-map",
externals: {
vscode: "commonjs vscode"
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: [
{
loader: "ts-loader",
options: {
compilerOptions: {
declaration: false,
declarationMap: false,
rootDir: __dirname,
},
},
},
],
},
{
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: "file-loader",
},
],
},
],
},
};
export default widgetConfig;
package.json
We need to be able to setup our environments in order to setup marquee
-
Marquee will look in the extensions
package.json
to see where our marquee widget is pointing towards. We can do this by:
{ "name": "vscode-spotify", "description": "Use Spotify inside vscode.", "version": "3.2.1", "publisher": "shyykoserhiy", "license": "MIT", "engines": { "vscode": "^1.49.0" ... "marquee": { "widget": "/out/marquee/widget.js" }, .... }
-
We also need to adjust the watch and compile commands
- We will use our
npm-run-all
command to help us concurrently runtsc
andwebpack
commands. tsc builds out the extension host while webpack builds out the webview. Both environments targets are different so it’s simpler to separate the concerns here.
"compile": "run-p compile:*", "compile:ts": "tsc --project ./", "compile:webpack": "webpack --mode production", "watch": "run-p watch:*", "watch:ts": "tsc --watch --project ./", "watch:webpack": "webpack --mode development --watch",
If you were running the
npm run watch
previously, you will need to restart it. - We will use our
tsconfig.json
We need to exclude
the new files dedicated to the marquee webview such as webpack and /marquee/*
since they are important in development but not required for packaging.
{
"compilerOptions": {
...
},
"exclude": [
"node_modules",
".vscode-test",
"webpack.config.ts",
"./src/marquee/*.ts"
]
}
Marquee Widget
We have configured marquee from the extension host side, we can now begin creating our widget. 🙌
- Create a new folder in the
src
folder and call it marquee- note - the path should be the output path of your widget set in
webpack.config.ts
when referencing your entry file.
- note - the path should be the output path of your widget set in
-
Create a
types.ts
- some Marquee types you can use.
import type { Webview } from "vscode"; interface VSCodeWebview extends Webview { getState: () => any; setState: (param: any) => void; } export interface ThirdPartyWidgetOptions { name: string; icon: any; label: string; tags: string[]; description: string; } export interface MarqueeInterface { defineWidget: ( widgetOptions: ThirdPartyWidgetOptions, constructor: CustomElementConstructor, options?: ElementDefinitionOptions ) => void; } export interface MarqueeWindow extends Window { marqueeExtension: MarqueeInterface; vscode: VSCodeWebview; }
-
Create
widget.ts
-
setup for marquee widget and tangle communication channel so we can listen to our
extension host
import * as Channel from "tangle/webviews"; declare const window: MarqueeWindow; // @ts-expect-error missing "esModuleInterop" in tsconfig const ch = new Channel<{ track: ITrack; playerState: IPlayerState; loginState: ILoginState | null; isRunning: boolean; }>("shyykoserhiy.vscode-spotify"); //this is whatever the extension id is const client = ch.attach(window.vscode);
-
Using
window.marqueeExtension.defineWidget({...})
will create the widget
window.marqueeExtension.defineWidget( { name: "marquee-spotify", icon: faMusic, label: "Spotify", tags: ["productivity"], description: "Extension of VS Code Spotify", }, StatefulMarqueeWidget //class made using lit.html );
c. Since you can use any UI framework to build out the widget, this portion may vary. Just remember that we are exporting out a
web component
. For this tutorial we are using lit.html (a lean and standard compliant choice) to build out our web component.- Using the tangle client, we can listen to events that get emitted from our extension host. Whenever the
tangle.emit
fires from the extension host we will we can update our property state here.
class StatefulMarqueeWidget extends LitElement { static styles = css` //css goes here ` // Reactive properties are properties that can trigger the reactive update cycle when changed, re-rendering the component, and optionally be read or written to attributes. @property() track: ITrack | undefined; isRunning: boolean | undefined; constructor(){ super(); // gets thedetails of the current song/track that is playing client.on("track", (track: ITrack) => { this.track = track; }) // checks if the spotify application is running client.on("isRunning", (isRunning: boolean) => { this.isRunning = isRunning; }); //tracks the pause/play state client.on("playerState", (state: IPlayerState) => { if (state.state === "paused") { //@ts-ignore this.shadowRoot?.getElementById("pausePlayIcon")?.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="100%" viewBox="0 0 449 512" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M17.494 0.768586C10.635 2.85559 5.366 7.26059 1.864 13.8346C0.0599984 17.2216 0 25.0456 0 255.835V494.335L2.158 498.335C7.839 508.863 20.604 514.298 31 510.615C37.108 508.452 437.525 277.031 441.338 273.46C446.716 268.424 448.926 263.291 448.926 255.835C448.926 248.379 446.717 243.249 441.338 238.207C437.789 234.882 37.439 3.35759 31.5 1.19559C27.627 -0.213414 21.351 -0.404414 17.494 0.768586Z" fill="white"/></svg>`; } else if (state.state === "playing") { //@ts-ignore this.shadowRoot?.getElementById("pausePlayIcon")?.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="100%" viewBox="0 0 313 512" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M43.3305 1.30915C34.2275 3.28715 23.5735 9.27315 16.3775 16.4532C9.2415 23.5712 2.8545 35.6231 1.1235 45.2321C-0.3745 53.5441 -0.3745 458.052 1.1235 466.364C1.7115 469.628 3.9935 476.052 6.1965 480.639C15.2135 499.425 34.9255 511.715 56.1445 511.781C63.2855 511.803 75.7245 508.629 82.3795 505.087C89.8335 501.119 101.128 489.581 104.926 482.055C111.491 469.043 111.054 485.184 111.054 255.798C111.054 26.4122 111.491 42.5532 104.926 29.5412C101.128 22.0152 89.8335 10.4772 82.3795 6.50915C71.0675 0.488151 56.2125 -1.48985 43.3305 1.30915ZM242.246 1.82315C238.225 2.87615 232.6 4.98115 229.746 6.50115C222.404 10.4092 210.981 22.0142 207.307 29.2982C200.546 42.7002 201.055 24.2502 201.055 255.798C201.055 487.346 200.546 468.896 207.307 482.298C210.978 489.576 222.402 501.187 229.729 505.087C251.345 516.592 278.304 512.531 295.732 495.143C302.868 488.025 309.255 475.973 310.986 466.364C312.484 458.052 312.484 53.5441 310.986 45.2321C310.398 41.9681 308.116 35.5442 305.913 30.9572C294.587 7.36215 267.834 -4.87985 242.246 1.82315Z" fill="white"/></svg>`; } if (state.isShuffling) { this.shadowRoot!.getElementById("shuffleActive")!.style.display = "block"; } else { this.shadowRoot!.getElementById("shuffleActive")!.style.display = "none"; } if (state.isRepeating) { this.shadowRoot!.getElementById("repeatActive")!.style.display = "block"; } else { this.shadowRoot!.getElementById("repeatActive")!.style.display = "none"; } }); } render() { // html goes here } }
this.track
should now show the track information of the currently playing song.d. use
render
to display frontend html
render() { if (!this.track) { return html` <div class="defaultWrapper"> <div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div> </div> `; } if (!this.isRunning){ return html` <div class="defaultWrapper"> <div class="wrapper-message">You must have Spotify Win/Mac App installed on and running to display information. This extension requires Spotify Premium to work on Windows.</div> </div> `; } return html` <div class="img-wrapper"> <img id="trackArtwork" class="${this.isHoveringControls ? "trackArtwork-darkened" : "trackArtwork"}" src="${this.track.artwork_url}" /> <div class="name-controller-wrapper" @mouseover="${() => this.isHoveringControls = true}" @mouseleave="${() => this.isHoveringControls = false}" > <div class="name-wrapper"> <p>${this.track.name}</p> <p>${this.track.artist}</p> </div> <section class="controller"> <div class="shuffle-repeat-btn"> <button class="shuffle" @click="${() => this._triggerSpotifyCommand("spotify.toggleShuffling")}" > <svg xmlns="http://www.w3.org/2000/svg" width="15" height="100%" viewBox="0 0 449 448" fill="none"> <path fill-rule="evenodd" clip-rule="evenodd" d="M341.717 1.47365C336.157 4.49065 335.954 5.87565 335.954 40.8396V72.8706L333.204 73.4326C312.675 77.6306 298.928 83.1506 284.104 93.1476C268.401 103.738 265.496 107.279 189.949 207.909C148.892 262.598 115.946 305.555 112.854 308.431C106.151 314.663 99.1392 319.161 90.9132 322.504C79.9082 326.975 71.9852 327.909 45.0442 327.909C15.7892 327.909 13.3872 328.409 6.65522 335.897C0.30422 342.961 -1.44778 350.433 1.17022 359.293C2.79322 364.786 11.0772 373.07 16.5702 374.693C22.6732 376.496 68.7452 376.313 80.9542 374.438C100.044 371.505 115.99 365.344 131.39 354.952C147.574 344.032 150.131 340.92 224.732 241.409C263.49 189.709 297.508 144.992 300.328 142.037C308.83 133.128 322.272 125.152 333.204 122.53L335.954 121.871V152.44C335.954 186.19 336.152 187.408 342.126 190.498C349.344 194.231 348.155 195.2 400.173 143.19L447.954 95.4166L403.204 50.5616C378.591 25.8916 356.699 4.40165 354.554 2.80765C350.204 -0.426354 346.019 -0.860353 341.717 1.47365ZM15.9542 73.1626C11.0062 74.7976 2.66522 83.4646 1.17022 88.5246C-1.36878 97.1166 0.275221 104.721 6.10422 111.342C13.0312 119.212 13.5952 119.331 46.9542 120.003C80.1112 120.67 82.8572 121.115 96.7772 128.074C109.337 134.353 116.566 141.847 138.454 171.281C148.904 185.333 157.679 196.83 157.954 196.831C158.916 196.834 187.452 157.748 187.189 156.788C186.546 154.438 155.954 114.743 150.024 108.564C133.945 91.8106 112.738 80.0456 88.2782 74.3086C79.4802 72.2446 21.5132 71.3266 15.9542 73.1626ZM242.954 270.523C234.979 281.269 228.579 290.513 228.731 291.065C229.564 294.089 262.779 336.445 268.69 342.021C287.128 359.414 305.869 368.773 333.704 374.485L335.954 374.947L335.958 407.178C335.961 436.947 336.105 439.644 337.839 442.489C338.872 444.183 341.158 446.165 342.919 446.895C349.771 449.733 349.651 449.832 400.575 398.972L448.071 351.535L404.263 307.624C380.168 283.473 358.276 261.957 355.614 259.811C350.291 255.519 346.788 254.909 341.921 257.426C336.176 260.397 335.954 261.807 335.954 295.378V325.947L333.204 325.294C328.132 324.09 318.741 319.806 312.411 315.808C303.597 310.241 296.048 301.708 275.954 274.598C266.329 261.612 258.229 250.987 257.954 250.986C257.679 250.986 250.929 259.777 242.954 270.523Z" fill="white" /> </svg> </button> <div id="shuffleActive">.</div> </div> <button class="prevBtn" @click="${() => this._triggerSpotifyCommand("spotify.previous")}" > <svg xmlns="http://www.w3.org/2000/svg" width="15" height="100%" viewBox="0 0 488 512" fill="none"> <path fill-rule="evenodd" clip-rule="evenodd" d="M18.517 2.29534C14.455 3.97834 11.019 6.39234 7.87901 9.76934C-0.741987 19.0403 0.0170136 -4.71869 0.0170136 256.002C0.0170136 485.214 0.0390133 487.845 2.01401 493.123C4.36901 499.419 10.027 505.491 16.728 508.914C21.134 511.165 22.637 511.361 35.517 511.361C48.13 511.361 50.04 511.125 54.796 508.975C61.778 505.82 68.547 498.286 70.531 491.462C71.852 486.915 72.003 458.241 71.775 253.826L71.517 21.3613L69.403 16.8613C66.748 11.2103 61.082 5.56634 55.117 2.62934C51.177 0.690343 48.652 0.325343 37.517 0.0873429C25.592 -0.167657 24.021 0.015343 18.517 2.29534ZM447.017 0.492343C440.173 1.63134 434.848 3.47634 428.06 7.06234C415.461 13.7183 130.543 210.523 123.507 217.43C105.336 235.268 101.117 259.068 112.351 280.361C119.452 293.82 119.526 293.875 274.349 400.399C354.24 455.367 422.478 501.636 427.849 504.479C451.968 517.245 472.467 512.64 482.155 492.278C488.313 479.336 488.02 491.164 487.982 256.523C487.944 25.0873 488.131 33.3433 482.668 21.4113C476.552 8.05134 468.112 1.79334 454.517 0.538343C451.767 0.284343 448.392 0.264343 447.017 0.492343Z" fill="white" /> </svg> </button> <button class="${this.isHoveringControls ? "pausePlay-hovered" : "pausePlay-default"}" id="pausePlayIcon" @click="${() => this._triggerSpotifyCommand("spotify.playPause")}" > </button> <button class="nextBtn" @click="${() => this._triggerSpotifyCommand("spotify.next")}" > <svg xmlns="http://www.w3.org/2000/svg" width="15" height="100%" viewBox="0 0 460 512" fill="none" class="icons-svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M16.463 1.46632C10.597 3.54932 5.546 8.13832 2.59 14.0703L0 19.2683V255.875V492.482L2.59 497.68C8.056 508.648 20.312 514.151 32 510.886C33.925 510.348 119.425 462.7 222 405.002L408.5 300.095L409 396.235C409.467 486.052 409.618 492.638 411.301 496.375C420.185 516.109 447.836 516.892 457.41 497.68L460 492.482V255.875V19.2683L457.41 14.0703C447.836 -5.14168 420.185 -4.35868 411.301 15.3753C409.618 19.1123 409.467 25.6983 409 115.515L408.5 211.655L222 106.748C119.425 49.0503 33.925 1.40232 32 0.864318C27.205 -0.474682 21.285 -0.245682 16.463 1.46632Z" fill="white" /> </svg> </button> <div class="shuffle-repeat-btn" @click="${() => this._triggerSpotifyCommand("spotify.toggleRepeating")}" > <button class="repeat"> <svg xmlns="http://www.w3.org/2000/svg" width="15" height="100%" viewBox="0 0 512 472" fill="none"> <path fill-rule="evenodd" clip-rule="evenodd" d="M349.422 1.1099C338.616 4.8869 333.099 17.1429 337.46 27.6849C338.408 29.9779 345.014 37.4389 354.698 47.1529L370.399 62.9029L234.41 62.9059C143.85 62.9079 95.4153 63.2669 89.4223 63.9819C48.3913 68.8739 14.3593 98.7589 3.10129 139.784L0.461288 149.403L0.131288 228.903C-0.215712 312.314 -0.178712 313.13 4.19429 319.281C7.22129 323.538 16.0173 327.265 21.5633 326.64C27.6483 325.954 34.7493 321.344 37.3313 316.403C39.3883 312.469 39.4313 311.096 39.9413 232.903C40.4213 159.408 40.5983 152.988 42.2873 147.903C49.1933 127.118 64.0703 112.236 84.9223 105.254C90.1433 103.506 97.5113 103.398 230.362 103.134L370.302 102.856L353.3 120.129C338.507 135.157 336.126 138.021 334.978 142.157C333.235 148.442 334.137 154.314 337.612 159.319C343.073 167.182 353.836 170.059 362.357 165.935C364.987 164.661 378.837 151.507 401.576 128.685C438.55 91.5759 438.942 91.0899 438.894 82.4029C438.855 75.1089 435.698 71.2809 401.621 37.2039C367.029 2.6119 363.67 -0.122104 355.922 0.00389602C353.997 0.034896 351.072 0.532897 349.422 1.1099ZM484.969 146.208C480.266 147.707 474.634 153.584 473.148 158.543C472.227 161.617 471.922 181.41 471.922 238.109C471.922 309.953 471.827 313.954 469.95 321.294C464.427 342.889 448.939 359.18 426.922 366.552C421.701 368.3 414.333 368.408 281.482 368.672L141.542 368.95L158.544 351.677C173.337 336.649 175.718 333.785 176.866 329.649C181.519 312.871 165.018 298.539 149.422 305.812C146.773 307.048 133.547 319.624 110.268 343.04C73.3053 380.223 72.9023 380.724 72.9503 389.403C72.9903 396.736 76.0913 400.482 110.718 435.019C139.042 463.268 145.179 468.916 149.164 470.401C161.181 474.88 174.466 466.707 175.678 454.09C176.536 445.158 175.081 442.831 157.584 425.153L141.501 408.903H277.488C365.634 408.903 416.456 408.537 421.948 407.862C444.976 405.033 465.436 395.073 481.764 378.745C494.801 365.708 503.752 350.187 508.771 331.914L511.383 322.403L511.702 242.903C511.946 182.228 511.73 162.354 510.791 158.973C507.875 148.47 496.113 142.655 484.969 146.208Z" fill="white" /> </svg> </button> <div id="repeatActive">.</div> </div> </section> </div> </div> `; }
-
Now refresh the extension-host and we should have something like this. But remember, you can come up with other ways to customize the display. Just play around with the HTML and make your own version of the vscode-spotify widget.
I hope this tutorial piqued your interest in creating your own custom widget for Marquee. We also have docs on building out Marquee Widgets here. Additionally if you need help integrating your extension or have any questions, you can get in touch with us on our Discord channel here: https://discord.com/invite/BQm8zRCBUY
Top comments (0)