├── lib ├── index.ts ├── build │ ├── init.unit.test.ts │ ├── init.ts │ └── build-safari.ts ├── utils.ts ├── messager.ts ├── addStyle.ts ├── ext │ └── injectAndExecuteCode.ts ├── addStyle.test.ts └── fileSelector.ts ├── .env.local.example ├── entrypoints ├── devtools-panel │ ├── examples │ │ ├── basic.ts │ │ ├── import.ts │ │ ├── jsx-preact.tsx │ │ ├── jsx-solid.tsx │ │ ├── jsx-react.tsx │ │ └── mixin.ts │ ├── utils │ │ ├── isWebWorker.ts │ │ ├── useEventBus.ts │ │ ├── formatCode.ts │ │ ├── fs.ts │ │ ├── initTypeAcquisition.ts │ │ ├── transformTopLevelAwait.test.ts │ │ ├── esm.ts │ │ ├── bundle.test.ts │ │ ├── localStoreReact.ts │ │ ├── bundleCombined.test.ts │ │ ├── transformExports.test.ts │ │ ├── transformTopLevelAwait.ts │ │ ├── transformExports.ts │ │ └── bundle.ts │ ├── main.tsx │ ├── index.html │ ├── monaco.ts │ ├── App.tsx │ ├── store.ts │ ├── commands │ │ ├── OpenAbout.tsx │ │ └── OpenSettings.tsx │ ├── Toolbar.tsx │ ├── app.css │ └── Editor.tsx └── devtools │ ├── main.ts │ └── index.html ├── docs ├── cover1.png ├── cover2.png ├── cover3.png ├── cover4.png ├── cover5.png ├── demo1.png ├── demo2.png └── demo3.png ├── public └── icon │ ├── 16.png │ ├── 32.png │ ├── 48.png │ ├── 96.png │ └── 128.png ├── tsconfig.json ├── .gitignore ├── integrations ├── shadow │ └── ShadowProvider.tsx └── theme │ ├── ThemeProvider.tsx │ └── ThemeToggle.tsx ├── patches └── wxt.patch ├── components.json ├── components └── ui │ ├── sonner.tsx │ ├── label.tsx │ ├── tooltip.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── select.tsx │ ├── dropdown-menu.tsx │ └── menubar.tsx ├── vitest.config.ts ├── README.md ├── wxt.config.ts ├── .github └── workflows │ └── release.yaml └── package.json /lib/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | DEVELOPMENT_TEAM= -------------------------------------------------------------------------------- /entrypoints/devtools-panel/examples/basic.ts: -------------------------------------------------------------------------------- 1 | console.log('Hello from Monaco!') 2 | -------------------------------------------------------------------------------- /docs/cover1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/cover1.png -------------------------------------------------------------------------------- /docs/cover2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/cover2.png -------------------------------------------------------------------------------- /docs/cover3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/cover3.png -------------------------------------------------------------------------------- /docs/cover4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/cover4.png -------------------------------------------------------------------------------- /docs/cover5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/cover5.png -------------------------------------------------------------------------------- /docs/demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/demo1.png -------------------------------------------------------------------------------- /docs/demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/demo2.png -------------------------------------------------------------------------------- /docs/demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/docs/demo3.png -------------------------------------------------------------------------------- /public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/public/icon/16.png -------------------------------------------------------------------------------- /public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/public/icon/32.png -------------------------------------------------------------------------------- /public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/public/icon/48.png -------------------------------------------------------------------------------- /public/icon/96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/public/icon/96.png -------------------------------------------------------------------------------- /public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxliuli/typescript-console/HEAD/public/icon/128.png -------------------------------------------------------------------------------- /entrypoints/devtools-panel/examples/import.ts: -------------------------------------------------------------------------------- 1 | import { add } from 'es-toolkit/compat' 2 | 3 | console.log(add(1, 2)) 4 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/examples/jsx-preact.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'preact' 2 | 3 | function App() { 4 | return

Hello World

5 | } 6 | 7 | render(, document.body) 8 | -------------------------------------------------------------------------------- /lib/build/init.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | describe('init', () => { 4 | it('should init', () => { 5 | expect(true).toBe(true) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/examples/jsx-solid.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'solid-js/web' 2 | 3 | function App() { 4 | return

Hello World

5 | } 6 | 7 | render(() as any, document.body) 8 | -------------------------------------------------------------------------------- /lib/messager.ts: -------------------------------------------------------------------------------- 1 | import { defineExtensionMessaging } from '@webext-core/messaging' 2 | 3 | export const messager = defineExtensionMessaging<{ 4 | executeCode: (code: string) => Promise 5 | }>() 6 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/examples/jsx-react.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | function App() { 4 | return

Hello World

5 | } 6 | 7 | createRoot(document.body).render() 8 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/examples/mixin.ts: -------------------------------------------------------------------------------- 1 | import { add as add1 } from 'es-toolkit/compat' 2 | import * as _ from 'es-toolkit/compat' 3 | import add2 from 'es-toolkit/compat/add' 4 | 5 | console.log(_.add(1, 2), add1(1, 2), add2(1, 2)) 6 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/isWebWorker.ts: -------------------------------------------------------------------------------- 1 | export function isWebWorker() { 2 | return ( 3 | typeof self === 'object' && 4 | typeof (self as any).WorkerGlobalScope === 'function' && 5 | self instanceof (self as any).WorkerGlobalScope 6 | ) 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "jsx": "react-jsx", 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": [ 9 | "./*" 10 | ] 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /entrypoints/devtools-panel/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './app.css' 3 | import { createRoot } from 'react-dom/client' 4 | import { App } from './App' 5 | 6 | const container = document.getElementById('app')! 7 | const root = createRoot(container) 8 | root.render() 9 | -------------------------------------------------------------------------------- /lib/addStyle.ts: -------------------------------------------------------------------------------- 1 | export function addStyle(shadow: ShadowRoot, styles: string[]) { 2 | const sheets = styles.map((style) => { 3 | const sheet = new CSSStyleSheet() 4 | sheet.replaceSync(style.replaceAll(':root', ':host')) 5 | return sheet 6 | }) 7 | shadow.adoptedStyleSheets = sheets 8 | } 9 | -------------------------------------------------------------------------------- /entrypoints/devtools/main.ts: -------------------------------------------------------------------------------- 1 | import { injectAndExecuteCode } from '@/lib/ext/injectAndExecuteCode' 2 | import { messager } from '@/lib/messager' 3 | 4 | browser.devtools.panels.create( 5 | 'TypeScript Console', 6 | 'icon/128.png', 7 | 'devtools-panel.html', 8 | () => { 9 | messager.onMessage('executeCode', (ev) => injectAndExecuteCode(ev.data)) 10 | }, 11 | ) 12 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/useEventBus.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { em, Events } from '../store' 3 | 4 | export function useEventBus( 5 | event: T, 6 | callback: Events[T], 7 | ) { 8 | useEffect(() => { 9 | em.on(event, callback as any) 10 | return () => { 11 | em.off(event, callback as any) 12 | } 13 | }, [event, callback]) 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .output 12 | stats.html 13 | stats-*.json 14 | .wxt 15 | web-ext.config.ts 16 | .env.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /integrations/shadow/ShadowProvider.tsx: -------------------------------------------------------------------------------- 1 | let _container: HTMLElement | null = null 2 | 3 | export function getShadowRoot() { 4 | if (!_container) { 5 | throw new Error('Shadow root not found') 6 | } 7 | return _container 8 | } 9 | 10 | export function ShadowProvider(props: { 11 | children: React.ReactNode 12 | container: HTMLElement 13 | }) { 14 | _container = props.container 15 | return props.children 16 | } 17 | -------------------------------------------------------------------------------- /entrypoints/devtools/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TypeScript Console 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TypeScript Console Panel 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /lib/ext/injectAndExecuteCode.ts: -------------------------------------------------------------------------------- 1 | export async function injectAndExecuteCode(code: string) { 2 | return new Promise((resolve, reject) => { 3 | // TODO: not working with Safari 4 | browser.devtools.inspectedWindow.eval( 5 | code, 6 | (_result: any, isException: any) => { 7 | if (isException) { 8 | reject(new Error(isException.value || 'Evaluation failed')) 9 | } else { 10 | resolve() 11 | } 12 | } 13 | ) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /patches/wxt.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/core/utils/env.mjs b/dist/core/utils/env.mjs 2 | index fdea86be6803ff509e8db9715a8dc591f1f14122..e2fd240bc5ee0760056a8deccc91a502d34d2e72 100644 3 | --- a/dist/core/utils/env.mjs 4 | +++ b/dist/core/utils/env.mjs 5 | @@ -3,6 +3,7 @@ import { expand } from "dotenv-expand"; 6 | export function loadEnv(mode, browser) { 7 | return expand( 8 | config({ 9 | + quiet: true, 10 | // Files on top override files below 11 | path: [ 12 | `.env.${mode}.${browser}.local`, 13 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /lib/addStyle.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { addStyle } from './addStyle' 3 | 4 | describe('addStyle', () => { 5 | it('should add a style to the shadow root', () => { 6 | const div = document.createElement('div') 7 | div.attachShadow({ mode: 'open' }) 8 | document.body.appendChild(div) 9 | const root = div.shadowRoot! 10 | addStyle(root, ['.test { color: red; }']) 11 | const span = document.createElement('span') 12 | span.classList.add('test') 13 | root.appendChild(span) 14 | expect(getComputedStyle(root.querySelector('span')!).color).toBe( 15 | 'rgb(255, 0, 0)', 16 | ) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/formatCode.ts: -------------------------------------------------------------------------------- 1 | import { formatWithCursor } from 'prettier/standalone' 2 | import prettierPluginESTree from 'prettier/plugins/estree' 3 | import prettierPluginTypescript from 'prettier/plugins/typescript' 4 | import { expose } from 'comlink' 5 | import { isWebWorker } from './isWebWorker' 6 | 7 | export function formatCode(code: string, cursorOffset: number) { 8 | return formatWithCursor(code, { 9 | cursorOffset, 10 | parser: 'typescript', 11 | plugins: [prettierPluginESTree, prettierPluginTypescript], 12 | semi: false, 13 | singleQuote: true, 14 | }) 15 | } 16 | 17 | if (isWebWorker()) { 18 | expose(formatCode) 19 | } 20 | -------------------------------------------------------------------------------- /lib/fileSelector.ts: -------------------------------------------------------------------------------- 1 | export async function fileSelector(options?: { 2 | accept?: string 3 | multiple?: boolean 4 | }): Promise { 5 | const input = document.createElement('input') 6 | input.type = 'file' 7 | if (options?.accept) { 8 | input.accept = options.accept 9 | } 10 | input.multiple = options?.multiple || false 11 | return await new Promise((resolve) => { 12 | input.addEventListener('change', () => { 13 | resolve(input.files) 14 | input.remove() 15 | }) 16 | input.addEventListener('cancel', () => { 17 | resolve(null) 18 | input.remove() 19 | }) 20 | input.click() 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/monaco.ts: -------------------------------------------------------------------------------- 1 | // Import the workers in a production-safe way. 2 | // This is different than in Monaco's documentation for Vite, 3 | // but avoids a weird error ("Unexpected usage") at runtime 4 | import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' 5 | import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' 6 | 7 | // @ts-expect-error 8 | self.MonacoEnvironment = { 9 | getWorker: function (_: string, label: string) { 10 | switch (label) { 11 | case 'typescript': 12 | case 'javascript': 13 | return new tsWorker() 14 | default: 15 | return new editorWorker() 16 | } 17 | }, 18 | } 19 | 20 | export * as monaco from 'monaco-editor' 21 | -------------------------------------------------------------------------------- /lib/build/init.ts: -------------------------------------------------------------------------------- 1 | import { fs, globby, question } from 'zx' 2 | 3 | const files = await globby('**/*.{ts,json,yaml}', { 4 | dot: true, 5 | ignore: ['node_modules/**', 'dist/**', __filename], 6 | }) 7 | 8 | const projectName = await question('Please enter the project name: ') 9 | const projectId = projectName.replaceAll(' ', '-').toLowerCase() 10 | 11 | const id = 'browser-extension-template' 12 | const name = 'Browser Extension Template' 13 | 14 | for (const it of files) { 15 | let content = (await fs.readFile(it, 'utf-8')) as string 16 | if (!content.includes(id) && !content.includes(name)) { 17 | continue 18 | } 19 | content = content.replaceAll(id, projectId).replaceAll(name, projectName) 20 | await fs.writeFile( 21 | it, 22 | content.replace('browser-extension-template', projectId), 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/App.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from '@/components/ui/sonner' 2 | import { ThemeProvider } from '@/integrations/theme/ThemeProvider' 3 | import { Toolbar } from './Toolbar' 4 | import { Editor } from './Editor' 5 | import { OpenSettings } from './commands/OpenSettings' 6 | import { OpenAbout } from './commands/OpenAbout' 7 | import { ShadowProvider } from '@/integrations/shadow/ShadowProvider' 8 | 9 | export function App() { 10 | return ( 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | 5 | export default defineConfig({ 6 | test: { 7 | projects: [ 8 | { 9 | plugins: [react(), tsconfigPaths()] as any, 10 | test: { 11 | exclude: ['**/*.unit.test.ts', 'node_modules/**'], 12 | browser: { 13 | enabled: true, 14 | provider: 'playwright', 15 | // https://vitest.dev/guide/browser/playwright 16 | instances: [{ browser: 'chromium', headless: true }], 17 | }, 18 | }, 19 | }, 20 | { 21 | plugins: [tsconfigPaths()] as any, 22 | test: { 23 | include: ['**/*.unit.test.ts'], 24 | exclude: ['*.test.ts', 'node_modules/**'], 25 | }, 26 | }, 27 | ], 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'esbuild-wasm' 2 | 3 | /** 4 | * esbuild 的浏览器文件系统插件 5 | * copy: https://github.com/hyrious/esbuild-repl/blob/main/src/helpers/fs.ts 6 | * @param modules 7 | */ 8 | export function esbuildPluginFs(files: Record): Plugin { 9 | return { 10 | name: 'esbuild-plugin-fs', 11 | setup({ onResolve, onLoad }) { 12 | onResolve({ filter: /()/ }, (args) => { 13 | const name = args.path.replace(/^\.\//, '') 14 | const code = files[name] 15 | if (code) { 16 | return { path: name, namespace: 'fs', pluginData: code } 17 | } 18 | }) 19 | // noinspection ES6ShorthandObjectProperty 20 | onLoad({ filter: /()/, namespace: 'fs' }, (args) => { 21 | const mod: string = args.pluginData 22 | if (mod) { 23 | return { contents: mod, loader: 'default' } 24 | } 25 | return 26 | }) 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /integrations/theme/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider as NextThemeProvider, useTheme as useNextTheme } from 'next-themes' 2 | import { useEffect } from 'react' 3 | import { getShadowRoot } from '../shadow/ShadowProvider' 4 | 5 | type Theme = 'dark' | 'light' | 'system' 6 | 7 | type ThemeProviderProps = { 8 | children: React.ReactNode 9 | defaultTheme?: Theme 10 | storageKey?: string 11 | } 12 | 13 | function ThemeClassSetter() { 14 | const { resolvedTheme } = useNextTheme() 15 | 16 | useEffect(() => { 17 | if (!resolvedTheme) { 18 | return 19 | } 20 | const root = getShadowRoot() 21 | root.classList.remove('light', 'dark') 22 | root.classList.add(resolvedTheme) 23 | }, [resolvedTheme]) 24 | return <> 25 | } 26 | 27 | export function ThemeProvider({ 28 | children, 29 | defaultTheme = 'system', 30 | storageKey = 'vite-ui-theme', 31 | ...props 32 | }: ThemeProviderProps) { 33 | return ( 34 | 35 | 36 | {children} 37 | 38 | ) 39 | } 40 | 41 | export const useTheme = useNextTheme 42 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/store.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'eventemitter3' 2 | 3 | import { create } from 'zustand' 4 | import { localStore } from './utils/localStoreReact' 5 | 6 | export interface Events { 7 | runScript: () => void 8 | openSettings: () => void 9 | openAbout: () => void 10 | 11 | openFile: () => void 12 | saveFile: () => void 13 | 14 | changeFontSize: (fontSize: number) => void 15 | setExecuting: (isExecuting: boolean) => void 16 | } 17 | 18 | export const em = new EventEmitter() 19 | 20 | // zustand store for execution state 21 | export interface ExecutionStore { 22 | isExecuting: boolean 23 | controller: AbortController | null 24 | start: (c: AbortController) => void 25 | stop: () => void 26 | } 27 | 28 | export const useExecutionStore = create((set, get) => ({ 29 | isExecuting: false, 30 | controller: null, 31 | start: (ctrl) => set({ isExecuting: true, controller: ctrl }), 32 | stop: () => { 33 | const ctrl = get().controller 34 | if (ctrl) { 35 | ctrl.abort() 36 | } 37 | set({ isExecuting: false, controller: null }) 38 | }, 39 | })) 40 | 41 | export const settings = localStore('settings', { 42 | fontSize: 14, 43 | }) 44 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/initTypeAcquisition.ts: -------------------------------------------------------------------------------- 1 | import { setupTypeAcquisition } from '@typescript/ata' 2 | import * as ts from 'typescript' 3 | import { expose } from 'comlink' 4 | import { isWebWorker } from './isWebWorker' 5 | 6 | let ta: ReturnType 7 | 8 | function initTypeAcquisition( 9 | addLibraryToRuntime: (code: string, path: string) => void, 10 | ) { 11 | ta = setupTypeAcquisition({ 12 | projectName: 'TypeScript Playground', 13 | typescript: ts, 14 | logger: console, 15 | delegate: { 16 | receivedFile: (code: string, path: string) => { 17 | addLibraryToRuntime(code, path) 18 | // console.log('Received file', code, path) 19 | }, 20 | progress: (dl: number, ttl: number) => { 21 | // console.log({ dl, ttl }) 22 | }, 23 | started: () => { 24 | console.log('ATA start') 25 | }, 26 | finished: (f) => { 27 | console.log('ATA done') 28 | }, 29 | }, 30 | }) 31 | } 32 | 33 | export const typeAcquisition = { 34 | init: initTypeAcquisition, 35 | dl: (code: string) => { 36 | if (!ta) { 37 | throw new Error('TypeAcquisition not initialized') 38 | } 39 | return ta(code) 40 | }, 41 | } 42 | 43 | if (isWebWorker()) { 44 | expose(typeAcquisition) 45 | } 46 | -------------------------------------------------------------------------------- /integrations/theme/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from 'lucide-react' 2 | 3 | import { Button } from '@/components/ui/button' 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from '@/components/ui/dropdown-menu' 10 | import { useTheme } from './ThemeProvider' 11 | 12 | export function ThemeToggle() { 13 | const { setTheme } = useTheme() 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 | 25 | setTheme('light')}> 26 | Light 27 | 28 | setTheme('dark')}> 29 | Dark 30 | 31 | setTheme('system')}> 32 | System 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/transformTopLevelAwait.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { applyTransforms } from './bundle' 3 | 4 | it('simple await', () => { 5 | const code = applyTransforms(` 6 | console.log(1) 7 | await new Promise(res => setTimeout(res, 1000)) 8 | console.log(2) 9 | `) 10 | expect(code).include('(async () => {') 11 | }) 12 | it('await value', async () => { 13 | expect(await eval(`Promise.resolve(1)`)).eq(1) 14 | const code = applyTransforms(`await Promise.resolve(1)`) 15 | expect(code).include('(async () => {') 16 | const r = await eval(code) 17 | expect(r).eq(1) 18 | }) 19 | it('no includes await', () => { 20 | const c = ` 21 | console.log(1) 22 | console.log(2) 23 | ` 24 | const code = applyTransforms(c) 25 | expect(code).include('console.log(1)') 26 | expect(code).include('console.log(2)') 27 | }) 28 | it('includes imports', async () => { 29 | const code = applyTransforms(` 30 | import { add } from 'es-toolkit/compat' 31 | 32 | await Promise.resolve(add(1, 2)) 33 | `) 34 | expect(code).include(`import { add } from 'es-toolkit/compat'`) 35 | }) 36 | it('includes await but not in start', async () => { 37 | const c = ` 38 | const r = await Promise.resolve(1) 39 | r 40 | `.trim() 41 | const code = applyTransforms(c) 42 | const r = await eval(code) 43 | expect(r).eq(1) 44 | }) 45 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/esm.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'esbuild-wasm' 2 | 3 | export function esm(options: { signal?: AbortSignal }): Plugin { 4 | return { 5 | name: 'esm', 6 | setup(build) { 7 | build.onResolve({ filter: /^[^./]/ }, async (args) => { 8 | if (args.path.startsWith('http')) { 9 | return { path: args.path, namespace: 'http-url' } 10 | } 11 | return { 12 | path: `https://esm.sh/${args.path}`, 13 | namespace: 'http-url', 14 | } 15 | }) 16 | 17 | build.onResolve({ filter: /.*/, namespace: 'http-url' }, (args) => { 18 | return { 19 | path: new URL(args.path, args.importer).toString(), 20 | namespace: 'http-url', 21 | } 22 | }) 23 | 24 | build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => { 25 | if (options.signal?.aborted) { 26 | throw new Error('Aborted') 27 | } 28 | const importPath = args.path.startsWith('/') 29 | ? 'https://esm.sh' + args.path 30 | : args.path 31 | try { 32 | const response = await fetch(importPath) 33 | const contents = await response.text() 34 | return { contents } 35 | } catch (error) { 36 | throw new Error(`Failed to fetch ${importPath}: ${error}`) 37 | } 38 | }) 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Console 2 | 3 | [![chrome-web-store](https://badgen.net/chrome-web-store/v/jkanoakidjoklcefakbdnnhgdenddppg)](https://chromewebstore.google.com/detail/jkanoakidjoklcefakbdnnhgdenddppg) [![producthunt](https://badgen.net/badge/producthunt/upvoted/orange)](https://www.producthunt.com/posts/typescript-console) 4 | 5 | Run and debug TypeScript code in the Chrome DevTools. 6 | 7 | ## Features 8 | 9 | - [x] Write and run TypeScript directly in DevTools 10 | - [x] Supports latest TypeScript features 11 | - [x] Easy to use: just Cmd/Ctrl+S to run 12 | - [x] Importing npm packages 13 | - [ ] Custom tsconfig.json 14 | - [ ] Adding breakpoints directly 15 | - [ ] Snippet functionality 16 | - [ ] AI assistant 17 | - [ ] VIM Mode 18 | - [x] TSX support 19 | - [x] ~~Custom keybinding~~ 20 | 21 | ## Usage 22 | 23 | > [Demo video](https://www.youtube.com/watch?v=TkHridClbyM) 24 | 25 | 1. Install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/jkanoakidjoklcefakbdnnhgdenddppg). 26 | 2. Open Chrome DevTools. 27 | 3. Open the "TypeScript Console" panel. 28 | ![demo1](./docs/demo1.png) 29 | 4. Show console drawer 30 | ![demo2](./docs/demo2.png) 31 | 5. Write and run TypeScript code. 32 | ![demo3](./docs/demo3.png) 33 | 6. Click the "Run" button or press `Cmd/Ctrl+S` to execute the code. 34 | 35 | ## Demo 36 | 37 | Normal operation 38 | ![cover1](./docs/cover1.png) 39 | 40 | Operation DOM 41 | ![cover2](./docs/cover2.png) 42 | 43 | Syntax error 44 | ![cover3](./docs/cover3.png) 45 | 46 | Runtime error 47 | ![cover4](./docs/cover4.png) 48 | 49 | Breakpoint debugging 50 | ![cover5](./docs/cover5.png) 51 | -------------------------------------------------------------------------------- /wxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, UserManifest } from 'wxt' 2 | import tailwindcss from '@tailwindcss/vite' 3 | 4 | export default defineConfig({ 5 | modules: ['@wxt-dev/module-react'], 6 | vite: () => ({ 7 | plugins: [tailwindcss()] as any, 8 | resolve: { 9 | alias: { 10 | '@': __dirname, 11 | }, 12 | }, 13 | }), 14 | manifestVersion: 3, 15 | manifest: (env) => { 16 | const manifest: UserManifest = { 17 | name: 'TypeScript Console', 18 | description: 'Run and debug TypeScript code in the Browser DevTools.', 19 | content_security_policy: { 20 | extension_pages: 21 | "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';", 22 | }, 23 | author: { 24 | email: 'rxliuli@gmail.com', 25 | }, 26 | action: { 27 | default_icon: { 28 | '16': 'icon/16.png', 29 | '32': 'icon/32.png', 30 | '48': 'icon/48.png', 31 | '96': 'icon/96.png', 32 | '128': 'icon/128.png', 33 | }, 34 | }, 35 | homepage_url: 'https://rxliuli.com/project/typescript-console', 36 | } 37 | if (env.browser === 'firefox') { 38 | manifest.browser_specific_settings = { 39 | gecko: { 40 | id: 41 | manifest.name!.toLowerCase().replaceAll(/[^a-z0-9]/g, '-') + 42 | '@rxliuli.com', 43 | }, 44 | } 45 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/author 46 | // @ts-expect-error 47 | manifest.author = 'rxliuli' 48 | } 49 | if (env.browser === 'safari') { 50 | manifest.name = 'Type-Safe Console' 51 | manifest.description = 'TypeScript Support for DevTools' 52 | } 53 | return manifest 54 | }, 55 | webExt: { 56 | disabled: true, 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/commands/OpenAbout.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Button } from '@/components/ui/button' 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | } from '@/components/ui/dialog' 10 | import { useEventBus } from '../utils/useEventBus' 11 | import copy from 'copy-to-clipboard' 12 | import esbuildPkg from 'esbuild-wasm/package.json' 13 | import monacoPkg from 'monaco-editor/package.json' 14 | import typescriptPkg from 'typescript/package.json' 15 | import { version } from '../../../package.json' 16 | 17 | export function OpenAbout() { 18 | const [open, setOpen] = useState(false) 19 | 20 | useEventBus('openAbout', () => { 21 | setOpen(true) 22 | }) 23 | 24 | const handleCopy = async () => { 25 | const text = [ 26 | `Version: ${version}`, 27 | 'Copyright © 2024 rxliuli', 28 | `TypeScript: ${typescriptPkg.version}`, 29 | `Monaco-Editor: ${monacoPkg.version}`, 30 | `ESBuild: ${esbuildPkg.version}`, 31 | ].join('\n') 32 | 33 | console.log('aboutRef?.textContent', text) 34 | copy(text) 35 | setOpen(false) 36 | } 37 | 38 | return ( 39 | 40 | 41 | 42 | About 43 | 44 |
45 |
Version: {version}
46 |
Copyright © 2024 rxliuli
47 |
TypeScript: {typescriptPkg.version}
48 |
Monaco-Editor: {monacoPkg.version}
49 |
ESBuild: {esbuildPkg.version}
50 |
51 | 52 | 53 | 54 |
55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { bundle } from './bundle' 3 | 4 | it('bundle simple code', async () => { 5 | const code = ` 6 | console.log('Hello, World!') 7 | `.trim() 8 | const r = await bundle(code) 9 | expect(r).include('console.log("Hello, World!")') 10 | }) 11 | 12 | it('import package', async () => { 13 | const code = ` 14 | import { add } from 'es-toolkit/compat' 15 | 16 | console.log(add(1, 2)) 17 | ` 18 | const r = await bundle(code) 19 | expect(r).include('(1, 2)') 20 | }) 21 | 22 | it('bundle with abort signal', async () => { 23 | const code = ` 24 | import { add } from 'es-toolkit/compat' 25 | 26 | console.log(add(1, 2)) 27 | ` 28 | const controller = new AbortController() 29 | controller.abort() 30 | await expect(bundle(code, { signal: controller.signal })).rejects.toThrow( 31 | 'Aborted', 32 | ) 33 | }) 34 | 35 | it('bundle with react jsx', async () => { 36 | const code = ` 37 | import { createRoot } from 'react-dom/client' 38 | 39 | function App() { 40 | return

Hello World

41 | } 42 | 43 | createRoot(document.body).render() 44 | ` 45 | const r = await bundle(code) 46 | expect(r).include('react') 47 | }) 48 | 49 | it('bundle with preact jsx', async () => { 50 | const code = ` 51 | import { render } from 'preact' 52 | 53 | function App() { 54 | return

Hello World

55 | } 56 | 57 | render(, document.body) 58 | ` 59 | const r = await bundle(code) 60 | expect(r).include('preact') 61 | }) 62 | 63 | it('bundle with solid jsx', async () => { 64 | const code = ` 65 | import { render } from "solid-js/web"; 66 | 67 | function App() { 68 | return

Hello World

; 69 | } 70 | 71 | render(, document.body); 72 | ` 73 | const r = await bundle(code) 74 | expect(r).include('solid-js') 75 | }) 76 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | DIRECTORY: .output 3 | PROJECT_NAME: typescript-console 4 | 5 | name: Release 6 | 7 | on: 8 | push: 9 | branches: ['main'] 10 | paths: 11 | - 'package.json' 12 | 13 | jobs: 14 | version: 15 | runs-on: ubuntu-latest 16 | outputs: 17 | changed: ${{ steps.version.outputs.changed }} 18 | version: ${{ steps.version.outputs.version }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 2 23 | - name: Check version change 24 | id: version 25 | uses: rxliuli/version-check@v1 26 | with: 27 | file: ./package.json 28 | 29 | release: 30 | permissions: 31 | contents: write 32 | needs: version 33 | if: ${{ needs.version.outputs.changed == 'true' }} 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: pnpm/action-setup@v3 38 | with: 39 | version: 'latest' 40 | - name: Use Node.js 41 | uses: actions/setup-node@v4 42 | with: 43 | node-version: 24 44 | cache: 'pnpm' 45 | - name: Install dependencies 46 | run: pnpm install 47 | - name: Zip extensions 48 | run: | 49 | pnpm run zip 50 | pnpm run zip:firefox 51 | 52 | - name: Create Release 53 | uses: softprops/action-gh-release@v2 54 | with: 55 | tag_name: 'v${{ needs.version.outputs.version }}' 56 | name: 'v${{ needs.version.outputs.version }}' 57 | draft: false 58 | prerelease: false 59 | files: | 60 | ${{ env.DIRECTORY }}/${{env.PROJECT_NAME}}-${{ needs.version.outputs.version }}-chrome.zip 61 | ${{ env.DIRECTORY }}/${{env.PROJECT_NAME}}-${{ needs.version.outputs.version }}-firefox.zip 62 | ${{ env.DIRECTORY }}/${{env.PROJECT_NAME}}-${{ needs.version.outputs.version }}-sources.zip -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 60 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/localStoreReact.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export interface Store { 4 | get(): T 5 | set(value: T): void 6 | subscribe(callback: (value: T) => void): () => void 7 | } 8 | 9 | class LocalStore implements Store { 10 | private key: string 11 | private initialValue: T 12 | private listeners: ((value: T) => void)[] = [] 13 | private currentValue: T 14 | 15 | constructor(key: string, initialValue: T) { 16 | this.key = key 17 | this.initialValue = initialValue 18 | 19 | // Initialize from localStorage or use initial value 20 | const stored = localStorage.getItem(key) 21 | if (stored !== null) { 22 | try { 23 | this.currentValue = JSON.parse(stored) 24 | } catch { 25 | this.currentValue = initialValue 26 | this.saveToLocalStorage(initialValue) 27 | } 28 | } else { 29 | this.currentValue = initialValue 30 | this.saveToLocalStorage(initialValue) 31 | } 32 | } 33 | 34 | private saveToLocalStorage(value: T) { 35 | localStorage.setItem(this.key, JSON.stringify(value, null, 2)) 36 | } 37 | 38 | get(): T { 39 | return this.currentValue 40 | } 41 | 42 | set(value: T): void { 43 | this.currentValue = value 44 | this.saveToLocalStorage(value) 45 | this.listeners.forEach(listener => listener(value)) 46 | } 47 | 48 | subscribe(callback: (value: T) => void): () => void { 49 | this.listeners.push(callback) 50 | return () => { 51 | const index = this.listeners.indexOf(callback) 52 | if (index > -1) { 53 | this.listeners.splice(index, 1) 54 | } 55 | } 56 | } 57 | } 58 | 59 | export function localStore(key: string, initial: T): Store { 60 | return new LocalStore(key, initial) 61 | } 62 | 63 | // React hook to use a store 64 | export function useStore(store: Store): [T, (value: T) => void] { 65 | const [value, setValue] = useState(store.get()) 66 | 67 | useEffect(() => { 68 | const unsubscribe = store.subscribe(setValue) 69 | return unsubscribe 70 | }, [store]) 71 | 72 | return [value, store.set.bind(store)] 73 | } -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/bundleCombined.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from 'vitest' 2 | import { bundle } from './bundle' 3 | 4 | describe('bundle with combined transforms', () => { 5 | it('should remove exports and handle top-level await in a single pass', async () => { 6 | const input = ` 7 | export const message = 'hello' 8 | 9 | export function greet(name: string) { 10 | return \`\${message}, \${name}!\` 11 | } 12 | 13 | const response = await fetch('https://api.example.com/data') 14 | const data = await response.json() 15 | 16 | export class MyClass { 17 | value = data.value 18 | } 19 | 20 | greet('world') 21 | ` 22 | 23 | const output = await bundle(input) 24 | 25 | // Should not contain any export statements 26 | expect(output).not.toContain('export const') 27 | expect(output).not.toContain('export function') 28 | expect(output).not.toContain('export class') 29 | 30 | // Should contain the declarations without export 31 | expect(output).toContain('const message') 32 | expect(output).toContain('function greet') 33 | expect(output).toContain('class MyClass') 34 | 35 | // Should wrap top-level await in an IIFE 36 | expect(output).toMatch(/var __result__\w+ = \(async \(\) => \{/) 37 | 38 | // Should be a valid IIFE format 39 | expect(output).toMatch(/^\(\(\) => \{/) 40 | }) 41 | 42 | it('should only remove exports if no top-level await is present', async () => { 43 | const input = ` 44 | export const message = 'hello world' 45 | 46 | export function sayHello() { 47 | console.log(message) 48 | } 49 | 50 | sayHello() 51 | ` 52 | 53 | const output = await bundle(input) 54 | 55 | // Should not contain exports 56 | expect(output).not.toContain('export const') 57 | expect(output).not.toContain('export function') 58 | 59 | // Should contain declarations without export (using var due to esbuild transformation) 60 | expect(output).toContain('var message') 61 | expect(output).toContain('function sayHello') 62 | 63 | // Should not create an async IIFE since there's no top-level await 64 | expect(output).not.toMatch(/var __result__\w+ = \(async \(\) => \{/) 65 | }) 66 | }) -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/commands/OpenSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { em, settings } from '../store' 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogHeader, 7 | DialogTitle, 8 | } from '@/components/ui/dialog' 9 | import { 10 | Select, 11 | SelectContent, 12 | SelectGroup, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from '@/components/ui/select' 17 | import { Label } from '@/components/ui/label' 18 | import { useEventBus } from '../utils/useEventBus' 19 | import { useStore } from '../utils/localStoreReact' 20 | 21 | export function OpenSettings() { 22 | const [open, setOpen] = useState(false) 23 | const [currentSettings, setSettings] = useStore(settings) 24 | 25 | useEventBus('openSettings', () => { 26 | setOpen(true) 27 | }) 28 | 29 | const fontSizeOptions = [12, 14, 16, 18, 20] 30 | 31 | const handleFontSizeChange = (value: string) => { 32 | const fontSize = parseInt(value) 33 | setSettings({ 34 | ...currentSettings, 35 | fontSize, 36 | }) 37 | em.emit('changeFontSize', fontSize) 38 | } 39 | 40 | return ( 41 | 42 | 43 | 44 | Settings 45 | 46 |
47 |
48 | 51 | 71 |
72 |
73 |
74 |
75 | ) 76 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-console", 3 | "description": "Run and debug TypeScript code in the Browser DevTools.", 4 | "private": true, 5 | "type": "module", 6 | "version": "0.6.3", 7 | "scripts": { 8 | "dev": "wxt", 9 | "dev:firefox": "wxt -b firefox", 10 | "build": "wxt build", 11 | "build:firefox": "wxt build -b firefox", 12 | "zip": "wxt zip", 13 | "zip:firefox": "wxt zip -b firefox", 14 | "build:safari": "vite-node lib/build/build-safari.ts", 15 | "compile": "tsc --noEmit", 16 | "postinstall": "wxt prepare", 17 | "test": "is-ci && pnpm exec playwright install || echo 'Not in CI' && vitest run", 18 | "init-project": "vite-node lib/build/init.ts" 19 | }, 20 | "dependencies": { 21 | "@radix-ui/react-dialog": "^1.1.15", 22 | "@radix-ui/react-dropdown-menu": "^2.1.16", 23 | "@radix-ui/react-label": "^2.1.7", 24 | "@radix-ui/react-menubar": "^1.1.16", 25 | "@radix-ui/react-select": "^2.2.6", 26 | "@radix-ui/react-slot": "^1.2.3", 27 | "@radix-ui/react-tooltip": "^1.2.8", 28 | "@tailwindcss/vite": "^4.1.13", 29 | "@typescript/ata": "^0.9.8", 30 | "@webext-core/messaging": "^2.3.0", 31 | "class-variance-authority": "^0.7.1", 32 | "clsx": "^2.1.1", 33 | "comlink": "^4.4.2", 34 | "copy-to-clipboard": "^3.3.3", 35 | "es-toolkit": "^1.39.10", 36 | "esbuild-wasm": "^0.25.9", 37 | "eventemitter3": "^5.0.1", 38 | "file-saver": "^2.0.5", 39 | "lodash-es": "^4.17.21", 40 | "lucide-react": "^0.544.0", 41 | "magic-string": "^0.30.19", 42 | "monaco-editor": "^0.53.0", 43 | "next-themes": "^0.4.6", 44 | "prettier": "^3.6.2", 45 | "react": "^19.1.1", 46 | "react-dom": "^19.1.1", 47 | "react-icons": "^5.5.0", 48 | "react-use": "^17.6.0", 49 | "serialize-error": "^12.0.0", 50 | "sonner": "^2.0.7", 51 | "source-map": "^0.7.6", 52 | "tailwind-merge": "^3.3.1", 53 | "tailwindcss": "^4.1.13", 54 | "tw-animate-css": "^1.3.8", 55 | "vfile-location": "^5.0.3", 56 | "zustand": "^5.0.8" 57 | }, 58 | "devDependencies": { 59 | "@types/file-saver": "^2.0.7", 60 | "@types/node": "^24.5.0", 61 | "@types/prettier": "^3.0.0", 62 | "@types/react": "^19.1.13", 63 | "@types/react-dom": "^19.1.9", 64 | "@vitejs/plugin-react": "^5.0.2", 65 | "@vitest/browser": "^3.2.4", 66 | "@wxt-dev/module-react": "^1.1.5", 67 | "dotenv": "^17.2.2", 68 | "find-up": "^8.0.0", 69 | "is-ci": "^4.1.0", 70 | "playwright": "^1.55.0", 71 | "preact": "^10.27.2", 72 | "solid-js": "^1.9.9", 73 | "typescript": "^5.9.2", 74 | "vite": "^7.1.5", 75 | "vite-tsconfig-paths": "^5.1.4", 76 | "vitest": "^3.2.4", 77 | "vitest-browser-react": "^1.0.1", 78 | "wxt": "^0.20.11", 79 | "zx": "^8.8.1" 80 | }, 81 | "packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b", 82 | "pnpm": { 83 | "patchedDependencies": { 84 | "wxt": "patches/wxt.patch" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuTrigger, 6 | } from '@/components/ui/dropdown-menu' 7 | import { em, useExecutionStore } from './store' 8 | import { PlayIcon, Loader2Icon, MenuIcon } from 'lucide-react' 9 | import { FaDiscord } from 'react-icons/fa' 10 | import { 11 | Tooltip, 12 | TooltipContent, 13 | TooltipProvider, 14 | TooltipTrigger, 15 | } from '@/components/ui/tooltip' 16 | import { Button } from '@/components/ui/button' 17 | 18 | export function Toolbar() { 19 | const isExecuting = useExecutionStore((s) => s.isExecuting) 20 | const stop = useExecutionStore((s) => s.stop) 21 | 22 | const getIcon = () => { 23 | if (isExecuting) { 24 | return 25 | } 26 | return 27 | } 28 | 29 | const getTooltipText = () => { 30 | return isExecuting ? 'Stop execution' : 'Run ⌘S' 31 | } 32 | 33 | return ( 34 |
35 | 36 | 37 | 41 | 42 | 43 | em.emit('openFile')}> 44 | Open File 45 | 46 | em.emit('saveFile')}> 47 | Save File 48 | 49 | em.emit('openSettings')}> 50 | Settings 51 | 52 | em.emit('openAbout')}> 53 | About 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 68 | 69 | 70 |

{getTooltipText()}

71 |
72 |
73 |
74 | 75 |
76 | 77 | 78 | 79 | 88 | 89 | 90 |

Join Discord Community

91 |
92 |
93 |
94 |
95 |
96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /lib/build/build-safari.ts: -------------------------------------------------------------------------------- 1 | import { $, globby } from 'zx' 2 | import path from 'node:path' 3 | import fs from 'node:fs/promises' 4 | import dotenv from 'dotenv' 5 | import { findUp } from 'find-up' 6 | 7 | const rootPath = path.dirname((await findUp('package.json'))!) 8 | dotenv.config({ path: path.resolve(rootPath, '.env.local'), quiet: true }) 9 | // https://github.com/vitejs/vite/issues/5885 10 | process.env.NODE_ENV = 'production' 11 | 12 | const ProjectName = 'Type-Safe Console' 13 | const AppCategory = 'public.app-category.developer-tools' 14 | const DevelopmentTeam = process.env.DEVELOPMENT_TEAM 15 | 16 | await $`pnpm wxt build -b safari` 17 | await $`xcrun safari-web-extension-converter --bundle-identifier com.rxliuli.typescript-console --force --project-location .output .output/safari-mv3` 18 | async function updateProjectConfig() { 19 | const projectConfigPath = path.resolve( 20 | rootPath, 21 | `.output/${ProjectName}/${ProjectName}.xcodeproj/project.pbxproj`, 22 | ) 23 | const packageJson = await import(path.resolve(rootPath, 'package.json')) 24 | const content = await fs.readFile(projectConfigPath, 'utf-8') 25 | const newContent = content 26 | .replaceAll( 27 | 'MARKETING_VERSION = 1.0;', 28 | `MARKETING_VERSION = ${packageJson.version};`, 29 | ) 30 | .replace( 31 | new RegExp( 32 | `INFOPLIST_KEY_CFBundleDisplayName = ("?${ProjectName}"?);`, 33 | 'g', 34 | ), 35 | `INFOPLIST_KEY_CFBundleDisplayName = $1;\n INFOPLIST_KEY_LSApplicationCategoryType = "${AppCategory}";`, 36 | ) 37 | .replace( 38 | new RegExp(`GCC_WARN_UNUSED_VARIABLE = YES;`, 'g'), 39 | `GCC_WARN_UNUSED_VARIABLE = YES;\n INFOPLIST_KEY_LSApplicationCategoryType = "${AppCategory}";`, 40 | ) 41 | .replace( 42 | new RegExp( 43 | `INFOPLIST_KEY_CFBundleDisplayName = ("?${ProjectName}"?);`, 44 | 'g', 45 | ), 46 | `INFOPLIST_KEY_CFBundleDisplayName = $1;\n INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;`, 47 | ) 48 | .replaceAll( 49 | `COPY_PHASE_STRIP = NO;`, 50 | DevelopmentTeam 51 | ? `COPY_PHASE_STRIP = NO;\n DEVELOPMENT_TEAM = ${DevelopmentTeam};` 52 | : 'COPY_PHASE_STRIP = NO;', 53 | ) 54 | .replace( 55 | /CURRENT_PROJECT_VERSION = \d+;/g, 56 | `CURRENT_PROJECT_VERSION = ${parseProjectVersion(packageJson.version)};`, 57 | ) 58 | await fs.writeFile(projectConfigPath, newContent) 59 | } 60 | 61 | async function updateInfoPlist() { 62 | const projectPath = path.resolve(rootPath, '.output', ProjectName) 63 | const files = await globby('**/*.plist', { 64 | cwd: projectPath, 65 | }) 66 | for (const file of files) { 67 | const content = await fs.readFile(path.resolve(projectPath, file), 'utf-8') 68 | await fs.writeFile( 69 | path.resolve(projectPath, file), 70 | content.replaceAll( 71 | '\n', 72 | ' CFBundleVersion\n $(CURRENT_PROJECT_VERSION)\n\n', 73 | ), 74 | ) 75 | } 76 | } 77 | 78 | function parseProjectVersion(version: string) { 79 | const [major, minor, patch] = version.split('.').map(Number) 80 | return major * 10000 + minor * 100 + patch 81 | } 82 | 83 | await updateProjectConfig() 84 | await updateInfoPlist() 85 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/transformExports.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { applyTransforms } from './bundle' 3 | 4 | describe('applyTransforms', () => { 5 | it('should remove export keyword from function declarations', () => { 6 | const input = ` 7 | export function hello() { 8 | return 'world' 9 | } 10 | 11 | function normal() { 12 | return 'normal' 13 | } 14 | ` 15 | 16 | const output = applyTransforms(input) 17 | expect(output).not.toContain('export function') 18 | expect(output).toContain('function hello()') 19 | expect(output).toContain('function normal()') 20 | }) 21 | 22 | it('should remove export keyword from variable declarations', () => { 23 | const input = ` 24 | export const message = 'hello' 25 | export let count = 0 26 | export var flag = true 27 | 28 | const normal = 'normal' 29 | ` 30 | 31 | const output = applyTransforms(input) 32 | expect(output).not.toContain('export const') 33 | expect(output).not.toContain('export let') 34 | expect(output).not.toContain('export var') 35 | expect(output).toContain('const message') 36 | expect(output).toContain('let count') 37 | expect(output).toContain('var flag') 38 | expect(output).toContain('const normal') 39 | }) 40 | 41 | it('should remove export declarations', () => { 42 | const input = ` 43 | const value = 42 44 | export { value } 45 | export { value as aliasedValue } 46 | ` 47 | 48 | const output = applyTransforms(input) 49 | expect(output).not.toContain('export {') 50 | expect(output).toContain('const value') 51 | }) 52 | 53 | it('should convert export assignments to expression statements', () => { 54 | const input = ` 55 | const result = { foo: 'bar' } 56 | export = result 57 | ` 58 | 59 | const output = applyTransforms(input) 60 | expect(output).not.toContain('export =') 61 | expect(output).toContain('result;') 62 | }) 63 | 64 | it('should remove export from class declarations', () => { 65 | const input = ` 66 | export class MyClass { 67 | constructor() {} 68 | } 69 | 70 | class NormalClass { 71 | constructor() {} 72 | } 73 | ` 74 | 75 | const output = applyTransforms(input) 76 | expect(output).not.toContain('export class') 77 | expect(output).toContain('class MyClass') 78 | expect(output).toContain('class NormalClass') 79 | }) 80 | 81 | it('should remove export from interface declarations', () => { 82 | const input = ` 83 | export interface MyInterface { 84 | prop: string 85 | } 86 | 87 | interface NormalInterface { 88 | prop: number 89 | } 90 | ` 91 | 92 | const output = applyTransforms(input) 93 | expect(output).not.toContain('export interface') 94 | expect(output).toContain('interface MyInterface') 95 | expect(output).toContain('interface NormalInterface') 96 | }) 97 | 98 | it('should remove export from type alias declarations', () => { 99 | const input = ` 100 | export type MyType = string | number 101 | 102 | type NormalType = boolean 103 | ` 104 | 105 | const output = applyTransforms(input) 106 | expect(output).not.toContain('export type') 107 | expect(output).toContain('type MyType') 108 | expect(output).toContain('type NormalType') 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/transformTopLevelAwait.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript' 2 | 3 | export function createTopLevelAwaitTransformer( 4 | resultVariableName: string, 5 | ): ts.TransformerFactory { 6 | return (context) => { 7 | return (sourceFile) => { 8 | let hasTopLevelAwait = false 9 | 10 | function visitor(node: ts.Node): ts.Node { 11 | if (ts.isAwaitExpression(node) && isAtTopLevel(node)) { 12 | hasTopLevelAwait = true 13 | } 14 | return ts.visitEachChild(node, visitor, context) 15 | } 16 | 17 | ts.visitNode(sourceFile, visitor) 18 | 19 | if (!hasTopLevelAwait) { 20 | return sourceFile 21 | } 22 | 23 | const imports = sourceFile.statements.filter( 24 | (s) => ts.isImportDeclaration(s) || ts.isImportEqualsDeclaration(s), 25 | ) 26 | 27 | const body = sourceFile.statements.filter( 28 | (s) => !ts.isImportDeclaration(s) && !ts.isImportEqualsDeclaration(s), 29 | ) 30 | 31 | const originalPositions = new Map() 32 | body.forEach((stmt) => { 33 | originalPositions.set(stmt, { pos: stmt.pos, end: stmt.end }) 34 | }) 35 | 36 | const processedBody = body.map((statement, index) => { 37 | if (index === body.length - 1) { 38 | if (ts.isExpressionStatement(statement)) { 39 | const returnStmt = ts.factory.createReturnStatement( 40 | statement.expression, 41 | ) 42 | ts.setTextRange(returnStmt, statement) 43 | return returnStmt 44 | } 45 | if (ts.isIdentifier(statement as any)) { 46 | const returnStmt = ts.factory.createReturnStatement( 47 | statement as any, 48 | ) 49 | ts.setTextRange(returnStmt, statement) 50 | return returnStmt 51 | } 52 | } 53 | return statement 54 | }) 55 | 56 | const asyncBody = ts.factory.createBlock( 57 | processedBody as ts.Statement[], 58 | true, 59 | ) 60 | if (body.length > 0) { 61 | ts.setTextRange(asyncBody, { 62 | pos: body[0].pos, 63 | end: body[body.length - 1].end, 64 | }) 65 | } 66 | 67 | const asyncArrowFunction = ts.factory.createArrowFunction( 68 | [ts.factory.createModifier(ts.SyntaxKind.AsyncKeyword)], 69 | undefined, 70 | [], 71 | undefined, 72 | undefined, 73 | asyncBody, 74 | ) 75 | 76 | const iife = ts.factory.createCallExpression( 77 | ts.factory.createParenthesizedExpression(asyncArrowFunction), 78 | undefined, 79 | [], 80 | ) 81 | 82 | const resultVariable = ts.factory.createVariableStatement( 83 | undefined, 84 | ts.factory.createVariableDeclarationList( 85 | [ 86 | ts.factory.createVariableDeclaration( 87 | resultVariableName, 88 | undefined, 89 | undefined, 90 | iife, 91 | ), 92 | ], 93 | ts.NodeFlags.Const, 94 | ), 95 | ) 96 | 97 | const resultExpression = ts.factory.createExpressionStatement( 98 | ts.factory.createIdentifier(resultVariableName), 99 | ) 100 | 101 | const newStatements = [...imports, resultVariable, resultExpression] 102 | 103 | return ts.factory.updateSourceFile( 104 | sourceFile, 105 | newStatements, 106 | sourceFile.isDeclarationFile, 107 | sourceFile.referencedFiles, 108 | sourceFile.typeReferenceDirectives, 109 | sourceFile.hasNoDefaultLib, 110 | sourceFile.libReferenceDirectives, 111 | ) 112 | } 113 | } 114 | } 115 | 116 | function isAtTopLevel(node: ts.Node): boolean { 117 | let current = node.parent 118 | while (current) { 119 | if ( 120 | ts.isFunctionLike(current) || 121 | ts.isClassDeclaration(current) || 122 | ts.isMethodDeclaration(current) 123 | ) { 124 | return false 125 | } 126 | current = current.parent 127 | } 128 | return true 129 | } 130 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { XIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Dialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function DialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function DialogPortal({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function DialogClose({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function DialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function DialogContent({ 48 | className, 49 | children, 50 | showCloseButton = true, 51 | ...props 52 | }: React.ComponentProps & { 53 | showCloseButton?: boolean 54 | }) { 55 | return ( 56 | 57 | 58 | 66 | {children} 67 | {showCloseButton && ( 68 | 72 | 73 | Close 74 | 75 | )} 76 | 77 | 78 | ) 79 | } 80 | 81 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 82 | return ( 83 |
88 | ) 89 | } 90 | 91 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 92 | return ( 93 |
101 | ) 102 | } 103 | 104 | function DialogTitle({ 105 | className, 106 | ...props 107 | }: React.ComponentProps) { 108 | return ( 109 | 114 | ) 115 | } 116 | 117 | function DialogDescription({ 118 | className, 119 | ...props 120 | }: React.ComponentProps) { 121 | return ( 122 | 127 | ) 128 | } 129 | 130 | export { 131 | Dialog, 132 | DialogClose, 133 | DialogContent, 134 | DialogDescription, 135 | DialogFooter, 136 | DialogHeader, 137 | DialogOverlay, 138 | DialogPortal, 139 | DialogTitle, 140 | DialogTrigger, 141 | } 142 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/transformExports.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript' 2 | 3 | /** 4 | * Creates a transformer that removes all export statements and declarations, 5 | * converting them to regular statements since IIFE format doesn't support exports. 6 | */ 7 | export function createRemoveExportsTransformer(): ts.TransformerFactory { 8 | return (context) => { 9 | return (sourceFile) => { 10 | function visitor(node: ts.Node): ts.Node | ts.Node[] | undefined { 11 | // Handle export declarations (export { ... }) 12 | if (ts.isExportDeclaration(node)) { 13 | // Remove the export declaration entirely 14 | return undefined 15 | } 16 | 17 | // Handle export assignments (export = something) 18 | if (ts.isExportAssignment(node)) { 19 | // Convert to expression statement 20 | return ts.factory.createExpressionStatement(node.expression) 21 | } 22 | 23 | // Handle export modifiers on declarations 24 | if (ts.canHaveModifiers(node)) { 25 | const modifiers = ts.getModifiers(node) 26 | if ( 27 | modifiers?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword) 28 | ) { 29 | // Remove export modifier 30 | const newModifiers = modifiers.filter( 31 | (mod) => mod.kind !== ts.SyntaxKind.ExportKeyword, 32 | ) 33 | 34 | // Handle different types of declarations 35 | if (ts.isFunctionDeclaration(node)) { 36 | return ts.factory.updateFunctionDeclaration( 37 | node, 38 | newModifiers, 39 | node.asteriskToken, 40 | node.name, 41 | node.typeParameters, 42 | node.parameters, 43 | node.type, 44 | node.body, 45 | ) 46 | } 47 | 48 | if (ts.isClassDeclaration(node)) { 49 | return ts.factory.updateClassDeclaration( 50 | node, 51 | newModifiers, 52 | node.name, 53 | node.typeParameters, 54 | node.heritageClauses, 55 | node.members, 56 | ) 57 | } 58 | 59 | if (ts.isVariableStatement(node)) { 60 | return ts.factory.updateVariableStatement( 61 | node, 62 | newModifiers, 63 | node.declarationList, 64 | ) 65 | } 66 | 67 | if (ts.isInterfaceDeclaration(node)) { 68 | return ts.factory.updateInterfaceDeclaration( 69 | node, 70 | newModifiers, 71 | node.name, 72 | node.typeParameters, 73 | node.heritageClauses, 74 | node.members, 75 | ) 76 | } 77 | 78 | if (ts.isTypeAliasDeclaration(node)) { 79 | return ts.factory.updateTypeAliasDeclaration( 80 | node, 81 | newModifiers, 82 | node.name, 83 | node.typeParameters, 84 | node.type, 85 | ) 86 | } 87 | 88 | if (ts.isEnumDeclaration(node)) { 89 | return ts.factory.updateEnumDeclaration( 90 | node, 91 | newModifiers, 92 | node.name, 93 | node.members, 94 | ) 95 | } 96 | 97 | if (ts.isModuleDeclaration(node)) { 98 | return ts.factory.updateModuleDeclaration( 99 | node, 100 | newModifiers, 101 | node.name, 102 | node.body, 103 | ) 104 | } 105 | } 106 | } 107 | 108 | return ts.visitEachChild(node, visitor, context) 109 | } 110 | 111 | const statements = sourceFile.statements 112 | .map((stmt) => { 113 | const result = visitor(stmt) 114 | if (result === undefined) { 115 | return undefined 116 | } 117 | if (Array.isArray(result)) { 118 | return result 119 | } 120 | return result as ts.Statement 121 | }) 122 | .filter((stmt): stmt is ts.Statement => stmt !== undefined) 123 | .flat() 124 | 125 | return ts.factory.updateSourceFile( 126 | sourceFile, 127 | statements, 128 | sourceFile.isDeclarationFile, 129 | sourceFile.referencedFiles, 130 | sourceFile.typeReferenceDirectives, 131 | sourceFile.hasNoDefaultLib, 132 | sourceFile.libReferenceDirectives, 133 | ) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'tw-animate-css'; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | :root { 7 | --background: oklch(1 0 0); 8 | --foreground: oklch(0.145 0 0); 9 | --card: oklch(1 0 0); 10 | --card-foreground: oklch(0.145 0 0); 11 | --popover: oklch(1 0 0); 12 | --popover-foreground: oklch(0.145 0 0); 13 | --primary: oklch(0.205 0 0); 14 | --primary-foreground: oklch(0.985 0 0); 15 | --secondary: oklch(0.97 0 0); 16 | --secondary-foreground: oklch(0.205 0 0); 17 | --muted: oklch(0.97 0 0); 18 | --muted-foreground: oklch(0.556 0 0); 19 | --accent: oklch(0.97 0 0); 20 | --accent-foreground: oklch(0.205 0 0); 21 | --destructive: oklch(0.577 0.245 27.325); 22 | --destructive-foreground: oklch(0.577 0.245 27.325); 23 | --border: oklch(0.922 0 0); 24 | --input: oklch(0.922 0 0); 25 | --ring: oklch(0.708 0 0); 26 | --chart-1: oklch(0.646 0.222 41.116); 27 | --chart-2: oklch(0.6 0.118 184.704); 28 | --chart-3: oklch(0.398 0.07 227.392); 29 | --chart-4: oklch(0.828 0.189 84.429); 30 | --chart-5: oklch(0.769 0.188 70.08); 31 | --radius: 0.625rem; 32 | --sidebar: oklch(0.985 0 0); 33 | --sidebar-foreground: oklch(0.145 0 0); 34 | --sidebar-primary: oklch(0.205 0 0); 35 | --sidebar-primary-foreground: oklch(0.985 0 0); 36 | --sidebar-accent: oklch(0.97 0 0); 37 | --sidebar-accent-foreground: oklch(0.205 0 0); 38 | --sidebar-border: oklch(0.922 0 0); 39 | --sidebar-ring: oklch(0.708 0 0); 40 | } 41 | 42 | .dark { 43 | --background: oklch(0.145 0 0); 44 | --foreground: oklch(0.985 0 0); 45 | --card: oklch(0.145 0 0); 46 | --card-foreground: oklch(0.985 0 0); 47 | --popover: oklch(0.145 0 0); 48 | --popover-foreground: oklch(0.985 0 0); 49 | --primary: oklch(0.985 0 0); 50 | --primary-foreground: oklch(0.205 0 0); 51 | --secondary: oklch(0.269 0 0); 52 | --secondary-foreground: oklch(0.985 0 0); 53 | --muted: oklch(0.269 0 0); 54 | --muted-foreground: oklch(0.708 0 0); 55 | --accent: oklch(0.269 0 0); 56 | --accent-foreground: oklch(0.985 0 0); 57 | --destructive: oklch(0.396 0.141 25.723); 58 | --destructive-foreground: oklch(0.637 0.237 25.331); 59 | --border: oklch(0.269 0 0); 60 | --input: oklch(0.269 0 0); 61 | --ring: oklch(0.439 0 0); 62 | --chart-1: oklch(0.488 0.243 264.376); 63 | --chart-2: oklch(0.696 0.17 162.48); 64 | --chart-3: oklch(0.769 0.188 70.08); 65 | --chart-4: oklch(0.627 0.265 303.9); 66 | --chart-5: oklch(0.645 0.246 16.439); 67 | --sidebar: oklch(0.205 0 0); 68 | --sidebar-foreground: oklch(0.985 0 0); 69 | --sidebar-primary: oklch(0.488 0.243 264.376); 70 | --sidebar-primary-foreground: oklch(0.985 0 0); 71 | --sidebar-accent: oklch(0.269 0 0); 72 | --sidebar-accent-foreground: oklch(0.985 0 0); 73 | --sidebar-border: oklch(0.269 0 0); 74 | --sidebar-ring: oklch(0.439 0 0); 75 | } 76 | 77 | @theme inline { 78 | --color-background: var(--background); 79 | --color-foreground: var(--foreground); 80 | --color-card: var(--card); 81 | --color-card-foreground: var(--card-foreground); 82 | --color-popover: var(--popover); 83 | --color-popover-foreground: var(--popover-foreground); 84 | --color-primary: var(--primary); 85 | --color-primary-foreground: var(--primary-foreground); 86 | --color-secondary: var(--secondary); 87 | --color-secondary-foreground: var(--secondary-foreground); 88 | --color-muted: var(--muted); 89 | --color-muted-foreground: var(--muted-foreground); 90 | --color-accent: var(--accent); 91 | --color-accent-foreground: var(--accent-foreground); 92 | --color-destructive: var(--destructive); 93 | --color-destructive-foreground: var(--destructive-foreground); 94 | --color-border: var(--border); 95 | --color-input: var(--input); 96 | --color-ring: var(--ring); 97 | --color-chart-1: var(--chart-1); 98 | --color-chart-2: var(--chart-2); 99 | --color-chart-3: var(--chart-3); 100 | --color-chart-4: var(--chart-4); 101 | --color-chart-5: var(--chart-5); 102 | --radius-sm: calc(var(--radius) - 4px); 103 | --radius-md: calc(var(--radius) - 2px); 104 | --radius-lg: var(--radius); 105 | --radius-xl: calc(var(--radius) + 4px); 106 | --color-sidebar: var(--sidebar); 107 | --color-sidebar-foreground: var(--sidebar-foreground); 108 | --color-sidebar-primary: var(--sidebar-primary); 109 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 110 | --color-sidebar-accent: var(--sidebar-accent); 111 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 112 | --color-sidebar-border: var(--sidebar-border); 113 | --color-sidebar-ring: var(--sidebar-ring); 114 | } 115 | 116 | /* TODO @layer base some bug for react shadcn */ 117 | /* @layer base { */ 118 | * { 119 | @apply border-border outline-ring/50; 120 | } 121 | body { 122 | @apply bg-background text-foreground; 123 | } 124 | /* } */ 125 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/utils/bundle.ts: -------------------------------------------------------------------------------- 1 | import { build, initialize } from 'esbuild-wasm' 2 | import { esbuildPluginFs } from './fs' 3 | import { esm } from './esm' 4 | import { createTopLevelAwaitTransformer } from './transformTopLevelAwait' 5 | import { createRemoveExportsTransformer } from './transformExports' 6 | import wasmUrl from 'esbuild-wasm/esbuild.wasm?url' 7 | import { serializeError } from 'serialize-error' 8 | import MagicString from 'magic-string' 9 | import * as ts from 'typescript' 10 | 11 | function transformJSX(code: string) { 12 | const imports = [ 13 | ...code 14 | .matchAll(/import [\s\S]+ from ['"]([^'"\n]+)['"]/gm) 15 | .map((it) => it[1]), 16 | ] 17 | const s = new MagicString(code) 18 | const type = imports.includes('preact') 19 | ? 'preact' 20 | : imports.some((it) => it.includes('solid-js')) 21 | ? 'solid' 22 | : imports.some((it) => it.includes('react')) 23 | ? 'react' 24 | : null 25 | if (!type) { 26 | return code 27 | } 28 | if (type === 'react') { 29 | s.prepend(`import { createElement as h, Fragment } from 'react'\n`) 30 | } 31 | if (type === 'preact') { 32 | s.prepend(`import { h, Fragment } from 'preact'\n`) 33 | } 34 | if (type === 'solid') { 35 | s.prepend( 36 | `import h from 'solid-js/h'\nconst Fragment = (props: { children: any }) => h(() => [props.children])\n`, 37 | ) 38 | } 39 | const map = s.generateMap({ 40 | source: 'example.tsx', 41 | }) 42 | return ( 43 | s.toString() + 44 | '\n//# sourceMappingURL=data:application/json;base64,' + 45 | map.toString().split(',')[1] 46 | ) 47 | } 48 | 49 | let isInit = false 50 | export async function initializeEsbuild() { 51 | if (isInit) { 52 | return 53 | } 54 | import.meta.env.BROWSER && console.log('Initializing esbuild...') 55 | try { 56 | await initialize({ 57 | wasmURL: wasmUrl, 58 | // Firefox Extension Page CSP disable blob worker 59 | worker: import.meta.env.CHROME, 60 | }) 61 | } catch (error) { 62 | if ( 63 | serializeError(error as Error).message !== 64 | 'Cannot call "initialize" more than once' 65 | ) { 66 | throw error 67 | } 68 | } 69 | isInit = true 70 | } 71 | 72 | /** 73 | * Apply both transforms (remove exports and top-level await) in a single AST pass 74 | */ 75 | export function applyTransforms( 76 | code: string, 77 | fileName = 'example.tsx', 78 | ): string { 79 | const sourceFile = ts.createSourceFile( 80 | fileName, 81 | code, 82 | ts.ScriptTarget.Latest, 83 | true, 84 | ts.ScriptKind.TSX, 85 | ) 86 | 87 | // Check if we need top-level await transformation 88 | let hasTopLevelAwait = false 89 | function checkTopLevelAwait(node: ts.Node) { 90 | if (ts.isAwaitExpression(node) && isAtTopLevel(node)) { 91 | hasTopLevelAwait = true 92 | } 93 | ts.forEachChild(node, checkTopLevelAwait) 94 | } 95 | checkTopLevelAwait(sourceFile) 96 | 97 | const resultVariableName = '__result__' + Math.random().toString(16).slice(2) 98 | 99 | // Apply both transformers in sequence 100 | const transformers = [ 101 | createRemoveExportsTransformer(), 102 | ...(hasTopLevelAwait 103 | ? [createTopLevelAwaitTransformer(resultVariableName)] 104 | : []), 105 | ] 106 | 107 | const result = ts.transform(sourceFile, transformers) 108 | const transformedSourceFile = result.transformed[0] 109 | 110 | const printer = ts.createPrinter({ 111 | newLine: ts.NewLineKind.LineFeed, 112 | removeComments: false, 113 | }) 114 | 115 | const output = printer.printFile(transformedSourceFile) 116 | result.dispose() 117 | 118 | return output 119 | } 120 | 121 | function isAtTopLevel(node: ts.Node): boolean { 122 | let current = node.parent 123 | while (current) { 124 | if ( 125 | ts.isFunctionLike(current) || 126 | ts.isClassDeclaration(current) || 127 | ts.isMethodDeclaration(current) 128 | ) { 129 | return false 130 | } 131 | current = current.parent 132 | } 133 | return true 134 | } 135 | 136 | export async function bundle( 137 | code: string, 138 | options?: { 139 | minify?: boolean 140 | sourcemap?: boolean | 'inline' 141 | signal?: AbortSignal 142 | }, 143 | ) { 144 | await initializeEsbuild() 145 | const before = transformJSX(code) 146 | const after = applyTransforms(before, 'example.tsx') 147 | const r = await build({ 148 | entryPoints: ['example.tsx'], 149 | jsx: 'transform', 150 | jsxFactory: 'h', 151 | jsxFragment: 'Fragment', 152 | target: 'esnext', 153 | platform: 'browser', 154 | plugins: [ 155 | esbuildPluginFs({ 156 | 'example.tsx': after, 157 | }), 158 | esm({ 159 | signal: options?.signal, 160 | }), 161 | ], 162 | treeShaking: true, 163 | write: false, 164 | bundle: true, 165 | sourcemap: 'inline', 166 | minify: false, 167 | format: 'iife', 168 | }) 169 | return r.outputFiles[0].text 170 | } 171 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Select({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function SelectGroup({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function SelectValue({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function SelectTrigger({ 26 | className, 27 | size = "default", 28 | children, 29 | ...props 30 | }: React.ComponentProps & { 31 | size?: "sm" | "default" 32 | }) { 33 | return ( 34 | 43 | {children} 44 | 45 | 46 | 47 | 48 | ) 49 | } 50 | 51 | function SelectContent({ 52 | className, 53 | children, 54 | position = "popper", 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | 70 | 71 | 78 | {children} 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | function SelectLabel({ 87 | className, 88 | ...props 89 | }: React.ComponentProps) { 90 | return ( 91 | 96 | ) 97 | } 98 | 99 | function SelectItem({ 100 | className, 101 | children, 102 | ...props 103 | }: React.ComponentProps) { 104 | return ( 105 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | ) 121 | } 122 | 123 | function SelectSeparator({ 124 | className, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 133 | ) 134 | } 135 | 136 | function SelectScrollUpButton({ 137 | className, 138 | ...props 139 | }: React.ComponentProps) { 140 | return ( 141 | 149 | 150 | 151 | ) 152 | } 153 | 154 | function SelectScrollDownButton({ 155 | className, 156 | ...props 157 | }: React.ComponentProps) { 158 | return ( 159 | 167 | 168 | 169 | ) 170 | } 171 | 172 | export { 173 | Select, 174 | SelectContent, 175 | SelectGroup, 176 | SelectItem, 177 | SelectLabel, 178 | SelectScrollDownButton, 179 | SelectScrollUpButton, 180 | SelectSeparator, 181 | SelectTrigger, 182 | SelectValue, 183 | } 184 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function DropdownMenu({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function DropdownMenuPortal({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | 18 | ) 19 | } 20 | 21 | function DropdownMenuTrigger({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 29 | ) 30 | } 31 | 32 | function DropdownMenuContent({ 33 | className, 34 | sideOffset = 4, 35 | ...props 36 | }: React.ComponentProps) { 37 | return ( 38 | 39 | 48 | 49 | ) 50 | } 51 | 52 | function DropdownMenuGroup({ 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 57 | ) 58 | } 59 | 60 | function DropdownMenuItem({ 61 | className, 62 | inset, 63 | variant = "default", 64 | ...props 65 | }: React.ComponentProps & { 66 | inset?: boolean 67 | variant?: "default" | "destructive" 68 | }) { 69 | return ( 70 | 80 | ) 81 | } 82 | 83 | function DropdownMenuCheckboxItem({ 84 | className, 85 | children, 86 | checked, 87 | ...props 88 | }: React.ComponentProps) { 89 | return ( 90 | 99 | 100 | 101 | 102 | 103 | 104 | {children} 105 | 106 | ) 107 | } 108 | 109 | function DropdownMenuRadioGroup({ 110 | ...props 111 | }: React.ComponentProps) { 112 | return ( 113 | 117 | ) 118 | } 119 | 120 | function DropdownMenuRadioItem({ 121 | className, 122 | children, 123 | ...props 124 | }: React.ComponentProps) { 125 | return ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | ) 142 | } 143 | 144 | function DropdownMenuLabel({ 145 | className, 146 | inset, 147 | ...props 148 | }: React.ComponentProps & { 149 | inset?: boolean 150 | }) { 151 | return ( 152 | 161 | ) 162 | } 163 | 164 | function DropdownMenuSeparator({ 165 | className, 166 | ...props 167 | }: React.ComponentProps) { 168 | return ( 169 | 174 | ) 175 | } 176 | 177 | function DropdownMenuShortcut({ 178 | className, 179 | ...props 180 | }: React.ComponentProps<"span">) { 181 | return ( 182 | 190 | ) 191 | } 192 | 193 | function DropdownMenuSub({ 194 | ...props 195 | }: React.ComponentProps) { 196 | return 197 | } 198 | 199 | function DropdownMenuSubTrigger({ 200 | className, 201 | inset, 202 | children, 203 | ...props 204 | }: React.ComponentProps & { 205 | inset?: boolean 206 | }) { 207 | return ( 208 | 217 | {children} 218 | 219 | 220 | ) 221 | } 222 | 223 | function DropdownMenuSubContent({ 224 | className, 225 | ...props 226 | }: React.ComponentProps) { 227 | return ( 228 | 236 | ) 237 | } 238 | 239 | export { 240 | DropdownMenu, 241 | DropdownMenuPortal, 242 | DropdownMenuTrigger, 243 | DropdownMenuContent, 244 | DropdownMenuGroup, 245 | DropdownMenuLabel, 246 | DropdownMenuItem, 247 | DropdownMenuCheckboxItem, 248 | DropdownMenuRadioGroup, 249 | DropdownMenuRadioItem, 250 | DropdownMenuSeparator, 251 | DropdownMenuShortcut, 252 | DropdownMenuSub, 253 | DropdownMenuSubTrigger, 254 | DropdownMenuSubContent, 255 | } 256 | -------------------------------------------------------------------------------- /components/ui/menubar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as MenubarPrimitive from "@radix-ui/react-menubar" 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Menubar({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | ) 23 | } 24 | 25 | function MenubarMenu({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function MenubarGroup({ 32 | ...props 33 | }: React.ComponentProps) { 34 | return 35 | } 36 | 37 | function MenubarPortal({ 38 | ...props 39 | }: React.ComponentProps) { 40 | return 41 | } 42 | 43 | function MenubarRadioGroup({ 44 | ...props 45 | }: React.ComponentProps) { 46 | return ( 47 | 48 | ) 49 | } 50 | 51 | function MenubarTrigger({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 64 | ) 65 | } 66 | 67 | function MenubarContent({ 68 | className, 69 | align = "start", 70 | alignOffset = -4, 71 | sideOffset = 8, 72 | ...props 73 | }: React.ComponentProps) { 74 | return ( 75 | 76 | 87 | 88 | ) 89 | } 90 | 91 | function MenubarItem({ 92 | className, 93 | inset, 94 | variant = "default", 95 | ...props 96 | }: React.ComponentProps & { 97 | inset?: boolean 98 | variant?: "default" | "destructive" 99 | }) { 100 | return ( 101 | 111 | ) 112 | } 113 | 114 | function MenubarCheckboxItem({ 115 | className, 116 | children, 117 | checked, 118 | ...props 119 | }: React.ComponentProps) { 120 | return ( 121 | 130 | 131 | 132 | 133 | 134 | 135 | {children} 136 | 137 | ) 138 | } 139 | 140 | function MenubarRadioItem({ 141 | className, 142 | children, 143 | ...props 144 | }: React.ComponentProps) { 145 | return ( 146 | 154 | 155 | 156 | 157 | 158 | 159 | {children} 160 | 161 | ) 162 | } 163 | 164 | function MenubarLabel({ 165 | className, 166 | inset, 167 | ...props 168 | }: React.ComponentProps & { 169 | inset?: boolean 170 | }) { 171 | return ( 172 | 181 | ) 182 | } 183 | 184 | function MenubarSeparator({ 185 | className, 186 | ...props 187 | }: React.ComponentProps) { 188 | return ( 189 | 194 | ) 195 | } 196 | 197 | function MenubarShortcut({ 198 | className, 199 | ...props 200 | }: React.ComponentProps<"span">) { 201 | return ( 202 | 210 | ) 211 | } 212 | 213 | function MenubarSub({ 214 | ...props 215 | }: React.ComponentProps) { 216 | return 217 | } 218 | 219 | function MenubarSubTrigger({ 220 | className, 221 | inset, 222 | children, 223 | ...props 224 | }: React.ComponentProps & { 225 | inset?: boolean 226 | }) { 227 | return ( 228 | 237 | {children} 238 | 239 | 240 | ) 241 | } 242 | 243 | function MenubarSubContent({ 244 | className, 245 | ...props 246 | }: React.ComponentProps) { 247 | return ( 248 | 256 | ) 257 | } 258 | 259 | export { 260 | Menubar, 261 | MenubarPortal, 262 | MenubarMenu, 263 | MenubarTrigger, 264 | MenubarContent, 265 | MenubarGroup, 266 | MenubarSeparator, 267 | MenubarLabel, 268 | MenubarItem, 269 | MenubarShortcut, 270 | MenubarCheckboxItem, 271 | MenubarRadioGroup, 272 | MenubarRadioItem, 273 | MenubarSub, 274 | MenubarSubTrigger, 275 | MenubarSubContent, 276 | } 277 | -------------------------------------------------------------------------------- /entrypoints/devtools-panel/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api' 3 | import { toast } from 'sonner' 4 | import { serializeError } from 'serialize-error' 5 | import type { typeAcquisition } from './utils/initTypeAcquisition' 6 | import TypeAcquisitionWorker from './utils/initTypeAcquisition?worker' 7 | import FormatCodeWorker from './utils/formatCode?worker' 8 | import type { formatCode } from './utils/formatCode' 9 | import { wrap, proxy } from 'comlink' 10 | import { useExecutionStore } from './store' 11 | import { useEventBus } from './utils/useEventBus' 12 | import { useTheme } from 'next-themes' 13 | import { useEffectOnce, useMount } from 'react-use' 14 | import { bundle } from './utils/bundle' 15 | import { fileSelector } from '@/lib/fileSelector' 16 | import { saveAs } from 'file-saver' 17 | import { injectAndExecuteCode } from '@/lib/ext/injectAndExecuteCode' 18 | import { messager } from '@/lib/messager' 19 | 20 | const STORAGE_KEY = 'devtools-editor-content' 21 | 22 | function saveEditorContent(editor: Monaco.editor.IStandaloneCodeEditor | null) { 23 | if (editor) { 24 | const content = editor.getValue() 25 | localStorage.setItem(STORAGE_KEY, content) 26 | } 27 | } 28 | 29 | function loadEditorContent(): string { 30 | return ( 31 | localStorage.getItem(STORAGE_KEY) || 32 | '// Cmd/Ctrl+S to execute\n' + 33 | "console.log('Hello from Monaco!')\n" + 34 | '// Add breakpoint in the line above to debug\n' + 35 | '// debugger' 36 | ) 37 | } 38 | 39 | export function Editor() { 40 | const editorContainerRef = useRef(null) 41 | const editorRef = useRef(null) 42 | const monacoRef = useRef(null) 43 | const { resolvedTheme } = useTheme() 44 | 45 | const executionStore = useExecutionStore() 46 | 47 | const execute = async () => { 48 | if (executionStore.isExecuting) { 49 | executionStore.stop() 50 | toast.info('Execution cancelled') 51 | return 52 | } 53 | 54 | const controller = new AbortController() 55 | executionStore.start(controller) 56 | try { 57 | await editorRef.current?.getAction('editor.action.formatDocument')?.run() 58 | console.log('Executing code...') 59 | const code = editorRef.current?.getValue() 60 | if (!code) { 61 | toast.error('No code to execute') 62 | return 63 | } 64 | const buildCode = await bundle(code, { 65 | signal: controller.signal, 66 | }) 67 | console.log('injected and executing code...') 68 | if (import.meta.env.SAFARI) { 69 | await messager.sendMessage('executeCode', buildCode) 70 | } else { 71 | await injectAndExecuteCode(buildCode) 72 | } 73 | toast.success('Code executed successfully') 74 | } catch (error) { 75 | if (error instanceof Error && error.message.includes('Abort')) { 76 | toast.info('Execution aborted') 77 | } else { 78 | console.error('Error:', error) 79 | toast.error('Compilation Error', { 80 | description: serializeError(error as Error).message, 81 | duration: 10000, 82 | }) 83 | } 84 | } finally { 85 | executionStore.stop() 86 | } 87 | } 88 | 89 | function updateEditorTheme(theme?: string) { 90 | const editorTheme = theme === 'dark' ? 'vs-dark' : 'vs' 91 | console.log('updateEditorTheme', editorTheme, editorRef.current) 92 | if (editorRef.current) { 93 | editorRef.current.updateOptions({ 94 | theme: editorTheme, 95 | }) 96 | } 97 | } 98 | 99 | // Initialize Monaco Editor 100 | useEffectOnce(() => { 101 | let mounted = true 102 | 103 | const initializeEditor = async () => { 104 | if (!editorContainerRef.current) return 105 | 106 | const monaco = (await import('./monaco')).monaco 107 | if (!mounted) return 108 | 109 | monacoRef.current = monaco 110 | monaco.editor.addKeybindingRules([ 111 | { 112 | keybinding: monaco.KeyCode.F12, 113 | command: null, 114 | }, 115 | ]) 116 | const defaults = monaco.languages.typescript.typescriptDefaults 117 | defaults.setCompilerOptions({ 118 | target: monaco.languages.typescript.ScriptTarget.ESNext, 119 | allowNonTsExtensions: true, 120 | moduleResolution: 121 | monaco.languages.typescript.ModuleResolutionKind.NodeJs, 122 | module: monaco.languages.typescript.ModuleKind.ESNext, 123 | jsx: monaco.languages.typescript.JsxEmit.Preserve, 124 | noEmit: true, 125 | strict: true, 126 | esModuleInterop: true, 127 | }) 128 | defaults.setDiagnosticsOptions({ diagnosticCodesToIgnore: [1375] }) 129 | monaco.languages.registerDocumentFormattingEditProvider('typescript', { 130 | provideDocumentFormattingEdits: async (model, _options, token) => { 131 | console.log('Formatting code...') 132 | const worker = new FormatCodeWorker() 133 | const f = wrap(worker) 134 | const formattedCode = await f(model.getValue(), 0) 135 | console.log('Code formatted') 136 | return [ 137 | { 138 | text: formattedCode.formatted, 139 | range: model.getFullModelRange(), 140 | }, 141 | ] 142 | }, 143 | }) 144 | 145 | const initialTheme = resolvedTheme === 'dark' ? 'vs-dark' : 'vs' 146 | const editor = monaco.editor.create(editorContainerRef.current, { 147 | language: 'typescript', 148 | theme: initialTheme, 149 | fontSize: 14, 150 | automaticLayout: true, 151 | minimap: { enabled: false }, 152 | scrollBeyondLastLine: false, 153 | wordWrap: 'on', 154 | tabSize: 2, 155 | insertSpaces: true, 156 | // 确保编辑器可以正常接收键盘输入 157 | readOnly: false, 158 | domReadOnly: false, 159 | // 防止某些快捷键被错误处理 160 | contextmenu: true, 161 | }) 162 | 163 | if (!mounted) { 164 | editor.dispose() 165 | return 166 | } 167 | 168 | editorRef.current = editor 169 | const model = monaco.editor.createModel( 170 | loadEditorContent(), 171 | 'typescript', 172 | monaco.Uri.file('example.tsx'), 173 | ) 174 | editor.setModel(model) 175 | 176 | const worker = new TypeAcquisitionWorker() 177 | const ta = wrap(worker) 178 | await ta.init(proxy(addLibraryToRuntime)) 179 | 180 | // 添加内容变化监听器 181 | editor.onDidChangeModelContent(async () => { 182 | saveEditorContent(editor) 183 | // 判断是否有错误 184 | const value = editor.getValue() 185 | await ta.dl(value) 186 | }) 187 | 188 | // 添加编辑器特定的键盘事件监听器 189 | editor.onKeyDown(handleEditorKeyDown) 190 | 191 | // editor 初始化完成后,执行一次 ta 192 | ta.dl(editor.getValue()) 193 | 194 | function addLibraryToRuntime(code: string, _path: string) { 195 | const path = 'file://' + _path 196 | defaults.addExtraLib(code, path) 197 | console.log(`[ATA] Adding ${path} to runtime`, { code }) 198 | } 199 | } 200 | 201 | initializeEditor() 202 | 203 | return () => { 204 | mounted = false 205 | if (monacoRef.current) { 206 | monacoRef.current.editor.getModels().forEach((model) => model.dispose()) 207 | } 208 | if (editorRef.current) { 209 | saveEditorContent(editorRef.current) 210 | editorRef.current.dispose() 211 | editorRef.current = null 212 | } 213 | } 214 | }) 215 | 216 | // Theme change effect 217 | useEffect(() => { 218 | updateEditorTheme(resolvedTheme) 219 | }, [resolvedTheme]) 220 | 221 | // Event listeners - setup once on mount 222 | const handleResize = () => { 223 | editorRef.current?.layout() 224 | } 225 | 226 | function handleGlobalKeyDown(event: KeyboardEvent) { 227 | if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { 228 | const activeElement = document.activeElement 229 | const editorDom = editorContainerRef.current 230 | if (editorDom && !editorDom.contains(activeElement)) { 231 | event.preventDefault() 232 | event.stopPropagation() 233 | execute() 234 | } 235 | } 236 | } 237 | 238 | function handleEditorKeyDown(event: Monaco.IKeyboardEvent) { 239 | if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { 240 | event.preventDefault() 241 | event.stopPropagation() 242 | execute() 243 | } 244 | } 245 | 246 | useEffectOnce(() => { 247 | window.addEventListener('resize', handleResize) 248 | window.addEventListener('keydown', handleGlobalKeyDown) 249 | 250 | return () => { 251 | window.removeEventListener('resize', handleResize) 252 | window.removeEventListener('keydown', handleGlobalKeyDown) 253 | } 254 | }) 255 | 256 | // Event bus listeners 257 | useEventBus('runScript', execute) 258 | useEventBus('changeFontSize', (fontSize) => { 259 | editorRef.current?.updateOptions({ fontSize }) 260 | }) 261 | useEventBus('openFile', async () => { 262 | if (!editorRef.current) { 263 | toast.error('Editor not initialized') 264 | return 265 | } 266 | const files = await fileSelector({ accept: '.ts,.tsx,.js,.jsx' }) 267 | if (files && files.length > 0) { 268 | const file = files[0] 269 | const text = await file.text() 270 | const editor = editorRef.current 271 | const model = editor.getModel() 272 | if (model) { 273 | const fullRange = model.getFullModelRange() 274 | editor.executeEdits('openFile', [ 275 | { 276 | range: fullRange, 277 | text: text, 278 | }, 279 | ]) 280 | } 281 | toast.success(`Loaded file: ${file.name}`) 282 | } 283 | }) 284 | useEventBus('saveFile', () => { 285 | if (!editorRef.current) { 286 | toast.error('Editor not initialized') 287 | return 288 | } 289 | const editor = editorRef.current 290 | const model = editor.getModel() 291 | if (model) { 292 | const blob = new Blob([model.getValue()], { 293 | type: 'text/plain;charset=utf-8', 294 | }) 295 | saveAs(blob, 'script.ts') 296 | } 297 | }) 298 | 299 | return
300 | } 301 | --------------------------------------------------------------------------------