├── .gitignore ├── .vscode ├── settings.json ├── extensions.json ├── launch.json ├── css_custom_data.json └── tasks.json ├── postcss.config.js ├── .prettierrc ├── tsconfig.json ├── tailwind.config.js ├── src ├── webviews │ └── src │ │ ├── lib │ │ ├── components │ │ │ ├── FieldWithDescription.tsx │ │ │ ├── Toggle.tsx │ │ │ └── Input.tsx │ │ ├── state │ │ │ ├── reactState.tsx │ │ │ └── zustandState.tsx │ │ ├── VSCodeAPI.tsx │ │ └── vscode.css │ │ ├── appState.tsx │ │ ├── View2.tsx │ │ ├── index.tsx │ │ └── View1.tsx ├── extension.ts └── NextWebview.ts ├── vite.config.js ├── .eslintrc.js ├── LICENSE ├── README.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "css.customData": [".vscode/css_custom_data.json"] 4 | } 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | // Taken from: https://tailwindcss.com/docs/installation#using-tailwind-with-postcss 3 | module.exports = { 4 | plugins: { 5 | tailwindcss: {}, 6 | 'postcss-nested': {}, 7 | autoprefixer: {}, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false, 4 | "singleQuote": true, 5 | "overrides": [ 6 | { 7 | "files": "tailwind.config.js", 8 | "options": { 9 | "quoteProps": "consistent", 10 | "printWidth": 200 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "lib": ["ES2020"], 6 | "outDir": "out", 7 | "sourceMap": true, 8 | "strict": true, 9 | "rootDir": "src" 10 | }, 11 | "exclude": ["node_modules", ".vscode-test", "src/webviews"] 12 | } 13 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("@types/tailwindcss/tailwind-config").TailwindConfig } */ 2 | module.exports = { 3 | mode: 'jit', 4 | purge: ['./src/webviews/**/*.{js,jsx,ts,tsx,css}'], 5 | darkMode: false, // or 'media' or 'class' 6 | theme: { 7 | extend: {}, 8 | }, 9 | variants: { 10 | extend: {}, 11 | }, 12 | plugins: [require('@githubocto/tailwind-vscode')], 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "dbaeumer.vscode-eslint", 8 | "csstools.postcss", 9 | "esbenp.prettier-vscode", 10 | "bradlc.vscode-tailwindcss" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/webviews/src/lib/components/FieldWithDescription.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | type FieldWithDescriptionProps = { 4 | title: string 5 | } 6 | 7 | const FieldWithDescription: FunctionComponent = props => { 8 | return ( 9 |
10 |
11 | {props.title} 12 |
13 | {props.children} 14 |
15 | ) 16 | } 17 | 18 | export default FieldWithDescription 19 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | const path = require('path') 3 | 4 | /** 5 | * @type {import('vite').UserConfig} 6 | */ 7 | export default defineConfig({ 8 | // root: 'src/webviews', 9 | publicDir: 'src/webviews/public', 10 | build: { 11 | outDir: 'out/webviews', 12 | target: 'esnext', 13 | minify: 'esbuild', 14 | lib: { 15 | entry: path.resolve(__dirname, 'src/webviews/src/index.tsx'), 16 | name: 'VSWebview', 17 | formats: ['es'], 18 | fileName: 'index', 19 | }, 20 | watch: {}, // yes, this is correct 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /**@type {import('eslint').Linter.Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | parser: '@typescript-eslint/parser', 6 | plugins: [ 7 | '@typescript-eslint', 8 | ], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | rules: { 14 | '@typescript-eslint/no-unused-vars': 0, 15 | '@typescript-eslint/no-explicit-any': 0, 16 | '@typescript-eslint/explicit-module-boundary-types': 0, 17 | '@typescript-eslint/no-non-null-assertion': 0, 18 | }, 19 | ignorePatterns: ["src/webviews"], 20 | env: ["node"] 21 | }; -------------------------------------------------------------------------------- /src/webviews/src/appState.tsx: -------------------------------------------------------------------------------- 1 | import createVSCodeZustand from './lib/state/zustandState' 2 | import VSCodeAPI from './lib/VSCodeAPI' 3 | 4 | type AppState = { 5 | foo: string 6 | bar: number 7 | toggle1: boolean 8 | toggle2: boolean 9 | setToggle1: React.ChangeEventHandler 10 | setToggle2: React.ChangeEventHandler 11 | } 12 | 13 | const useAppState = createVSCodeZustand('myAppState', set => ({ 14 | foo: '', 15 | bar: 0, 16 | toggle1: false, 17 | toggle2: true, 18 | setToggle1: e => set({ toggle1: e.currentTarget.checked }), 19 | setToggle2: e => set({ toggle2: e.currentTarget.checked }), 20 | })) 21 | 22 | export default useAppState 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 14 | "outFiles": ["${workspaceFolder}/out/**/*.js"], 15 | "preLaunchTask": "${defaultBuildTask}" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/webviews/src/lib/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import React, { FunctionComponent, useMemo } from 'react' 3 | import FieldWithDescription from './FieldWithDescription' 4 | 5 | type ToggleProps = { 6 | handleChange: React.ChangeEventHandler 7 | title: string 8 | label: string 9 | checked?: boolean 10 | } 11 | 12 | const Toggle: FunctionComponent = props => { 13 | const id = useMemo(() => nanoid(), []) 14 | return ( 15 | 16 |
17 | 23 | 24 |
25 |
26 | ) 27 | } 28 | 29 | export default Toggle 30 | -------------------------------------------------------------------------------- /src/webviews/src/lib/state/reactState.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, useState } from 'react' 2 | import VSCodeAPI from '../VSCodeAPI' 3 | 4 | /** 5 | * Returns a stateful value, and a function to update it. 6 | * Serializes the state to the VSCode API. 7 | * 8 | * @export 9 | * @template S 10 | * @param {(S | (() => S))} initialState The initial state. 11 | * @param {*} uniqueStateKey A unique key to identify the state. 12 | * @return {*} {[S, Dispatch]} 13 | */ 14 | export default function useVSCodeState( 15 | initialState: S | (() => S), 16 | uniqueStateKey 17 | ): [S, Dispatch] { 18 | const [localState, setLocalState] = useState( 19 | VSCodeAPI.getState()[uniqueStateKey] || initialState 20 | ) 21 | 22 | const setState = (newState: S) => { 23 | VSCodeAPI.setState({ ...VSCodeAPI.getState(), [uniqueStateKey]: newState }) 24 | setLocalState(newState) 25 | } 26 | return [localState, setState] 27 | } 28 | -------------------------------------------------------------------------------- /src/webviews/src/View2.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { GoMarkGithub } from 'react-icons/go' 3 | import shallow from 'zustand/shallow' 4 | import useAppState from './appState' 5 | import Toggle from './lib/components/Toggle' 6 | 7 | type View2Props = {} 8 | 9 | const View2: FunctionComponent = props => { 10 | const [toggle1, setToggle1] = useAppState( 11 | state => [state.toggle1, state.setToggle1], 12 | shallow 13 | ) 14 | const [toggle2, setToggle2] = useAppState( 15 | state => [state.toggle2, state.setToggle2], 16 | shallow 17 | ) 18 | return ( 19 |
20 |

21 | View2 22 | 28 | 34 |

35 |
36 | ) 37 | } 38 | 39 | export default View2 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 GitHub Next 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/css_custom_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "atDirectives": [ 3 | { 4 | "name": "@tailwind", 5 | "description": "Use the @tailwind directive to insert Tailwind’s `base`, `components`, `utilities`, and `screens` styles into your CSS.", 6 | "references": [ 7 | { 8 | "name": "Tailwind’s “Functions & Directives” documentation", 9 | "url": "https://tailwindcss.com/docs/functions-and-directives/#tailwind" 10 | } 11 | ] 12 | }, 13 | { 14 | "name": "@screen", 15 | "description": "Use the @screen directive to apply Tailwind's responsive breakpoints", 16 | "references": [ 17 | { 18 | "name": "Tailwind’s “Functions & Directives” documentation", 19 | "url": "https://tailwindcss.com/docs/functions-and-directives/#screen" 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "@layer", 25 | "description": "Use the @layer directive to tell Tailwind which \"bucket\" a set of custom styles belong to.", 26 | "references": [ 27 | { 28 | "name": "Tailwind’s “Functions & Directives” documentation", 29 | "url": "https://tailwindcss.com/docs/functions-and-directives/#layer" 30 | } 31 | ] 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /src/webviews/src/lib/state/zustandState.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import create, { State, StateCreator, UseStore } from 'zustand' 3 | import { persist, StateStorage } from 'zustand/middleware' 4 | import VSCodeAPI from '../VSCodeAPI' 5 | 6 | /** 7 | * Creates a Zustand store which is automatically persisted to VS Code state. 8 | * 9 | * @export 10 | * @template TState 11 | * @param {string} name A globally-unique name for the store. 12 | * @param {StateCreator} createState A function which creates the initial state. 13 | * @return {*} {UseStore} 14 | */ 15 | export default function createVSCodeZustand( 16 | name: string, 17 | createState: StateCreator 18 | ): UseStore { 19 | return create( 20 | persist(createState, { 21 | name, 22 | getStorage: () => VSCodeStateStorage, 23 | }) 24 | ) 25 | } 26 | 27 | const VSCodeStateStorage: StateStorage = { 28 | getItem: async (name: string): Promise => { 29 | return await VSCodeAPI.getState()[name] 30 | }, 31 | setItem: async (name: string, value: string): Promise => { 32 | return VSCodeAPI.setState({ 33 | ...VSCodeAPI.getState(), 34 | [name]: value, 35 | }) 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/webviews/src/lib/VSCodeAPI.tsx: -------------------------------------------------------------------------------- 1 | declare const acquireVsCodeApi: Function 2 | 3 | interface VSCodeApi { 4 | getState: () => any 5 | setState: (newState: any) => any 6 | postMessage: (message: any) => void 7 | } 8 | 9 | class VSCodeWrapper { 10 | private readonly vscodeApi: VSCodeApi = acquireVsCodeApi() 11 | 12 | /** 13 | * Send a message to the extension framework. 14 | * @param message 15 | */ 16 | public postMessage(message: any): void { 17 | this.vscodeApi.postMessage(message) 18 | } 19 | 20 | /** 21 | * Add listener for messages from extension framework. 22 | * @param callback called when the extension sends a message 23 | * @returns function to clean up the message eventListener. 24 | */ 25 | public onMessage(callback: (message: any) => void): () => void { 26 | window.addEventListener('message', callback) 27 | return () => window.removeEventListener('message', callback) 28 | } 29 | 30 | public getState = (): any => { 31 | return this.vscodeApi.getState() ?? {} 32 | } 33 | 34 | public setState = (newState: any): any => { 35 | return this.vscodeApi.setState(newState) 36 | } 37 | } 38 | 39 | // Singleton to prevent multiple fetches of VSCodeAPI. 40 | const VSCodeAPI = new VSCodeWrapper() 41 | export default VSCodeAPI 42 | -------------------------------------------------------------------------------- /src/webviews/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import View1 from './View1' 4 | import View2 from './View2' 5 | import './lib/vscode.css' 6 | import { 7 | MemoryRouter as Router, 8 | Routes, 9 | Route, 10 | useLocation, 11 | useNavigate, 12 | } from 'react-router-dom' 13 | 14 | // TODO: Type the incoming config data 15 | let config: any = {} 16 | let workspace = '' 17 | 18 | const root = document.getElementById('root') 19 | 20 | if (root) { 21 | workspace = root.getAttribute('data-workspace') || '' 22 | } 23 | 24 | window.addEventListener('message', e => { 25 | // Here's where you'd do stuff with the message 26 | // Maybe stick it into state management or something? 27 | const message = e.data 28 | console.debug(message) 29 | }) 30 | 31 | const rootEl = document.getElementById('root') 32 | 33 | function AppRoutes() { 34 | let location = useLocation() 35 | let navigate = useNavigate() 36 | useEffect(() => { 37 | navigate(`/${rootEl.dataset.route}`, { replace: true }) 38 | }, []) 39 | 40 | return ( 41 | 42 | } /> 43 | } /> 44 | 45 | ) 46 | } 47 | 48 | ReactDOM.render( 49 | 50 | 51 | 52 | 53 | , 54 | rootEl 55 | ) 56 | -------------------------------------------------------------------------------- /src/webviews/src/lib/vscode.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | input[type='radio'], 7 | input[type='checkbox'] { 8 | @apply rounded-none; 9 | @apply appearance-none; 10 | @apply p-0; 11 | @apply h-4; 12 | @apply w-4; 13 | 14 | &:checked { 15 | background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3E%3C/svg%3E"); 16 | } 17 | 18 | &:hover, 19 | &:focus, 20 | &:active { 21 | box-shadow: 0 0 0 1px var(--vscode-settings-dropdownListBorder); 22 | } 23 | 24 | @apply bg-vscode-settings-checkboxBackground; 25 | @apply text-vscode-settings-checkboxForeground; 26 | @apply border-vscode-settings-checkboxBorder; 27 | 28 | &:checked { 29 | @apply bg-vscode-settings-checkboxBackground; 30 | @apply text-vscode-settings-checkboxForeground; 31 | } 32 | 33 | &:active, 34 | &:checked:active { 35 | @apply bg-vscode-inputOption-activeBackground; 36 | } 37 | 38 | &:focus, 39 | &:checked:focus { 40 | @apply bg-vscode-settings-checkboxBackground; 41 | } 42 | 43 | &:hover, 44 | &:checked:hover, 45 | &:focus:hover { 46 | @apply bg-vscode-inputOption-activeBackground; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "vite build", 8 | "type": "npm", 9 | "script": "dev", 10 | "problemMatcher": { 11 | "owner": "typescript", 12 | "fileLocation": "absolute", 13 | "pattern": { 14 | "regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$", 15 | "file": 1, 16 | "line": 2, 17 | "column": 3, 18 | "severity": 4, 19 | "message": 5 20 | }, 21 | "background": { 22 | "activeOnStart": false, 23 | "beginsPattern": "build started...", 24 | "endsPattern": "^built in \\d+ms." 25 | } 26 | }, 27 | "group": { 28 | "kind": "build", 29 | "isDefault": true 30 | }, 31 | "dependsOn": ["tsc watch"], 32 | "isBackground": true, 33 | "presentation": { 34 | "reveal": "never", 35 | "panel": "shared" 36 | } 37 | }, 38 | { 39 | "label": "tsc watch", 40 | "type": "typescript", 41 | "tsconfig": "tsconfig.json", 42 | "option": "watch", 43 | "problemMatcher": ["$tsc-watch"], 44 | "group": "build", 45 | "isBackground": true, 46 | "presentation": { 47 | "reveal": "never", 48 | "panel": "shared" 49 | } 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // import { 2 | // enableHotReload, 3 | // hotRequireExportedFn, 4 | // registerUpdateReconciler, 5 | // } from '@hediet/node-reload' 6 | // import { Disposable } from '@hediet/std/disposable' 7 | import * as vscode from 'vscode' 8 | // import MyWebview from './MyWebview' 9 | import { NextWebviewPanel } from './NextWebview' 10 | 11 | // if (process.env.NODE_ENV === 'development') { 12 | // enableHotReload({ entryModule: module }) 13 | // } 14 | // registerUpdateReconciler(module) 15 | 16 | // export class Extension { 17 | // public readonly dispose = Disposable.fn() 18 | 19 | // constructor() { 20 | // super() 21 | 22 | // // Disposables are disposed automatically on reload. 23 | // const item = this.dispose.track(vscode.window.createStatusBarItem()) 24 | // item.text = 'Hallo Welt' 25 | // item.show() 26 | // } 27 | // } 28 | 29 | export function activate(context: vscode.ExtensionContext) { 30 | context.subscriptions.push( 31 | vscode.commands.registerCommand('NextWebview1.start', () => { 32 | const webview = NextWebviewPanel.getInstance({ 33 | extensionUri: context.extensionUri, 34 | route: 'view1', 35 | title: 'GitHub Next Webview 1', 36 | viewId: 'ghnextA', 37 | }) 38 | // const webview = MyWebview.createOrShow(context.extensionUri) 39 | // setInterval(() => { 40 | // // MyWebview.update() 41 | // console.debug('!!!!!! reloading webview!') 42 | // }, 1000) 43 | }), 44 | vscode.commands.registerCommand('NextWebview2.start', () => { 45 | const webview = NextWebviewPanel.getInstance({ 46 | extensionUri: context.extensionUri, 47 | route: 'view2', 48 | title: 'GitHub Next Webview 2', 49 | viewId: 'ghnextB', 50 | }) 51 | }) 52 | ) 53 | 54 | // context.subscriptions.push( 55 | // hotRequireExportedFn(module, Extension, Extension => new Extension()) 56 | // ) 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vscode-react-webviews 2 | 3 | A sample/starter template for developing VS Code extensions with webviews 4 | 5 | ## How do I use it? 6 | 7 | `npx degit githubnext/vscode-react-webviews` 8 | 9 | Then edit the template to your hearts' content. 10 | 11 | ## Why does this exist? 12 | 13 | The VS Code extension environment comes with two general approaches to development. Where possible, it is best to use the builtin APIs as they avoid many common performance pitfalls and permit you to reuse existing UX patterns. 14 | 15 | However, if your application cannot be implemented using the builtins, then you must implement your UIs using webviews. Webviews in VS Code give you all of the power, but using them effectively can involve a lot of headache, and it's very easy to do things that ruin performance. 16 | 17 | This project is a template of sorts. It encodes a lot of best practices for building extensions that rely on webviews — like custom editors, panels, and sidebars. It assumes that you are building your webviews in React, and it is set up to accomodate multiple webviews in a single extension. 18 | 19 | Some other niceties: 20 | 21 | - A working Typescript setup for both the extension _and_ the webview. 22 | - A sample react app 23 | - Tailwind CSS for styling, using JIT for speedy builds 24 | - VS Code theme colors exposed as Tailwind colors for ease of "theme-native" styling. 25 | - Lots of little quality-of-life refinements, like a tasks.json which knows to wait for builds to complete before launching the extension host, and css_custom_data.json so that VS Code doesn't complain at you for using `@tailwind` directives in your css. 26 | - [Vite](https://vitejs.dev/) for the speediest-possible building and bundling with [esbuild](https://esbuild.github.io/). 27 | 28 | Sadly, incremental builds are not possible with the way that VS Code currently works. That means that each build is effectively "bundling for production". 29 | 30 | Similarly, because webviews are iframes, there's no hot module replacement. There's no websockets across the boundary between the extension host and the iframe. 31 | 32 | # License 33 | 34 | MIT 35 | -------------------------------------------------------------------------------- /src/webviews/src/lib/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | import React, { FunctionComponent, useMemo } from 'react' 3 | import FieldWithDescription from './FieldWithDescription' 4 | 5 | type InputProps = { 6 | handleChange: React.ChangeEventHandler 7 | title: string 8 | label: string 9 | value: string 10 | placeholder?: string 11 | success?: string 12 | error?: string 13 | type?: string 14 | inputStyle?: object 15 | } 16 | 17 | export const Input: FunctionComponent = props => { 18 | const { type = 'text', inputStyle = {}, placeholder = '' } = props 19 | const id = useMemo(() => nanoid(), []) 20 | let feedback 21 | if (props.error) { 22 | feedback = ( 23 |
24 |
25 |
{props.error}
26 |
27 | ) 28 | } else if (props.success) { 29 | feedback = ( 30 |
31 |
32 |
{props.success}
33 |
34 | ) 35 | } 36 | return ( 37 | 38 |
39 | 40 |
41 | 50 | {props.children} 51 |
52 |
53 | {feedback} 54 |
55 | ) 56 | } 57 | 58 | export default Input 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@githubnext/vscode-react-webviews", 3 | "version": "1.0.0", 4 | "description": "A sample/starter template for developing VS Code extensions with webviews", 5 | "main": "out/extension.js", 6 | "scripts": { 7 | "vscode:prepublish": "npm run compile", 8 | "compile": "tsc -p ./", 9 | "lint": "eslint . --ext .ts,.tsx", 10 | "watch": "tsc -watch -p ./", 11 | "format": "prettier --write **/*.ts", 12 | "dev": "vite build" 13 | }, 14 | "engines": { 15 | "vscode": "^1.52.0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/githubnext/vscode-react-webviews.git" 20 | }, 21 | "keywords": [ 22 | "vscode" 23 | ], 24 | "author": "Next Devex ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/githubnext/vscode-react-webviews.git/issues" 28 | }, 29 | "homepage": "https://github.com/githubnext/vscode-react-webviews.git#readme", 30 | "devDependencies": { 31 | "@githubocto/tailwind-vscode": "^1.0.4", 32 | "@types/node": "^16.4.13", 33 | "@types/react": "^17.0.16", 34 | "@types/react-dom": "^17.0.9", 35 | "@types/tailwindcss": "^2.2.1", 36 | "@types/vscode": "^1.59.0", 37 | "@typescript-eslint/eslint-plugin": "^5.1.0", 38 | "@typescript-eslint/parser": "^5.1.0", 39 | "autoprefixer": "^10.3.1", 40 | "eslint": "^8.1.0", 41 | "postcss": "^8.3.6", 42 | "postcss-nested": "^5.0.6", 43 | "prettier": "^2.3.2", 44 | "tailwindcss": "^2.2.7", 45 | "typescript": "^4.3.5" 46 | }, 47 | "dependencies": { 48 | "@hediet/node-reload": "^0.7.3", 49 | "history": "^5.0.1", 50 | "nanoid": "^3.1.23", 51 | "react": "^17.0.2", 52 | "react-dom": "^17.0.2", 53 | "react-icons": "^4.2.0", 54 | "react-router-dom": "^6.0.0-beta.2", 55 | "vite": "^2.4.4", 56 | "zustand": "^3.5.12" 57 | }, 58 | "activationEvents": [ 59 | "onCommand:NextWebview1.start", 60 | "onCommand:NextWebview2.start" 61 | ], 62 | "contributes": { 63 | "commands": [ 64 | { 65 | "command": "NextWebview1.start", 66 | "title": "Open Webview 1", 67 | "category": "GitHub Next" 68 | }, 69 | { 70 | "command": "NextWebview2.start", 71 | "title": "Open Webview 2", 72 | "category": "GitHub Next" 73 | } 74 | ] 75 | } 76 | } -------------------------------------------------------------------------------- /src/webviews/src/View1.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { VscFlame } from 'react-icons/vsc' 3 | import { Link } from 'react-router-dom' 4 | import Input from './lib/components/Input' 5 | import Toggle from './lib/components/Toggle' 6 | import useVSCodeState from './lib/state/reactState' 7 | import VSCodeAPI from './lib/VSCodeAPI' 8 | 9 | type View1Props = {} 10 | 11 | const View1: FunctionComponent = props => { 12 | const [val, setVal] = useVSCodeState('', 'val') 13 | const [success, setSuccess] = useVSCodeState('', 'success') 14 | const [err, setErr] = useVSCodeState('', 'err') 15 | const [checked, setChecked] = useVSCodeState(false, 'checked') 16 | const [checked2, setChecked2] = useVSCodeState(false, 'checked2') 17 | console.log('Rendering View1') 18 | return ( 19 |
20 |
21 | Hello, world! 22 |
23 |
24 |
25 | 26 |
27 |
This is some body copy. It has the base text size.
28 |
29 | { 37 | const newval = e.target.value 38 | setVal(newval) 39 | if (newval === '42') { 40 | setSuccess('That is the correct value!') 41 | setErr(undefined) 42 | } else { 43 | setSuccess(undefined) 44 | setErr('The only correct answer is 42') 45 | } 46 | }} 47 | /> 48 | setChecked(e.target.checked)} 53 | /> 54 | setChecked2(e.target.checked)} 59 | /> 60 |

Current VSCodeAPI State:

61 | 62 |
{JSON.stringify(VSCodeAPI.getState(), null, 2)}
63 |
64 |
65 | View 2 66 |
67 |
68 | ) 69 | } 70 | 71 | export default View1 72 | -------------------------------------------------------------------------------- /src/NextWebview.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode' 2 | import { nanoid } from 'nanoid' 3 | 4 | type NextWebviewOptions = { 5 | extensionUri: vscode.Uri 6 | route: string 7 | title: string 8 | viewId: string 9 | scriptUri?: vscode.Uri 10 | styleUri?: vscode.Uri 11 | nonce?: string 12 | handleMessage?: (message: any) => any 13 | } 14 | 15 | abstract class NextWebview { 16 | protected readonly _opts: Required 17 | 18 | public constructor(options: NextWebviewOptions) { 19 | // fill out the internal configuration with defaults 20 | this._opts = Object.assign( 21 | { 22 | scriptUri: vscode.Uri.joinPath( 23 | options.extensionUri, 24 | 'out/webviews/index.es.js' 25 | ), 26 | styleUri: vscode.Uri.joinPath( 27 | options.extensionUri, 28 | 'out/webviews/style.css' 29 | ), 30 | nonce: nanoid(), 31 | handleMessage: () => {}, 32 | }, 33 | options 34 | ) 35 | } 36 | 37 | protected getWebviewOptions(): vscode.WebviewOptions { 38 | return { 39 | // Enable javascript in the webview 40 | enableScripts: true, 41 | 42 | // And restrict the webview to only loading content from our extension's `out` directory. 43 | localResourceRoots: [vscode.Uri.joinPath(this._opts.extensionUri, 'out')], 44 | } 45 | } 46 | 47 | protected handleMessage(message: any): void { 48 | this._opts.handleMessage(message) 49 | } 50 | 51 | protected _getContent(webview: vscode.Webview) { 52 | // Prepare webview URIs 53 | const scriptUri = webview.asWebviewUri(this._opts.scriptUri) 54 | const styleUri = webview.asWebviewUri(this._opts.styleUri) 55 | 56 | // Return the HTML with all the relevant content embedded 57 | // Also sets a Content-Security-Policy that permits all the sources 58 | // we specified. Note that img-src allows `self` and `data:`, 59 | // which is at least a little scary, but otherwise we can't stick 60 | // SVGs in CSS as background images via data URLs, which is hella useful. 61 | return /* html */ ` 62 | 63 | 64 | 65 | 66 | 67 | 71 | 72 | 73 | 74 | 75 | 76 | 79 | 80 | Next Webview 81 | 82 | 83 |
84 | 85 | 86 | ` 87 | } 88 | 89 | public abstract update(): void 90 | } 91 | 92 | export class NextWebviewPanel extends NextWebview implements vscode.Disposable { 93 | private static instances: { [id: string]: NextWebviewPanel } = {} 94 | 95 | private readonly panel: vscode.WebviewPanel 96 | private _disposables: vscode.Disposable[] = [] 97 | 98 | // Singleton 99 | public static getInstance( 100 | opts: NextWebviewOptions & { column?: vscode.ViewColumn } 101 | ): NextWebviewPanel { 102 | const _opts = Object.assign( 103 | { 104 | column: vscode.window.activeTextEditor 105 | ? vscode.window.activeTextEditor.viewColumn 106 | : undefined, 107 | }, 108 | opts 109 | ) 110 | 111 | let instance = NextWebviewPanel.instances[_opts.viewId] 112 | if (instance) { 113 | // If we already have an instance, use it to show the panel 114 | instance.panel.reveal(_opts.column) 115 | } else { 116 | // Otherwise, create an instance 117 | instance = new NextWebviewPanel(_opts) 118 | NextWebviewPanel.instances[_opts.viewId] = instance 119 | } 120 | 121 | return instance 122 | } 123 | 124 | private constructor( 125 | opts: NextWebviewOptions & { column?: vscode.ViewColumn } 126 | ) { 127 | // Create the webview panel 128 | super(opts) 129 | this.panel = vscode.window.createWebviewPanel( 130 | opts.route, 131 | opts.title, 132 | opts.column || vscode.ViewColumn.One, 133 | this.getWebviewOptions() 134 | ) 135 | // Update the content 136 | this.update() 137 | 138 | // Listen for when the panel is disposed 139 | // This happens when the user closes the panel or when the panel is closed programmatically 140 | this.panel.onDidDispose(() => this.dispose(), null, this._disposables) 141 | 142 | // Update the content based on view changes 143 | // this.panel.onDidChangeViewState( 144 | // e => { 145 | // console.debug('View state changed! ', this._opts.viewId,) 146 | // if (this.panel.visible) { 147 | // this.update() 148 | // } 149 | // }, 150 | // null, 151 | // this._disposables 152 | // ) 153 | 154 | this.panel.webview.onDidReceiveMessage( 155 | this.handleMessage, 156 | this, 157 | this._disposables 158 | ) 159 | } 160 | 161 | // Panel updates may also update the panel title 162 | // in addition to the webview content. 163 | public update() { 164 | console.debug('Updating! ', this._opts.viewId) 165 | this.panel.title = this._opts.title 166 | this.panel.webview.html = this._getContent(this.panel.webview) 167 | } 168 | 169 | public dispose() { 170 | // Disposes of this instance 171 | // Next time getInstance() is called, it will construct a new instance 172 | console.debug('Disposing! ', this._opts.viewId) 173 | delete NextWebviewPanel.instances[this._opts.viewId] 174 | 175 | // Clean up our resources 176 | this.panel.dispose() 177 | while (this._disposables.length) { 178 | const x = this._disposables.pop() 179 | if (x) { 180 | x.dispose() 181 | } 182 | } 183 | } 184 | } 185 | 186 | // export class NextWebviewPanelSerializer implements vscode.WebviewPanelSerializer { 187 | // deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: unknown): Thenable { 188 | // console.log('deserialized state: ', state) 189 | // webviewPanel 190 | // } 191 | 192 | // } 193 | 194 | export class NextWebviewSidebar 195 | extends NextWebview 196 | implements vscode.WebviewViewProvider 197 | { 198 | private _webview?: vscode.WebviewView 199 | 200 | public resolveWebviewView( 201 | webviewView: vscode.WebviewView, 202 | context: vscode.WebviewViewResolveContext, 203 | token: vscode.CancellationToken 204 | ): void | Thenable { 205 | // Create the webviewView and configure it 206 | this._webview = webviewView 207 | this._webview.webview.options = this.getWebviewOptions() 208 | // Set the initial html 209 | this.update() 210 | // Handle messages from the webview 211 | this._webview.webview.onDidReceiveMessage(this.handleMessage, this) 212 | } 213 | 214 | // WebviewView updates are just "write the html to the view" 215 | update(): void { 216 | if (this._webview) { 217 | this._webview.webview.html = this._getContent(this._webview.webview) 218 | } 219 | } 220 | } 221 | --------------------------------------------------------------------------------