├── .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 |
3 |
4 |
5 |
6 | Browse Lite
7 |
8 |
9 | Embedded browser in VS Code
10 |
11 |
12 |
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 |
32 |
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 |
64 | {this.emulatedDevices.map((device: any) => {
65 | return (
66 |
67 | {device.name}
68 |
69 | )
70 | })}
71 |
72 |
73 |
74 |
81 | 𝗑
82 |
89 |
90 |
91 |
92 | {zoomLevels.map((level: any) => {
93 | return (
94 |
95 | {level.label}
96 |
97 | )
98 | })}
99 |
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 |
31 | Refresh
32 |
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 |
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 | {/*
74 | Inspect
75 | */}
76 |
81 |
82 |
83 |
88 |
89 |
90 |
94 |
95 |
96 |
101 |
106 |
107 |
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 |
--------------------------------------------------------------------------------