├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── USAGE.md ├── assets └── icon.png ├── img ├── blobs.png ├── preview.png ├── sqlite.png └── vals.png ├── package-lock.json ├── package.json ├── resources └── activity.svg ├── src ├── blob │ ├── fs.ts │ └── tree.ts ├── client.ts ├── commands.ts ├── definition.ts ├── extension.ts ├── secrets.ts ├── sqlite │ ├── document.ts │ └── tree.ts ├── template.ts ├── uri.ts └── val │ ├── fs.ts │ └── tree.ts ├── tsconfig.json └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | vscode.proposed.d.ts -------------------------------------------------------------------------------- /.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 | 'semi': [2, "always"], 15 | '@typescript-eslint/no-unused-vars': 0, 16 | '@typescript-eslint/no-explicit-any': 0, 17 | '@typescript-eslint/explicit-module-boundary-types': 0, 18 | '@typescript-eslint/no-non-null-assertion': 0, 19 | '@typescript-eslint/no-namespace': 0, 20 | 'no-inner-declarations': 0, 21 | } 22 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | yarn.lock 4 | *.vsix 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "amodio.tsl-problem-matcher" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.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 Web Extension in VS Code", 10 | "type": "extensionHost", 11 | "debugWebWorkerHost": true, 12 | "request": "launch", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}", 15 | "--extensionDevelopmentKind=web" 16 | ], 17 | "outFiles": [ 18 | "${workspaceFolder}/dist/**/*.js" 19 | ], 20 | "preLaunchTask": "npm: watch" 21 | }, 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "dist": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "dist": true // set this to false to include "out" folder in search results 8 | }, 9 | "typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version 10 | "typescript.tsc.autoDetect": "off", 11 | "editor.insertSpaces": false, 12 | } 13 | -------------------------------------------------------------------------------- /.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 | "type": "npm", 8 | "script": "compile", 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | }, 13 | "problemMatcher": [ 14 | "$ts-webpack", 15 | "$tslint-webpack" 16 | ] 17 | }, 18 | { 19 | "type": "npm", 20 | "script": "watch", 21 | "group": "build", 22 | "isBackground": true, 23 | "problemMatcher": [ 24 | "$ts-webpack-watch", 25 | "$tslint-webpack-watch" 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | node_modules 11 | img 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.8.10 4 | 5 | - rename a blob from the sidebar 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 [Achille Lacoin] 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Val Town - VS Code Integration 2 | 3 | Use Val Town from the comfort of VS Code. 4 | 5 | ## Setup 6 | 7 | Use the `Val Town: Set Token` command to set your api token, that you can get from the [Val Town Website](https://www.val.town/settings/api). 8 | 9 | ## Features 10 | 11 | ### Author Vals from VS Code 12 | 13 | ![val demo](https://raw.githubusercontent.com/pomdtr/valtown-vscode/master/img/vals.png) 14 | 15 | ## Preview Web Endpoints 16 | 17 | ![preview demo](https://raw.githubusercontent.com/pomdtr/valtown-vscode/master/img/preview.png) 18 | 19 | ### Edit/Manage your Blobs 20 | 21 | ![blob demo](https://raw.githubusercontent.com/pomdtr/valtown-vscode/master/img/blobs.png) 22 | 23 | ### Run SQLite Queries 24 | 25 | ![sqlite demo](https://raw.githubusercontent.com/pomdtr/valtown-vscode/master/img/sqlite.png) 26 | 27 | ## Sidebar Configuration 28 | 29 | You can configure the sidebar val tree by editing the `valtown.tree` entry in your settings. 30 | 31 | ```jsonc 32 | { 33 | "valtown.tree": [ 34 | // Add a val to your sidebar 35 | "@stevekrouse/fetchJSON", 36 | // You can also pass an api endpoint, that must a paginated list of vals 37 | { 38 | "title": "Liked Vals", 39 | "icon": "heart", 40 | "path": "https://api.val.town/api/v1/me/likes" 41 | }, 42 | // Some vars are available to use in the url 43 | { 44 | "title": "My Vals", 45 | "icon": "home", 46 | "path": "https://api.val.town/api/v1/users/${user:me}/vals" // user:me is the current id 47 | }, 48 | { 49 | "title": "Standard Library", 50 | "icon": "book", 51 | "path": "https://api.val.town/api/v1/users/${user:stevekrouse}/vals" // user: is the id of the user with that username 52 | }, 53 | // You can also nest items 54 | { 55 | "title": "Pinned Vals", 56 | "icon": "pin", 57 | "items": [ 58 | "@std/blob", 59 | "@std/sqlite", 60 | ] 61 | }, 62 | { 63 | "title": "Tags", 64 | "icon": "tag", 65 | "items": [ 66 | // The search endpoint also returns a paginated list of vals! 67 | { 68 | "title": "#vscode", 69 | "icon": "tag", 70 | "path": "https://api.val.town/api/v1/users/search?query=${encodeURIComponent:#vscode}" 71 | }, 72 | { 73 | "title": "#blob", 74 | "icon": "blog", 75 | "path": "https://api.val.town/api/v1/users/search?query=${encodeURIComponent:#blog}" 76 | }, 77 | ] 78 | }, 79 | 80 | ] 81 | } 82 | ``` 83 | 84 | A list of all the available icons can be found [here](https://code.visualstudio.com/api/references/icons-in-labels#icon-listing). 85 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Contributing a View Container & View 2 | 3 | - Contribute a view container using the [viewsContainers](https://code.visualstudio.com/api/references/contribution-points#contributes.viewsContainers) extension point. 4 | - Contribute a view using the [views](https://code.visualstudio.com/api/references/contribution-points#contributes.views) extension point. 5 | - Register a data provider for the view using the [TreeDataProvider](https://code.visualstudio.com/api/references/vscode-api#_TreeDataProvider) API. 6 | - Contribute actions to the view using `view/title` and `view/item/context` locations in [menus](https://code.visualstudio.com/api/references/contribution-points#contributesmenus) extension point. 7 | 8 | ## contributes.viewsContainers extension point 9 | 10 | As of Visual Studio Code v1.23.0, you can move custom views into your own view container which will show up in the activity bar. 11 | 12 | To do such, extension writers can add a `viewsContainers` object in the contributes section. each object will require three things: 13 | 14 | - `id`: The name of the new view you're creating 15 | - `title`: The name which will show up at the top of the view 16 | - `icon`: an image which will be displayed for the view container in the activity bar 17 | 18 | ## contributes.views extension point 19 | 20 | You must specify an identifier and name for the view. You can contribute to following locations 21 | 22 | - `explorer`: Explorer view in the Side bar 23 | - `debug`: Debug view in the Side bar 24 | - `scm`: Source Control Management view in the Side bar 25 | 26 | When the user opens the view, VS Code will then emit an activationEvent `onView:${viewId}` (e.g. `onView:nodeDependencies` for the example below). You can also control the visibility of the view by providing the `when` context value. 27 | 28 | Following, in the views object, you can then add a field with the same string as the `id` in the `viewsContainers`. 29 | 30 | ```json 31 | "contributes": { 32 | "viewsContainers": { 33 | "activitybar": [ 34 | { 35 | "id": "package-explorer", 36 | "title": "Package Explorer", 37 | "icon": "media/dep.svg" 38 | } 39 | ] 40 | }, 41 | "views": { 42 | "tree-view": [ 43 | { 44 | "id": "nodeDependencies", 45 | "name": "Node Dependencies", 46 | "when": "workspaceHasPackageJSON" 47 | } 48 | ] 49 | } 50 | } 51 | ``` 52 | 53 | ## View actions 54 | 55 | You can contribute actions at following locations in the view 56 | 57 | - `view/title`: Location to show actions in the view title. Primary or inline actions use `"group": "navigation"` and rest are secondary actions which are in `...` menu. 58 | - `view/item/context`: Location to show actions for the tree item. Inline actions use `"group": "inline"` and rest are secondary actions which are in `...` menu. 59 | 60 | You can control the visibility of these actions using the `when` property. 61 | 62 | Examples: 63 | 64 | ```json 65 | "contributes": { 66 | "commands": [ 67 | { 68 | "command": "nodeDependencies.refreshEntry", 69 | "title": "Refresh", 70 | "icon": { 71 | "light": "resources/light/refresh.svg", 72 | "dark": "resources/dark/refresh.svg" 73 | } 74 | } 75 | ], 76 | "menus": { 77 | "view/title": [ 78 | { 79 | "command": "nodeDependencies.refreshEntry", 80 | "when": "view == nodeDependencies", 81 | "group": "navigation" 82 | } 83 | ] 84 | } 85 | } 86 | ``` 87 | 88 | **Note:** If you want to show an action for specific items, you can do it by defining context of a tree item using `TreeItem.contextValue` and you can specify the context value for key `viewItem` in `when` expression. 89 | 90 | Examples: 91 | 92 | ```json 93 | "contributes": { 94 | "menus": { 95 | "view/item/context": [ 96 | { 97 | "command": "nodeDependencies.deleteEntry", 98 | "when": "view == nodeDependencies && viewItem == dependency" 99 | } 100 | ] 101 | } 102 | } 103 | ``` 104 | 105 | ## TreeDataProvider 106 | 107 | Extension writers should register a [provider](https://code.visualstudio.com/api/references/vscode-api#TreeDataProvider) programmatically to populate data in the view. 108 | 109 | ```typescript 110 | vscode.window.registerTreeDataProvider('nodeDependencies', new DepNodeProvider()); 111 | ``` 112 | 113 | See [nodeDependencies.ts](src/nodeDependencies.ts) for the implementation. 114 | 115 | ## TreeView 116 | 117 | If you would like to perform some UI operations on the view programmatically, you can use `window.createTreeView` instead of `window.registerDataProvider`. This will give access to the view which you can use for performing view operations. 118 | 119 | ```typescript 120 | vscode.window.createTreeView('ftpExplorer', { 121 | treeDataProvider: new FtpTreeDataProvider(), 122 | }); 123 | ``` 124 | 125 | See [ftpExplorer.ts](src/ftpExplorer.ts) for the implementation. 126 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/val-town/val-town-vscode/858bb92b9c1ca4f3cebcd9e1c3cc66bd078cdb06/assets/icon.png -------------------------------------------------------------------------------- /img/blobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/val-town/val-town-vscode/858bb92b9c1ca4f3cebcd9e1c3cc66bd078cdb06/img/blobs.png -------------------------------------------------------------------------------- /img/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/val-town/val-town-vscode/858bb92b9c1ca4f3cebcd9e1c3cc66bd078cdb06/img/preview.png -------------------------------------------------------------------------------- /img/sqlite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/val-town/val-town-vscode/858bb92b9c1ca4f3cebcd9e1c3cc66bd078cdb06/img/sqlite.png -------------------------------------------------------------------------------- /img/vals.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/val-town/val-town-vscode/858bb92b9c1ca4f3cebcd9e1c3cc66bd078cdb06/img/vals.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "valtown", 3 | "displayName": "Val Town", 4 | "description": "VS Code integration for val.town", 5 | "version": "0.8.12", 6 | "publisher": "pomdtr", 7 | "private": true, 8 | "icon": "assets/icon.png", 9 | "license": "MIT", 10 | "homepage": "https://val.town", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/pomdtr/val-town-vscode" 14 | }, 15 | "engines": { 16 | "vscode": "^1.74.0" 17 | }, 18 | "categories": [ 19 | "Education", 20 | "Other" 21 | ], 22 | "activationEvents": [ 23 | "onFileSystem:vt+val", 24 | "onFileSystem:vt+blob" 25 | ], 26 | "browser": "./dist/extension.js", 27 | "contributes": { 28 | "viewsContainers": { 29 | "activitybar": [ 30 | { 31 | "id": "valtown", 32 | "title": "Val Town", 33 | "icon": "resources/activity.svg" 34 | } 35 | ] 36 | }, 37 | "views": { 38 | "valtown": [ 39 | { 40 | "id": "valtown.tree", 41 | "name": "Vals" 42 | }, 43 | { 44 | "id": "valtown.references", 45 | "name": "References" 46 | }, 47 | { 48 | "id": "valtown.dependencies", 49 | "name": "Dependencies" 50 | }, 51 | { 52 | "id": "valtown.blobs", 53 | "name": "Blobs" 54 | }, 55 | { 56 | "id": "valtown.sqlite", 57 | "name": "SQLite" 58 | } 59 | ] 60 | }, 61 | "viewsWelcome": [ 62 | { 63 | "view": "valtown.tree", 64 | "contents": "Set your Val Town token to use this extension.\n[Set Token](command:valtown.setToken)\n[API Tokens](https://www.val.town/settings/api)", 65 | "when": "!valtown.loggedIn" 66 | }, 67 | { 68 | "view": "valtown.references", 69 | "contents": "Open a val to see its references.", 70 | "when": "resourceScheme != 'vt+val'" 71 | }, 72 | { 73 | "view": "valtown.dependencies", 74 | "contents": "Open a val to see its dependencies.", 75 | "when": "resourceScheme != 'vt+val'" 76 | }, 77 | { 78 | "view": "valtown.blobs", 79 | "contents": "Set your Val Town token to use this extension.\n[Set Token](command:valtown.setToken)\n[API Tokens](https://www.val.town/settings/api)", 80 | "when": "!valtown.loggedIn" 81 | }, 82 | { 83 | "view": "valtown.sqlite", 84 | "contents": "Set your Val Town token to use this extension.\n[Set Token](command:valtown.setToken)\n[API Tokens](https://www.val.town/settings/api)", 85 | "when": "!valtown.loggedIn" 86 | } 87 | ], 88 | "commands": [ 89 | { 90 | "command": "valtown.createVal", 91 | "title": "Create Val", 92 | "category": "Val Town", 93 | "icon": "$(plus)" 94 | }, 95 | { 96 | "command": "valtown.createValFromTemplate", 97 | "title": "Create Val from Template", 98 | "category": "Val Town", 99 | "icon": "$(new-file)" 100 | }, 101 | { 102 | "command": "valtown.openPreview", 103 | "title": "Open Preview", 104 | "category": "Val Town", 105 | "icon": "$(preview)" 106 | }, 107 | { 108 | "command": "valtown.openPreviewToSide", 109 | "title": "Open Preview to the Side", 110 | "category": "Val Town", 111 | "icon": "$(open-preview)" 112 | }, 113 | { 114 | "command": "valtown.deleteVal", 115 | "title": "Delete Val", 116 | "category": "Val Town", 117 | "icon": "$(trash)" 118 | }, 119 | { 120 | "command": "valtown.refresh", 121 | "title": "Refresh View", 122 | "category": "Val Town", 123 | "icon": "$(refresh)" 124 | }, 125 | { 126 | "command": "valtown.rename", 127 | "title": "Rename Val", 128 | "category": "Val Town", 129 | "icon": "$(edit)" 130 | }, 131 | { 132 | "command": "valtown.setPrivate", 133 | "title": "Private", 134 | "category": "Val Town" 135 | }, 136 | { 137 | "command": "valtown.setUnlisted", 138 | "title": "Unlisted", 139 | "category": "Val Town" 140 | }, 141 | { 142 | "command": "valtown.setPublic", 143 | "title": "Public", 144 | "category": "Val Town" 145 | }, 146 | { 147 | "command": "valtown.open", 148 | "title": "Open Val", 149 | "category": "Val Town" 150 | }, 151 | { 152 | "command": "valtown.blob.quickOpen", 153 | "title": "Open Blob...", 154 | "category": "Val Town" 155 | }, 156 | { 157 | "command": "copyValID", 158 | "title": "Val ID", 159 | "category": "Val Town" 160 | }, 161 | { 162 | "command": "valtown.copyModuleURL", 163 | "title": "Module URL", 164 | "category": "Val Town" 165 | }, 166 | { 167 | "command": "valtown.copyScriptTag", 168 | "title": "Script Tag", 169 | "category": "Val Town" 170 | }, 171 | { 172 | "command": "valtown.copyValUrl", 173 | "title": "Val URL", 174 | "category": "Val Town", 175 | "icon": "$(link)" 176 | }, 177 | { 178 | "command": "valtown.copyEmbedUrl", 179 | "title": "Embed URL", 180 | "category": "Val Town" 181 | }, 182 | { 183 | "command": "valtown.copyEmailAddress", 184 | "title": "Email Address", 185 | "category": "Val Town" 186 | }, 187 | { 188 | "command": "valtown.copyHttpEndpoint", 189 | "title": "HTTP Endpoint", 190 | "category": "Val Town" 191 | }, 192 | { 193 | "command": "valtown.openValUrl", 194 | "title": "Val URL", 195 | "category": "Val Town", 196 | "icon": "$(globe)" 197 | }, 198 | { 199 | "command": "valtown.openHttpEndpoint", 200 | "title": "HTTP Endpoint", 201 | "category": "Val Town" 202 | }, 203 | { 204 | "command": "valtown.setToken", 205 | "category": "Val Town", 206 | "title": "Set Token" 207 | }, 208 | { 209 | "command": "valtown.clearToken", 210 | "category": "Val Town", 211 | "title": "Clear Token" 212 | }, 213 | { 214 | "command": "valtown.openValLogs", 215 | "category": "Val Town", 216 | "title": "Val Logs" 217 | }, 218 | { 219 | "command": "valtown.blob.refresh", 220 | "category": "Val Town", 221 | "title": "Refresh", 222 | "icon": "$(refresh)" 223 | }, 224 | { 225 | "command": "valtown.blob.delete", 226 | "category": "Val Town", 227 | "title": "Delete Blob" 228 | }, 229 | { 230 | "command": "valtown.blob.rename", 231 | "category": "Val Town", 232 | "title": "Rename Blob" 233 | }, 234 | { 235 | "command": "valtown.blob.create", 236 | "category": "Val Town", 237 | "title": "Create Blob", 238 | "icon": "$(plus)" 239 | }, 240 | { 241 | "command": "valtown.blob.upload", 242 | "category": "Val Town", 243 | "title": "Upload Blob", 244 | "icon": "$(cloud-upload)" 245 | }, 246 | { 247 | "command": "valtown.blob.download", 248 | "category": "Val Town", 249 | "title": "Download Blob", 250 | "icon": "$(cloud-download)" 251 | }, 252 | { 253 | "command": "valtown.blob.copyKey", 254 | "category": "Val Town", 255 | "title": "Copy Blob Key", 256 | "icon": "$(copy)" 257 | }, 258 | { 259 | "command": "valtown.sqlite.refresh", 260 | "category": "Val Town", 261 | "title": "Refresh", 262 | "icon": "$(refresh)" 263 | }, 264 | { 265 | "command": "valtown.sqlite.copyTableName", 266 | "category": "Val Town", 267 | "title": "Copy Table Name" 268 | }, 269 | { 270 | "command": "valtown.sqlite.copyColumnName", 271 | "category": "Val Town", 272 | "title": "Copy Column Name" 273 | }, 274 | { 275 | "command": "valtown.sqlite.newQuery", 276 | "category": "Val Town", 277 | "title": "New Query", 278 | "icon": "$(new-file)" 279 | }, 280 | { 281 | "command": "valtown.sqlite.runQuery", 282 | "category": "Val Town", 283 | "title": "Run Query", 284 | "icon": "$(play)" 285 | }, 286 | { 287 | "command": "valtown.sqlite.dropTable", 288 | "category": "Val Town", 289 | "title": "Drop Table" 290 | }, 291 | { 292 | "command": "valtown.vals.config", 293 | "category": "Val Town", 294 | "title": "Configure Vals Folders", 295 | "icon": "$(gear)" 296 | }, 297 | { 298 | "command": "valtown.val.openReadme", 299 | "category": "Val Town", 300 | "title": "Open Val Readme", 301 | "icon": "$(markdown)" 302 | }, 303 | { 304 | "command": "valtown.val.open", 305 | "category": "Val Town", 306 | "title": "Open Val", 307 | "icon": "$(file-code)" 308 | } 309 | ], 310 | "configuration": [ 311 | { 312 | "title": "Val Town", 313 | "type": "object", 314 | "properties": { 315 | "valtown.endpoint": { 316 | "type": "string", 317 | "description": "The Val Town API endpoint to use.", 318 | "default": "https://api.val.town" 319 | }, 320 | "valtown.tree": { 321 | "type": "array", 322 | "description": "Val Tree.", 323 | "items": { 324 | "type": "object", 325 | "additionalProperties": false, 326 | "properties": { 327 | "title": { 328 | "type": "string", 329 | "description": "The title of the val." 330 | }, 331 | "icon": { 332 | "type": "string", 333 | "description": "The icon of the folder." 334 | }, 335 | "url": { 336 | "type": "string", 337 | "description": "The url to fetch the vals from." 338 | }, 339 | "items": { 340 | "type": "array" 341 | } 342 | } 343 | }, 344 | "default": [ 345 | { 346 | "title": "Home Vals", 347 | "icon": "home", 348 | "url": "https://api.val.town/v1/users/${user:me}/vals" 349 | }, 350 | { 351 | "title": "Liked Vals", 352 | "icon": "heart", 353 | "url": "https://api.val.town/v1/me/likes" 354 | } 355 | ] 356 | } 357 | } 358 | } 359 | ], 360 | "menus": { 361 | "commandPalette": [ 362 | { 363 | "command": "valtown.setToken", 364 | "when": "!valtown.loggedIn" 365 | }, 366 | { 367 | "command": "valtown.clearToken", 368 | "when": "valtown.loggedIn" 369 | }, 370 | { 371 | "command": "valtown.open", 372 | "when": "valtown.loggedIn" 373 | }, 374 | { 375 | "command": "valtown.createVal", 376 | "when": "valtown.loggedIn" 377 | }, 378 | { 379 | "command": "valtown.createValFromTemplate", 380 | "when": "valtown.loggedIn" 381 | }, 382 | { 383 | "command": "valtown.deleteVal", 384 | "when": "false" 385 | }, 386 | { 387 | "command": "valtown.refresh", 388 | "when": "false" 389 | }, 390 | { 391 | "command": "valtown.setPrivate", 392 | "when": "false" 393 | }, 394 | { 395 | "command": "valtown.setUnlisted", 396 | "when": "false" 397 | }, 398 | { 399 | "command": "valtown.setPublic", 400 | "when": "false" 401 | }, 402 | { 403 | "command": "copyValID", 404 | "when": "false" 405 | }, 406 | { 407 | "command": "valtown.copyModuleURL", 408 | "when": "false" 409 | }, 410 | { 411 | "command": "valtown.copyScriptTag", 412 | "when": "false" 413 | }, 414 | { 415 | "command": "valtown.copyValUrl", 416 | "when": "false" 417 | }, 418 | { 419 | "command": "valtown.copyEmbedUrl", 420 | "when": "false" 421 | }, 422 | { 423 | "command": "valtown.copyEmailAddress", 424 | "when": "false" 425 | }, 426 | { 427 | "command": "valtown.copyHttpEndpoint", 428 | "when": "false" 429 | }, 430 | { 431 | "command": "valtown.openValUrl", 432 | "when": "false" 433 | }, 434 | { 435 | "command": "valtown.openHttpEndpoint", 436 | "when": "false" 437 | }, 438 | { 439 | "command": "valtown.openValLogs", 440 | "when": "false" 441 | }, 442 | { 443 | "command": "valtown.blob.refresh", 444 | "when": "false" 445 | }, 446 | { 447 | "command": "valtown.blob.delete", 448 | "when": "false" 449 | }, 450 | { 451 | "command": "valtown.blob.rename", 452 | "when": "false" 453 | }, 454 | { 455 | "command": "valtown.blob.download", 456 | "when": "false" 457 | }, 458 | { 459 | "command": "valtown.blob.copyKey", 460 | "when": "false" 461 | }, 462 | { 463 | "command": "valtown.sqlite.refresh", 464 | "when": "false" 465 | }, 466 | { 467 | "command": "valtown.sqlite.copyTableName", 468 | "when": "false" 469 | }, 470 | { 471 | "command": "valtown.sqlite.copyColumnName", 472 | "when": "false" 473 | }, 474 | { 475 | "command": "valtown.sqlite.dropTable", 476 | "when": "false" 477 | }, 478 | { 479 | "command": "valtown.vals.config", 480 | "when": "false" 481 | }, 482 | { 483 | "command": "valtown.val.openReadme", 484 | "when": "false" 485 | }, 486 | { 487 | "command": "valtown.val.open", 488 | "when": "false" 489 | } 490 | ], 491 | "editor/title": [ 492 | { 493 | "command": "valtown.openPreviewToSide", 494 | "alt": "valtown.openPreview", 495 | "group": "navigation@1", 496 | "when": "resourceScheme == 'vt+val' && resourceLangId != markdown" 497 | }, 498 | { 499 | "command": "valtown.val.openReadme", 500 | "group": "navigation@1", 501 | "when": "resourceScheme == 'vt+val' && resourceLangId != markdown" 502 | }, 503 | { 504 | "command": "valtown.val.open", 505 | "group": "navigation@1", 506 | "when": "resourceScheme == 'vt+val' && resourceLangId == markdown" 507 | }, 508 | { 509 | "command": "valtown.openValUrl", 510 | "group": "navigation@1", 511 | "when": "resourceScheme == 'vt+val'" 512 | }, 513 | { 514 | "command": "valtown.sqlite.runQuery", 515 | "group": "navigation@1", 516 | "when": "editorLangId == 'sql'" 517 | } 518 | ], 519 | "view/title": [ 520 | { 521 | "command": "valtown.createVal", 522 | "group": "navigation@1", 523 | "when": "view == valtown.tree && valtown.loggedIn" 524 | }, 525 | { 526 | "command": "valtown.createValFromTemplate", 527 | "group": "navigation@2", 528 | "when": "view == valtown.tree && valtown.loggedIn" 529 | }, 530 | { 531 | "command": "valtown.vals.config", 532 | "group": "navigation@3", 533 | "when": "view == valtown.tree && valtown.loggedIn" 534 | }, 535 | { 536 | "command": "valtown.refresh", 537 | "group": "navigation@4", 538 | "when": "view == valtown.tree && valtown.loggedIn" 539 | }, 540 | { 541 | "command": "valtown.blob.create", 542 | "group": "navigation@1", 543 | "when": "view == valtown.blobs && valtown.loggedIn" 544 | }, 545 | { 546 | "command": "valtown.blob.upload", 547 | "group": "navigation@2", 548 | "when": "view == valtown.blobs && valtown.loggedIn" 549 | }, 550 | { 551 | "command": "valtown.blob.refresh", 552 | "group": "navigation@3", 553 | "when": "view == valtown.blobs && valtown.loggedIn" 554 | }, 555 | { 556 | "command": "valtown.sqlite.newQuery", 557 | "group": "navigation@1", 558 | "when": "view == valtown.sqlite && valtown.loggedIn" 559 | }, 560 | { 561 | "command": "valtown.sqlite.refresh", 562 | "group": "navigation@2", 563 | "when": "view == valtown.sqlite && valtown.loggedIn" 564 | } 565 | ], 566 | "view/item/context": [ 567 | { 568 | "command": "valtown.rename", 569 | "group": "navigation@1", 570 | "when": "viewItem == val" 571 | }, 572 | { 573 | "submenu": "valtown.submenus.copy", 574 | "group": "navigation@2", 575 | "when": "viewItem == val" 576 | }, 577 | { 578 | "submenu": "valtown.submenus.open", 579 | "group": "navigation@3", 580 | "when": "viewItem == val" 581 | }, 582 | { 583 | "submenu": "valtown.submenus.privacy", 584 | "group": "navigation@4", 585 | "when": "viewItem == val" 586 | }, 587 | { 588 | "command": "valtown.deleteVal", 589 | "group": "navigation@6", 590 | "when": "viewItem == val" 591 | }, 592 | { 593 | "command": "valtown.blob.copyKey", 594 | "group": "navigation@1", 595 | "when": "viewItem == blob" 596 | }, 597 | { 598 | "command": "valtown.blob.download", 599 | "group": "navigation@2", 600 | "when": "viewItem == blob" 601 | }, 602 | { 603 | "command": "valtown.blob.rename", 604 | "group": "navigation@3", 605 | "when": "viewItem == blob" 606 | }, 607 | { 608 | "command": "valtown.blob.delete", 609 | "group": "navigation@4", 610 | "when": "viewItem == blob" 611 | }, 612 | { 613 | "command": "valtown.sqlite.dropTable", 614 | "group": "navigation@2", 615 | "when": "view == valtown.sqlite && viewItem == table" 616 | }, 617 | { 618 | "command": "valtown.sqlite.copyTableName", 619 | "group": "navigation@2", 620 | "when": "view == valtown.sqlite && viewItem == table" 621 | }, 622 | { 623 | "command": "valtown.sqlite.copyColumnName", 624 | "group": "navigation@2", 625 | "when": "view == valtown.sqlite && viewItem == column" 626 | } 627 | ], 628 | "valtown.submenus.privacy": [ 629 | { 630 | "command": "valtown.setPrivate", 631 | "group": "navigation@1", 632 | "when": "viewItem == val" 633 | }, 634 | { 635 | "command": "valtown.setUnlisted", 636 | "group": "navigation@2", 637 | "when": "viewItem == val" 638 | }, 639 | { 640 | "command": "valtown.setPublic", 641 | "group": "navigation@3", 642 | "when": "viewItem == val" 643 | } 644 | ], 645 | "valtown.submenus.open": [ 646 | { 647 | "command": "valtown.openValUrl", 648 | "group": "navigation@1", 649 | "when": "viewItem == val" 650 | }, 651 | { 652 | "command": "valtown.openHttpEndpoint", 653 | "group": "navigation@2", 654 | "when": "viewItem == val" 655 | }, 656 | { 657 | "command": "valtown.openValLogs", 658 | "group": "navigation@3", 659 | "when": "viewItem == val" 660 | } 661 | ], 662 | "valtown.submenus.copy": [ 663 | { 664 | "command": "valtown.copyValUrl", 665 | "group": "navigation@1", 666 | "when": "viewItem == val" 667 | }, 668 | { 669 | "command": "valtown.copyModuleURL", 670 | "group": "navigation@2", 671 | "when": "viewItem == val" 672 | }, 673 | { 674 | "command": "valtown.copyScriptTag", 675 | "group": "navigation@3", 676 | "when": "viewItem == val" 677 | }, 678 | { 679 | "command": "valtown.copyEmbedUrl", 680 | "group": "navigation@4", 681 | "when": "viewItem == val" 682 | }, 683 | { 684 | "command": "copyValID", 685 | "group": "navigation@5", 686 | "when": "viewItem == val" 687 | }, 688 | { 689 | "command": "valtown.copyHttpEndpoint", 690 | "group": "navigation@6", 691 | "when": "viewItem == val" 692 | }, 693 | { 694 | "command": "valtown.copyEmailAddress", 695 | "group": "navigation@7", 696 | "when": "viewItem == val" 697 | } 698 | ] 699 | }, 700 | "submenus": [ 701 | { 702 | "id": "valtown.submenus.open", 703 | "label": "Open" 704 | }, 705 | { 706 | "id": "valtown.submenus.privacy", 707 | "label": "Set Privacy" 708 | }, 709 | { 710 | "id": "valtown.submenus.copy", 711 | "label": "Copy" 712 | }, 713 | { 714 | "id": "valtown.privacy", 715 | "label": "Set Privacy" 716 | } 717 | ] 718 | }, 719 | "scripts": { 720 | "vscode:prepublish": "npm run package", 721 | "compile": "webpack", 722 | "watch": "webpack --watch", 723 | "package": "webpack --mode production --devtool hidden-source-map", 724 | "lint": "eslint src --ext=ts" 725 | }, 726 | "devDependencies": { 727 | "@types/node": "^16.18.34", 728 | "@types/vscode": "^1.73.0", 729 | "@typescript-eslint/eslint-plugin": "^6.7.0", 730 | "@typescript-eslint/parser": "^6.7.0", 731 | "eslint": "^8.26.0", 732 | "ts-loader": "^9.5.0", 733 | "typescript": "^5.2.2", 734 | "webpack": "^5.89.0", 735 | "webpack-cli": "^5.1.4" 736 | } 737 | } 738 | -------------------------------------------------------------------------------- /resources/activity.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/blob/fs.ts: -------------------------------------------------------------------------------- 1 | import { ValtownClient } from "../client"; 2 | import * as vscode from "vscode"; 3 | 4 | export const FS_SCHEME = "vt+blob"; 5 | 6 | class BlobFileSystemProvider implements vscode.FileSystemProvider { 7 | constructor(private client: ValtownClient) {} 8 | private _emitter = new vscode.EventEmitter(); 9 | readonly onDidChangeFile: vscode.Event = 10 | this._emitter.event; 11 | 12 | async readFile(uri: vscode.Uri) { 13 | const key = uri.path.slice(1); 14 | return this.client.readBlob(key); 15 | } 16 | 17 | async delete(uri: vscode.Uri) { 18 | const key = uri.path.slice(1); 19 | await this.client.deleteBlob(key); 20 | this._emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); 21 | await vscode.commands.executeCommand("valtown.blob.refresh"); 22 | } 23 | 24 | async rename( 25 | source: vscode.Uri, 26 | destination: vscode.Uri, 27 | options: { readonly overwrite: boolean } 28 | ) { 29 | const oldKey = source.path.slice(1); 30 | const newKey = destination.path.slice(1); 31 | await this.client.renameBlob(oldKey, newKey); 32 | this._emitter.fire([ 33 | { type: vscode.FileChangeType.Deleted, uri: source }, 34 | { type: vscode.FileChangeType.Created, uri: destination }, 35 | ]); 36 | await vscode.commands.executeCommand("valtown.blob.refresh"); 37 | } 38 | 39 | async copy( 40 | source: vscode.Uri, 41 | destination: vscode.Uri, 42 | options: { readonly overwrite: boolean } 43 | ) { 44 | const oldKey = source.path.slice(1); 45 | const newKey = destination.path.slice(1); 46 | await this.client.copyBlob(oldKey, newKey); 47 | this._emitter.fire([ 48 | { type: vscode.FileChangeType.Created, uri: destination }, 49 | ]); 50 | await vscode.commands.executeCommand("valtown.blob.refresh"); 51 | } 52 | 53 | async stat(uri: vscode.Uri) { 54 | const prefix = uri.path.slice(1); 55 | const files = await this.client.listBlobs(uri.path.slice(1)); 56 | if (files.length === 0) { 57 | throw vscode.FileSystemError.FileNotFound(uri); 58 | } 59 | const isDir = files[0].key !== prefix; 60 | return { 61 | type: isDir ? vscode.FileType.Directory : vscode.FileType.File, 62 | ctime: Date.now(), 63 | mtime: Date.now(), 64 | size: isDir ? 0 : 100, 65 | }; 66 | } 67 | 68 | async writeFile( 69 | uri: vscode.Uri, 70 | content: Uint8Array, 71 | options: { readonly create: boolean; readonly overwrite: boolean } 72 | ) { 73 | await this.client.writeBlob(uri.path.slice(1), content); 74 | await vscode.commands.executeCommand("valtown.refresh"); 75 | } 76 | 77 | watch( 78 | uri: vscode.Uri, 79 | options: { 80 | readonly recursive: boolean; 81 | readonly excludes: readonly string[]; 82 | } 83 | ): vscode.Disposable { 84 | // ignore, fires for all changes... 85 | return new vscode.Disposable(() => {}); 86 | } 87 | 88 | createDirectory(uri: vscode.Uri) {} 89 | 90 | async readDirectory(uri: vscode.Uri) { 91 | const blobs = await this.client.listBlobs(); 92 | return blobs.map( 93 | (blob) => [blob.key, vscode.FileType.File] as [string, vscode.FileType] 94 | ); 95 | } 96 | } 97 | 98 | export function registerBlobFileSystemProvider( 99 | context: vscode.ExtensionContext, 100 | client: ValtownClient 101 | ) { 102 | const fs = new BlobFileSystemProvider(client); 103 | context.subscriptions.push( 104 | vscode.workspace.registerFileSystemProvider(FS_SCHEME, fs), 105 | vscode.commands.registerCommand("valtown.blob.quickOpen", async () => { 106 | const uris = await vscode.window.showOpenDialog({ 107 | defaultUri: vscode.Uri.parse("vt+blob:/"), 108 | title: "Select a blob to open", 109 | }); 110 | 111 | if (!uris || uris.length === 0) { 112 | return; 113 | } 114 | 115 | await vscode.commands.executeCommand("vscode.open", uris[0]); 116 | }), 117 | vscode.commands.registerCommand("valtown.blob.create", async () => { 118 | const input = await vscode.window.showInputBox({ 119 | prompt: "Enter a name for the new blob", 120 | }); 121 | if (!input) { 122 | return; 123 | } 124 | 125 | const uri = vscode.Uri.parse(`vt+blob:/${encodeURIComponent(input)}`); 126 | await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode("\n")); 127 | await vscode.commands.executeCommand("vscode.open", uri); 128 | await vscode.commands.executeCommand("valtown.blob.refresh"); 129 | }), 130 | vscode.commands.registerCommand("valtown.blob.upload", async (arg) => { 131 | const fileUris = await vscode.window.showOpenDialog({ 132 | canSelectFolders: false, 133 | canSelectMany: false, 134 | openLabel: "Upload", 135 | }); 136 | 137 | if (!fileUris || fileUris.length === 0) { 138 | return; 139 | } 140 | const fileUri = fileUris[0]; 141 | const filename = fileUri.path.split("/").pop() || ""; 142 | 143 | const blobUri = await vscode.window.showSaveDialog({ 144 | defaultUri: vscode.Uri.parse("vt+blob:/"), 145 | saveLabel: "Upload", 146 | title: "Save blob as", 147 | }); 148 | if (!blobUri) { 149 | return; 150 | } 151 | 152 | const fileContent = await vscode.workspace.fs.readFile(fileUri); 153 | await vscode.workspace.fs.writeFile(blobUri, fileContent); 154 | await vscode.window.showInformationMessage(`Uploaded ${filename}`); 155 | await vscode.commands.executeCommand("valtown.blob.refresh"); 156 | }), 157 | vscode.commands.registerCommand("valtown.blob.download", async (arg) => { 158 | const key = arg.id; 159 | const blobUri = vscode.Uri.parse(`vt+blob:/${encodeURIComponent(key)}`); 160 | 161 | const fileUri = await vscode.window.showSaveDialog({ 162 | saveLabel: "Download", 163 | title: "Save blob as", 164 | }); 165 | if (!fileUri) { 166 | return; 167 | } 168 | 169 | const blobContent = await vscode.workspace.fs.readFile(blobUri); 170 | await vscode.workspace.fs.writeFile(fileUri, blobContent); 171 | await vscode.window.showInformationMessage(`Downloaded ${key}`); 172 | }), 173 | vscode.commands.registerCommand("valtown.blob.delete", async (arg) => { 174 | const key = arg.id; 175 | await vscode.workspace.fs.delete( 176 | vscode.Uri.parse(`vt+blob:/${encodeURIComponent(key)}`) 177 | ); 178 | 179 | await vscode.commands.executeCommand("valtown.blob.refresh"); 180 | }), 181 | vscode.commands.registerCommand("valtown.blob.rename", async (arg) => { 182 | const key = arg.id; 183 | const oldUri = vscode.Uri.parse(`vt+blob:/${encodeURIComponent(key)}`); 184 | const newUri = await vscode.window.showSaveDialog({ 185 | defaultUri: oldUri, 186 | saveLabel: "Rename", 187 | title: "Rename blob", 188 | }); 189 | 190 | if (!newUri) { 191 | return; 192 | } 193 | 194 | await vscode.workspace.fs.rename(oldUri, newUri, { overwrite: true }); 195 | 196 | await vscode.commands.executeCommand("valtown.blob.refresh"); 197 | }), 198 | vscode.commands.registerCommand("valtown.blob.copyKey", async (arg) => { 199 | const key = arg.id; 200 | await vscode.env.clipboard.writeText(key); 201 | }) 202 | ); 203 | } 204 | -------------------------------------------------------------------------------- /src/blob/tree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ValtownClient } from "../client"; 3 | 4 | export class ValTreeView implements vscode.TreeDataProvider { 5 | constructor(private client: ValtownClient) {} 6 | 7 | private _onDidChangeTreeData: vscode.EventEmitter< 8 | vscode.TreeItem | undefined | null | void 9 | > = new vscode.EventEmitter(); 10 | readonly onDidChangeTreeData: vscode.Event< 11 | vscode.TreeItem | undefined | null | void 12 | > = this._onDidChangeTreeData.event; 13 | refresh() { 14 | this._onDidChangeTreeData.fire(); 15 | } 16 | 17 | async getChildren(_: vscode.TreeItem | undefined) { 18 | const blobs = await this.client.listBlobs(); 19 | return blobs.map((blob) => ({ 20 | id: blob.key, 21 | label: blob.key, 22 | contextValue: "blob", 23 | description: `${blob.size / 1000} kb`, 24 | resourceUri: vscode.Uri.parse(`vt+blob:/${encodeURIComponent(blob.key)}`), 25 | iconPath: vscode.ThemeIcon.File, 26 | command: { 27 | command: "vscode.open", 28 | title: "Open Blob", 29 | arguments: [`vt+blob:/${encodeURIComponent(blob.key)}`], 30 | }, 31 | })); 32 | } 33 | 34 | getTreeItem( 35 | element: vscode.TreeItem 36 | ): vscode.TreeItem | Thenable { 37 | return element; 38 | } 39 | } 40 | 41 | export async function registerBlobTreeView( 42 | context: vscode.ExtensionContext, 43 | client: ValtownClient 44 | ) { 45 | const tree = new ValTreeView(client); 46 | context.subscriptions.push( 47 | vscode.window.createTreeView("valtown.blobs", { 48 | treeDataProvider: tree, 49 | showCollapseAll: true, 50 | }) 51 | ); 52 | 53 | context.subscriptions.push( 54 | vscode.commands.registerCommand("valtown.blob.refresh", async () => { 55 | tree.refresh(); 56 | }) 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | export type ValPrivacy = "public" | "private" | "unlisted"; 2 | export type BaseVal = { 3 | id: string; 4 | name: string; 5 | code: string; 6 | privacy: ValPrivacy; 7 | version: number; 8 | createdAt: string; 9 | author: { 10 | id: string; 11 | username: string; 12 | }; 13 | }; 14 | 15 | export type User = { 16 | id: string; 17 | bio: string; 18 | username: string; 19 | profileImageUrl: string; 20 | }; 21 | 22 | // We patch these types to only support JSON values 23 | export type InValue = null | string | number | boolean; 24 | export type InArgs = Array | Record; 25 | export type InStatement = 26 | | { 27 | sql: string; 28 | args: InArgs; 29 | } 30 | | string; 31 | 32 | export type Blob = { 33 | key: string; 34 | size: number; 35 | lastModified: string; 36 | }; 37 | 38 | export type FullVal = BaseVal & { 39 | readme: string; 40 | referenceCount: number; 41 | }; 42 | 43 | type Paginated = { 44 | data: T[]; 45 | links: { 46 | self: string; 47 | next?: string; 48 | prev?: string; 49 | }; 50 | }; 51 | 52 | export type Version = { 53 | valID: string; 54 | version: number; 55 | createdAt: string; 56 | }; 57 | 58 | const templates = { 59 | http: `export default async function (req: Request): Promise { 60 | return Response.json({ ok: true }) 61 | }`, 62 | email: `export default async function (email: Email) { 63 | 64 | }`, 65 | cron: `export default async function (interval: Interval) { 66 | 67 | }`, 68 | }; 69 | 70 | export type ValTemplate = keyof typeof templates; 71 | 72 | export class ValtownClient { 73 | private _user: User | undefined; 74 | 75 | constructor( 76 | public endpoint: string, 77 | private token?: string 78 | ) {} 79 | 80 | setToken(token?: string) { 81 | this.token = token; 82 | } 83 | 84 | setEndpoint(endpoint: string) { 85 | this.endpoint = endpoint; 86 | } 87 | 88 | get authenticated() { 89 | return !!this.token; 90 | } 91 | 92 | async paginate(url: string | URL) { 93 | if (typeof url === "string") { 94 | url = new URL(url); 95 | } 96 | url.searchParams.set("limit", "100"); 97 | 98 | const data = []; 99 | while (true) { 100 | const resp = await this.fetch(url); 101 | if (!resp.ok) { 102 | throw new Error(await resp.text()); 103 | } 104 | const body = (await resp.json()) as Paginated; 105 | data.push(...body.data); 106 | 107 | if (!body.links?.next) { 108 | break; 109 | } 110 | 111 | url = new URL(body.links?.next); 112 | if (url.protocol === "http:") { 113 | url.protocol = "https:"; 114 | } 115 | } 116 | 117 | return data; 118 | } 119 | 120 | async fetch(url: string | URL, init?: RequestInit) { 121 | if (!this.token) { 122 | throw new Error("No token"); 123 | } 124 | 125 | const { hostname } = new URL(url); 126 | if (hostname !== "api.val.town") { 127 | return fetch(url, init); 128 | } 129 | 130 | return fetch(url, { 131 | ...init, 132 | headers: { 133 | ...init?.headers, 134 | Authorization: `Bearer ${this.token}`, 135 | }, 136 | }); 137 | } 138 | 139 | async user(): Promise { 140 | if (!this._user) { 141 | const resp = await this.fetch(`${this.endpoint}/v1/me`); 142 | if (!resp.ok) { 143 | throw new Error(await resp.text()); 144 | } 145 | 146 | const user = await resp.json(); 147 | this._user = user; 148 | return user; 149 | } 150 | 151 | return this._user; 152 | } 153 | 154 | async createVal(options?: { template?: ValTemplate; privacy?: ValPrivacy }) { 155 | // empty vals are not allowed, so we add a space 156 | let code = options?.template ? templates[options?.template] : "\n"; 157 | 158 | const resp = await this.fetch(`${this.endpoint}/v1/vals`, { 159 | method: "POST", 160 | headers: { 161 | "Content-Type": "application/json", 162 | }, 163 | body: JSON.stringify({ 164 | code, 165 | privacy: options?.privacy || "private", 166 | }), 167 | }); 168 | if (!resp.ok) { 169 | throw new Error(await resp.text()); 170 | } 171 | 172 | return resp.json() as Promise; 173 | } 174 | 175 | async listLikedVals() { 176 | let endpoint = `${this.endpoint}/v1/me/likes?limit=100`; 177 | const vals: BaseVal[] = []; 178 | 179 | while (true) { 180 | const resp = await this.fetch(endpoint); 181 | if (!resp.ok) { 182 | throw new Error(await resp.text()); 183 | } 184 | 185 | const body = (await resp.json()) as Paginated; 186 | vals.push(...body.data); 187 | if (!body.links.next) { 188 | break; 189 | } 190 | 191 | endpoint = body.links.next; 192 | } 193 | 194 | return vals; 195 | } 196 | 197 | async listMyVals() { 198 | const user = await this.user(); 199 | let endpoint = `${this.endpoint}/v1/users/${user.id}/vals?limit=100`; 200 | const vals = [] as BaseVal[]; 201 | 202 | while (true) { 203 | const resp = await this.fetch(endpoint); 204 | if (!resp.ok) { 205 | throw new Error(await resp.text()); 206 | } 207 | const body = (await resp.json()) as Paginated; 208 | vals.push(...body.data); 209 | 210 | if (!body.links.next) { 211 | break; 212 | } 213 | 214 | endpoint = body.links.next; 215 | } 216 | 217 | return vals; 218 | } 219 | 220 | async listBlobs(prefix?: string) { 221 | const resp = await this.fetch( 222 | prefix 223 | ? `${this.endpoint}/v1/blob?prefix=${encodeURIComponent(prefix)}` 224 | : `${this.endpoint}/v1/blob` 225 | ); 226 | if (!resp.ok) { 227 | throw new Error(await resp.text()); 228 | } 229 | 230 | return resp.json() as Promise; 231 | } 232 | 233 | async readBlob(key: string) { 234 | const resp = await this.fetch( 235 | `${this.endpoint}/v1/blob/${encodeURIComponent(key)}` 236 | ); 237 | 238 | if (!resp.ok) { 239 | throw new Error(await resp.text()); 240 | } 241 | 242 | return new Uint8Array(await resp.arrayBuffer()); 243 | } 244 | 245 | async writeBlob(key: string, data: Uint8Array) { 246 | const resp = await this.fetch( 247 | `${this.endpoint}/v1/blob/${encodeURIComponent(key)}`, 248 | { 249 | method: "POST", 250 | body: data, 251 | } 252 | ); 253 | 254 | if (!resp.ok) { 255 | throw new Error(await resp.text()); 256 | } 257 | } 258 | 259 | async deleteBlob(key: string) { 260 | const resp = await this.fetch( 261 | `${this.endpoint}/v1/blob/${encodeURIComponent(key)}`, 262 | { 263 | method: "DELETE", 264 | } 265 | ); 266 | 267 | if (!resp.ok) { 268 | throw new Error(await resp.text()); 269 | } 270 | } 271 | 272 | async copyBlob(oldKey: string, newKey: string) { 273 | const resp = await this.fetch( 274 | `https://api.val.town/v1/blob/${encodeURIComponent(oldKey)}` 275 | ); 276 | await this.writeBlob(newKey, new Uint8Array(await resp.arrayBuffer())); 277 | } 278 | 279 | async renameBlob(oldKey: string, newKey: string) { 280 | await this.copyBlob(oldKey, newKey); 281 | await this.deleteBlob(oldKey); 282 | } 283 | 284 | async listVersions(valId: string) { 285 | let endpoint = `${this.endpoint}/v1/vals/${valId}/versions?limit=100`; 286 | const versions = [] as Version[]; 287 | while (true) { 288 | const resp = await this.fetch(endpoint); 289 | if (!resp.ok) { 290 | throw new Error(await resp.text()); 291 | } 292 | 293 | const body = (await resp.json()) as Paginated; 294 | versions.push(...body.data); 295 | 296 | if (!body.links.next) { 297 | break; 298 | } 299 | 300 | endpoint = body.links.next; 301 | } 302 | 303 | return versions; 304 | } 305 | 306 | async getVal(valId: string, version?: number) { 307 | const endpoint = version 308 | ? `${this.endpoint}/v1/vals/${valId}/versions/${version}` 309 | : `${this.endpoint}/v1/vals/${valId}`; 310 | const resp = await this.fetch(endpoint); 311 | 312 | if (!resp.ok) { 313 | throw new Error(await resp.text()); 314 | } 315 | 316 | return resp.json() as Promise; 317 | } 318 | 319 | async renameVal(valID: string, name: string) { 320 | const resp = await this.fetch(`${this.endpoint}/v1/vals/${valID}`, { 321 | method: "PUT", 322 | headers: { 323 | "Content-Type": "application/json", 324 | }, 325 | body: JSON.stringify({ 326 | name, 327 | }), 328 | }); 329 | 330 | if (!resp.ok) { 331 | throw new Error(await resp.text()); 332 | } 333 | } 334 | 335 | async setPrivacy(valID: string, privacy: "public" | "unlisted" | "private") { 336 | const resp = await this.fetch(`${this.endpoint}/v1/vals/${valID}`, { 337 | method: "PUT", 338 | headers: { 339 | "Content-Type": "application/json", 340 | }, 341 | body: JSON.stringify({ 342 | privacy, 343 | }), 344 | }); 345 | 346 | if (!resp.ok) { 347 | throw new Error(await resp.text()); 348 | } 349 | } 350 | 351 | async writeVal(valID: string, code: string) { 352 | const resp = await this.fetch( 353 | `${this.endpoint}/v1/vals/${valID}/versions`, 354 | { 355 | method: "POST", 356 | headers: { 357 | "Content-Type": "application/json", 358 | }, 359 | body: JSON.stringify({ 360 | code, 361 | }), 362 | } 363 | ); 364 | 365 | if (!resp.ok) { 366 | throw new Error(await resp.text()); 367 | } 368 | } 369 | 370 | async writeReadme(valID: string, readme: string) { 371 | const resp = await this.fetch(`${this.endpoint}/v1/vals/${valID}`, { 372 | method: "PUT", 373 | headers: { 374 | "Content-Type": "application/json", 375 | }, 376 | body: JSON.stringify({ 377 | readme, 378 | }), 379 | }); 380 | 381 | if (!resp.ok) { 382 | throw new Error(await resp.text()); 383 | } 384 | } 385 | 386 | async deleteVal(valID: string) { 387 | const resp = await this.fetch(`${this.endpoint}/v1/vals/${valID}`, { 388 | method: "DELETE", 389 | }); 390 | 391 | if (!resp.ok) { 392 | throw new Error(await resp.text()); 393 | } 394 | } 395 | 396 | async searchVals(query: string) { 397 | return this.paginate( 398 | `${this.endpoint}/v1/search/vals?query=${encodeURIComponent(query)}` 399 | ); 400 | } 401 | 402 | async resolveUser(username: string) { 403 | if (username.startsWith("@")) { 404 | username = username.slice(1); 405 | } 406 | 407 | const resp = await this.fetch(`${this.endpoint}/v1/alias/${username}`); 408 | if (!resp.ok) { 409 | throw new Error(await resp.text()); 410 | } 411 | 412 | return resp.json() as Promise; 413 | } 414 | 415 | async resolveVal(username: string, valname: string) { 416 | if (username.startsWith("@")) { 417 | username = username.slice(1); 418 | } 419 | 420 | const resp = await this.fetch( 421 | `${this.endpoint}/v1/alias/${username}/${valname}` 422 | ); 423 | 424 | if (!resp.ok) { 425 | throw new Error(await resp.text()); 426 | } 427 | 428 | return resp.json() as Promise; 429 | } 430 | 431 | async extractDependencies(code: string) { 432 | const esmUrlRegex = 433 | /https:\/\/esm\.town\/v\/([a-zA-Z_$][0-9a-zA-Z_$]*)\/([a-zA-Z_$][0-9a-zA-Z_$]*)/g; 434 | 435 | const matches = [...code.matchAll(esmUrlRegex)]; 436 | if (matches.length === 0) { 437 | return []; 438 | } 439 | 440 | const dependencies = await Promise.all( 441 | matches.flatMap(async (match) => { 442 | const [, author, name] = match; 443 | try { 444 | return await this.resolveVal(author, name); 445 | } catch (e) { 446 | return null; 447 | } 448 | }) 449 | ); 450 | 451 | return dependencies.filter((val): val is FullVal => !!val); 452 | } 453 | 454 | async execute(statement: InStatement) { 455 | const res = await this.fetch(`${this.endpoint}/v1/sqlite/execute`, { 456 | method: "POST", 457 | body: JSON.stringify({ statement }), 458 | }); 459 | 460 | if (!res.ok) { 461 | throw new Error(await res.text()); 462 | } 463 | 464 | return res.json(); 465 | } 466 | } 467 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { clearToken, saveToken } from "./secrets"; 3 | import { BaseVal, ValTemplate, ValtownClient } from "./client"; 4 | 5 | export function registerCommands( 6 | context: vscode.ExtensionContext, 7 | client: ValtownClient, 8 | ) { 9 | context.subscriptions.push( 10 | vscode.commands.registerCommand( 11 | "valtown.setToken", 12 | async (token?: string) => { 13 | if (!token) { 14 | token = await vscode.window.showInputBox({ 15 | prompt: "ValTown Token", 16 | placeHolder: "Token", 17 | validateInput: async (value) => { 18 | if (!value) { 19 | return "Token cannot be empty"; 20 | } 21 | }, 22 | }); 23 | 24 | if (!token) { 25 | return; 26 | } 27 | } 28 | 29 | await saveToken(context, token); 30 | }, 31 | ), 32 | vscode.commands.registerCommand("valtown.clearToken", async () => { 33 | await clearToken(context); 34 | }), 35 | vscode.commands.registerCommand("valtown.createVal", async () => { 36 | const val = await client.createVal(); 37 | vscode.commands.executeCommand( 38 | "vscode.open", 39 | vscode.Uri.parse( 40 | `vt+val:/${val.author.username}/${val.name}.tsx`, 41 | ), 42 | ); 43 | 44 | vscode.commands.executeCommand("valtown.refresh"); 45 | }), 46 | vscode.commands.registerCommand( 47 | "valtown.createValFromTemplate", 48 | async () => { 49 | const template = await vscode.window.showQuickPick< 50 | vscode.QuickPickItem & { value: ValTemplate } 51 | >( 52 | [ 53 | { 54 | label: "HTTP handler", 55 | value: "http", 56 | }, 57 | { 58 | label: "Scheduled function", 59 | value: "cron", 60 | }, 61 | { 62 | label: "Email handler", 63 | value: "email", 64 | }, 65 | ], 66 | { 67 | title: "Select a template", 68 | }, 69 | ); 70 | if (!template) { 71 | return; 72 | } 73 | 74 | const val = await client.createVal({ 75 | template: template.value, 76 | privacy: template.value === "cron" ? "private" : "unlisted", 77 | }); 78 | vscode.commands.executeCommand( 79 | "vscode.open", 80 | vscode.Uri.parse( 81 | `vt+val:/${val.author.username}/${val.name}.tsx`, 82 | ), 83 | ); 84 | vscode.commands.executeCommand("valtown.refresh"); 85 | }, 86 | ), 87 | vscode.commands.registerCommand("valtown.rename", async (arg) => { 88 | const { val } = arg; 89 | const name = await vscode.window.showInputBox({ 90 | prompt: "Val name", 91 | value: val.name, 92 | validateInput: async (value) => { 93 | if (!value) { 94 | return "Val name cannot be empty"; 95 | } 96 | }, 97 | }); 98 | if (!name) { 99 | return; 100 | } 101 | 102 | const oldURI = vscode.Uri.parse( 103 | `vt+val:/${val.author.username}/${val.name}.tsx`, 104 | ); 105 | const newURI = vscode.Uri.parse( 106 | `vt+val:/${val.author.username}/${name}.tsx`, 107 | ); 108 | 109 | vscode.workspace.fs.rename(oldURI, newURI, { overwrite: false }); 110 | }), 111 | vscode.commands.registerCommand("valtown.setPublic", async (arg) => { 112 | await client.setPrivacy(arg.val.id, "public"); 113 | await vscode.commands.executeCommand("valtown.refresh"); 114 | }), 115 | vscode.commands.registerCommand("valtown.setUnlisted", async (arg) => { 116 | await client.setPrivacy(arg.val.id, "unlisted"); 117 | await vscode.commands.executeCommand("valtown.refresh"); 118 | }), 119 | vscode.commands.registerCommand("valtown.setPrivate", async (arg) => { 120 | await client.setPrivacy(arg.val.id, "private"); 121 | await vscode.commands.executeCommand("valtown.refresh"); 122 | }), 123 | vscode.commands.registerCommand("valtown.copyModuleURL", async (arg) => { 124 | const { name, author } = arg.val; 125 | vscode.env.clipboard.writeText( 126 | `https://esm.town/v/${author.username}/${name}`, 127 | ); 128 | vscode.window.showInformationMessage(`Module URL copied to clipboard`); 129 | }), 130 | vscode.commands.registerCommand("copyValID", async (arg) => { 131 | vscode.env.clipboard.writeText(arg.val.id); 132 | vscode.window.showInformationMessage(`Val ID copied to clipboard`); 133 | }), 134 | vscode.commands.registerCommand("valtown.deleteVal", async (arg) => { 135 | const { author, name } = arg.val as BaseVal; 136 | 137 | vscode.workspace.fs.delete( 138 | vscode.Uri.parse(`vt+val:/${author.username}/${name}.tsx`), 139 | ); 140 | 141 | await vscode.commands.executeCommand("valtown.refresh"); 142 | }), 143 | vscode.commands.registerCommand("valtown.copyValUrl", async (arg) => { 144 | const { author, name } = arg.val; 145 | vscode.env.clipboard.writeText( 146 | `https://val.town/v/${author.username}/${name}`, 147 | ); 148 | vscode.window.showInformationMessage(`Val link copied to clipboard`); 149 | }), 150 | vscode.commands.registerCommand("valtown.openValUrl", async (arg) => { 151 | let valUrl: string; 152 | if ("val" in arg) { 153 | const { author, name } = arg.val; 154 | valUrl = `https://val.town/v/${author.username}/${name}`; 155 | } else { 156 | const [author, filename] = arg.path.slice(1).split("/"); 157 | valUrl = `https://val.town/v/${author}/${filename.split(".")[0]}`; 158 | } 159 | 160 | await vscode.env.openExternal(vscode.Uri.parse(valUrl)); 161 | }), 162 | vscode.commands.registerCommand("valtown.copyScriptTag", async (arg) => { 163 | const { author, name } = arg.val; 164 | // prettier-ignore 165 | vscode.env.clipboard.writeText( 166 | ``, 167 | ); 168 | vscode.window.showInformationMessage(`Script tag copied to clipboard`); 169 | }), 170 | vscode.commands.registerCommand("valtown.copyEmailAddress", async (arg) => { 171 | const { author, name } = arg.val; 172 | vscode.env.clipboard.writeText( 173 | `${author.username}.${name}@valtown.email`, 174 | ); 175 | vscode.window.showInformationMessage(`Email Address copied to clipboard`); 176 | }), 177 | vscode.commands.registerCommand("valtown.openValLogs", async (arg) => { 178 | const { author, name } = arg.val; 179 | await vscode.env.openExternal( 180 | vscode.Uri.parse( 181 | `https://val.town/v/${ 182 | author?.username?.slice(1) 183 | }/${name}/evaluations`, 184 | ), 185 | ); 186 | }), 187 | vscode.commands.registerCommand("valtown.copyEmbedUrl", async (arg) => { 188 | const { author, name } = arg.val; 189 | vscode.env.clipboard.writeText( 190 | `https://val.town/embed/${author.username}.${name}`, 191 | ); 192 | vscode.window.showInformationMessage(`Embed URL to clipboard`); 193 | }), 194 | vscode.commands.registerCommand("valtown.copyHttpEndpoint", async (arg) => { 195 | const { author, name } = arg.val; 196 | vscode.env.clipboard.writeText( 197 | `https://${author.username}-${name}.web.val.run`, 198 | ); 199 | vscode.window.showInformationMessage( 200 | `Val HTTP endpoint copied to clipboard`, 201 | ); 202 | }), 203 | vscode.commands.registerCommand("valtown.openHttpEndpoint", async (arg) => { 204 | const { author, name } = arg.val; 205 | vscode.env.openExternal( 206 | vscode.Uri.parse( 207 | `https://${author.username}-${name}.web.val.run`, 208 | ), 209 | ); 210 | }), 211 | vscode.commands.registerCommand("valtown.open", async () => { 212 | const slugRegex = /^@[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$/; 213 | const urlRegex = 214 | /^https:\/\/val\.town\/v\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+$/; 215 | const valSlug = await vscode.window.showInputBox({ 216 | prompt: "Val slug", 217 | placeHolder: "@stevekouse/fetchJSON", 218 | validateInput: async (value) => { 219 | if (!slugRegex.test(value || "") && !urlRegex.test(value || "")) { 220 | return "Invalid val name"; 221 | } 222 | }, 223 | }); 224 | 225 | if (!valSlug) { 226 | return; 227 | } 228 | let valUri: vscode.Uri; 229 | if (valSlug.startsWith("@")) { 230 | valUri = vscode.Uri.parse(`vt+val:/${valSlug.slice(1)}.tsx`); 231 | } else { 232 | const [, author, name] = new URL(valSlug).pathname.split("/"); 233 | valUri = vscode.Uri.parse(`vt+val:/${author}/${name}.tsx`); 234 | } 235 | vscode.commands.executeCommand("vscode.open", valUri); 236 | }), 237 | vscode.commands.registerCommand( 238 | "valtown.openPreviewToSide", 239 | async (arg) => { 240 | let httpEndpoint: string; 241 | if ("val" in arg) { 242 | const { author, name } = arg.val; 243 | httpEndpoint = `https://${author.username}-${name}.web.val.run`; 244 | } else { 245 | const [author, filename] = arg.path.slice(1).split("/"); 246 | httpEndpoint = `https://${author}-${ 247 | filename.split(".")[0] 248 | }.web.val.run`; 249 | } 250 | vscode.commands.executeCommand( 251 | "simpleBrowser.api.open", 252 | httpEndpoint, 253 | { 254 | viewColumn: vscode.ViewColumn.Beside, 255 | }, 256 | ); 257 | }, 258 | ), 259 | vscode.commands.registerCommand("valtown.openPreview", async (arg) => { 260 | let httpEndpoint: string; 261 | if ("val" in arg) { 262 | const { author, name } = arg.val; 263 | httpEndpoint = `https://${author.username}-${name}.web.val.run`; 264 | } else { 265 | const [author, filename] = arg.path.slice(1).split("/"); 266 | httpEndpoint = `https://${author}-${ 267 | filename.split(".")[0] 268 | }.web.val.run`; 269 | } 270 | vscode.commands.executeCommand( 271 | "simpleBrowser.api.open", 272 | httpEndpoint, 273 | ); 274 | }), 275 | ); 276 | } 277 | -------------------------------------------------------------------------------- /src/definition.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ValtownClient } from "./client"; 3 | export function register( 4 | client: ValtownClient, 5 | context: vscode.ExtensionContext 6 | ) { 7 | const urlPattern = 8 | /https:\/\/esm\.town\/v\/([a-zA-Z_$][0-9a-zA-Z_$]*)\/([a-zA-Z_$][0-9a-zA-Z_$]*)/g; 9 | 10 | context.subscriptions.push( 11 | vscode.languages.registerDefinitionProvider( 12 | { 13 | scheme: "vt+val", 14 | language: "typescriptreact", 15 | }, 16 | { 17 | async provideDefinition(document, position, token) { 18 | const range = document.getWordRangeAtPosition(position, urlPattern); 19 | if (!range) { 20 | return null; 21 | } 22 | 23 | const text = document.getText(range); 24 | const uri = vscode.Uri.parse(text); 25 | const [, author, name] = uri.path.slice(1).split("/"); 26 | 27 | return new vscode.Location( 28 | vscode.Uri.parse(`vt+val:/${author}/${name}.tsx`), 29 | new vscode.Position(0, 0) 30 | ); 31 | }, 32 | } 33 | ) 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import * as vscode from "vscode"; 4 | 5 | import { registerValTreeView } from "./val/tree"; 6 | import { registerBlobTreeView } from "./blob/tree"; 7 | import { ValtownClient } from "./client"; 8 | import { registerValFileSystemProvider } from "./val/fs"; 9 | import { registerSqliteTreeView } from "./sqlite/tree"; 10 | import { registerBlobFileSystemProvider } from "./blob/fs"; 11 | import { loadToken } from "./secrets"; 12 | import { registerCommands } from "./commands"; 13 | import { registerUriHandler } from "./uri"; 14 | import * as sqliteDoc from "./sqlite/document"; 15 | import * as definition from "./definition"; 16 | 17 | export async function activate(context: vscode.ExtensionContext) { 18 | // set output channel 19 | const outputChannel = vscode.window.createOutputChannel("Val Town"); 20 | context.subscriptions.push(outputChannel); 21 | 22 | const config = vscode.workspace.getConfiguration("valtown"); 23 | const endpoint = config.get("endpoint", "https://api.val.town"); 24 | outputChannel.appendLine(`Using endpoint: ${endpoint}`); 25 | 26 | let token = await loadToken(context); 27 | if (token) { 28 | await vscode.commands.executeCommand( 29 | "setContext", 30 | "valtown.loggedIn", 31 | true, 32 | ); 33 | } 34 | 35 | const client = new ValtownClient(endpoint, token); 36 | context.secrets.onDidChange(async (e) => { 37 | if (e.key !== "valtown.token") { 38 | return; 39 | } 40 | 41 | const token = await loadToken(context); 42 | client.setToken(token); 43 | await vscode.commands.executeCommand( 44 | "setContext", 45 | "valtown.loggedIn", 46 | !!token, 47 | ); 48 | await vscode.commands.executeCommand("valtown.refresh"); 49 | await vscode.commands.executeCommand("valtown.blob.refresh"); 50 | }); 51 | 52 | vscode.workspace.onDidChangeConfiguration(async (e) => { 53 | if (!e.affectsConfiguration("valtown.endpoint")) { 54 | return; 55 | } 56 | 57 | const token = await loadToken(context); 58 | client.setToken(token); 59 | await vscode.commands.executeCommand( 60 | "setContext", 61 | "valtown.ready", 62 | token !== undefined, 63 | ); 64 | await vscode.commands.executeCommand("valtown.refresh"); 65 | }); 66 | 67 | outputChannel.appendLine("Registering uri handler"); 68 | registerUriHandler(context, client); 69 | outputChannel.appendLine("Registering tree view"); 70 | registerValTreeView(context, client); 71 | registerBlobTreeView(context, client); 72 | registerSqliteTreeView(context, client); 73 | sqliteDoc.register(context, client); 74 | registerSqliteTreeView; 75 | outputChannel.appendLine("Registering file system provider"); 76 | registerBlobFileSystemProvider(context, client); 77 | registerValFileSystemProvider(context, client); 78 | definition.register(client, context); 79 | outputChannel.appendLine("Registering commands"); 80 | registerCommands(context, client); 81 | outputChannel.appendLine("ValTown extension activated"); 82 | } 83 | 84 | export async function deactivate() {} 85 | -------------------------------------------------------------------------------- /src/secrets.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | export async function loadToken(context: vscode.ExtensionContext) { 4 | const config = vscode.workspace.getConfiguration("valtown") 5 | const endpoint = config.get("endpoint", "https://api.val.town"); 6 | 7 | try { 8 | const tokens = JSON.parse(await context.secrets.get("valtown.token") || "{}") as Record; 9 | return tokens[endpoint]; 10 | } catch (e) { 11 | return 12 | } 13 | } 14 | 15 | export async function saveToken(context: vscode.ExtensionContext, token: string) { 16 | const config = vscode.workspace.getConfiguration("valtown") 17 | const endpoint = config.get("endpoint", "https://api.val.town"); 18 | 19 | try { 20 | const tokens = JSON.parse(await context.secrets.get("valtown.token") || "{}"); 21 | tokens[endpoint] = token; 22 | await context.secrets.store("valtown.token", JSON.stringify(tokens)); 23 | } catch (e) { 24 | const tokens = {} as Record; 25 | tokens[endpoint] = token; 26 | await context.secrets.store("valtown.token", JSON.stringify(tokens)); 27 | } 28 | } 29 | 30 | export async function clearToken(context: vscode.ExtensionContext) { 31 | const config = vscode.workspace.getConfiguration("valtown") 32 | const endpoint = config.get("endpoint", "https://api.val.town"); 33 | 34 | try { 35 | const tokens = JSON.parse(await context.secrets.get("valtown.token") || "{}"); 36 | delete tokens[endpoint]; 37 | await context.secrets.store("valtown.token", JSON.stringify(tokens)); 38 | } catch (e) { 39 | return 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/sqlite/document.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ValtownClient } from "../client"; 3 | 4 | export function register( 5 | context: vscode.ExtensionContext, 6 | client: ValtownClient 7 | ) { 8 | context.subscriptions.push( 9 | vscode.workspace.registerTextDocumentContentProvider("vt+sqlite", { 10 | async provideTextDocumentContent(uri: vscode.Uri) { 11 | const queryParams = new URLSearchParams(uri.query); 12 | const query = queryParams.get("query"); 13 | if (!query) { 14 | throw new Error("Missing query"); 15 | } 16 | 17 | try { 18 | const res = await client.execute(query); 19 | return JSON.stringify(res, null, 2); 20 | } catch (e: any) { 21 | return e.message; 22 | } 23 | }, 24 | }) 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/sqlite/tree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ValtownClient } from "../client"; 3 | 4 | function columnIcon(type: string) { 5 | switch (type.toLowerCase()) { 6 | case "integer": 7 | case "real": 8 | return new vscode.ThemeIcon("symbol-number"); 9 | case "text": 10 | return new vscode.ThemeIcon("symbol-string"); 11 | case "blob": 12 | return new vscode.ThemeIcon("symbol-array"); 13 | case "null": 14 | return new vscode.ThemeIcon("symbol-null"); 15 | default: 16 | return new vscode.ThemeIcon("symbol-variable"); 17 | } 18 | } 19 | 20 | export class SqliteTreeView 21 | implements vscode.TreeDataProvider 22 | { 23 | constructor(private client: ValtownClient) {} 24 | 25 | private _onDidChangeTreeData: vscode.EventEmitter< 26 | vscode.TreeItem | undefined | null | void 27 | > = new vscode.EventEmitter(); 28 | readonly onDidChangeTreeData: vscode.Event< 29 | vscode.TreeItem | undefined | null | void 30 | > = this._onDidChangeTreeData.event; 31 | refresh() { 32 | this._onDidChangeTreeData.fire(); 33 | } 34 | 35 | async getChildren(element: vscode.TreeItem | undefined) { 36 | if (!this.client.authenticated) { 37 | return []; 38 | } 39 | 40 | if (!element) { 41 | const { rows }: { rows: string[][] } = await this.client.execute( 42 | `SELECT name FROM sqlite_schema WHERE type ='table' AND name NOT LIKE 'sqlite_%';` 43 | ); 44 | 45 | return rows.map(([table]) => ({ 46 | id: table, 47 | label: table, 48 | collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, 49 | iconPath: new vscode.ThemeIcon("list-unordered"), 50 | contextValue: "table", 51 | })); 52 | } 53 | 54 | const { rows }: { rows: string[][] } = await this.client.execute( 55 | `PRAGMA table_info(${element.label});` 56 | ); 57 | 58 | return rows.map(([_, name, type]) => ({ 59 | id: `${element.label}.${name}`, 60 | label: name, 61 | description: type.toLowerCase(), 62 | collapsibleState: vscode.TreeItemCollapsibleState.None, 63 | iconPath: columnIcon(type), 64 | contextValue: "column", 65 | })); 66 | } 67 | 68 | getTreeItem( 69 | element: vscode.TreeItem 70 | ): vscode.TreeItem | Thenable { 71 | return element; 72 | } 73 | } 74 | 75 | export async function registerSqliteTreeView( 76 | context: vscode.ExtensionContext, 77 | client: ValtownClient 78 | ) { 79 | const tree = new SqliteTreeView(client); 80 | context.subscriptions.push( 81 | vscode.window.createTreeView("valtown.sqlite", { 82 | treeDataProvider: tree, 83 | showCollapseAll: true, 84 | }) 85 | ); 86 | 87 | context.subscriptions.push( 88 | vscode.commands.registerCommand("valtown.sqlite.refresh", async () => { 89 | tree.refresh(); 90 | }), 91 | vscode.commands.registerCommand("valtown.sqlite.copyTableName", (node) => { 92 | vscode.env.clipboard.writeText(node.label); 93 | }), 94 | vscode.commands.registerCommand("valtown.sqlite.copyColumnName", (node) => { 95 | vscode.env.clipboard.writeText(node.label); 96 | }), 97 | vscode.commands.registerCommand("valtown.sqlite.newQuery", async () => { 98 | // create a new untitled document 99 | let doc = await vscode.workspace.openTextDocument({ 100 | language: "sql", 101 | content: "", 102 | }); 103 | await vscode.window.showTextDocument(doc); 104 | }), 105 | vscode.commands.registerCommand("valtown.sqlite.runQuery", async () => { 106 | const editor = vscode.window.activeTextEditor; 107 | if (!editor) { 108 | return; 109 | } 110 | 111 | const query = await editor.document.getText(); 112 | const uri = vscode.Uri.parse( 113 | `vt+sqlite:/results.json?query=${encodeURIComponent(query)}` 114 | ); 115 | const doc = await vscode.workspace.openTextDocument(uri); 116 | await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); 117 | }), 118 | vscode.commands.registerCommand( 119 | "valtown.sqlite.dropTable", 120 | async (node) => { 121 | const table = node.label; 122 | 123 | const confirm = await vscode.window.showWarningMessage( 124 | `Are you sure you want to delete table ${table}?`, 125 | { modal: true }, 126 | "Delete" 127 | ); 128 | 129 | if (confirm !== "Delete") { 130 | return; 131 | } 132 | 133 | await client.execute(`DROP TABLE ${table};`); 134 | tree.refresh(); 135 | } 136 | ) 137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/template.ts: -------------------------------------------------------------------------------- 1 | export class Renderer { 2 | constructor( 3 | private templateFuncs: Record< 4 | string, 5 | (arg: string) => string | Promise 6 | >, 7 | ) {} 8 | 9 | async render( 10 | template: string, 11 | ) { 12 | for ( 13 | const match of [ 14 | ...template.matchAll(/\$\{([a-zA-Z0-9_]+):([^}]+)\}/g), 15 | ] 16 | ) { 17 | const [_, key, value] = match; 18 | if (!(key in this.templateFuncs)) { 19 | throw new Error(`Unknown template function: ${key}`); 20 | } 21 | 22 | template = template.replace( 23 | match[0], 24 | await this.templateFuncs[key](value), 25 | ); 26 | } 27 | 28 | return template; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/uri.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ValtownClient } from "./client"; 3 | 4 | export function registerUriHandler( 5 | context: vscode.ExtensionContext, 6 | client: ValtownClient, 7 | ) { 8 | context.subscriptions.push( 9 | vscode.window.registerUriHandler({ 10 | async handleUri(uri) { 11 | const parts = uri.path.slice(1).split("/"); 12 | if (parts.length !== 3 || parts[0] !== "v") { 13 | vscode.window.showErrorMessage( 14 | `Invalid valtown URI: ${uri.toString()}`, 15 | ); 16 | return; 17 | } 18 | 19 | const [_, author, name] = parts; 20 | const val = await client.resolveVal(author, name); 21 | vscode.commands.executeCommand( 22 | "vscode.open", 23 | vscode.Uri.parse(`vt+val:/${author}/${name}.tsx`), 24 | ); 25 | }, 26 | }), 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/val/fs.ts: -------------------------------------------------------------------------------- 1 | import { FullVal, ValtownClient } from "../client"; 2 | import * as vscode from "vscode"; 3 | 4 | export const FS_SCHEME = "vt+val"; 5 | 6 | class ValFileSystemProvider implements vscode.FileSystemProvider { 7 | constructor(private client: ValtownClient) {} 8 | 9 | private _emitter = new vscode.EventEmitter(); 10 | readonly onDidChangeFile: vscode.Event = 11 | this._emitter.event; 12 | 13 | async extractVal(uri: vscode.Uri): Promise { 14 | const [author, filename] = uri.path.slice(1).split("/"); 15 | const name = filename.split(".")[0]; 16 | return this.client.resolveVal(author, name); 17 | } 18 | 19 | static extractVersion(uri: vscode.Uri) { 20 | const match = uri.path.match(/@(\d+)/); 21 | if (match) { 22 | return parseInt(match[1]); 23 | } 24 | } 25 | 26 | async readFile(uri: vscode.Uri) { 27 | const val = await this.extractVal(uri); 28 | if (uri.path.endsWith(".md")) { 29 | return new TextEncoder().encode(val.readme || ""); 30 | } 31 | return new TextEncoder().encode(val.code || ""); 32 | } 33 | 34 | async delete(uri: vscode.Uri) { 35 | const val = await this.extractVal(uri); 36 | await this.client.deleteVal(val.id); 37 | this._emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); 38 | vscode.commands.executeCommand("valtown.refresh"); 39 | } 40 | 41 | async rename( 42 | oldUri: vscode.Uri, 43 | newUri: vscode.Uri, 44 | options: { readonly overwrite: boolean } 45 | ) { 46 | const oldVal = await this.extractVal(oldUri); 47 | const name = newUri.path.split("/").pop()?.replace(".tsx", ""); 48 | if (!name) { 49 | vscode.window.showErrorMessage("Invalid name"); 50 | return; 51 | } 52 | 53 | await this.client.renameVal(oldVal.id, name); 54 | this._emitter.fire([ 55 | { type: vscode.FileChangeType.Deleted, uri: oldUri }, 56 | { type: vscode.FileChangeType.Created, uri: newUri }, 57 | ]); 58 | vscode.commands.executeCommand("valtown.refresh"); 59 | } 60 | 61 | async stat(uri: vscode.Uri) { 62 | if (uri.path.split("/").length < 3) { 63 | return { 64 | type: vscode.FileType.Directory, 65 | ctime: 0, 66 | mtime: 0, 67 | size: 0, 68 | }; 69 | } 70 | 71 | try { 72 | const val = await this.extractVal(uri); 73 | const user = await this.client.user(); 74 | 75 | return { 76 | type: vscode.FileType.File, 77 | permissions: 78 | val.author.id !== user.id 79 | ? vscode.FilePermission.Readonly 80 | : undefined, 81 | ctime: new Date(val.createdAt).getTime(), 82 | mtime: new Date(val.createdAt).getTime(), 83 | size: new TextEncoder().encode(val.code || "").length, 84 | }; 85 | } catch (_) { 86 | throw vscode.FileSystemError.FileNotFound(uri); 87 | } 88 | } 89 | 90 | async writeFile( 91 | uri: vscode.Uri, 92 | content: Uint8Array, 93 | options: { readonly create: boolean; readonly overwrite: boolean } 94 | ) { 95 | const val = await this.extractVal(uri); 96 | if (uri.path.endsWith(".md")) { 97 | await this.client.writeReadme(val.id, new TextDecoder().decode(content)); 98 | return; 99 | } 100 | await this.client.writeVal(val.id, new TextDecoder().decode(content)); 101 | vscode.commands.executeCommand("valtown.refresh"); 102 | } 103 | 104 | watch( 105 | uri: vscode.Uri, 106 | options: { 107 | readonly recursive: boolean; 108 | readonly excludes: readonly string[]; 109 | } 110 | ): vscode.Disposable { 111 | return new vscode.Disposable(() => {}); 112 | } 113 | 114 | createDirectory(uri: vscode.Uri): void | Thenable { 115 | vscode.window.showErrorMessage("Cannot create directories in ValTown"); 116 | } 117 | 118 | async readDirectory(uri: vscode.Uri) { 119 | if (uri.path === "/") { 120 | vscode.window.showErrorMessage("Cannot read root directory"); 121 | return []; 122 | } 123 | 124 | const username = uri.path.split("/").pop() || ""; 125 | const user = await this.client.resolveUser(username); 126 | 127 | const vals = await this.client.paginate(`/users/${user.id}/vals`); 128 | return vals.map( 129 | (val) => 130 | [`${val.name}.tsx`, vscode.FileType.File] as [string, vscode.FileType] 131 | ); 132 | } 133 | } 134 | 135 | export function registerValFileSystemProvider( 136 | context: vscode.ExtensionContext, 137 | client: ValtownClient 138 | ) { 139 | const fs = new ValFileSystemProvider(client); 140 | 141 | context.subscriptions.push( 142 | vscode.workspace.registerFileSystemProvider(FS_SCHEME, fs), 143 | vscode.commands.registerCommand("valtown.val.openReadme", async (arg) => { 144 | let readmeUrl: string; 145 | if ("val" in arg) { 146 | const { author, name } = arg.val; 147 | readmeUrl = `vt+val:/${author.username}/${name}.md`; 148 | } else { 149 | const [author, filename] = arg.path.slice(1).split("/"); 150 | const name = filename.split(".")[0]; 151 | readmeUrl = `vt+val:/${author}/${name}.md`; 152 | } 153 | vscode.commands.executeCommand( 154 | "vscode.open", 155 | vscode.Uri.parse(readmeUrl) 156 | ); 157 | }), 158 | vscode.commands.registerCommand("valtown.val.open", async (arg) => { 159 | let readmeUrl: string; 160 | if ("val" in arg) { 161 | const { author, name } = arg.val; 162 | readmeUrl = `vt+val:/${author.username}/${name}.tsx`; 163 | } else { 164 | const [author, filename] = arg.path.slice(1).split("/"); 165 | const name = filename.split(".")[0]; 166 | readmeUrl = `vt+val:/${author}/${name}.tsx`; 167 | } 168 | vscode.commands.executeCommand( 169 | "vscode.open", 170 | vscode.Uri.parse(readmeUrl) 171 | ); 172 | }) 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /src/val/tree.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { BaseVal, ValtownClient } from "../client"; 3 | import { Renderer } from "../template"; 4 | 5 | type ValTreeItem = vscode.TreeItem & { val?: BaseVal }; 6 | 7 | type ValFolder = { 8 | title: string; 9 | icon?: string; 10 | } & ({ url: string } | { items: (string | ValFolder)[] }); 11 | 12 | export function valIcon(privacy: "public" | "private" | "unlisted") { 13 | switch (privacy) { 14 | case "public": 15 | return new vscode.ThemeIcon("globe"); 16 | case "private": 17 | return new vscode.ThemeIcon("lock"); 18 | case "unlisted": 19 | return new vscode.ThemeIcon("link"); 20 | } 21 | } 22 | 23 | function folderToTreeItem(folder: ValFolder) { 24 | return { 25 | label: folder.title, 26 | iconPath: folder.icon 27 | ? new vscode.ThemeIcon(folder.icon) 28 | : new vscode.ThemeIcon("folder"), 29 | collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, 30 | ...folder, 31 | }; 32 | } 33 | 34 | function valToTreeItem( 35 | val: BaseVal, 36 | collapsibleState: vscode.TreeItemCollapsibleState, 37 | ): ValTreeItem { 38 | const resourceUri = `vt+val:/${val.author.username}/${val.name}.tsx`; 39 | 40 | return { 41 | contextValue: "val", 42 | resourceUri: vscode.Uri.parse(resourceUri), 43 | label: val.name, 44 | tooltip: `v${val.version}`, 45 | description: val.author.username, 46 | iconPath: valIcon(val.privacy), 47 | collapsibleState, 48 | val, 49 | command: { 50 | command: "vscode.open", 51 | title: "Open Val", 52 | arguments: [resourceUri], 53 | }, 54 | } as vscode.TreeItem & { val: BaseVal }; 55 | } 56 | 57 | function hasDeps(val: BaseVal) { 58 | return /https:\/\/esm\.town\/v\/([a-zA-Z_$][0-9a-zA-Z_$]*)\/([a-zA-Z_$][0-9a-zA-Z_$]*)/ 59 | .test( 60 | val.code, 61 | ); 62 | } 63 | 64 | export class ValTreeView implements vscode.TreeDataProvider { 65 | private renderer: Renderer; 66 | constructor(private client: ValtownClient) { 67 | this.renderer = new Renderer({ 68 | encodeURIComponent, 69 | user: async (arg) => { 70 | if (!arg) { 71 | throw new Error("Missing argument"); 72 | } 73 | 74 | if (arg == "me") { 75 | const user = await this.client.user(); 76 | return user.id; 77 | } 78 | 79 | const user = await this.client.resolveUser(arg); 80 | return user.id; 81 | }, 82 | }); 83 | } 84 | 85 | private _onDidChangeTreeData: vscode.EventEmitter< 86 | vscode.TreeItem | undefined | null | void 87 | > = new vscode.EventEmitter(); 88 | readonly onDidChangeTreeData: vscode.Event< 89 | vscode.TreeItem | undefined | null | void 90 | > = this._onDidChangeTreeData.event; 91 | 92 | refresh() { 93 | this._onDidChangeTreeData.fire(); 94 | } 95 | 96 | async getChildren( 97 | element?: (vscode.TreeItem & ValFolder) | ValTreeItem | undefined, 98 | ) { 99 | if (!this.client.authenticated) { 100 | return []; 101 | } 102 | 103 | if (!element) { 104 | const config = vscode.workspace.getConfiguration("valtown"); 105 | return config.get("tree", []).map(folderToTreeItem); 106 | } 107 | 108 | if ("val" in element && element.val) { 109 | const vals = await this.client.extractDependencies(element.val.code); 110 | if (vals.length === 0) { 111 | return [ 112 | { 113 | label: "No dependencies found", 114 | collapsibleState: vscode.TreeItemCollapsibleState.None, 115 | }, 116 | ]; 117 | } 118 | 119 | return vals.map((val) => 120 | valToTreeItem( 121 | val, 122 | hasDeps(val) 123 | ? vscode.TreeItemCollapsibleState.Collapsed 124 | : vscode.TreeItemCollapsibleState.None, 125 | ) 126 | ); 127 | } 128 | 129 | if ("url" in element) { 130 | const url = await this.renderer.render(element.url); 131 | const vals = await this.client.paginate(url); 132 | if (vals.length === 0) { 133 | return [ 134 | { 135 | label: "No vals found", 136 | collapsibleState: vscode.TreeItemCollapsibleState.None, 137 | }, 138 | ]; 139 | } 140 | return vals.map((val) => 141 | valToTreeItem( 142 | val, 143 | hasDeps(val) 144 | ? vscode.TreeItemCollapsibleState.Collapsed 145 | : vscode.TreeItemCollapsibleState.None, 146 | ) 147 | ); 148 | } 149 | 150 | if ("items" in element) { 151 | const items = await Promise.all( 152 | element.items.map(async (item) => { 153 | if (typeof item === "object") { 154 | return folderToTreeItem(item); 155 | } 156 | 157 | const [author, name] = item.split("/"); 158 | const val = await this.client.resolveVal(author, name); 159 | 160 | return valToTreeItem( 161 | val, 162 | hasDeps(val) 163 | ? vscode.TreeItemCollapsibleState.Collapsed 164 | : vscode.TreeItemCollapsibleState.None, 165 | ); 166 | }), 167 | ); 168 | 169 | if (items.length === 0) { 170 | return [ 171 | { 172 | label: "No pinned vals found", 173 | collapsibleState: vscode.TreeItemCollapsibleState.None, 174 | }, 175 | ]; 176 | } 177 | 178 | return items; 179 | } 180 | 181 | // TODO: This is a bit of a hack, but it works for now 182 | throw new Error("Unknown element"); 183 | } 184 | 185 | getTreeItem( 186 | element: vscode.TreeItem, 187 | ): vscode.TreeItem | Thenable { 188 | return element; 189 | } 190 | } 191 | 192 | class DependencyTreeViewProvider 193 | implements vscode.TreeDataProvider { 194 | constructor(private client: ValtownClient) {} 195 | 196 | private _onDidChangeTreeData: vscode.EventEmitter< 197 | ValTreeItem | undefined | null | void 198 | > = new vscode.EventEmitter(); 199 | readonly onDidChangeTreeData: vscode.Event< 200 | ValTreeItem | undefined | null | void 201 | > = this._onDidChangeTreeData.event; 202 | 203 | refresh() { 204 | this._onDidChangeTreeData.fire(); 205 | } 206 | 207 | async getChildren(element?: ValTreeItem) { 208 | let code: string | undefined; 209 | if (element) { 210 | code = element.val!.code; 211 | } else { 212 | code = vscode.window.activeTextEditor?.document.getText(); 213 | } 214 | 215 | if (!code) { 216 | return [ 217 | { 218 | label: "No dependencies found", 219 | }, 220 | ]; 221 | } 222 | const vals = await this.client.extractDependencies(code); 223 | if (vals.length === 0) { 224 | return [ 225 | { 226 | label: "No dependencies found", 227 | collapsibleState: vscode.TreeItemCollapsibleState.None, 228 | }, 229 | ]; 230 | } 231 | 232 | return vals.map((val) => 233 | valToTreeItem( 234 | val, 235 | hasDeps(val) 236 | ? vscode.TreeItemCollapsibleState.Collapsed 237 | : vscode.TreeItemCollapsibleState.None, 238 | ) 239 | ); 240 | } 241 | 242 | getTreeItem( 243 | element: vscode.TreeItem, 244 | ): vscode.TreeItem | Thenable { 245 | return element; 246 | } 247 | } 248 | 249 | class ReferenceTreeViewProvider 250 | implements vscode.TreeDataProvider { 251 | constructor(private client: ValtownClient) {} 252 | 253 | private _onDidChangeTreeData: vscode.EventEmitter< 254 | ValTreeItem | undefined | null | void 255 | > = new vscode.EventEmitter(); 256 | readonly onDidChangeTreeData: vscode.Event< 257 | ValTreeItem | undefined | null | void 258 | > = this._onDidChangeTreeData.event; 259 | 260 | refresh() { 261 | this._onDidChangeTreeData.fire(); 262 | } 263 | 264 | async getChildren( 265 | element: ValTreeItem | undefined, 266 | ): Promise { 267 | const resourceUri = vscode.window.activeTextEditor?.document.uri; 268 | const [author, filename] = resourceUri?.path.slice(1).split("/") || []; 269 | if (!author || !filename) { 270 | return []; 271 | } 272 | 273 | const esmUrl = `https://esm.town/v/${author}/${filename.split(".")[0]}`; 274 | const vals = await ( 275 | await this.client.searchVals(esmUrl) 276 | ).filter((val) => { 277 | return val.code.includes(esmUrl + '"') || val.code.includes(esmUrl + "?"); 278 | }); 279 | 280 | if (vals.length === 0) { 281 | return [ 282 | { 283 | label: "No references found", 284 | collapsibleState: vscode.TreeItemCollapsibleState.None, 285 | }, 286 | ]; 287 | } 288 | return vals.map((val) => 289 | valToTreeItem(val, vscode.TreeItemCollapsibleState.None) 290 | ); 291 | } 292 | 293 | getTreeItem( 294 | element: vscode.TreeItem, 295 | ): vscode.TreeItem | Thenable { 296 | return element; 297 | } 298 | } 299 | 300 | export async function registerValTreeView( 301 | context: vscode.ExtensionContext, 302 | client: ValtownClient, 303 | ) { 304 | const valTree = new ValTreeView(client); 305 | context.subscriptions.push( 306 | vscode.window.createTreeView("valtown.tree", { 307 | treeDataProvider: valTree, 308 | showCollapseAll: true, 309 | }), 310 | ); 311 | 312 | context.subscriptions.push( 313 | vscode.workspace.onDidChangeConfiguration((e) => { 314 | if (e.affectsConfiguration("valtown.tree")) { 315 | valTree.refresh(); 316 | } 317 | }), 318 | ); 319 | 320 | const referenceTree = new ReferenceTreeViewProvider(client); 321 | context.subscriptions.push( 322 | vscode.window.registerTreeDataProvider("valtown.references", referenceTree), 323 | ); 324 | 325 | const dependencyTree = new DependencyTreeViewProvider(client); 326 | context.subscriptions.push( 327 | vscode.window.registerTreeDataProvider( 328 | "valtown.dependencies", 329 | dependencyTree, 330 | ), 331 | ); 332 | 333 | vscode.window.onDidChangeActiveTextEditor((editor) => { 334 | if (editor?.document.uri.scheme === "vt+val") { 335 | referenceTree.refresh(); 336 | dependencyTree.refresh(); 337 | } 338 | }); 339 | 340 | context.subscriptions.push( 341 | vscode.commands.registerCommand("valtown.refresh", async () => { 342 | valTree.refresh(); 343 | }), 344 | vscode.commands.registerCommand("valtown.vals.refresh", async () => { 345 | valTree.refresh(); 346 | }), 347 | vscode.commands.registerCommand("valtown.vals.config", async () => { 348 | await vscode.commands.executeCommand( 349 | "workbench.action.openSettings", 350 | "valtown.tree", 351 | ); 352 | }), 353 | ); 354 | } 355 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "lib": [ 6 | "es2020", 7 | "WebWorker" 8 | ], 9 | "outDir": "out", 10 | "sourceMap": true, 11 | "rootDir": "src", 12 | "skipLibCheck": true, 13 | "strict": true 14 | }, 15 | "exclude": [ 16 | "node_modules", 17 | ".vscode-test" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See License.txt in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | //@ts-check 7 | 'use strict'; 8 | 9 | //@ts-check 10 | /** @typedef {import('webpack').Configuration} WebpackConfig **/ 11 | 12 | const path = require('path'); 13 | const webpack = require('webpack'); 14 | 15 | /** @type WebpackConfig */ 16 | const webExtensionConfig = { 17 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 18 | target: 'webworker', // extensions run in a webworker context 19 | entry: { 20 | extension: './src/extension.ts', // source of the web extension main file 21 | // 'test/suite/index': './src/web/test/suite/index.ts', // source of the web extension test runner 22 | }, 23 | output: { 24 | filename: '[name].js', 25 | path: path.join(__dirname, './dist'), 26 | libraryTarget: 'commonjs', 27 | }, 28 | resolve: { 29 | mainFields: ['browser', 'module', 'main'], // look for `browser` entry point in imported node modules 30 | extensions: ['.ts', '.js'], // support ts-files and js-files 31 | alias: { 32 | // provides alternate implementation for node module and source files 33 | }, 34 | fallback: { 35 | // Webpack 5 no longer polyfills Node.js core modules automatically. 36 | // see https://webpack.js.org/configuration/resolve/#resolvefallback 37 | // for the list of Node.js core module polyfills. 38 | assert: require.resolve('assert'), 39 | }, 40 | }, 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.ts$/, 45 | exclude: /node_modules/, 46 | use: [ 47 | { 48 | loader: 'ts-loader', 49 | }, 50 | ], 51 | }, 52 | ], 53 | }, 54 | plugins: [], 55 | externals: { 56 | vscode: 'commonjs vscode', // ignored because it doesn't exist 57 | }, 58 | performance: { 59 | hints: false, 60 | }, 61 | devtool: 'nosources-source-map', // create a source map that points to the original source file 62 | }; 63 | 64 | module.exports = [webExtensionConfig]; 65 | --------------------------------------------------------------------------------