├── .eslintignore ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── LICENSE ├── README.md ├── client ├── App.css ├── App.tsx ├── components │ ├── contextmenu │ │ ├── contextmenu-models.ts │ │ ├── contextmenu.tsx │ │ └── contextmenuItem.tsx │ ├── device-settings │ │ ├── device-settings.css │ │ └── device-settings.tsx │ ├── error-page │ │ ├── error-page.css │ │ └── error-page.tsx │ ├── loading-bar │ │ ├── loading-bar.css │ │ └── loading-bar.tsx │ ├── screencast │ │ ├── screencast.css │ │ └── screencast.tsx │ ├── toolbar │ │ ├── toolbar.css │ │ └── toolbar.tsx │ ├── url-input │ │ ├── url-input.css │ │ └── url-input.tsx │ └── viewport │ │ ├── viewport.css │ │ └── viewport.tsx ├── connection.ts ├── main.tsx └── utils │ ├── cdpHelper.ts │ └── logger.ts ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── resources └── icon.png ├── src ├── BrowserClient.ts ├── BrowserPage.ts ├── Clipboard.ts ├── Config.ts ├── ContentProvider.ts ├── DebugProvider.ts ├── ExtensionConfiguration.ts ├── Panel.ts ├── PanelManager.ts ├── UnderlyingDebugAdapter.ts ├── ambient.d.ts └── extension.ts ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | assets 4 | public 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: antfu 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v2 20 | 21 | - name: Set node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: lts/* 25 | cache: pnpm 26 | 27 | - name: Setup 28 | run: npm i -g @antfu/ni 29 | 30 | - name: Install 31 | run: nci 32 | 33 | - name: Lint 34 | run: nr lint 35 | 36 | build: 37 | runs-on: ${{ matrix.os }} 38 | 39 | strategy: 40 | matrix: 41 | node: [lts/*] 42 | os: [ubuntu-latest, windows-latest, macos-latest] 43 | fail-fast: false 44 | 45 | steps: 46 | - uses: actions/checkout@v3 47 | 48 | - name: Install pnpm 49 | uses: pnpm/action-setup@v2 50 | 51 | - name: Set node version to ${{ matrix.node }} 52 | uses: actions/setup-node@v3 53 | with: 54 | node-version: ${{ matrix.node }} 55 | cache: pnpm 56 | 57 | - name: Setup 58 | run: npm i -g @antfu/ni 59 | 60 | - name: Install 61 | run: nci 62 | 63 | - name: Build 64 | run: nr build 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v2 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: '20' 20 | registry-url: https://registry.npmjs.org/ 21 | - run: npm i -g @antfu/ni 22 | - run: nci 23 | - run: npx changelogithub 24 | env: 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | - run: nr build 27 | - run: npx vsce publish -p ${{secrets.VSCE_TOKEN}} --no-dependencies 28 | env: 29 | VSCE_TOKEN: ${{secrets.VSCE_TOKEN}} 30 | - run: npx ovsx publish -p ${{secrets.OVSX_TOKEN}} --no-dependencies 31 | env: 32 | OVSX_TOKEN: ${{secrets.OVSX_TOKEN}} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | vars.css 24 | *.vsix 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "type": "chrome", 7 | "request": "launch", 8 | "name": "Launch Chrome", 9 | "url": "http://localhost:3001", 10 | "webRoot": "${workspaceFolder}" 11 | }, 12 | { 13 | "name": "Launch Extension", 14 | "type": "extensionHost", 15 | "request": "launch", 16 | "runtimeExecutable": "${execPath}", 17 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 18 | "stopOnEntry": false, 19 | "sourceMaps": true, 20 | "outFiles": [ 21 | "${workspaceRoot}/dist/extension.js" 22 | ], 23 | "preLaunchTask": "npm: build" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vite.autoStart": false, 3 | 4 | // Enable the ESlint flat config support 5 | "eslint.experimental.useFlatConfig": true, 6 | 7 | // Disable the default formatter, use eslint instead 8 | "prettier.enable": false, 9 | "editor.formatOnSave": false, 10 | 11 | // Auto fix 12 | "editor.codeActionsOnSave": { 13 | "source.fixAll": "explicit", 14 | "source.organizeImports": "never" 15 | }, 16 | 17 | // Silent the stylistic rules in you IDE, but still auto fix them 18 | "eslint.rules.customizations": [ 19 | { "rule": "style/*", "severity": "off" }, 20 | { "rule": "*-indent", "severity": "off" }, 21 | { "rule": "*-spacing", "severity": "off" }, 22 | { "rule": "*-spaces", "severity": "off" }, 23 | { "rule": "*-order", "severity": "off" }, 24 | { "rule": "*-dangle", "severity": "off" }, 25 | { "rule": "*-newline", "severity": "off" }, 26 | { "rule": "*quotes", "severity": "off" }, 27 | { "rule": "*semi", "severity": "off" } 28 | ], 29 | 30 | // Enable eslint for all supported languages 31 | "eslint.validate": [ 32 | "javascript", 33 | "javascriptreact", 34 | "typescript", 35 | "typescriptreact", 36 | "vue", 37 | "html", 38 | "markdown", 39 | "json", 40 | "jsonc", 41 | "yaml" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "npm: build", 8 | "type": "shell", 9 | "command": "npm run build:dev" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .eslintignore 2 | .github 3 | .gitignore 4 | .vscode 5 | .yarnrc 6 | /index.html 7 | tsconfig.json 8 | vite.config.ts 9 | yarn.lock 10 | 11 | cleint/ 12 | client/* 13 | src/* 14 | node_modules/* 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kenneth Auchenberg 4 | Copyright (c) 2021 Anthony Fu 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 |

6 | Browse Lite 7 |

8 |

9 | Embedded browser in VS Code 10 |

11 |

12 | Visual Studio Marketplace Version 13 | 14 | 15 | > Forked from [Browser Preview](https://github.com/auchenberg/vscode-browser-preview) by [Kenneth Auchenberg](https://github.com/auchenberg) 16 | 17 | - ⚡️ Faster page refreshing 18 | - 🌗 Dark mode aware 19 | - 🎨 Theme-aware UI 20 | - 🐞 Built-in devtools support 21 | - 🔌 Extendable actions 22 | - 🖥 Re-open in the system browser 23 | - ✅ No Telemetry 24 | - 🍃 Much lighter [`10.3MB` ➡️ `212KB`](https://user-images.githubusercontent.com/11247099/109819001-90a65a00-7c6e-11eb-8d82-465ec8b22eba.png) 25 | 26 |

27 |
Run Browse Lite: Open... command to start the browser
28 |

29 | 30 |

31 | Preview 1 32 | Preview 2 33 |

34 | 35 | This extension was originally built for [VS Code for Vite](https://github.com/antfu/vscode-vite). 36 | 37 | ## Sponsors 38 | 39 | This project is part of my [Sponsor Program](https://github.com/sponsors/antfu). 40 | 41 |

42 | 43 | 44 | 45 |

46 | 47 | ## License 48 | 49 | MIT - Copyright (c) 2019 Kenneth Auchenberg 50 | 51 | MIT - Copyright (c) 2021 Anthony Fu 52 | -------------------------------------------------------------------------------- /client/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | font-size: 14px; 7 | font-weight: 400; 8 | overflow: hidden; 9 | } 10 | 11 | #root { 12 | height: 100%; 13 | } 14 | 15 | .App { 16 | text-align: center; 17 | height: 100%; 18 | display: flex; 19 | flex-direction: column; 20 | } 21 | -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './App.css' 3 | 4 | import { resolve as getElementSourceMetadata } from 'element-to-source' 5 | import type { ExtensionConfiguration } from '../src/ExtensionConfiguration' 6 | import Toolbar from './components/toolbar/toolbar' 7 | import Viewport from './components/viewport/viewport' 8 | import Connection from './connection' 9 | import { CDPHelper } from './utils/cdpHelper' 10 | 11 | interface ElementSource { 12 | charNumber: number 13 | columnNumber: number 14 | fileName: string 15 | lineNumber: string 16 | } 17 | 18 | interface IState { 19 | format: 'jpeg' | 'png' 20 | frame: object | null 21 | url: string 22 | quality: number 23 | errorText: string | undefined 24 | everyNthFrame: number 25 | isDebug: boolean 26 | isVerboseMode: boolean 27 | isInspectEnabled: boolean 28 | isDeviceEmulationEnabled: boolean 29 | viewportMetadata: IViewport 30 | history: { 31 | canGoBack: boolean 32 | canGoForward: boolean 33 | } 34 | } 35 | 36 | interface IViewport { 37 | height: number | null 38 | width: number | null 39 | cursor: string | null 40 | emulatedDeviceId: string | null 41 | isLoading: boolean 42 | isFixedSize: boolean 43 | isFixedZoom: boolean 44 | isResizable: boolean 45 | loadingPercent: number 46 | highlightNode: { 47 | nodeId: string 48 | sourceMetadata: ElementSource | null 49 | } | null 50 | highlightInfo: object | null 51 | deviceSizeRatio: number 52 | screenZoom: number 53 | scrollOffsetX: number 54 | scrollOffsetY: number 55 | } 56 | 57 | class App extends React.Component { 58 | private connection: Connection 59 | private viewport: Viewport = undefined! 60 | private cdpHelper: CDPHelper 61 | private nextViewportSize: { width: number; height: number } | undefined 62 | 63 | constructor(props: any) { 64 | super(props) 65 | this.state = { 66 | frame: null, 67 | format: 'png', 68 | url: 'about:blank', 69 | quality: 100, 70 | everyNthFrame: 1, 71 | isVerboseMode: false, 72 | isDebug: false, 73 | errorText: undefined, 74 | isInspectEnabled: false, 75 | isDeviceEmulationEnabled: false, 76 | history: { 77 | canGoBack: false, 78 | canGoForward: false, 79 | }, 80 | viewportMetadata: { 81 | cursor: null, 82 | deviceSizeRatio: 1, 83 | height: null, 84 | width: null, 85 | highlightNode: null, 86 | highlightInfo: null, 87 | emulatedDeviceId: 'Responsive', 88 | isLoading: false, 89 | isFixedSize: false, 90 | isFixedZoom: false, 91 | isResizable: false, 92 | loadingPercent: 0.0, 93 | screenZoom: 1, 94 | scrollOffsetX: 0, 95 | scrollOffsetY: 0, 96 | }, 97 | } 98 | 99 | this.connection = new Connection() 100 | this.onToolbarActionInvoked = this.onToolbarActionInvoked.bind(this) 101 | this.onViewportChanged = this.onViewportChanged.bind(this) 102 | 103 | this.connection.enableVerboseLogging(this.state.isVerboseMode) 104 | 105 | this.connection.on('Page.navigatedWithinDocument', (result: any) => { 106 | this.requestNavigationHistory() 107 | }) 108 | 109 | this.connection.on('extension.appConfiguration', (v) => { 110 | this.updateState(v) 111 | }) 112 | 113 | this.connection.on('Page.frameNavigated', (result: any) => { 114 | const { frame } = result 115 | const isMainFrame = !frame.parentId 116 | 117 | if (isMainFrame) { 118 | this.requestNavigationHistory() 119 | this.updateState({ 120 | viewportMetadata: { 121 | ...this.state.viewportMetadata, 122 | isLoading: true, 123 | loadingPercent: 0.1, 124 | }, 125 | errorText: frame.unreachableUrl 126 | ? `Failed to reach ${frame.unreachableUrl}` 127 | : undefined, 128 | }) 129 | } 130 | }) 131 | 132 | this.connection.on('Page.loadEventFired', (result: any) => { 133 | this.updateState({ 134 | viewportMetadata: { 135 | ...this.state.viewportMetadata, 136 | loadingPercent: 1.0, 137 | }, 138 | }) 139 | 140 | setTimeout(() => { 141 | this.updateState({ 142 | viewportMetadata: { 143 | ...this.state.viewportMetadata, 144 | isLoading: false, 145 | loadingPercent: 0, 146 | }, 147 | }) 148 | }, 500) 149 | }) 150 | 151 | this.connection.on('Page.screencastFrame', (result: any) => { 152 | this.handleScreencastFrame(result) 153 | }) 154 | 155 | this.connection.on('Page.windowOpen', (result: any) => { 156 | this.connection.send('extension.windowOpenRequested', { 157 | url: result.url, 158 | }) 159 | }) 160 | 161 | this.connection.on('Page.javascriptDialogOpening', (result: any) => { 162 | const { url, message, type } = result 163 | 164 | this.connection.send('extension.windowDialogRequested', { 165 | url, 166 | message, 167 | type, 168 | }) 169 | }) 170 | 171 | this.connection.on('Page.frameResized', (result: any) => { 172 | this.stopCasting() 173 | this.startCasting() 174 | }) 175 | 176 | this.connection.on( 177 | 'extension.appConfiguration', 178 | (payload: ExtensionConfiguration) => { 179 | if (!payload) 180 | return 181 | 182 | this.stopCasting() 183 | this.startCasting() 184 | 185 | if (payload.startUrl) 186 | this.handleNavigate(payload.startUrl) 187 | }, 188 | ) 189 | 190 | this.connection.on( 191 | 'extension.navigateTo', 192 | ({ url }: { url: string }) => { 193 | this.handleNavigate(url) 194 | }, 195 | ) 196 | 197 | this.connection.on('extension.viewport', (viewport: IViewport) => { 198 | this.handleViewportSizeChange(viewport) 199 | // this.enableViewportDeviceEmulation('Live Share') 200 | // TODO: Scroll the page 201 | }) 202 | 203 | // Initialize 204 | this.connection.send('Page.enable') 205 | this.connection.send('DOM.enable') 206 | this.connection.send('CSS.enable') 207 | this.connection.send('Overlay.enable') 208 | 209 | this.requestNavigationHistory() 210 | this.startCasting() 211 | 212 | this.cdpHelper = new CDPHelper(this.connection) 213 | } 214 | 215 | private async handleScreencastFrame(result: any) { 216 | const { sessionId, data, metadata } = result 217 | this.connection.send('Page.screencastFrameAck', { sessionId }) 218 | 219 | this.requestNodeHighlighting() 220 | 221 | await this.updateState({ 222 | frame: { 223 | base64Data: data, 224 | metadata, 225 | }, 226 | viewportMetadata: { 227 | ...this.state.viewportMetadata, 228 | ...this.nextViewportSize, 229 | scrollOffsetX: metadata.scrollOffsetX, 230 | scrollOffsetY: metadata.scrollOffsetY, 231 | }, 232 | }) 233 | 234 | this.nextViewportSize = undefined 235 | } 236 | 237 | public componentDidUpdate() { 238 | const { isVerboseMode } = this.state 239 | 240 | this.connection.enableVerboseLogging(isVerboseMode) 241 | } 242 | 243 | public render() { 244 | return ( 245 |
246 | { 247 | // hide navbar for devtools 248 | this.state.isDebug 249 | ? null 250 | : ( 251 | 260 | ) 261 | } 262 | this.viewport = c!} 273 | /> 274 |
275 | ) 276 | } 277 | 278 | public stopCasting() { 279 | this.connection.send('Page.stopScreencast') 280 | } 281 | 282 | public startCasting() { 283 | const params = { 284 | quality: this.state.quality, 285 | format: this.state.format, 286 | everyNthFrame: this.state.everyNthFrame, 287 | } 288 | 289 | this.connection.send('Page.startScreencast', params) 290 | } 291 | 292 | private async requestNavigationHistory() { 293 | const history: any = await this.connection.send( 294 | 'Page.getNavigationHistory', 295 | ) 296 | 297 | if (!history) 298 | return 299 | 300 | const historyIndex = history.currentIndex 301 | const historyEntries = history.entries 302 | const currentEntry = historyEntries[historyIndex] 303 | const url = currentEntry.url 304 | 305 | this.updateState({ 306 | url, 307 | history: { 308 | canGoBack: historyIndex === 0, 309 | canGoForward: historyIndex === historyEntries.length - 1, 310 | }, 311 | }) 312 | 313 | const panelTitle = currentEntry.title || currentEntry.url 314 | 315 | this.connection.send('extension.updateTitle', { 316 | title: panelTitle, 317 | }) 318 | } 319 | 320 | private async onViewportChanged(action: string, data: any) { 321 | switch (action) { 322 | case 'inspectHighlightRequested': 323 | this.handleInspectHighlightRequested(data) 324 | break 325 | case 'inspectElement': 326 | await this.handleInspectElementRequest(data) 327 | this.handleToggleInspect() 328 | break 329 | case 'hoverElementChanged': 330 | await this.handleElementChanged(data) 331 | break 332 | case 'interaction': 333 | this.connection.send(data.action, data.params) 334 | break 335 | 336 | case 'deviceChange': 337 | await this.updateState({ 338 | viewportMetadata: { 339 | ...this.state.viewportMetadata, 340 | ...data, 341 | }, 342 | }) 343 | this.viewport.calculateViewport() 344 | break 345 | case 'size': 346 | if (data.height !== undefined && data.width !== undefined) { 347 | this.connection.send('Page.setDeviceMetricsOverride', { 348 | deviceScaleFactor: window.devicePixelRatio || 1, 349 | mobile: false, 350 | height: Math.floor(data.height), 351 | width: Math.floor(data.width), 352 | }) 353 | this.nextViewportSize = { 354 | height: data.height, 355 | width: data.width, 356 | } 357 | } 358 | 359 | break 360 | } 361 | } 362 | 363 | private async updateState(newState: Partial) { 364 | return new Promise((resolve) => { 365 | this.setState(newState as any, resolve) 366 | }) 367 | } 368 | 369 | private async handleInspectHighlightRequested(data: any) { 370 | const highlightNodeInfo: any = await this.connection.send( 371 | 'DOM.getNodeForLocation', 372 | { 373 | x: data.params.position.x, 374 | y: data.params.position.y, 375 | }, 376 | ) 377 | 378 | if (highlightNodeInfo) { 379 | let nodeId = highlightNodeInfo.nodeId 380 | 381 | if (!highlightNodeInfo.nodeId && highlightNodeInfo.backendNodeId) { 382 | nodeId = await this.cdpHelper.getNodeIdFromBackendId( 383 | highlightNodeInfo.backendNodeId, 384 | ) 385 | } 386 | 387 | this.setState({ 388 | viewportMetadata: { 389 | ...this.state.viewportMetadata, 390 | highlightNode: { 391 | nodeId, 392 | sourceMetadata: null, 393 | }, 394 | }, 395 | }) 396 | 397 | // await this.handleHighlightNodeClickType(); 398 | 399 | this.requestNodeHighlighting() 400 | } 401 | } 402 | 403 | private async resolveHighlightNodeSourceMetadata() { 404 | if (!this.state.viewportMetadata.highlightNode) 405 | return 406 | 407 | const nodeId = this.state.viewportMetadata.highlightNode.nodeId 408 | const nodeDetails: any = await this.connection.send('DOM.resolveNode', { 409 | nodeId, 410 | }) 411 | 412 | if (nodeDetails.object) { 413 | const objectId = nodeDetails.object.objectId 414 | const nodeProperties = await this.cdpHelper.resolveElementProperties( 415 | objectId, 416 | 3, 417 | ) 418 | 419 | if (nodeProperties) { 420 | const sourceMetadata = getElementSourceMetadata(nodeProperties) 421 | 422 | if (!sourceMetadata.fileName) 423 | return 424 | 425 | this.setState({ 426 | viewportMetadata: { 427 | ...this.state.viewportMetadata, 428 | highlightNode: { 429 | ...this.state.viewportMetadata.highlightNode, 430 | sourceMetadata: { 431 | fileName: sourceMetadata.fileName, 432 | columnNumber: sourceMetadata.columnNumber, 433 | lineNumber: sourceMetadata.lineNumber, 434 | charNumber: sourceMetadata.charNumber, 435 | }, 436 | }, 437 | }, 438 | }) 439 | } 440 | } 441 | } 442 | 443 | private async handleInspectElementRequest(data: any) { 444 | if (!this.state.viewportMetadata.highlightNode) 445 | return 446 | 447 | await this.resolveHighlightNodeSourceMetadata() 448 | 449 | const nodeId = this.state.viewportMetadata.highlightNode.nodeId 450 | 451 | // Trigger CDP request to enable DOM explorer 452 | // TODO: No sure this works. 453 | this.connection.send('Overlay.inspectNodeRequested', { 454 | nodeId, 455 | }) 456 | 457 | const sourceMetadata = this.state.viewportMetadata.highlightNode 458 | .sourceMetadata 459 | 460 | if (sourceMetadata) { 461 | this.connection.send('extension.openFile', { 462 | fileName: sourceMetadata.fileName, 463 | lineNumber: sourceMetadata.lineNumber, 464 | columnNumber: sourceMetadata.columnNumber, 465 | charNumber: sourceMetadata.charNumber, 466 | }) 467 | } 468 | } 469 | 470 | private onToolbarActionInvoked(action: string, data: any): Promise { 471 | switch (action) { 472 | case 'forward': 473 | this.connection.send('Page.goForward') 474 | break 475 | case 'backward': 476 | this.connection.send('Page.goBackward') 477 | break 478 | case 'refresh': 479 | this.connection.send('Page.reload') 480 | break 481 | case 'inspect': 482 | this.handleToggleInspect() 483 | break 484 | case 'emulateDevice': 485 | this.handleToggleDeviceEmulation() 486 | break 487 | case 'urlChange': 488 | this.handleNavigate(data.url) 489 | break 490 | case 'readClipboard': 491 | return this.connection.send('Clipboard.readText') 492 | case 'writeClipboard': 493 | this.handleClipboardWrite(data) 494 | break 495 | case 'viewportSizeChange': 496 | this.handleViewportSizeChange(data) 497 | break 498 | case 'viewportDeviceChange': 499 | this.handleViewportDeviceChange(data) 500 | break 501 | } 502 | // return an empty promise 503 | return Promise.resolve() 504 | } 505 | 506 | private handleToggleInspect() { 507 | if (this.state.isInspectEnabled) { 508 | // Hide browser highlight 509 | this.connection.send('Overlay.hideHighlight') 510 | 511 | // Hide local highlight 512 | this.updateState({ 513 | isInspectEnabled: false, 514 | viewportMetadata: { 515 | ...this.state.viewportMetadata, 516 | highlightInfo: null, 517 | highlightNode: null, 518 | }, 519 | }) 520 | } 521 | else { 522 | this.updateState({ 523 | isInspectEnabled: true, 524 | }) 525 | } 526 | } 527 | 528 | private async handleNavigate(url: string) { 529 | await this.handleSetUserAgent() 530 | const data: any = await this.connection.send('Page.navigate', { url }) 531 | this.setState({ url, errorText: data.errorText }) 532 | } 533 | 534 | private async handleSetUserAgent(userAgent: string = navigator.userAgent) { 535 | return this.connection.send('Network.setUserAgentOverride', { userAgent }) 536 | } 537 | 538 | private handleViewportSizeChange(data: any) { 539 | this.onViewportChanged('size', { 540 | width: data.width, 541 | height: data.height, 542 | }) 543 | } 544 | 545 | private handleViewportDeviceChange(data: any) { 546 | const isResizable = data.device.name === 'Responsive' 547 | const isFixedSize = data.device.name !== 'Responsive' 548 | const isFixedZoom = data.device.name === 'Responsive' 549 | const screenZoom = 1 550 | 551 | this.onViewportChanged('deviceChange', { 552 | emulatedDeviceId: data.device.name, 553 | isResizable, 554 | isFixedSize, 555 | isFixedZoom, 556 | screenZoom, 557 | }) 558 | 559 | if (data.device.viewport) { 560 | this.onViewportChanged('size', { 561 | width: data.device.viewport.width, 562 | height: data.device.viewport.height, 563 | }) 564 | } 565 | 566 | this.handleSetUserAgent(data.device.userAgent) 567 | } 568 | 569 | private handleToggleDeviceEmulation() { 570 | if (this.state.isDeviceEmulationEnabled) 571 | this.disableViewportDeviceEmulation() 572 | else 573 | this.enableViewportDeviceEmulation() 574 | } 575 | 576 | private disableViewportDeviceEmulation() { 577 | // console.log('app.disableViewportDeviceEmulation') 578 | this.handleViewportDeviceChange({ 579 | device: { 580 | name: 'Responsive', 581 | viewport: { 582 | width: this.state.viewportMetadata.width, 583 | height: this.state.viewportMetadata.height, 584 | }, 585 | }, 586 | }) 587 | this.updateState({ 588 | isDeviceEmulationEnabled: false, 589 | }) 590 | } 591 | 592 | private enableViewportDeviceEmulation(deviceName = 'Responsive') { 593 | // console.log('app.enableViewportDeviceEmulation') 594 | this.handleViewportDeviceChange({ 595 | device: { 596 | name: deviceName, 597 | viewport: { 598 | width: this.state.viewportMetadata.width, 599 | height: this.state.viewportMetadata.height, 600 | }, 601 | }, 602 | }) 603 | this.updateState({ 604 | isDeviceEmulationEnabled: true, 605 | }) 606 | } 607 | 608 | private handleClipboardWrite(data: any) { 609 | // overwrite the clipboard only if there is a valid value 610 | if (data && (data as any).value) 611 | return this.connection.send('Clipboard.writeText', data) 612 | } 613 | 614 | private async handleElementChanged(data: any) { 615 | const nodeInfo: any = await this.connection.send('DOM.getNodeForLocation', { 616 | x: data.params.position.x, 617 | y: data.params.position.y, 618 | }) 619 | 620 | const cursor = await this.cdpHelper.getCursorForNode(nodeInfo) 621 | 622 | this.setState({ 623 | viewportMetadata: { 624 | ...this.state.viewportMetadata, 625 | cursor, 626 | }, 627 | }) 628 | } 629 | 630 | private async requestNodeHighlighting() { 631 | if (this.state.viewportMetadata.highlightNode) { 632 | const nodeId = this.state.viewportMetadata.highlightNode.nodeId 633 | const highlightBoxModel: any = await this.connection.send( 634 | 'DOM.getBoxModel', 635 | { 636 | nodeId, 637 | }, 638 | ) 639 | 640 | // Trigger hightlight in regular browser. 641 | await this.connection.send('Overlay.highlightNode', { 642 | nodeId, 643 | highlightConfig: { 644 | showInfo: true, 645 | showStyles: true, 646 | showRulers: true, 647 | showExtensionLines: true, 648 | }, 649 | }) 650 | 651 | if (highlightBoxModel && highlightBoxModel.model) { 652 | this.setState({ 653 | viewportMetadata: { 654 | ...this.state.viewportMetadata, 655 | highlightInfo: highlightBoxModel.model, 656 | }, 657 | }) 658 | } 659 | } 660 | } 661 | } 662 | 663 | export default App 664 | -------------------------------------------------------------------------------- /client/components/contextmenu/contextmenu-models.ts: -------------------------------------------------------------------------------- 1 | export interface IContextMenu { 2 | menuItems: Array 3 | isVisible: boolean 4 | position: IPosition 5 | } 6 | 7 | export interface IContextMenuProps extends IContextMenu { 8 | setVisibility: (value: boolean) => void 9 | setUrl: (value: string) => void 10 | enterUrl: () => void 11 | selectUrl: (element?: HTMLInputElement) => void 12 | onActionInvoked: (action: string, data?: object) => Promise 13 | selectedUrlInput: string 14 | } 15 | 16 | export interface IContextMenuItemState { 17 | itemType: ContextMenuItemsType 18 | isDisabled?: boolean 19 | } 20 | 21 | export interface IContextMenuItemProps extends IContextMenuItemState { 22 | handleClick: (event: React.MouseEvent) => void 23 | setVisibility: (value: boolean) => void 24 | } 25 | 26 | export enum ContextMenuItemsType { 27 | Cut = 'Cut', 28 | Copy = 'Copy', 29 | Paste = 'Paste', 30 | PasteAndGo = 'Paste & Go', 31 | Delete = 'Delete', 32 | SelectAll = 'Select All', 33 | Seperator = '-', 34 | } 35 | 36 | export interface IPosition { 37 | x: number 38 | y: number 39 | } 40 | -------------------------------------------------------------------------------- /client/components/contextmenu/contextmenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { 3 | IContextMenu, 4 | IContextMenuProps, 5 | } from './contextmenu-models' 6 | import { 7 | ContextMenuItemsType, 8 | } from './contextmenu-models' 9 | import ContextMenuItem from './contextmenuItem' 10 | 11 | class ContextMenu extends React.Component { 12 | private ref?: HTMLUListElement 13 | constructor(props: IContextMenuProps) { 14 | super(props) 15 | this.state = { 16 | isVisible: false, 17 | position: { 18 | x: 0, 19 | y: 0, 20 | }, 21 | // assign menuItems 22 | menuItems: [ 23 | { 24 | itemType: ContextMenuItemsType.Cut, 25 | setVisibility: this.props.setVisibility, 26 | handleClick: e => this.CutHandler(e), 27 | }, 28 | { 29 | itemType: ContextMenuItemsType.Copy, 30 | setVisibility: this.props.setVisibility, 31 | handleClick: e => this.CopyHandler(e), 32 | }, 33 | { 34 | itemType: ContextMenuItemsType.Paste, 35 | setVisibility: this.props.setVisibility, 36 | handleClick: e => this.PasteHandler(e), 37 | }, 38 | { 39 | itemType: ContextMenuItemsType.PasteAndGo, 40 | setVisibility: this.props.setVisibility, 41 | handleClick: e => this.PasteGoHandler(e), 42 | }, 43 | { 44 | itemType: ContextMenuItemsType.Delete, 45 | setVisibility: this.props.setVisibility, 46 | handleClick: e => this.DeleteHandler(e), 47 | }, 48 | { 49 | itemType: ContextMenuItemsType.Seperator, 50 | setVisibility: this.props.setVisibility, 51 | handleClick: () => {}, 52 | }, 53 | { 54 | itemType: ContextMenuItemsType.SelectAll, 55 | setVisibility: this.props.setVisibility, 56 | handleClick: e => this.SelectAllHandler(e), 57 | }, 58 | ], 59 | } 60 | 61 | this.setRef = this.setRef.bind(this) 62 | this.handleClickOutside = this.handleClickOutside.bind(this) 63 | } 64 | 65 | UNSAFE_componentWillReceiveProps(nextProps: IContextMenuProps) { 66 | if ( 67 | nextProps.position.x !== this.state.position.x 68 | || nextProps.position.y !== this.state.position.y 69 | ) { 70 | this.setState({ 71 | position: nextProps.position, 72 | }) 73 | } 74 | if (nextProps.isVisible !== this.state.isVisible) { 75 | this.setState({ 76 | isVisible: nextProps.isVisible, 77 | }) 78 | this.manageMenuItemsStatus() 79 | } 80 | } 81 | 82 | componentDidMount() { 83 | document.addEventListener('mousedown', this.handleClickOutside) 84 | } 85 | 86 | componentWillUnmount() { 87 | document.removeEventListener('mousedown', this.handleClickOutside) 88 | } 89 | 90 | async manageMenuItemsStatus() { 91 | const _menuItems = [...this.state.menuItems] 92 | 93 | // Cut 94 | const cut = this.state.menuItems.find( 95 | x => x.itemType === ContextMenuItemsType.Cut, 96 | ) 97 | const cutIndex = this.state.menuItems.findIndex( 98 | x => x.itemType === ContextMenuItemsType.Cut, 99 | ) 100 | 101 | // Copy 102 | const copy = this.state.menuItems.find( 103 | x => x.itemType === ContextMenuItemsType.Copy, 104 | ) 105 | const copyIndex = this.state.menuItems.findIndex( 106 | x => x.itemType === ContextMenuItemsType.Copy, 107 | ) 108 | 109 | // Delete 110 | const deleteItem = this.state.menuItems.find( 111 | x => x.itemType === ContextMenuItemsType.Delete, 112 | ) 113 | const deleteItemIndex = this.state.menuItems.findIndex( 114 | x => x.itemType === ContextMenuItemsType.Delete, 115 | ) 116 | const length = window?.getSelection()?.toString()?.length 117 | if (length && length > 0) { 118 | if (cut) { 119 | _menuItems[cutIndex].isDisabled = false 120 | this.setState({ menuItems: _menuItems }) 121 | } 122 | 123 | if (copy) { 124 | _menuItems[copyIndex].isDisabled = false 125 | this.setState({ menuItems: _menuItems }) 126 | } 127 | 128 | if (deleteItem) { 129 | _menuItems[deleteItemIndex].isDisabled = false 130 | this.setState({ menuItems: _menuItems }) 131 | } 132 | } 133 | else { 134 | if (cut) { 135 | _menuItems[cutIndex].isDisabled = true 136 | this.setState({ menuItems: _menuItems }) 137 | } 138 | 139 | if (copy) { 140 | _menuItems[copyIndex].isDisabled = true 141 | this.setState({ menuItems: _menuItems }) 142 | } 143 | 144 | if (deleteItem) { 145 | _menuItems[deleteItemIndex].isDisabled = true 146 | this.setState({ menuItems: _menuItems }) 147 | } 148 | } 149 | 150 | // Paste 151 | const paste = this.state.menuItems.find( 152 | x => x.itemType === ContextMenuItemsType.Paste, 153 | ) 154 | const pasteIndex = this.state.menuItems.findIndex( 155 | x => x.itemType === ContextMenuItemsType.Paste, 156 | ) 157 | 158 | // Paste & Go 159 | const pasteGo = this.state.menuItems.find( 160 | x => x.itemType === ContextMenuItemsType.PasteAndGo, 161 | ) 162 | const pasteGoIndex = this.state.menuItems.findIndex( 163 | x => x.itemType === ContextMenuItemsType.PasteAndGo, 164 | ) 165 | if ( 166 | this.props.onActionInvoked 167 | && (await this.props.onActionInvoked('readClipboard')) 168 | ) { 169 | if (paste) { 170 | _menuItems[pasteIndex].isDisabled = false 171 | this.setState({ menuItems: _menuItems }) 172 | } 173 | 174 | if (pasteGo) { 175 | _menuItems[pasteGoIndex].isDisabled = false 176 | this.setState({ menuItems: _menuItems }) 177 | } 178 | } 179 | else { 180 | if (paste) { 181 | _menuItems[pasteIndex].isDisabled = true 182 | this.setState({ menuItems: _menuItems }) 183 | } 184 | 185 | if (pasteGo) { 186 | _menuItems[pasteGoIndex].isDisabled = true 187 | this.setState({ menuItems: _menuItems }) 188 | } 189 | } 190 | } 191 | 192 | public render() { 193 | const className = ['contextMenu'] 194 | if (!this.state.isVisible) 195 | className.push('hidden') 196 | 197 | const menuStyle = { 198 | left: this.state.position.x, 199 | top: this.state.position.y, 200 | } 201 | 202 | return ( 203 |
    204 | {this.state.menuItems.map((item, index) => { 205 | return 206 | })} 207 |
208 | ) 209 | } 210 | 211 | private setRef(node: HTMLUListElement) { 212 | this.ref = node 213 | } 214 | 215 | private handleClickOutside(e: MouseEvent) { 216 | if (this.ref && !this.ref.contains(e.target as Node)) 217 | this.props.setVisibility(false) 218 | } 219 | 220 | private async CutHandler(event: React.MouseEvent) { 221 | if (this.props.onActionInvoked && this.props.selectedUrlInput) { 222 | await this.props.onActionInvoked('writeClipboard', { 223 | value: this.props.selectedUrlInput, 224 | }) 225 | this.props.setUrl('') 226 | } 227 | } 228 | 229 | private async CopyHandler(event: React.MouseEvent) { 230 | if (this.props.onActionInvoked && this.props.selectedUrlInput) { 231 | await this.props.onActionInvoked('writeClipboard', { 232 | value: this.props.selectedUrlInput, 233 | }) 234 | } 235 | } 236 | 237 | private async PasteHandler(event: React.MouseEvent) { 238 | if (this.props.onActionInvoked) { 239 | const value: string = await this.props.onActionInvoked('readClipboard') 240 | if (value) 241 | this.props.setUrl(value) 242 | } 243 | } 244 | 245 | private async PasteGoHandler(event: React.MouseEvent) { 246 | if (this.props.onActionInvoked && this.props.enterUrl) { 247 | const value: string = await this.props.onActionInvoked('readClipboard') 248 | if (value) { 249 | this.props.setUrl(value) 250 | this.props.enterUrl() 251 | } 252 | } 253 | } 254 | 255 | private async DeleteHandler(event: React.MouseEvent) { 256 | if (this.props.selectedUrlInput) 257 | this.props.setUrl('') 258 | } 259 | 260 | private async SelectAllHandler(event: React.MouseEvent) { 261 | if (this.props.selectUrl) 262 | this.props.selectUrl() 263 | } 264 | } 265 | 266 | export default ContextMenu 267 | -------------------------------------------------------------------------------- /client/components/contextmenu/contextmenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { 3 | IContextMenuItemProps, 4 | IContextMenuItemState, 5 | } from './contextmenu-models' 6 | import { 7 | ContextMenuItemsType, 8 | } from './contextmenu-models' 9 | 10 | class ContextMenuItem extends React.Component< 11 | IContextMenuItemProps, 12 | IContextMenuItemState 13 | > { 14 | constructor(props: IContextMenuItemProps) { 15 | super(props) 16 | this.state = { 17 | itemType: this.props.itemType, 18 | isDisabled: this.props.isDisabled || false, 19 | } 20 | 21 | this.handleClick = this.handleClick.bind(this) 22 | } 23 | 24 | UNSAFE_componentWillReceiveProps(nextProps: IContextMenuItemProps) { 25 | if (nextProps.isDisabled !== this.state.isDisabled) { 26 | this.setState({ 27 | isDisabled: nextProps.isDisabled, 28 | }) 29 | } 30 | } 31 | 32 | private handleClick(event: React.MouseEvent) { 33 | this.props.handleClick(event) 34 | this.props.setVisibility(false) 35 | } 36 | 37 | public render() { 38 | const className = ['contextMenuItem'] 39 | if (this.state.isDisabled) 40 | className.push('disabled') 41 | 42 | if (this.state.itemType === ContextMenuItemsType.Seperator) { 43 | className.push('disabled') 44 | className.push('separator') 45 | } 46 | 47 | if (this.state.itemType === ContextMenuItemsType.Seperator) { 48 | return
  • 49 | } 50 | else { 51 | return ( 52 |
  • 53 | {this.state.itemType} 54 |
  • 55 | ) 56 | } 57 | } 58 | } 59 | 60 | export default ContextMenuItem 61 | -------------------------------------------------------------------------------- /client/components/device-settings/device-settings.css: -------------------------------------------------------------------------------- 1 | .device-settings { 2 | display: none; 3 | padding: 8px; 4 | } 5 | 6 | .device-settings * { 7 | vertical-align: middle; 8 | } 9 | 10 | .device-settings.active { 11 | display: block; 12 | } 13 | 14 | .device-selector { 15 | height: 29px; 16 | margin: 0 8px; 17 | padding: 6px; 18 | } 19 | 20 | input::-webkit-outer-spin-button, 21 | input::-webkit-inner-spin-button { 22 | -webkit-appearance: none; 23 | margin: 0; 24 | } 25 | 26 | .viewport-size-input { 27 | max-width: 50px; 28 | height: 29px; 29 | box-sizing: border-box; 30 | text-align: center; 31 | outline: 0; 32 | -moz-appearance: textfield; 33 | } 34 | 35 | .metadata .spacer { 36 | padding: 0 2px; 37 | } 38 | -------------------------------------------------------------------------------- /client/components/device-settings/device-settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // @ts-expect-error 4 | import devices from 'browser-viewport-device-descriptions' 5 | import './device-settings.css' 6 | 7 | class DeviceSettings extends React.Component { 8 | private emulatedDevices: any[] 9 | private viewportMetadata: any 10 | 11 | constructor(props: any) { 12 | super(props) 13 | 14 | this.handleWidthChange = this.handleWidthChange.bind(this) 15 | this.handleHeightChange = this.handleHeightChange.bind(this) 16 | this.handleDeviceChange = this.handleDeviceChange.bind(this) 17 | 18 | this.emulatedDevices = [ 19 | { name: 'Responsive', userAgent: '', viewport: [] }, 20 | { 21 | name: 'Macbook 15', 22 | userAgent: '', 23 | viewport: { 24 | width: 1440, 25 | height: 900, 26 | }, 27 | }, 28 | { 29 | name: 'Macbook 13', 30 | userAgent: '', 31 | viewport: { 32 | width: 1280, 33 | height: 800, 34 | }, 35 | }, 36 | ] 37 | .concat(devices) 38 | .filter((d: any) => !d.viewport.isLandscape) 39 | } 40 | 41 | public render() { 42 | this.viewportMetadata = this.props.viewportMetadata 43 | 44 | const selectedDevice = this.viewportMetadata.emulatedDeviceId || '' 45 | 46 | const procentageZoom = Math.round(this.viewportMetadata.screenZoom * 100) 47 | const zoomLevels = [ 48 | { label: `Fit (${procentageZoom}%)`, value: 'fit' }, 49 | // { label: '50%', value: '0.5' }, 50 | // { label: '75%', value: '0.75' }, 51 | // { label: '100%', value: '1' }, 52 | // { label: '125%', value: '1.25' }, 53 | // { label: '150%', value: '1.50' } 54 | ] 55 | 56 | const viewportHeight = this.viewportMetadata.height | 0 57 | const viewportWidth = this.viewportMetadata.width | 0 58 | 59 | // console.log(this.viewportMetadata, selectedDevice) 60 | 61 | return ( 62 |
    63 | 72 | 73 | 74 | 81 | 𝗑 82 | 89 | 90 | 91 | 100 |
    101 | ) 102 | } 103 | 104 | private handleDeviceChange(e: React.ChangeEvent) { 105 | const deviceName = e.target.value 106 | const device = this.emulatedDevices.find((d: any) => d.name == deviceName) 107 | 108 | this.props.onDeviceChange(device) 109 | } 110 | 111 | private handleHeightChange(e: React.ChangeEvent) { 112 | const newVal = Number.parseInt(e.target.value) 113 | 114 | this.props.onViewportSizeChange({ 115 | height: newVal, 116 | width: this.props.width, 117 | }) 118 | } 119 | 120 | private handleWidthChange(e: React.ChangeEvent) { 121 | const newVal = Number.parseInt(e.target.value) 122 | 123 | this.props.onViewportSizeChange({ 124 | width: newVal, 125 | height: this.props.height, 126 | }) 127 | } 128 | } 129 | 130 | export default DeviceSettings 131 | -------------------------------------------------------------------------------- /client/components/error-page/error-page.css: -------------------------------------------------------------------------------- 1 | .error-page .title { 2 | font-size: 1.4em; 3 | } 4 | 5 | .error-page .icon { 6 | font-size: 5em; 7 | } 8 | 9 | .error-page .message { 10 | opacity: 0.8; 11 | font-size: 1.2em; 12 | margin-top: 4px; 13 | margin-bottom: 20px; 14 | } 15 | -------------------------------------------------------------------------------- /client/components/error-page/error-page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './error-page.css' 3 | 4 | export function UimExclamationCircle(props: React.SVGProps) { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | 14 | export function ErrorPage(props: { 15 | errorText?: string 16 | onActionInvoked?: any 17 | } = {}) { 18 | function handleRefresh() { 19 | props.onActionInvoked('refresh', {}) 20 | } 21 | 22 | return ( 23 |
    24 | 25 |
    Failed to load the page
    26 |
    { props.errorText || null }
    27 | 33 |
    34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /client/components/loading-bar/loading-bar.css: -------------------------------------------------------------------------------- 1 | .loading-bar { 2 | pointer-events: none; 3 | transition: 400ms linear all; 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | right: 0; 8 | } 9 | 10 | .bar { 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | z-index: 1000; 15 | display: none; 16 | width: 100%; 17 | height: 1px; 18 | background: var(--vscode-progressBar-background); 19 | border-radius: 0 1px 1px 0; 20 | transition: width 350ms; 21 | } 22 | -------------------------------------------------------------------------------- /client/components/loading-bar/loading-bar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './loading-bar.css' 3 | 4 | interface ILoadingBarState { 5 | percent: number 6 | } 7 | 8 | class LoadingBar extends React.Component { 9 | constructor(props: any) { 10 | super(props) 11 | } 12 | 13 | public render() { 14 | return ( 15 |
    16 |
    17 |
    18 |
    19 | ) 20 | } 21 | 22 | private getBarStyle() { 23 | const { percent } = this.props 24 | 25 | return { 26 | display: percent > 0 ? 'block' : 'none', 27 | width: `${percent * 100}%`, 28 | } 29 | } 30 | } 31 | 32 | export default LoadingBar 33 | -------------------------------------------------------------------------------- /client/components/screencast/screencast.css: -------------------------------------------------------------------------------- 1 | img.screencast { 2 | -webkit-backface-visibility: hidden; 3 | -webkit-transform: translateZ(0); /* Chrome, Safari, Opera */ 4 | transform: translateZ(0); 5 | image-rendering: -moz-crisp-edges; /* Firefox */ 6 | image-rendering: -o-crisp-edges; /* Opera */ 7 | image-rendering: -webkit-optimize-contrast;/* Webkit (non-standard naming) */ 8 | image-rendering: crisp-edges; 9 | backface-visibility: hidden; 10 | outline: none; 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | max-height: unset !important; 15 | max-width: unset !important; 16 | } 17 | -------------------------------------------------------------------------------- /client/components/screencast/screencast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './screencast.css' 3 | 4 | // This implementation is heavily inspired by https://cs.chromium.org/chromium/src/third_party/blink/renderer/devtools/front_end/screencast/ScreencastView.js 5 | 6 | class Screencast extends React.Component { 7 | private canvasRef: React.RefObject 8 | private imageRef: React.RefObject 9 | private frameId: number | null 10 | 11 | constructor(props: any) { 12 | super(props) 13 | this.canvasRef = React.createRef() 14 | this.imageRef = React.createRef() 15 | this.frameId = null 16 | 17 | this.handleMouseEvent = this.handleMouseEvent.bind(this) 18 | this.handleKeyEvent = this.handleKeyEvent.bind(this) 19 | this.renderLoop = this.renderLoop.bind(this) 20 | 21 | this.state = { 22 | imageZoom: 1, 23 | screenOffsetTop: 0, 24 | } 25 | } 26 | 27 | static getDerivedStateFromProps(nextProps: any, prevState: any) { 28 | if (nextProps.frame !== prevState.frame) { 29 | return { 30 | frame: nextProps.frame, 31 | } 32 | } 33 | else { return null } 34 | } 35 | 36 | public componentDidMount() { 37 | this.startLoop() 38 | } 39 | 40 | public componentWillUnmount() { 41 | this.stopLoop() 42 | } 43 | 44 | public startLoop() { 45 | if (!this.frameId) 46 | this.frameId = window.requestAnimationFrame(this.renderLoop) 47 | } 48 | 49 | public stopLoop() { 50 | if (this.frameId) 51 | window.cancelAnimationFrame(this.frameId) 52 | } 53 | 54 | public renderLoop() { 55 | this.frameId = window.requestAnimationFrame(this.renderLoop) // Set up next iteration of the loop 56 | } 57 | 58 | public render() { 59 | const canvasStyle = { 60 | cursor: this.props.viewportMetadata?.cursor || 'auto', 61 | } 62 | const base64Data = this.props.frame?.base64Data 63 | const format = this.props.format 64 | 65 | return ( 66 | 84 | ) 85 | } 86 | 87 | private handleContextMenu(event: React.MouseEvent) { 88 | event.preventDefault() 89 | } 90 | 91 | private handleMouseEvent(event: React.MouseEvent) { 92 | event.stopPropagation() 93 | if (this.props.isInspectEnabled) { 94 | if (event.type === 'click') { 95 | const position = this.convertIntoScreenSpace(event, this.state) 96 | this.props.onInspectElement({ 97 | position, 98 | }) 99 | } 100 | else if (event.type === 'mousemove') { 101 | const position = this.convertIntoScreenSpace(event, this.state) 102 | this.props.onInspectHighlightRequested({ 103 | position, 104 | }) 105 | } 106 | } 107 | else { 108 | this.dispatchMouseEvent(event.nativeEvent) 109 | } 110 | 111 | if (event.type === 'mousemove') { 112 | const position = this.convertIntoScreenSpace(event, this.state) 113 | this.props.onMouseMoved({ 114 | position, 115 | }) 116 | } 117 | 118 | if (event.type === 'mousedown') { 119 | if (this.canvasRef.current) 120 | this.canvasRef.current.focus() 121 | } 122 | } 123 | 124 | private convertIntoScreenSpace(event: any, state: any) { 125 | let screenOffsetTop = 0 126 | if (this.canvasRef && this.canvasRef.current) 127 | screenOffsetTop = this.canvasRef.current.getBoundingClientRect().top 128 | 129 | const { screenZoom } = this.props.viewportMetadata 130 | const { scrollOffsetX, scrollOffsetY } = this.props.frame.metadata 131 | 132 | return { 133 | x: Math.round(event.clientX / screenZoom + scrollOffsetX), 134 | y: Math.round(event.clientY / screenZoom - screenOffsetTop + scrollOffsetY), 135 | } 136 | } 137 | 138 | private handleKeyEvent(event: React.KeyboardEvent) { 139 | // Prevents events from penetrating into toolbar input 140 | event.stopPropagation() 141 | this.emitKeyEvent(event.nativeEvent) 142 | 143 | if (event.key === 'Tab') 144 | event.preventDefault() 145 | 146 | if (this.canvasRef.current) 147 | this.canvasRef.current.focus() 148 | } 149 | 150 | private modifiersForEvent(event: any) { 151 | return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0) 152 | } 153 | 154 | private readonly isMac: boolean = /macintosh|mac os x/i.test(navigator.userAgent) 155 | 156 | private readonly clipboardMockMap = new Map([ 157 | ['KeyC', 'document.dispatchEvent(new ClipboardEvent("copy"))'], 158 | ['KeyX', 'document.execCommand("cut")'], // 'document.dispatchEvent(new ClipboardEvent("cut"))', 159 | ['KeyV', 'document.dispatchEvent(new ClipboardEvent("paste"))'], 160 | ]) 161 | 162 | private emitKeyEvent(event: any) { 163 | // HACK Simulate macos keyboard event. 164 | if (this.isMac && event.metaKey && this.clipboardMockMap.has(event.code)) { 165 | this.props.onInteraction('Runtime.evaluate', { expression: this.clipboardMockMap.get(event.code) }) 166 | return 167 | } 168 | 169 | let type 170 | switch (event.type) { 171 | case 'keydown': 172 | type = 'keyDown' 173 | break 174 | case 'keyup': 175 | type = 'keyUp' 176 | break 177 | case 'keypress': 178 | type = 'char' 179 | break 180 | default: 181 | return 182 | } 183 | 184 | const text = event.type === 'keypress' ? String.fromCharCode(event.charCode) : undefined 185 | const params = { 186 | type, 187 | modifiers: this.modifiersForEvent(event), 188 | text, 189 | unmodifiedText: text ? text.toLowerCase() : undefined, 190 | keyIdentifier: event.keyIdentifier, 191 | code: event.code, 192 | key: event.key, 193 | windowsVirtualKeyCode: event.keyCode, 194 | nativeVirtualKeyCode: event.keyCode, 195 | autoRepeat: false, 196 | isKeypad: false, 197 | isSystemKey: false, 198 | } 199 | 200 | this.props.onInteraction('Input.dispatchKeyEvent', params) 201 | } 202 | 203 | private dispatchMouseEvent(event: any) { 204 | let clickCount = 0 205 | const buttons = { 0: 'none', 1: 'left', 2: 'middle', 3: 'right' } 206 | const types = { 207 | mousedown: 'mousePressed', 208 | mouseup: 'mouseReleased', 209 | mousemove: 'mouseMoved', 210 | wheel: 'mouseWheel', 211 | } 212 | 213 | if (!(event.type in types)) 214 | return 215 | 216 | const { screenZoom } = this.props.viewportMetadata 217 | 218 | const x = Math.round(event.offsetX / screenZoom) 219 | const y = Math.round(event.offsetY / screenZoom) 220 | 221 | const type = (types as any)[event.type] 222 | 223 | if (type == 'mousePressed' || type == 'mouseReleased') 224 | clickCount = 1 225 | 226 | const params = { 227 | type, 228 | x, 229 | y, 230 | modifiers: this.modifiersForEvent(event), 231 | button: (buttons as any)[event.which], 232 | clickCount, 233 | deltaX: 0, 234 | deltaY: 0, 235 | } 236 | 237 | if (type === 'mouseWheel') { 238 | params.deltaX = event.deltaX / screenZoom 239 | params.deltaY = event.deltaY / screenZoom 240 | } 241 | 242 | this.props.onInteraction('Input.dispatchMouseEvent', params) 243 | } 244 | } 245 | 246 | export default Screencast 247 | -------------------------------------------------------------------------------- /client/components/toolbar/toolbar.css: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | background: var(--vscode-tab-activeBackground); 3 | padding: 6px 6px 5px 6px; 4 | border-bottom: 1px solid var(--vscode-tab-border); 5 | box-shadow: -1px 1px 2px rgba(0, 0, 0, 0.1); 6 | } 7 | 8 | .inner { 9 | display: flex; 10 | } 11 | 12 | button { 13 | cursor: pointer !important; 14 | background: transparent; 15 | transition: background-color 0.2s ease-in-out; 16 | padding: 0 5px; 17 | color: var(--vscode-tab-activeForeground); 18 | outline: none; 19 | border: none; 20 | font-size: 21px; 21 | border-radius: 2px; 22 | cursor: pointer; 23 | 24 | height: 29px; 25 | width: 29px; 26 | } 27 | 28 | button svg { 29 | display: block; 30 | font-size: 0.85em; 31 | } 32 | 33 | button:not([disabled]):active:hover { 34 | background-color: var(--vscode-button-secondaryBackground); 35 | } 36 | 37 | button:not([disabled]):hover, 38 | button:not([disabled]):active { 39 | background-color: var(--vscode-button-secondaryBackground); 40 | } 41 | 42 | button:disabled { 43 | opacity: 0.1; 44 | } 45 | 46 | button.active { 47 | background-color: var(--vscode-button-secondaryBackground); 48 | } 49 | -------------------------------------------------------------------------------- /client/components/toolbar/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './toolbar.css' 3 | 4 | import UrlInput from '../url-input/url-input' 5 | import DeviceSettings from '../device-settings/device-settings' 6 | 7 | export function CarbonArrowLeft(props: React.SVGProps) { 8 | return ( 9 | 10 | ) 11 | } 12 | 13 | export function CarbonArrowRight(props: React.SVGProps) { 14 | return ( 15 | 16 | ) 17 | } 18 | 19 | export function CarbonRenew(props: React.SVGProps) { 20 | return ( 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | export function CarbonDevices(props: React.SVGProps) { 29 | return ( 30 | 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | interface IToolbarProps { 38 | canGoBack: boolean 39 | canGoForward: boolean 40 | isInspectEnabled: boolean 41 | isDeviceEmulationEnabled: boolean 42 | url: string 43 | viewport: any 44 | onActionInvoked: (action: string, data?: object) => Promise 45 | } 46 | 47 | class Toolbar extends React.Component { 48 | private viewportMetadata: any 49 | 50 | constructor(props: any) { 51 | super(props) 52 | 53 | this.handleBack = this.handleBack.bind(this) 54 | this.handleForward = this.handleForward.bind(this) 55 | this.handleRefresh = this.handleRefresh.bind(this) 56 | this.handleUrlChange = this.handleUrlChange.bind(this) 57 | this.handleInspect = this.handleInspect.bind(this) 58 | this.handleEmulateDevice = this.handleEmulateDevice.bind(this) 59 | this.handleDeviceChange = this.handleDeviceChange.bind(this) 60 | this.handleViewportSizeChange = this.handleViewportSizeChange.bind(this) 61 | } 62 | 63 | public render() { 64 | this.viewportMetadata = this.props.viewport 65 | 66 | return ( 67 |
    68 |
    69 | {/* */} 76 | 83 | 90 | 96 | 101 | 108 |
    109 | 115 |
    116 | ) 117 | } 118 | 119 | private handleUrlChange(url: string) { 120 | this.props.onActionInvoked('urlChange', { url }) 121 | } 122 | 123 | private handleBack() { 124 | this.props.onActionInvoked('backward', {}) 125 | } 126 | 127 | private handleForward() { 128 | this.props.onActionInvoked('forward', {}) 129 | } 130 | 131 | private handleRefresh() { 132 | this.props.onActionInvoked('refresh', {}) 133 | } 134 | 135 | private handleInspect() { 136 | this.props.onActionInvoked('inspect', {}) 137 | } 138 | 139 | private handleEmulateDevice() { 140 | this.props.onActionInvoked('emulateDevice', {}) 141 | } 142 | 143 | private handleViewportSizeChange(viewportSize: any) { 144 | this.props.onActionInvoked('viewportSizeChange', { 145 | height: viewportSize.height, 146 | width: viewportSize.width, 147 | }) 148 | } 149 | 150 | private handleDeviceChange(device: any) { 151 | this.props.onActionInvoked('viewportDeviceChange', { 152 | device, 153 | }) 154 | } 155 | } 156 | 157 | export default Toolbar 158 | -------------------------------------------------------------------------------- /client/components/url-input/url-input.css: -------------------------------------------------------------------------------- 1 | .urlbar { 2 | display: flex; 3 | flex: 1; 4 | user-select: all; 5 | margin: 0 4px 0 4px; 6 | padding: 0 6px; 7 | width: 100%; 8 | height: 29px; 9 | } 10 | 11 | input, select { 12 | color: var(--vscode-input-foreground); 13 | border: 0; 14 | outline: none !important; 15 | border-radius: 2px; 16 | background: var(--vscode-input-background); 17 | border: 1px solid var(--vscode-input-border); 18 | color: var(--vscode-input-foreground); 19 | overflow: hidden; 20 | } 21 | 22 | input:focus, select:focus, input:active, select:active { 23 | border-color: var(--vscode-inputOption-activeBorder); 24 | color: var(--vscode-inputOption-activeForeground); 25 | } 26 | 27 | .contextMenu { 28 | display: flex; 29 | flex-direction: column; 30 | position: absolute; 31 | left: 0; 32 | top: 0; 33 | background: #fff; 34 | border: 1px solid #ccc; 35 | border-radius: 4px; 36 | color: #484848; 37 | padding: 0; 38 | margin: 0; 39 | z-index: 1100; 40 | width: 150px; 41 | list-style-type: none; 42 | } 43 | 44 | .contextMenuItem { 45 | border: 0; 46 | outline: 0; 47 | padding: 5px 10px 5px 10px; 48 | text-align: left; 49 | vertical-align: middle; 50 | cursor: default; 51 | } 52 | 53 | .contextMenuItem:hover { 54 | background-color: #ebebeb; 55 | } 56 | 57 | .disabled { 58 | pointer-events: none; 59 | opacity: 0.5; 60 | } 61 | 62 | .hidden { 63 | display: none; 64 | } 65 | .separator { 66 | background-color: #ccc; 67 | height: 1px; 68 | padding: 0; 69 | margin: 3px 0 3px 0; 70 | } 71 | -------------------------------------------------------------------------------- /client/components/url-input/url-input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ContextMenu from '../contextmenu/contextmenu' 3 | import type { IContextMenuProps } from '../contextmenu/contextmenu-models' 4 | import './url-input.css' 5 | 6 | interface IUrlInputState { 7 | isFocused: boolean 8 | hasChanged: boolean 9 | url: string 10 | urlSelectionStart: number | null 11 | urlSelectionEnd: number | null 12 | contextMenuProps: IContextMenuProps 13 | } 14 | 15 | class UrlInput extends React.Component { 16 | private ref?: HTMLInputElement 17 | constructor(props: any) { 18 | super(props) 19 | this.state = { 20 | hasChanged: false, 21 | isFocused: false, 22 | url: this.props.url, 23 | urlSelectionStart: 0, 24 | urlSelectionEnd: 0, 25 | contextMenuProps: { 26 | menuItems: [], 27 | isVisible: false, 28 | position: { x: 0, y: 0 }, 29 | setVisibility: this.setVisibility.bind(this), 30 | setUrl: this.setUrl.bind(this), 31 | enterUrl: this.enterUrl.bind(this), 32 | selectUrl: this.selectUrl.bind(this), 33 | onActionInvoked: this.props.onActionInvoked, 34 | selectedUrlInput: '', 35 | }, 36 | } 37 | 38 | this.handleChange = this.handleChange.bind(this) 39 | this.handleFocus = this.handleFocus.bind(this) 40 | this.handleBlur = this.handleBlur.bind(this) 41 | this.handleKeyDown = this.handleKeyDown.bind(this) 42 | this.handleContextMenu = this.handleContextMenu.bind(this) 43 | 44 | this.setRef = this.setRef.bind(this) 45 | } 46 | 47 | UNSAFE_componentWillReceiveProps(nextProps: any) { 48 | if (nextProps.url !== this.state.url && !this.state.hasChanged) { 49 | this.setState({ 50 | url: nextProps.url, 51 | }) 52 | } 53 | } 54 | 55 | // changing ContextMenu visibility status from child components 56 | public setVisibility(value: boolean) { 57 | this.setState({ 58 | contextMenuProps: { 59 | ...this.state.contextMenuProps, 60 | isVisible: value, 61 | }, 62 | }) 63 | } 64 | 65 | // changing url from child components 66 | public setUrl(value: string) { 67 | // if selectionStart and selectionEnd are available, then we have to 68 | // only modify that part of the url 69 | let newCursorPosition: number | null = null 70 | if (this.state.urlSelectionStart && this.state.urlSelectionEnd) { 71 | const _url: string = this.state.url 72 | const firstPart: string = _url.slice(0, this.state.urlSelectionStart) 73 | const secondPart: string = _url.slice(this.state.urlSelectionEnd) 74 | 75 | // set newCursorPosition 76 | newCursorPosition = (firstPart + value).length 77 | 78 | value = firstPart + value + secondPart 79 | } 80 | else if (this.state.urlSelectionStart) { 81 | const _url: string = this.state.url 82 | const firstPart: string = _url.slice(0, this.state.urlSelectionStart) 83 | const secondPart: string = _url.slice(this.state.urlSelectionStart) 84 | 85 | // set newCursorPosition 86 | newCursorPosition = (firstPart + value).length 87 | 88 | value = firstPart + value + secondPart 89 | } 90 | 91 | if (value !== this.state.url) { 92 | this.setState({ 93 | url: value, 94 | hasChanged: true, 95 | isFocused: true, 96 | }) 97 | 98 | // set urlCursorPosition 99 | if (this.ref && newCursorPosition) { 100 | this.ref.focus() 101 | this.ref.setSelectionRange(newCursorPosition, newCursorPosition) 102 | } 103 | } 104 | } 105 | 106 | public render() { 107 | return ( 108 | <> 109 | 120 | 121 | 122 | ) 123 | } 124 | 125 | private setRef(node: HTMLInputElement) { 126 | this.ref = node 127 | } 128 | 129 | private handleChange(e: React.ChangeEvent) { 130 | this.setState({ 131 | url: e.target.value, 132 | hasChanged: true, 133 | }) 134 | } 135 | 136 | private handleFocus(e: React.FocusEvent) { 137 | this.selectUrl(e.target) 138 | } 139 | 140 | // select all url from child components 141 | private selectUrl(element?: HTMLInputElement) { 142 | if (!element && this.ref) 143 | element = this.ref 144 | 145 | if (element) { 146 | element.select() 147 | this.setState({ 148 | isFocused: true, 149 | }) 150 | } 151 | } 152 | 153 | private handleBlur(e: React.FocusEvent) { 154 | this.setState({ 155 | isFocused: false, 156 | }) 157 | } 158 | 159 | private handleContextMenu(e: React.MouseEvent) { 160 | e.preventDefault() 161 | this.setState({ 162 | urlSelectionStart: (e.currentTarget as HTMLInputElement).selectionStart, 163 | urlSelectionEnd: (e.currentTarget as HTMLInputElement).selectionEnd, 164 | contextMenuProps: { 165 | ...this.state.contextMenuProps, 166 | isVisible: true, 167 | position: { 168 | x: e.clientX, 169 | y: e.clientY, 170 | }, 171 | selectedUrlInput: this.state.isFocused 172 | ? window?.getSelection()?.toString() || '' 173 | : '', 174 | }, 175 | }) 176 | } 177 | 178 | private enterUrl() { 179 | let url = this.state.url.trimLeft() 180 | const schemeRegex = /^(https?|about|chrome|file):/ 181 | 182 | if (!url.match(schemeRegex)) 183 | url = `http://${this.state.url}` 184 | 185 | this.setState({ 186 | hasChanged: false, 187 | }) 188 | 189 | this.props.onUrlChanged(url) 190 | } 191 | 192 | private handleKeyDown(e: React.KeyboardEvent) { 193 | if (e.keyCode === 13) { 194 | // Enter 195 | this.enterUrl() 196 | } 197 | } 198 | } 199 | 200 | export default UrlInput 201 | -------------------------------------------------------------------------------- /client/components/viewport/viewport.css: -------------------------------------------------------------------------------- 1 | .viewport { 2 | display: flex; 3 | background: var(---vscode-background); 4 | position: relative; 5 | overflow: hidden; 6 | box-sizing: border-box; 7 | height: 100%; 8 | width: 100%; 9 | user-select: none; 10 | 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .viewport-resizer { 16 | background: var(--vscode-button-secondaryBackground); 17 | } 18 | 19 | .viewport-resizer:hover { 20 | background-color: var(--vscode-button-secondaryHoverBackground); 21 | } 22 | 23 | .resizer-top { 24 | left: 0 !important; 25 | top: -10px !important; 26 | } 27 | 28 | .resizer-top:before { 29 | content: ''; 30 | position: absolute; 31 | top: calc(50% + 1px); 32 | left: calc(50% - 12px); 33 | height: 2px; 34 | width: 24px; 35 | background: rgba(0, 0, 0, 0.4); 36 | } 37 | 38 | .resizer-top:after { 39 | content: ''; 40 | position: absolute; 41 | top: calc(50% - 3px); 42 | left: calc(50% - 12px); 43 | height: 2px; 44 | width: 24px; 45 | background: rgba(0, 0, 0, 0.4); 46 | } 47 | 48 | .resizer-top-left { 49 | width: 10px !important; 50 | height: 10px !important; 51 | z-index: 2; 52 | left: -10px !important; 53 | top: -10px !important; 54 | } 55 | 56 | .resizer-top-left:before { 57 | content: ''; 58 | position: absolute; 59 | left: 2px; 60 | bottom: 2px; 61 | width: 5px; 62 | height: 5px; 63 | border-left: 2px solid rgba(0, 0, 0, 0.4); 64 | border-top: 2px solid rgba(0, 0, 0, 0.4); 65 | } 66 | 67 | .resizer-top-right { 68 | width: 10px !important; 69 | height: 10px !important; 70 | z-index: 2; 71 | right: -10px !important; 72 | top: -10px !important; 73 | } 74 | 75 | .resizer-top-right:before { 76 | content: ''; 77 | position: absolute; 78 | right: 2px; 79 | bottom: 2px; 80 | width: 5px; 81 | height: 5px; 82 | border-right: 2px solid rgba(0, 0, 0, 0.4); 83 | border-top: 2px solid rgba(0, 0, 0, 0.4); 84 | } 85 | 86 | .resizer-bottom:before { 87 | content: ''; 88 | position: absolute; 89 | top: calc(50% + 1px); 90 | left: calc(50% - 12px); 91 | height: 2px; 92 | width: 24px; 93 | background: rgba(0, 0, 0, 0.4); 94 | } 95 | 96 | .resizer-bottom { 97 | bottom: -10px !important; 98 | } 99 | 100 | .resizer-bottom:after { 101 | content: ''; 102 | position: absolute; 103 | top: calc(50% - 3px); 104 | left: calc(50% - 12px); 105 | height: 2px; 106 | width: 24px; 107 | background: rgba(0, 0, 0, 0.4); 108 | } 109 | 110 | .resizer-bottom-left { 111 | width: 10px !important; 112 | height: 10px !important; 113 | z-index: 2; 114 | left: -10px !important; 115 | bottom: -10px !important; 116 | } 117 | 118 | .resizer-bottom-left:before { 119 | content: ''; 120 | position: absolute; 121 | left: 2px; 122 | bottom: 2px; 123 | width: 5px; 124 | height: 5px; 125 | border-left: 2px solid rgba(0, 0, 0, 0.4); 126 | border-bottom: 2px solid rgba(0, 0, 0, 0.4); 127 | } 128 | 129 | .resizer-bottom-right { 130 | width: 10px !important; 131 | height: 10px !important; 132 | z-index: 2; 133 | right: -10px !important; 134 | bottom: -10px !important; 135 | } 136 | 137 | .resizer-bottom-right:before { 138 | content: ''; 139 | position: absolute; 140 | right: 2px; 141 | bottom: 2px; 142 | width: 5px; 143 | height: 5px; 144 | border-right: 2px solid rgba(0, 0, 0, 0.4); 145 | border-bottom: 2px solid rgba(0, 0, 0, 0.4); 146 | } 147 | 148 | .resizer-left { 149 | left: -10px !important; 150 | } 151 | 152 | .resizer-left:before { 153 | content: ''; 154 | position: absolute; 155 | left: 2px; 156 | top: calc(50% - 12px); 157 | width: 2px; 158 | height: 24px; 159 | background: rgba(0, 0, 0, 0.4); 160 | } 161 | 162 | .resizer-left:after { 163 | content: ''; 164 | position: absolute; 165 | left: 6px; 166 | top: calc(50% - 12px); 167 | width: 2px; 168 | height: 24px; 169 | background: rgba(0, 0, 0, 0.4); 170 | } 171 | 172 | .resizer-right { 173 | right: -10px !important; 174 | } 175 | 176 | .resizer-right:before { 177 | content: ''; 178 | position: absolute; 179 | left: 2px; 180 | top: calc(50% - 12px); 181 | width: 2px; 182 | height: 24px; 183 | background: rgba(0, 0, 0, 0.4); 184 | } 185 | 186 | .resizer-right:after { 187 | content: ''; 188 | position: absolute; 189 | left: 6px; 190 | top: calc(50% - 12px); 191 | width: 2px; 192 | height: 24px; 193 | background: rgba(0, 0, 0, 0.4); 194 | } 195 | -------------------------------------------------------------------------------- /client/components/viewport/viewport.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './viewport.css' 3 | 4 | import { Resizable } from 're-resizable' 5 | import debounce from 'lodash/debounce' 6 | import Loading from '../loading-bar/loading-bar' 7 | import Screencast from '../screencast/screencast' 8 | import { ErrorPage } from '../error-page/error-page' 9 | 10 | class Viewport extends React.Component { 11 | private viewportRef: React.RefObject 12 | private debouncedResizeHandler: any 13 | private viewportPadding: any 14 | private onActionInvoked: any 15 | 16 | constructor(props: any) { 17 | super(props) 18 | this.viewportRef = React.createRef() 19 | this.viewportPadding = { 20 | top: 70, 21 | left: 30, 22 | right: 30, 23 | bottom: 30, 24 | } 25 | 26 | this.debouncedResizeHandler = debounce(this.handleViewportResize.bind(this), 50) 27 | this.handleInspectElement = this.handleInspectElement.bind(this) 28 | this.handleInspectHighlightRequested = this.handleInspectHighlightRequested.bind(this) 29 | this.handleScreencastInteraction = this.handleScreencastInteraction.bind(this) 30 | this.handleResizeStop = this.handleResizeStop.bind(this) 31 | this.handleMouseMoved = this.handleMouseMoved.bind(this) 32 | this.onActionInvoked = this.props.onActionInvoked.bind(this) 33 | } 34 | 35 | public componentDidMount() { 36 | this.debouncedResizeHandler() 37 | window.addEventListener('resize', this.debouncedResizeHandler) 38 | } 39 | 40 | public componentWillUnmount() { 41 | window.removeEventListener('resize', this.debouncedResizeHandler) 42 | } 43 | 44 | public render() { 45 | const viewport = this.props.viewport 46 | 47 | const width = Math.round(viewport.width * viewport.screenZoom) 48 | const height = Math.round(viewport.height * viewport.screenZoom) 49 | 50 | let resizableEnableOptions = { 51 | top: false, 52 | right: false, 53 | bottom: false, 54 | left: false, 55 | topRight: false, 56 | bottomRight: false, 57 | bottomLeft: false, 58 | topLeft: false, 59 | } 60 | 61 | if (viewport.isResizable) { 62 | resizableEnableOptions = { 63 | top: true, 64 | topRight: true, 65 | topLeft: true, 66 | bottom: true, 67 | bottomRight: true, 68 | bottomLeft: true, 69 | left: true, 70 | right: true, 71 | } 72 | } 73 | 74 | return ( 75 |
    79 | 80 | { 81 | this.props.errorText 82 | ? ( 83 | 87 | ) 88 | : ( 89 | 108 | 120 | 121 | ) 122 | } 123 |
    124 | ) 125 | } 126 | 127 | public calculateViewport() { 128 | // console.log('viewport.calculateViewport') 129 | this.calculateViewportSize() 130 | this.calculateViewportZoom() 131 | } 132 | 133 | private calculateViewportZoom() { 134 | let screenZoom = 1 135 | 136 | const viewport = this.props.viewport 137 | 138 | if (viewport.isFixedZoom) 139 | return 140 | 141 | if (viewport.isFixedSize) { 142 | const screenViewportDimensions = { 143 | height: window.innerHeight - 38, // TODO: Remove hardcoded toolbar height 144 | width: window.innerWidth, 145 | } 146 | 147 | if (this.props.isDeviceEmulationEnabled) { 148 | // Add padding to enable space for resizers 149 | screenViewportDimensions.width 150 | = screenViewportDimensions.width - this.viewportPadding.left - this.viewportPadding.right 151 | screenViewportDimensions.height 152 | = screenViewportDimensions.height - this.viewportPadding.bottom - this.viewportPadding.top 153 | } 154 | 155 | screenZoom = Math.min( 156 | screenViewportDimensions.width / viewport.width, 157 | screenViewportDimensions.height / viewport.height, 158 | ) 159 | } 160 | 161 | if (screenZoom === viewport.screenZoom) 162 | return 163 | 164 | // console.log('viewport.calculateViewportZoom.emitChange') 165 | 166 | this.emitViewportChanges({ screenZoom }) 167 | } 168 | 169 | private calculateViewportSize() { 170 | const viewport = this.props.viewport 171 | 172 | if (viewport.isFixedSize) 173 | return 174 | 175 | if (this.viewportRef.current) { 176 | const dim = this.viewportRef.current.getBoundingClientRect() 177 | 178 | let viewportWidth = dim.width 179 | let viewportHeight = dim.height 180 | 181 | if (this.props.isDeviceEmulationEnabled) { 182 | // Add padding to enable space for resizers 183 | viewportWidth = viewportWidth - this.viewportPadding.left - this.viewportPadding.right 184 | viewportHeight = viewportHeight - this.viewportPadding.bottom - this.viewportPadding.top 185 | } 186 | 187 | viewportHeight = Math.floor(viewportHeight) 188 | viewportWidth = Math.floor(viewportWidth) 189 | 190 | if ( 191 | viewportWidth === Math.floor(viewport.width) 192 | && viewportHeight === Math.floor(viewport.height) 193 | ) 194 | return 195 | 196 | // console.log('viewport.calculateViewportSize.emitChange') 197 | 198 | this.emitViewportChanges({ 199 | width: viewportWidth, 200 | height: viewportHeight, 201 | }) 202 | } 203 | } 204 | 205 | private handleViewportResize() { 206 | // console.log('viewport.handleViewportResize') 207 | this.calculateViewport() 208 | } 209 | 210 | private handleResizeStop(e: any, direction: any, ref: any, delta: any) { 211 | const viewport = this.props.viewport 212 | 213 | this.emitViewportChanges({ 214 | width: viewport.width + delta.width, 215 | height: viewport.height + delta.height, 216 | isFixedSize: true, 217 | }) 218 | } 219 | 220 | private handleInspectElement(params: object) { 221 | this.props.onViewportChanged('inspectElement', { 222 | params, 223 | }) 224 | } 225 | 226 | private handleInspectHighlightRequested(params: object) { 227 | this.props.onViewportChanged('inspectHighlightRequested', { 228 | params, 229 | }) 230 | } 231 | 232 | private handleScreencastInteraction(action: string, params: object) { 233 | this.props.onViewportChanged('interaction', { 234 | action, 235 | params, 236 | }) 237 | } 238 | 239 | private handleMouseMoved(params: object) { 240 | this.props.onViewportChanged('hoverElementChanged', { 241 | params, 242 | }) 243 | } 244 | 245 | private emitViewportChanges(newViewport: any) { 246 | this.props.onViewportChanged('size', newViewport) 247 | } 248 | } 249 | 250 | export default Viewport 251 | -------------------------------------------------------------------------------- /client/connection.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter2 } from 'eventemitter2' 2 | import Logger from './utils/logger' 3 | 4 | export default class Connection extends EventEmitter2 { 5 | private lastId: number 6 | private vscode: any 7 | private callbacks: Map 8 | private logger: Logger 9 | 10 | constructor() { 11 | super() 12 | this.lastId = 0 13 | this.callbacks = new Map() 14 | this.logger = new Logger() 15 | 16 | window.addEventListener('message', event => this.onMessage(event)) 17 | } 18 | 19 | send(method: string, params = {}): Promise { 20 | const id = ++this.lastId 21 | 22 | this.logger.log('SEND ► ', method, params) 23 | 24 | if (!this.vscode) { 25 | try { 26 | // @ts-expect-error 27 | this.vscode = acquireVsCodeApi() 28 | } 29 | catch { 30 | this.vscode = null 31 | } 32 | } 33 | 34 | if (this.vscode) { 35 | this.vscode.postMessage({ 36 | callbackId: id, 37 | params, 38 | type: method, 39 | }) 40 | } 41 | 42 | return new Promise((resolve, reject) => { 43 | this.callbacks.set(id, { resolve, reject, error: new Error('Unknown'), method }) 44 | }) 45 | } 46 | 47 | onMessage(message: any) { 48 | const object: any = message.data 49 | 50 | if (object) { 51 | if (object.callbackId) { 52 | // this.logger.log(`◀ RECV callbackId: ${object.callbackId}`) 53 | const callback: any = this.callbacks.get(object.callbackId) 54 | // Callbacks could be all rejected if someone has called `.dispose()`. 55 | if (callback) { 56 | this.callbacks.delete(object.callbackId) 57 | if (object.error) 58 | callback.reject(object.error, callback.method, object) 59 | else 60 | callback.resolve(object.result) 61 | } 62 | } 63 | else { 64 | // this.logger.log(`◀ RECV method: ${object.method}`) 65 | this.emit(object.method, object.result) 66 | } 67 | } 68 | } 69 | 70 | enableVerboseLogging(verbose: boolean) { 71 | if (verbose) 72 | this.logger.enable() 73 | else 74 | this.logger.disable() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render(, document.getElementById('root') as HTMLElement) 6 | -------------------------------------------------------------------------------- /client/utils/cdpHelper.ts: -------------------------------------------------------------------------------- 1 | export class CDPHelper { 2 | private connection: any 3 | 4 | constructor(connection: any) { 5 | this.connection = connection 6 | } 7 | 8 | public async resolveElementProperties(objectId: any, maxDepth: number) { 9 | const initialProperties = await this.getProperties(objectId) 10 | 11 | const resolve = async (props: any, passedDepth: number) => { 12 | const resolveResult = {} 13 | const internalCurentDepth = passedDepth | 0 14 | 15 | for (const item of props) { 16 | let value = null 17 | if (item.value) { 18 | if (item.value.type === 'object') { 19 | if (item.value.objectId) { 20 | if (internalCurentDepth < maxDepth) { 21 | value = await this.getProperties(item.value.objectId) 22 | if (Array.isArray(value)) { 23 | const newDepth = internalCurentDepth + 1 24 | value = await resolve(value, newDepth) 25 | } 26 | } 27 | else { 28 | value = '' 29 | } 30 | } 31 | else if (item.value.value) { 32 | value = item.value.value 33 | } 34 | } 35 | else if (item.value.type === 'function') { 36 | value = 'function' 37 | } 38 | else if (item.value.type === 'string') { 39 | value = item.value.value 40 | } 41 | else if (item.value.type === 'number') { 42 | value = item.value.value 43 | } 44 | } 45 | 46 | Object.defineProperty(resolveResult, item.name, { 47 | value, 48 | enumerable: item.enumerable, 49 | configurable: item.configurable, 50 | writable: item.writable, 51 | }) 52 | } 53 | return resolveResult 54 | } 55 | 56 | const result = await resolve(initialProperties, 0) 57 | 58 | return result 59 | } 60 | 61 | public async getProperties(objectId: string) { 62 | const data: any = await this.connection.send('Runtime.getProperties', { 63 | objectId, 64 | ownProperties: true, 65 | }) 66 | 67 | return data.result as Array 68 | } 69 | 70 | public async getCursorForNode(nodeInfo: any) { 71 | let nodeId = nodeInfo.nodeId 72 | if (!nodeInfo.nodeId) 73 | nodeId = this.getNodeIdFromBackendId(nodeInfo.backendNodeId) 74 | 75 | if (!nodeId) 76 | return 77 | 78 | const computedStyleReq = await this.connection.send('CSS.getComputedStyleForNode', { 79 | nodeId, 80 | }) 81 | 82 | const cursorCSS = computedStyleReq.computedStyle.find((c: any) => c.name === 'cursor') 83 | 84 | return cursorCSS.value 85 | } 86 | 87 | public async getNodeIdFromBackendId(backendNodeId: any) { 88 | await this.connection.send('DOM.getDocument') 89 | const nodeIdsReq = await this.connection.send('DOM.pushNodesByBackendIdsToFrontend', { 90 | backendNodeIds: [backendNodeId], 91 | }) 92 | 93 | if (nodeIdsReq) 94 | return nodeIdsReq.nodeIds[0] 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export default class Logger { 2 | private enabled = false 3 | 4 | public enable() { 5 | this.enabled = true 6 | } 7 | 8 | public disable() { 9 | this.enabled = false 10 | } 11 | 12 | public log(...messages: any[]) { 13 | if (!this.enabled) 14 | return 15 | // eslint-disable-next-line no-console 16 | console.log(...messages) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const antfu = require('@antfu/eslint-config').default 3 | 4 | module.exports = antfu( 5 | { 6 | ignores: [ 7 | 'assets', 8 | 'public', 9 | ], 10 | }, 11 | { 12 | rules: { 13 | 'unused-imports/no-unused-vars': 0, 14 | 'eqeqeq': 0, 15 | 'node/prefer-global/process': 0, 16 | 'ts/ban-ts-comment': 0, 17 | 'unicorn/prefer-node-protocol': 0, 18 | }, 19 | }, 20 | ) 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
    9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "publisher": "antfu", 3 | "name": "browse-lite", 4 | "displayName": "Browse Lite", 5 | "version": "0.3.9", 6 | "packageManager": "pnpm@8.9.2", 7 | "description": "Embedded browser in VS Code.", 8 | "author": { 9 | "name": "Anthony Fu", 10 | "email": "hi@antfu.me" 11 | }, 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/antfu/vscode-browse-lite.git" 16 | }, 17 | "categories": [ 18 | "Other" 19 | ], 20 | "main": "./dist/extension.js", 21 | "icon": "resources/icon.png", 22 | "engines": { 23 | "vscode": "^1.83.0" 24 | }, 25 | "activationEvents": [ 26 | "onOpenExternalUri:http", 27 | "onOpenExternalUri:https", 28 | "onDebugInitialConfigurations", 29 | "onDebug", 30 | "onFileSystem:vsls" 31 | ], 32 | "contributes": { 33 | "commands": [ 34 | { 35 | "category": "Browse Lite", 36 | "command": "browse-lite.open", 37 | "title": "Open..." 38 | }, 39 | { 40 | "category": "Browse Lite", 41 | "command": "browse-lite.openActiveFile", 42 | "title": "Open Active File in Preview" 43 | }, 44 | { 45 | "category": "Browse Lite", 46 | "command": "browse-lite.controls.refresh", 47 | "title": "Refresh Page", 48 | "icon": "$(refresh)" 49 | }, 50 | { 51 | "category": "Browse Lite", 52 | "command": "browse-lite.controls.external", 53 | "title": "Open page with system browser", 54 | "icon": "$(link-external)" 55 | }, 56 | { 57 | "category": "Browse Lite", 58 | "command": "browse-lite.controls.debug", 59 | "title": "Open debug page", 60 | "icon": "$(bug)" 61 | } 62 | ], 63 | "configuration": { 64 | "title": "Browse Lite", 65 | "type": "object", 66 | "properties": { 67 | "browse-lite.startUrl": { 68 | "description": "The default start url for new Browse Lite instances", 69 | "type": "string" 70 | }, 71 | "browse-lite.chromeExecutable": { 72 | "description": "The full path to the executable, including the complete filename of the executable", 73 | "type": "string" 74 | }, 75 | "browse-lite.verbose": { 76 | "default": false, 77 | "description": "Toggles verbose logging", 78 | "type": "boolean" 79 | }, 80 | "browse-lite.debugHost": { 81 | "default": "localhost", 82 | "description": "Host name for debug", 83 | "type": "string" 84 | }, 85 | "browse-lite.debugPort": { 86 | "default": 9222, 87 | "description": "Port number for debug, when occupied, it will try to find another one by self bumpping.", 88 | "type": "number" 89 | }, 90 | "browse-lite.format": { 91 | "default": "png", 92 | "enum": [ 93 | "png", 94 | "jpeg" 95 | ], 96 | "description": "The type of image used in rendering preview.", 97 | "type": "string" 98 | }, 99 | "browse-lite.ignoreHttpsErrors": { 100 | "default": true, 101 | "description": "Ignore HTTPS errors if you are using self-signed SSL certificates", 102 | "type": "boolean" 103 | }, 104 | "browse-lite.quality": { 105 | "default": 100, 106 | "description": "Image quality of screencasting", 107 | "type": "number" 108 | }, 109 | "browse-lite.everyNthFrame": { 110 | "default": 1, 111 | "description": "Skip for frames of screencasting", 112 | "type": "number" 113 | }, 114 | "browse-lite.localFileAutoReload": { 115 | "default": true, 116 | "description": "Automatically reload page on local file changes", 117 | "type": "boolean" 118 | }, 119 | "browse-lite.storeUserData": { 120 | "default": true, 121 | "description": "Store cookies, localStorage, etc. on disk, so that you don't lose them when you close session. This will help you not get logged out.", 122 | "type": "boolean" 123 | }, 124 | "browse-lite.proxy": { 125 | "description": "Add proxy parameters during initial loading of the browser.", 126 | "type": "string" 127 | }, 128 | "browse-lite.otherArgs": { 129 | "description": "Add other parameters during initial loading of the browser.", 130 | "type": "string" 131 | } 132 | } 133 | }, 134 | "debuggers": [ 135 | { 136 | "type": "browse-lite", 137 | "label": "Browse Lite", 138 | "configurationSnippets": [ 139 | { 140 | "label": "Browse Lite: Launch", 141 | "description": "Launch Browse Lite to localhost", 142 | "body": { 143 | "type": "browse-lite", 144 | "request": "launch", 145 | "name": "Browse Lite: Launch", 146 | "url": "http://localhost:3000" 147 | } 148 | }, 149 | { 150 | "label": "Browse Lite: Attach", 151 | "description": "Attach to open Browse Lite instances", 152 | "body": { 153 | "type": "browse-lite", 154 | "request": "attach", 155 | "name": "Browse Lite: Attach" 156 | } 157 | } 158 | ], 159 | "configurationAttributes": { 160 | "launch": { 161 | "properties": { 162 | "url": { 163 | "type": "string", 164 | "description": "Absolute url to launch", 165 | "default": "http://localhost:3000" 166 | }, 167 | "webRoot": { 168 | "type": "string", 169 | "description": "This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"", 170 | "default": "${workspaceFolder}" 171 | }, 172 | "pathMapping": { 173 | "type": "object", 174 | "description": "A mapping of URLs/paths to local folders, to resolve scripts in Chrome to scripts on disk", 175 | "default": { 176 | "/": "${workspaceFolder}" 177 | } 178 | }, 179 | "trace": { 180 | "type": [ 181 | "boolean", 182 | "string" 183 | ], 184 | "enum": [ 185 | "verbose", 186 | true 187 | ], 188 | "default": true, 189 | "description": "When 'true', the debugger will log tracing info to a file. When 'verbose', it will also show logs in the console." 190 | }, 191 | "sourceMapPathOverrides": { 192 | "type": "object", 193 | "description": "A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk. See README for details.", 194 | "default": { 195 | "webpack:///./*": "${webRoot}/*", 196 | "webpack:///src/*": "${webRoot}/*", 197 | "webpack:///*": "*", 198 | "webpack:///./~/*": "${webRoot}/node_modules/*", 199 | "meteor://💻app/*": "${webRoot}/*" 200 | } 201 | } 202 | } 203 | }, 204 | "attach": { 205 | "properties": { 206 | "urlFilter": { 207 | "type": "string", 208 | "description": "Will search for a page with this url and attach to it, if found. Can have * wildcards.", 209 | "default": "" 210 | }, 211 | "webRoot": { 212 | "type": "string", 213 | "description": "This specifies the workspace absolute path to the webserver root. Used to resolve paths like `/app.js` to files on disk. Shorthand for a pathMapping for \"/\"", 214 | "default": "${workspaceFolder}" 215 | }, 216 | "pathMapping": { 217 | "type": "object", 218 | "description": "A mapping of URLs/paths to local folders, to resolve scripts in Chrome to scripts on disk", 219 | "default": { 220 | "/": "${workspaceFolder}" 221 | } 222 | }, 223 | "trace": { 224 | "type": [ 225 | "boolean", 226 | "string" 227 | ], 228 | "enum": [ 229 | "verbose", 230 | true 231 | ], 232 | "default": true, 233 | "description": "When 'true', the debugger will log tracing info to a file. When 'verbose', it will also show logs in the console." 234 | }, 235 | "sourceMapPathOverrides": { 236 | "type": "object", 237 | "description": "A set of mappings for rewriting the locations of source files from what the sourcemap says, to their locations on disk. See README for details.", 238 | "default": { 239 | "webpack:///./*": "${webRoot}/*", 240 | "webpack:///src/*": "${webRoot}/*", 241 | "webpack:///*": "*", 242 | "webpack:///./~/*": "${webRoot}/node_modules/*", 243 | "meteor://💻app/*": "${webRoot}/*" 244 | } 245 | } 246 | } 247 | } 248 | } 249 | } 250 | ], 251 | "menus": { 252 | "editor/title": [ 253 | { 254 | "when": "resourceScheme == webview-panel && browse-lite-active", 255 | "command": "browse-lite.controls.external", 256 | "group": "navigation" 257 | }, 258 | { 259 | "when": "resourceScheme == webview-panel && browse-lite-active && !browse-lite-debug-active", 260 | "command": "browse-lite.controls.debug", 261 | "group": "navigation" 262 | } 263 | ], 264 | "commandPalette": [ 265 | { 266 | "command": "browse-lite.controls.refresh", 267 | "when": "false" 268 | }, 269 | { 270 | "command": "browse-lite.controls.external", 271 | "when": "false" 272 | }, 273 | { 274 | "command": "browse-lite.controls.debug", 275 | "when": "false" 276 | } 277 | ] 278 | } 279 | }, 280 | "scripts": { 281 | "build:dev": "vite build && npm run build:ts", 282 | "publish": "vsce publish --no-dependencies", 283 | "pack": "vsce package --no-dependencies", 284 | "build": "rimraf dist && vite build && pnpm run build:ts --minify", 285 | "build:ts": "tsup src/extension.ts --external=vscode -d dist", 286 | "lint": "eslint .", 287 | "release": "bumpp" 288 | }, 289 | "devDependencies": { 290 | "@antfu/eslint-config": "^1.0.0-beta.27", 291 | "@chiragrupani/karma-chromium-edge-launcher": "^2.3.1", 292 | "@types/karma-chrome-launcher": "^3.1.2", 293 | "@types/lodash": "^4.14.199", 294 | "@types/node": "20.8.6", 295 | "@types/react": "^17.0.53", 296 | "@types/react-dom": "^18.2.13", 297 | "@types/vscode": "1.83.0", 298 | "@vitejs/plugin-react-refresh": "^1.3.6", 299 | "@vscode/vsce": "^2.21.1", 300 | "browser-viewport-device-descriptions": "^1.1.0", 301 | "bumpp": "^9.2.0", 302 | "conventional-github-releaser": "^3.1.5", 303 | "element-to-source": "^1.0.1", 304 | "eslint": "^8.51.0", 305 | "event-emitter-enhancer": "^2.0.0", 306 | "eventemitter2": "^6.4.9", 307 | "find-up": "^6.3.0", 308 | "karma-chrome-launcher": "^3.2.0", 309 | "lodash": "^4.17.21", 310 | "ovsx": "^0.8.3", 311 | "puppeteer-core": "^21.3.8", 312 | "re-resizable": "^6.9.11", 313 | "react": "^18.2.0", 314 | "react-dom": "^18.2.0", 315 | "rimraf": "^5.0.5", 316 | "tsup": "^7.2.0", 317 | "typescript": "^5.2.2", 318 | "vite": "^4.4.11" 319 | }, 320 | "preview": true, 321 | "extensionKind": [ 322 | "ui", 323 | "workspace" 324 | ] 325 | } 326 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/vscode-browse-lite/a9510bde29f764912a4ca1613759755870cb73d7/resources/icon.png -------------------------------------------------------------------------------- /src/BrowserClient.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { platform } from 'os' 3 | import { existsSync } from 'fs' 4 | import { join } from 'path' 5 | import edge from '@chiragrupani/karma-chromium-edge-launcher' 6 | import chrome from 'karma-chrome-launcher' 7 | import type { Browser } from 'puppeteer-core' 8 | import puppeteer from 'puppeteer-core' 9 | import type { ExtensionContext } from 'vscode' 10 | import { window, workspace } from 'vscode' 11 | import type { ExtensionConfiguration } from './ExtensionConfiguration' 12 | import { tryPort } from './Config' 13 | import { BrowserPage } from './BrowserPage' 14 | 15 | export class BrowserClient extends EventEmitter { 16 | private browser: Browser 17 | 18 | constructor(private config: ExtensionConfiguration, private ctx: ExtensionContext) { 19 | super() 20 | } 21 | 22 | private async launchBrowser() { 23 | const chromeArgs = [] 24 | 25 | this.config.debugPort = await tryPort(this.config.debugPort) 26 | 27 | chromeArgs.push(`--remote-debugging-port=${this.config.debugPort}`) 28 | 29 | chromeArgs.push('--allow-file-access-from-files') 30 | 31 | chromeArgs.push('--remote-allow-origins=*') 32 | 33 | // chromeArgs.push('--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36') 34 | 35 | if (this.config.proxy && this.config.proxy.length > 0) 36 | chromeArgs.push(`--proxy-server=${this.config.proxy}`) 37 | 38 | if (this.config.otherArgs && this.config.otherArgs.length > 0) 39 | chromeArgs.push(this.config.otherArgs) 40 | 41 | const chromePath = this.config.chromeExecutable || this.getChromiumPath() 42 | 43 | if (!chromePath) { 44 | window.showErrorMessage( 45 | 'No Chrome installation found, or no Chrome executable set in the settings', 46 | ) 47 | return 48 | } 49 | 50 | if (platform() === 'linux') 51 | chromeArgs.push('--no-sandbox') 52 | 53 | const extensionSettings = workspace.getConfiguration('browse-lite') 54 | const ignoreHTTPSErrors = extensionSettings.get('ignoreHttpsErrors') 55 | 56 | let userDataDir 57 | if (this.config.storeUserData) 58 | userDataDir = join(this.ctx.globalStorageUri.fsPath, 'UserData') 59 | 60 | this.browser = await puppeteer.launch({ 61 | executablePath: chromePath, 62 | args: chromeArgs, 63 | ignoreHTTPSErrors, 64 | ignoreDefaultArgs: ['--mute-audio'], 65 | userDataDir, 66 | }) 67 | 68 | // close the initial empty page 69 | ; (await this.browser.pages()).map(i => i.close()) 70 | } 71 | 72 | public async newPage(): Promise { 73 | if (!this.browser) 74 | await this.launchBrowser() 75 | 76 | const page = new BrowserPage(this.browser, await this.browser.newPage()) 77 | await page.launch() 78 | return page 79 | } 80 | 81 | public dispose(): Promise { 82 | return new Promise((resolve) => { 83 | if (this.browser) { 84 | this.browser.close() 85 | this.browser = null 86 | } 87 | resolve() 88 | }) 89 | } 90 | 91 | public getChromiumPath(): string | undefined { 92 | const knownChromiums = [...Object.entries(chrome), ...Object.entries(edge)] 93 | 94 | for (const [key, info] of knownChromiums) { 95 | if (!key.startsWith('launcher')) 96 | continue 97 | 98 | const path = info?.[1]?.prototype?.DEFAULT_CMD?.[process.platform] 99 | if (path && typeof path === 'string' && existsSync(path)) 100 | return path 101 | } 102 | 103 | return undefined 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/BrowserPage.ts: -------------------------------------------------------------------------------- 1 | import EventEmitterEnhancer, { EnhancedEventEmitter } from 'event-emitter-enhancer' 2 | import type { Browser, CDPSession, Page } from 'puppeteer-core' 3 | import { Clipboard } from './Clipboard' 4 | import { isDarkTheme } from './Config' 5 | 6 | enum ExposedFunc { 7 | EmitCopy = 'EMIT_BROWSER_LITE_ON_COPY', 8 | GetPaste = 'EMIT_BROWSER_LITE_GET_PASTE', 9 | EnableCopyPaste = 'ENABLE_BROWSER_LITE_HOOK_COPY_PASTE', 10 | } 11 | 12 | export class BrowserPage extends EnhancedEventEmitter { 13 | private client: CDPSession 14 | private clipboard: Clipboard 15 | 16 | constructor( 17 | public readonly browser: Browser, 18 | public readonly page: Page, 19 | ) { 20 | super() 21 | this.clipboard = new Clipboard() 22 | } 23 | 24 | get id(): string { 25 | return this.page.mainFrame()._id 26 | } 27 | 28 | public dispose() { 29 | this.removeAllElseListeners() 30 | // @ts-expect-error 31 | this.removeAllListeners() 32 | this.client.detach() 33 | Promise.allSettled([ 34 | this.page.removeExposedFunction(ExposedFunc.EnableCopyPaste), 35 | this.page.removeExposedFunction(ExposedFunc.EmitCopy), 36 | this.page.removeExposedFunction(ExposedFunc.GetPaste), 37 | ]).then(() => { 38 | this.page.close() 39 | }) 40 | } 41 | 42 | public async send(action: string, data: object = {}, callbackId?: number) { 43 | // console.log('► browserPage.send', action) 44 | switch (action) { 45 | case 'Page.goForward': 46 | await this.page.goForward() 47 | break 48 | case 'Page.goBackward': 49 | await this.page.goBack() 50 | break 51 | case 'Clipboard.readText': 52 | try { 53 | this.emit({ 54 | callbackId, 55 | result: await this.clipboard.readText(), 56 | } as any) 57 | } 58 | catch (e) { 59 | this.emit({ 60 | callbackId, 61 | error: e.message, 62 | } as any) 63 | } 64 | break 65 | default: 66 | this.client 67 | .send(action as any, data) 68 | .then((result: any) => { 69 | this.emit({ 70 | callbackId, 71 | result, 72 | } as any) 73 | }) 74 | .catch((err: any) => { 75 | this.emit({ 76 | callbackId, 77 | error: err.message, 78 | } as any) 79 | }) 80 | } 81 | } 82 | 83 | public async launch(): Promise { 84 | await Promise.allSettled([ 85 | // TODO setting for enable sync copy and paste 86 | this.page.exposeFunction(ExposedFunc.EnableCopyPaste, () => true), 87 | this.page.exposeFunction(ExposedFunc.EmitCopy, (text: string) => this.clipboard.writeText(text)), 88 | this.page.exposeFunction(ExposedFunc.GetPaste, () => this.clipboard.readText()), 89 | ]) 90 | this.page.evaluateOnNewDocument(() => { 91 | // custom embedded devtools 92 | localStorage.setItem('screencastEnabled', 'false') 93 | localStorage.setItem('panel-selectedTab', 'console') 94 | 95 | // sync copy and paste 96 | if (window[ExposedFunc.EnableCopyPaste]?.()) { 97 | const copyHandler = (event: ClipboardEvent) => { 98 | const text = event.clipboardData?.getData('text/plain') || document.getSelection()?.toString() 99 | text && window[ExposedFunc.EmitCopy]?.(text) 100 | } 101 | document.addEventListener('copy', copyHandler) 102 | document.addEventListener('cut', copyHandler) 103 | document.addEventListener('paste', async (event) => { 104 | event.preventDefault() 105 | const text = await window[ExposedFunc.GetPaste]?.() 106 | text && document.execCommand('insertText', false, text) 107 | }) 108 | } 109 | }) 110 | 111 | this.page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: isDarkTheme() ? 'dark' : 'light' }]) 112 | 113 | this.client = await this.page.target().createCDPSession() 114 | 115 | // @ts-expect-error 116 | EventEmitterEnhancer.modifyInstance(this.client) 117 | 118 | // @ts-expect-error 119 | this.client.else((action: string, data: object) => { 120 | // console.log('◀ browserPage.received', action) 121 | this.emit({ 122 | method: action, 123 | result: data, 124 | } as any) 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Clipboard.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'vscode' 2 | 3 | export class Clipboard { 4 | writeText(value: string): Thenable { 5 | return env.clipboard.writeText(value) 6 | } 7 | 8 | readText(): Thenable { 9 | return env.clipboard.readText() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Config.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http' 2 | import type { ExtensionContext } from 'vscode' 3 | import { workspace } from 'vscode' 4 | import type { ExtensionConfiguration } from './ExtensionConfiguration' 5 | 6 | export function getConfig(key: string, v?: T) { 7 | return workspace.getConfiguration().get(key, v) 8 | } 9 | 10 | export function isDarkTheme() { 11 | const theme = getConfig('workbench.colorTheme', '').toLowerCase() 12 | 13 | // must be dark 14 | if (theme.match(/dark|black/i) != null) 15 | return true 16 | 17 | // must be light 18 | if (theme.match(/light/i) != null) 19 | return false 20 | 21 | // IDK, maybe dark 22 | return true 23 | } 24 | 25 | function isPortFree(port: number) { 26 | return new Promise((resolve) => { 27 | const server = createServer() 28 | .listen(port, () => { 29 | server.close() 30 | resolve(true) 31 | }) 32 | .on('error', () => { 33 | resolve(false) 34 | }) 35 | }) 36 | } 37 | export function timeout(ms: number) { 38 | return new Promise(resolve => setTimeout(resolve, ms)) 39 | } 40 | 41 | export async function tryPort(start = 4000): Promise { 42 | if (await isPortFree(start)) 43 | return start 44 | return tryPort(start + 1) 45 | } 46 | 47 | export function getConfigs(ctx: ExtensionContext): ExtensionConfiguration { 48 | return { 49 | extensionPath: ctx.extensionPath, 50 | columnNumber: 2, 51 | isDebug: false, 52 | quality: getConfig('browse-lite.quality', 100), 53 | everyNthFrame: getConfig('browse-lite.everyNthFrame', 1), 54 | format: getConfig('browse-lite.format', 'png'), 55 | isVerboseMode: getConfig('browse-lite.verbose', false), 56 | chromeExecutable: getConfig('browse-lite.chromeExecutable'), 57 | startUrl: getConfig('browse-lite.startUrl', 'https://github.com/antfu/vscode-browse-lite'), 58 | debugHost: getConfig('browse-lite.debugHost', 'localhost'), 59 | debugPort: getConfig('browse-lite.debugPort', 9222), 60 | storeUserData: getConfig('browse-lite.storeUserData', true), 61 | proxy: getConfig('browse-lite.proxy', ''), 62 | otherArgs: getConfig('browse-lite.otherArgs', ''), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ContentProvider.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import fs from 'fs' 3 | import type { Webview } from 'vscode' 4 | import { Uri } from 'vscode' 5 | import type { ExtensionConfiguration } from './ExtensionConfiguration' 6 | 7 | export class ContentProvider { 8 | constructor(private config: ExtensionConfiguration) { } 9 | 10 | getContent(webview: Webview) { 11 | const root = join(this.config.extensionPath, 'dist/client') 12 | const indexHTML = fs.readFileSync(join(root, 'index.html'), 'utf-8') 13 | 14 | return indexHTML.replace( 15 | /(src|href)="(.*?)"/g, 16 | (_, tag, url) => `${tag}="${webview.asWebviewUri(Uri.file(join(root, url.slice(1))))}"`, 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/DebugProvider.ts: -------------------------------------------------------------------------------- 1 | import type { CancellationToken, DebugAdapterTracker, DebugConfiguration, DebugConfigurationProvider, DebugSession, ProviderResult, WorkspaceFolder } from 'vscode' 2 | import { debug, window } from 'vscode' 3 | import type { PanelManager } from './PanelManager' 4 | import { getUnderlyingDebugType } from './UnderlyingDebugAdapter' 5 | 6 | export class DebugProvider { 7 | private readonly underlyingDebugType = getUnderlyingDebugType() 8 | 9 | constructor(private manager: PanelManager) { 10 | debug.onDidTerminateDebugSession((e: DebugSession) => { 11 | if (e.name === 'Browse Lite: Launch' && e.configuration.urlFilter) { 12 | // TODO: Improve this with some unique ID per browser window instead of url, to avoid closing multiple instances 13 | this.manager.disposeByUrl(e.configuration.urlFilter) 14 | } 15 | }) 16 | 17 | debug.registerDebugAdapterTrackerFactory( 18 | this.underlyingDebugType, 19 | { 20 | createDebugAdapterTracker(session: DebugSession): ProviderResult { 21 | const config = session.configuration 22 | if (!config._browseLite || !config._browseLiteLaunch) 23 | return undefined 24 | 25 | return manager.create(config._browseLiteLaunch).then(() => undefined) 26 | }, 27 | }, 28 | ) 29 | } 30 | 31 | getProvider(): DebugConfigurationProvider { 32 | const manager = this.manager 33 | const debugType = this.underlyingDebugType 34 | 35 | return { 36 | provideDebugConfigurations( 37 | folder: WorkspaceFolder | undefined, 38 | token?: CancellationToken, 39 | ): ProviderResult { 40 | return Promise.resolve([ 41 | { 42 | type: 'browse-lite', 43 | name: 'Browse Lite: Attach', 44 | request: 'attach', 45 | }, 46 | { 47 | type: 'browse-lite', 48 | request: 'launch', 49 | name: 'Browse Lite: Launch', 50 | url: 'http://localhost:3000', 51 | }, 52 | ]) 53 | }, 54 | resolveDebugConfiguration( 55 | folder: WorkspaceFolder | undefined, 56 | config: DebugConfiguration, 57 | token?: CancellationToken, 58 | // @ts-expect-error 59 | ): ProviderResult { 60 | if (!config || config.type !== 'browse-lite') 61 | return null 62 | 63 | config.type = debugType 64 | config._browseLite = true 65 | 66 | if (config.request === 'launch') { 67 | config.name = 'Browse Lite: Launch' 68 | config.port = manager.config.debugPort 69 | config.request = 'attach' 70 | config.urlFilter = config.url 71 | config._browseLiteLaunch = config.url 72 | 73 | if (config.port === null) { 74 | window.showErrorMessage( 75 | 'Could not launch Browse Lite window', 76 | ) 77 | } 78 | else { 79 | return config 80 | } 81 | } 82 | else if (config.request === 'attach') { 83 | config.name = 'Browse Lite: Attach' 84 | config.port = manager.config.debugPort 85 | 86 | if (config.port === null) { 87 | window.showErrorMessage( 88 | 'No Browse Lite window was found. Open a Browse Lite window or use the "launch" request type.', 89 | ) 90 | } 91 | else { 92 | return config 93 | } 94 | } 95 | else { 96 | window.showErrorMessage( 97 | 'No supported launch config was found.', 98 | ) 99 | } 100 | }, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/ExtensionConfiguration.ts: -------------------------------------------------------------------------------- 1 | export interface ExtensionConfiguration { 2 | chromeExecutable?: string 3 | extensionPath: string 4 | format: 'jpeg' | 'png' 5 | isVerboseMode: boolean 6 | startUrl: string 7 | columnNumber: number 8 | quality: number 9 | everyNthFrame: number 10 | isDebug?: boolean 11 | debugHost: string 12 | debugPort: number 13 | storeUserData: boolean 14 | proxy: string 15 | otherArgs: string 16 | } 17 | -------------------------------------------------------------------------------- /src/Panel.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import type { Disposable, TextDocument, WebviewPanel } from 'vscode' 3 | import { Position, Selection, Uri, ViewColumn, commands, env, window, workspace } from 'vscode' 4 | import { EventEmitter2 } from 'eventemitter2' 5 | 6 | import type { BrowserClient } from './BrowserClient' 7 | import type { BrowserPage } from './BrowserPage' 8 | import type { ExtensionConfiguration } from './ExtensionConfiguration' 9 | import { ContentProvider } from './ContentProvider' 10 | 11 | export class Panel extends EventEmitter2 { 12 | private static readonly viewType = 'browse-lite' 13 | private _panel: WebviewPanel | null 14 | public disposables: Disposable[] = [] 15 | public url = '' 16 | public title = '' 17 | private state = {} 18 | private contentProvider: ContentProvider 19 | public browserPage: BrowserPage | null 20 | private browser: BrowserClient 21 | public config: ExtensionConfiguration 22 | public parentPanel: Panel | undefined 23 | public debugPanel: Panel | undefined 24 | public disposed = false 25 | 26 | constructor(config: ExtensionConfiguration, browser: BrowserClient, parentPanel?: Panel) { 27 | super() 28 | this.config = config 29 | this._panel = null 30 | this.browserPage = null 31 | this.browser = browser 32 | this.parentPanel = parentPanel 33 | this.contentProvider = new ContentProvider(this.config) 34 | 35 | if (parentPanel) 36 | parentPanel.once('disposed', () => this.dispose()) 37 | } 38 | 39 | get isDebugPage() { 40 | return !!this.parentPanel 41 | } 42 | 43 | public async launch(startUrl?: string) { 44 | try { 45 | this.browserPage = await this.browser.newPage() 46 | if (this.browserPage) { 47 | this.browserPage.else((data: any) => { 48 | if (this._panel) 49 | this._panel.webview.postMessage(data) 50 | }) 51 | } 52 | } 53 | catch (err) { 54 | window.showErrorMessage(err.message) 55 | } 56 | 57 | this._panel = window.createWebviewPanel( 58 | Panel.viewType, 59 | 'Browse Lite', 60 | this.isDebugPage ? ViewColumn.Three : ViewColumn.Two, 61 | { 62 | enableScripts: true, 63 | retainContextWhenHidden: true, 64 | localResourceRoots: [ 65 | Uri.file(path.join(this.config.extensionPath, 'dist/client')), 66 | ], 67 | }, 68 | ) 69 | this._panel.webview.html = this.contentProvider.getContent(this._panel.webview) 70 | this._panel.onDidDispose(() => this.dispose(), null, this.disposables) 71 | this._panel.onDidChangeViewState(() => this.emit(this._panel.active ? 'focus' : 'blur'), null, this.disposables) 72 | this._panel.webview.onDidReceiveMessage( 73 | (msg) => { 74 | if (msg.type === 'extension.updateTitle') { 75 | this.title = msg.params.title 76 | if (this._panel) { 77 | this._panel.title = this.isDebugPage ? `DevTools - ${this.parentPanel.title}` : msg.params.title 78 | try { 79 | this._panel.iconPath = Uri.parse(`https://favicon.yandex.net/favicon/${new URL(this.browserPage?.page.url() || '').hostname}`) 80 | } 81 | catch (err) {} 82 | return 83 | } 84 | } 85 | if (msg.type === 'extension.windowOpenRequested') { 86 | this.emit('windowOpenRequested', { url: msg.params.url }) 87 | this.url = msg.params.url 88 | } 89 | if (msg.type === 'extension.openFile') 90 | this.handleOpenFileRequest(msg.params) 91 | 92 | if (msg.type === 'extension.windowDialogRequested') { 93 | const { message, type } = msg.params 94 | if (type == 'alert') { 95 | window.showInformationMessage(message) 96 | if (this.browserPage) { 97 | this.browserPage.send('Page.handleJavaScriptDialog', { 98 | accept: true, 99 | }) 100 | } 101 | } 102 | else if (type === 'prompt') { 103 | window 104 | .showInputBox({ placeHolder: message }) 105 | .then((result) => { 106 | if (this.browserPage) { 107 | this.browserPage.send('Page.handleJavaScriptDialog', { 108 | accept: true, 109 | promptText: result, 110 | }) 111 | } 112 | }) 113 | } 114 | else if (type === 'confirm') { 115 | window.showQuickPick(['Ok', 'Cancel']).then((result) => { 116 | if (this.browserPage) { 117 | this.browserPage.send('Page.handleJavaScriptDialog', { 118 | accept: result === 'Ok', 119 | }) 120 | } 121 | }) 122 | } 123 | } 124 | 125 | if (msg.type === 'extension.appStateChanged') { 126 | this.state = msg.params.state 127 | this.emit('stateChanged') 128 | } 129 | 130 | if (this.browserPage) { 131 | try { 132 | // not sure about this one but this throws later with unhandled 133 | // 'extension.appStateChanged' message 134 | if (msg.type !== 'extension.appStateChanged') 135 | this.browserPage.send(msg.type, msg.params, msg.callbackId) 136 | 137 | this.emit(msg.type, msg.params) 138 | } 139 | catch (err) { 140 | window.showErrorMessage(err) 141 | } 142 | } 143 | }, 144 | null, 145 | this.disposables, 146 | ) 147 | 148 | if (startUrl) { 149 | this.config.startUrl = startUrl 150 | this.url = this.url || startUrl 151 | } 152 | 153 | this._panel.webview.postMessage({ 154 | method: 'extension.appConfiguration', 155 | result: { 156 | ...this.config, 157 | isDebug: this.isDebugPage, 158 | }, 159 | }) 160 | 161 | this.emit('focus') 162 | } 163 | 164 | public navigateTo(url: string) { 165 | this._panel.webview.postMessage({ 166 | method: 'extension.navigateTo', 167 | result: { 168 | url, 169 | }, 170 | }) 171 | this.url = url 172 | } 173 | 174 | public async createDebugPanel() { 175 | if (this.isDebugPage) 176 | return 177 | if (this.debugPanel) 178 | return this.debugPanel 179 | 180 | const panel = new Panel(this.config, this.browser, this) 181 | this.debugPanel = panel 182 | panel.on('focus', () => { 183 | commands.executeCommand('setContext', 'browse-lite-debug-active', true) 184 | }) 185 | panel.on('blur', () => { 186 | commands.executeCommand('setContext', 'browse-lite-debug-active', false) 187 | }) 188 | panel.once('disposed', () => { 189 | commands.executeCommand('setContext', 'browse-lite-debug-active', false) 190 | this.debugPanel = undefined 191 | }) 192 | const domain = `${this.config.debugHost}:${this.config.debugPort}` 193 | await panel.launch(`http://${domain}/devtools/inspector.html?ws=${domain}/devtools/page/${this.browserPage.id}&experiments=true`) 194 | return panel 195 | } 196 | 197 | public reload() { 198 | this.browserPage?.send('Page.reload') 199 | } 200 | 201 | public goBackward() { 202 | this.browserPage?.send('Page.goBackward') 203 | } 204 | 205 | public goForward() { 206 | this.browserPage?.send('Page.goForward') 207 | } 208 | 209 | public getState() { 210 | return this.state 211 | } 212 | 213 | public openExternal(close = true) { 214 | if (this.url) { 215 | env.openExternal(Uri.parse(this.url)) 216 | if (close) 217 | this.dispose() 218 | } 219 | } 220 | 221 | public setViewport(viewport: any) { 222 | this._panel!.webview.postMessage({ 223 | method: 'extension.viewport', 224 | result: viewport, 225 | }) 226 | } 227 | 228 | public show() { 229 | if (this._panel) 230 | this._panel.reveal() 231 | } 232 | 233 | public dispose() { 234 | this.disposed = true 235 | if (this._panel) 236 | this._panel.dispose() 237 | 238 | if (this.browserPage) { 239 | this.browserPage.dispose() 240 | this.browserPage = null 241 | } 242 | while (this.disposables.length) { 243 | const x = this.disposables.pop() 244 | if (x) 245 | x.dispose() 246 | } 247 | this.emit('disposed') 248 | this.removeAllListeners() 249 | } 250 | 251 | private handleOpenFileRequest(params: any) { 252 | const lineNumber = params.lineNumber 253 | const columnNumber = params.columnNumber | params.charNumber | 0 254 | 255 | const workspacePath = `${workspace.rootPath || ''}/` 256 | const relativePath = params.fileName.replace(workspacePath, '') 257 | 258 | workspace.findFiles(relativePath, '', 1).then((file) => { 259 | if (!file || !file.length) 260 | return 261 | 262 | const firstFile = file[0] 263 | 264 | // Open document 265 | workspace.openTextDocument(firstFile).then( 266 | (document: TextDocument) => { 267 | // Show the document 268 | window.showTextDocument(document, ViewColumn.One).then( 269 | (document) => { 270 | if (lineNumber) { 271 | // Adjust line position from 1 to zero-based. 272 | const pos = new Position(-1 + lineNumber, columnNumber) 273 | document.selection = new Selection(pos, pos) 274 | } 275 | }, 276 | (reason) => { 277 | window.showErrorMessage(`Failed to show file. ${reason}`) 278 | }, 279 | ) 280 | }, 281 | (err) => { 282 | window.showErrorMessage(`Failed to open file. ${err}`) 283 | }, 284 | ) 285 | }) 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/PanelManager.ts: -------------------------------------------------------------------------------- 1 | import type { ExtensionContext, Uri } from 'vscode' 2 | import { commands, workspace } from 'vscode' 3 | import * as EventEmitter from 'eventemitter2' 4 | 5 | import { BrowserClient } from './BrowserClient' 6 | import { getConfig, getConfigs } from './Config' 7 | import { Panel } from './Panel' 8 | import type { ExtensionConfiguration } from './ExtensionConfiguration' 9 | 10 | export class PanelManager extends EventEmitter.EventEmitter2 { 11 | public panels: Set 12 | public current: Panel | undefined 13 | public browser: BrowserClient 14 | public config: ExtensionConfiguration 15 | 16 | constructor(public readonly ctx: ExtensionContext) { 17 | super() 18 | this.panels = new Set() 19 | this.config = getConfigs(this.ctx) 20 | 21 | this.on('windowOpenRequested', (params) => { 22 | this.create(params.url) 23 | }) 24 | } 25 | 26 | private async refreshSettings() { 27 | const prev = this.config 28 | 29 | this.config = { 30 | ...getConfigs(this.ctx), 31 | debugPort: prev.debugPort, 32 | } 33 | } 34 | 35 | public async create(startUrl: string | Uri = this.config.startUrl) { 36 | this.refreshSettings() 37 | 38 | if (!this.browser) 39 | this.browser = new BrowserClient(this.config, this.ctx) 40 | 41 | const panel = new Panel(this.config, this.browser) 42 | 43 | panel.once('disposed', () => { 44 | if (this.current === panel) { 45 | this.current = undefined 46 | commands.executeCommand('setContext', 'browse-lite-active', false) 47 | } 48 | this.panels.delete(panel) 49 | if (this.panels.size === 0) { 50 | this.browser.dispose() 51 | this.browser = null 52 | } 53 | 54 | this.emit('windowDisposed', panel) 55 | }) 56 | 57 | panel.on('windowOpenRequested', (params) => { 58 | this.emit('windowOpenRequested', params) 59 | }) 60 | 61 | panel.on('focus', () => { 62 | this.current = panel 63 | commands.executeCommand('setContext', 'browse-lite-active', true) 64 | }) 65 | 66 | panel.on('blur', () => { 67 | if (this.current === panel) { 68 | this.current = undefined 69 | commands.executeCommand('setContext', 'browse-lite-active', false) 70 | } 71 | }) 72 | 73 | this.panels.add(panel) 74 | 75 | await panel.launch(startUrl.toString()) 76 | 77 | this.emit('windowCreated', panel) 78 | 79 | this.ctx.subscriptions.push({ 80 | dispose: () => panel.dispose(), 81 | }) 82 | 83 | return panel 84 | } 85 | 86 | public async createFile(filepath: string) { 87 | if (!filepath) 88 | return 89 | 90 | const panel = await this.create(`file://${filepath}`) 91 | if (getConfig('browse-lite.localFileAutoReload')) { 92 | panel.disposables.push( 93 | workspace.createFileSystemWatcher(filepath, true, false, false).onDidChange(() => { 94 | // TODO: check filename 95 | panel.reload() 96 | }), 97 | ) 98 | } 99 | return panel 100 | } 101 | 102 | public disposeByUrl(url: string) { 103 | this.panels.forEach((b: Panel) => { 104 | if (b.config.startUrl === url) 105 | b.dispose() 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/UnderlyingDebugAdapter.ts: -------------------------------------------------------------------------------- 1 | import { extensions } from 'vscode' 2 | 3 | export function getUnderlyingDebugType(): string { 4 | if (extensions.getExtension('msjsdiag.debugger-for-chrome')) 5 | return 'chrome' 6 | if (extensions.getExtension('msjsdiag.debugger-for-edge')) 7 | return 'edge' 8 | // TODO: support VS Code built-in debugger 9 | return 'chrome' 10 | } 11 | -------------------------------------------------------------------------------- /src/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'karma-chrome-launcher' { 2 | type OSMap = Record 3 | export const example: ['type', { prototype: { DEFAULT_CMD: OSMap } }] 4 | } 5 | 6 | declare module '@chiragrupani/karma-chromium-edge-launcher' { 7 | type OSMap = Record 8 | export const example: ['type', { prototype: { DEFAULT_CMD: OSMap } }] 9 | } 10 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import type { ExtensionContext, Uri } from 'vscode' 2 | import { commands, debug, window } from 'vscode' 3 | 4 | import { DebugProvider } from './DebugProvider' 5 | import { PanelManager } from './PanelManager' 6 | 7 | export function activate(ctx: ExtensionContext) { 8 | const manager = new PanelManager(ctx) 9 | const debugProvider = new DebugProvider(manager) 10 | 11 | ctx.subscriptions.push( 12 | 13 | debug.registerDebugConfigurationProvider( 14 | 'browse-lite', 15 | debugProvider.getProvider(), 16 | ), 17 | 18 | commands.registerCommand('browse-lite.open', async (url?: string | Uri) => { 19 | try { 20 | return await manager.create(url) 21 | } 22 | catch (e) { 23 | console.error(e) 24 | } 25 | }), 26 | 27 | commands.registerCommand('browse-lite.openActiveFile', () => { 28 | const filename = window.activeTextEditor?.document?.fileName 29 | manager.createFile(filename) 30 | }), 31 | 32 | commands.registerCommand('browse-lite.controls.refresh', () => { 33 | manager.current?.reload() 34 | }), 35 | 36 | commands.registerCommand('browse-lite.controls.external', () => { 37 | manager.current?.openExternal(true) 38 | }), 39 | 40 | commands.registerCommand('browse-lite.controls.debug', async () => { 41 | const panel = await manager.current?.createDebugPanel() 42 | panel?.show() 43 | }), 44 | 45 | ) 46 | 47 | try { 48 | // https://code.visualstudio.com/updates/v1_53#_external-uri-opener 49 | // @ts-expect-error proposed API 50 | ctx.subscriptions.push(window.registerExternalUriOpener?.( 51 | 'browse-lite.opener', 52 | { 53 | canOpenExternalUri: () => 2, 54 | openExternalUri(resolveUri: Uri) { 55 | manager.create(resolveUri) 56 | }, 57 | }, 58 | { 59 | schemes: ['http', 'https'], 60 | label: 'Open URL using Browse Lite', 61 | }, 62 | )) 63 | } 64 | catch {} 65 | } 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "jsx": "react-jsx", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "allowJs": true, 14 | "strict": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noEmit": true, 17 | "outDir": "build", 18 | "sourceMap": true, 19 | "allowSyntheticDefaultImports": true, 20 | "esModuleInterop": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "isolatedModules": true, 23 | "skipLibCheck": true 24 | }, 25 | "include": [ 26 | "client" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import React from '@vitejs/plugin-react-refresh' 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | React(), 7 | ], 8 | build: { 9 | outDir: 'dist/client', 10 | }, 11 | }) 12 | --------------------------------------------------------------------------------