├── .editorconfig
├── .eslintrc.json
├── .gitignore
├── .husky
└── pre-commit
├── .lintstagedrc.json
├── .prettierrc
├── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── tasks.json
├── CHANGELOG.md
├── README.md
├── angular.json
├── eslint.config.mjs
├── nodemon.json
├── package.json
├── public
├── 404.html
├── assets
│ └── prettier
│ │ ├── parser-babel.js
│ │ ├── parser-html.js
│ │ ├── parser-postcss.js
│ │ ├── parser-typescript.js
│ │ └── standalone.js
├── favicon.ico
├── logo.jpg
├── proxy-console
│ ├── index.html
│ └── index.js
└── templates
│ ├── angular.zip
│ ├── eslint-prettier.zip
│ ├── next.zip
│ ├── node.zip
│ ├── react-ts.zip
│ ├── solid-ts.zip
│ ├── standard.zip
│ ├── static.zip
│ ├── vanilla.zip
│ └── vue3-ts.zip
├── screenshots
├── screenshot-2.png
├── screenshot-3.png
└── screenshot.png
├── scripts
├── genProxyScript.js
└── zip.js
├── src
├── app
│ ├── _shared
│ │ ├── components
│ │ │ ├── confirm-dialog
│ │ │ │ └── confirm-dialog.ts
│ │ │ ├── github-token-dialog
│ │ │ │ ├── github-token-dialog.component.html
│ │ │ │ ├── github-token-dialog.component.scss
│ │ │ │ └── github-token-dialog.component.ts
│ │ │ ├── github-url-dialog
│ │ │ │ ├── github-url-dialog.component.html
│ │ │ │ ├── github-url-dialog.component.scss
│ │ │ │ └── github-url-dialog.component.ts
│ │ │ └── template-modal
│ │ │ │ ├── config.ts
│ │ │ │ ├── template-modal.component.html
│ │ │ │ ├── template-modal.component.scss
│ │ │ │ └── template-modal.component.ts
│ │ ├── constant.ts
│ │ ├── service
│ │ │ ├── Emitter.ts
│ │ │ ├── device-guard.ts
│ │ │ ├── device.service.ts
│ │ │ ├── gist.service.ts
│ │ │ ├── local-storage.service.ts
│ │ │ └── redirect-guard.ts
│ │ ├── types
│ │ │ └── index.ts
│ │ └── utils
│ │ │ └── index.ts
│ ├── app.component.ts
│ ├── app.config.ts
│ ├── editor
│ │ ├── components
│ │ │ └── shortcut-dialog.component.ts
│ │ ├── constants
│ │ │ └── index.ts
│ │ ├── editor.component.ts
│ │ ├── features
│ │ │ ├── footer
│ │ │ │ ├── footer.component.html
│ │ │ │ ├── footer.component.scss
│ │ │ │ └── footer.component.ts
│ │ │ ├── header
│ │ │ │ ├── header.component.html
│ │ │ │ ├── header.component.scss
│ │ │ │ └── header.component.ts
│ │ │ └── main
│ │ │ │ ├── components
│ │ │ │ └── resizer
│ │ │ │ │ ├── resize-container.scss
│ │ │ │ │ ├── resize-container.ts
│ │ │ │ │ ├── resize-manager.service.ts
│ │ │ │ │ ├── resizer.scss
│ │ │ │ │ └── resizer.ts
│ │ │ │ ├── edit
│ │ │ │ ├── code-editor
│ │ │ │ │ ├── code-editor.component.scss
│ │ │ │ │ ├── code-editor.component.ts
│ │ │ │ │ ├── code-editor.service.ts
│ │ │ │ │ ├── deps-parsre.service.ts
│ │ │ │ │ ├── prettier.service.ts
│ │ │ │ │ └── type-loader.service.ts
│ │ │ │ ├── edit.component.html
│ │ │ │ ├── edit.component.scss
│ │ │ │ ├── edit.component.ts
│ │ │ │ └── edit.service.ts
│ │ │ │ ├── main.component.html
│ │ │ │ ├── main.component.scss
│ │ │ │ ├── main.component.ts
│ │ │ │ ├── main.service.ts
│ │ │ │ ├── ouput
│ │ │ │ ├── console
│ │ │ │ │ ├── _console-script.js
│ │ │ │ │ ├── _console-script.ts
│ │ │ │ │ ├── compound-obj-renderer.html
│ │ │ │ │ ├── compound-obj-renderer.scss
│ │ │ │ │ ├── compound-obj-renderer.ts
│ │ │ │ │ ├── console.component.html
│ │ │ │ │ ├── console.component.scss
│ │ │ │ │ ├── console.component.ts
│ │ │ │ │ ├── console.service.ts
│ │ │ │ │ ├── getProxyConsoleScript.ts
│ │ │ │ │ └── primitive-renderer.component.ts
│ │ │ │ ├── preview
│ │ │ │ │ ├── preview.component.html
│ │ │ │ │ ├── preview.component.scss
│ │ │ │ │ └── preview.component.ts
│ │ │ │ └── terminal
│ │ │ │ │ ├── terminal.component.scss
│ │ │ │ │ ├── terminal.component.ts
│ │ │ │ │ └── terminal.service.ts
│ │ │ │ └── sidebar
│ │ │ │ ├── fileTree
│ │ │ │ ├── file-tree.component.html
│ │ │ │ ├── file-tree.component.scss
│ │ │ │ └── file-tree.component.ts
│ │ │ │ ├── sidebar.component.html
│ │ │ │ ├── sidebar.component.scss
│ │ │ │ └── sidebar.component.ts
│ │ ├── services
│ │ │ ├── editor-state.service.ts
│ │ │ ├── file-loader
│ │ │ │ ├── loader-factory.service.ts
│ │ │ │ ├── loaders
│ │ │ │ │ ├── _mock
│ │ │ │ │ │ └── mockFile.ts
│ │ │ │ │ ├── gist-file-loader.service.ts
│ │ │ │ │ ├── github-file-loader.service.ts
│ │ │ │ │ ├── local-filer-loader.ts
│ │ │ │ │ ├── mock-filer-loader.ts
│ │ │ │ │ └── zip-file-loader.service.ts
│ │ │ │ └── type.ts
│ │ │ ├── file-saver
│ │ │ │ ├── file-saver.service.ts
│ │ │ │ ├── storage
│ │ │ │ │ ├── gist.ts
│ │ │ │ │ └── local.ts
│ │ │ │ └── type.ts
│ │ │ ├── node-container.service.ts
│ │ │ └── shortcut.ts
│ │ └── utils
│ │ │ └── file.ts
│ └── home
│ │ ├── components
│ │ └── header
│ │ │ ├── header.component.html
│ │ │ ├── header.component.scss
│ │ │ └── header.component.ts
│ │ ├── home.component.html
│ │ ├── home.component.scss
│ │ ├── home.component.ts
│ │ └── home.service.ts
├── assets
│ └── imgs
│ │ ├── github.png
│ │ ├── header-logo.png
│ │ ├── home-github.svg
│ │ ├── home-local.svg
│ │ ├── home-template-bg.png
│ │ ├── prettier.svg
│ │ └── template
│ │ ├── angular.svg
│ │ ├── next.svg
│ │ ├── node.svg
│ │ ├── react.svg
│ │ ├── solid.svg
│ │ ├── static.svg
│ │ ├── vanilla.svg
│ │ └── vue.svg
├── environments
│ ├── environment.development.ts
│ └── environment.ts
├── global.d.ts
├── index.html
├── main.ts
└── styles.scss
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.spec.json
├── vercel.json
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.ts]
12 | quote_type = single
13 |
14 | [*.md]
15 | max_line_length = off
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "overrides": [
3 | {
4 | "files": ["*.ts"],
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/recommended",
8 | "plugin:@typescript-eslint/stylistic",
9 | "plugin:@angular-eslint/recommended",
10 | "plugin:prettier/recommended"
11 | ],
12 | "plugins": ["eslint-plugin-unused-imports"],
13 | "rules": {
14 | "@angular-eslint/directive-selector": [
15 | "error",
16 | {
17 | "type": "attribute",
18 | "prefix": "app",
19 | "style": "camelCase"
20 | }
21 | ],
22 | "@angular-eslint/component-selector": [
23 | "error",
24 | {
25 | "type": "element",
26 | "prefix": "app",
27 | "style": "kebab-case"
28 | }
29 | ],
30 | "@typescript-eslint/triple-slash-reference": "off",
31 | "@typescript-eslint/no-unused-vars": "off",
32 | "no-unused-vars": "off",
33 | "unused-imports/no-unused-imports": "off",
34 | "no-useless-constructor": "off",
35 | "no-useless-escape": "off",
36 | "@typescript-eslint/no-explicit-any": "off",
37 | "@typescript-eslint/no-empty-function": "off"
38 | }
39 | },
40 | {
41 | "files": ["*.html"],
42 | "extends": ["plugin:@angular-eslint/template/recommended"],
43 | "rules": {}
44 | },
45 | {
46 | "files": ["*.html"],
47 | "excludedFiles": ["*inline-template-*.component.html"],
48 | "extends": ["plugin:prettier/recommended"],
49 | "rules": {
50 | "prettier/prettier": ["error", { "parser": "angular" }]
51 | }
52 | }
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
2 |
3 | # Compiled output
4 | /dist
5 | /tmp
6 | /out-tsc
7 | /bazel-out
8 |
9 | # Node
10 | /node_modules
11 | npm-debug.log
12 | yarn-error.log
13 |
14 | # IDEs and editors
15 | .idea/
16 | .project
17 | .classpath
18 | .c9/
19 | *.launch
20 | .settings/
21 | *.sublime-workspace
22 |
23 | # Visual Studio Code
24 | .vscode/*
25 | !.vscode/settings.json
26 | !.vscode/tasks.json
27 | !.vscode/launch.json
28 | !.vscode/extensions.json
29 | .history/*
30 |
31 | # Miscellaneous
32 | /.angular/cache
33 | .sass-cache/
34 | /connect.lock
35 | /coverage
36 | /libpeerconnection.log
37 | testem.log
38 | /typings
39 |
40 | # System files
41 | .DS_Store
42 | Thumbs.db
43 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 | npx lint-staged
--------------------------------------------------------------------------------
/.lintstagedrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "*.ts": [
3 | "npx eslint --fix"
4 | ],
5 | "*.html": [
6 | "npx eslint --fix"
7 | ],
8 | "*.scss": [
9 | "npx eslint --fix"
10 | ]
11 | }
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "singleQuote": true,
5 | "semi": true,
6 | "bracketSpacing": true,
7 | "arrowParens": "avoid",
8 | "trailingComma": "es5",
9 | "printWidth": 120
10 | }
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
3 | "recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint", ]
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
3 | "version": "0.2.0",
4 | "configurations": [
5 | {
6 | "name": "Launch Chrome against localhost",
7 | "type": "chrome",
8 | "request": "launch",
9 | "url": "http://localhost:4200/edit?source=mock",
10 | // "url": "http://localhost:4200?source=http://localhost:4200/templates/standard.zip&terminal=dev&pkgManager=yarn",
11 | // "url": "http://localhost:4200?source=http://localhost:4200/templates/react-google-map.zip&terminal=start",
12 | // "url": " http://localhost:4200/edit?terminal=dev&source=https://github.com/chenxiaoyao6228/fe-notes/tree/main/Editor/_demo/webcontainers-express-app",
13 | "sourceMaps": true,
14 | "webRoot": "${workspaceRoot}",
15 | "skipFiles": [
16 | "node_modules/**",
17 | "**/node_modules/**"
18 | ]
19 | },
20 | {
21 | "name": "ng test",
22 | "type": "chrome",
23 | "request": "launch",
24 | "preLaunchTask": "npm: test",
25 | "url": "http://localhost:9876/debug.html"
26 | }
27 | ]
28 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[html]": {
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll.eslint": "always"
6 | },
7 | "editor.formatOnSave": true
8 | },
9 | "[typescript]": {
10 | "editor.defaultFormatter": "esbenp.prettier-vscode",
11 | "editor.codeActionsOnSave": {
12 | "source.fixAll.eslint": "always"
13 | },
14 | "editor.formatOnSave": true
15 | },
16 | "[scss]": {
17 | "editor.defaultFormatter": "esbenp.prettier-vscode",
18 | "editor.codeActionsOnSave": {
19 | "source.fixAll.eslint": "always"
20 | },
21 | "editor.formatOnSave": true
22 | },
23 | "editor.suggest.snippetsPreventQuickSuggestions": false,
24 | "editor.inlineSuggest.enabled": true
25 | }
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
3 | "version": "2.0.0",
4 | "tasks": [
5 | {
6 | "type": "npm",
7 | "script": "start",
8 | "isBackground": true,
9 | "problemMatcher": {
10 | "owner": "typescript",
11 | "pattern": "$tsc",
12 | "background": {
13 | "activeOnStart": true,
14 | "beginsPattern": {
15 | "regexp": "(.*?)"
16 | },
17 | "endsPattern": {
18 | "regexp": "bundle generation complete"
19 | }
20 | }
21 | }
22 | },
23 | {
24 | "type": "npm",
25 | "script": "test",
26 | "isBackground": true,
27 | "problemMatcher": {
28 | "owner": "typescript",
29 | "pattern": "$tsc",
30 | "background": {
31 | "activeOnStart": true,
32 | "beginsPattern": {
33 | "regexp": "(.*?)"
34 | },
35 | "endsPattern": {
36 | "regexp": "bundle generation complete"
37 | }
38 | }
39 | }
40 | }
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 1.0.0
2 |
3 | - Create projects from existing templates (10+ templates included) without cloning or installing anything locally
4 | - Support import from [GitHub folder](https://code-studio.chenxiaoyao.cn/edit?source=https://github.com/chenxiaoyao6228/fe-notes/tree/main/React%E7%9B%B8%E5%85%B3/_demo/react-starter) or local folder
5 | - Sync your project to Gist (if GitHub token is provided) or download as ZIP
6 | - File Manager: easily create, edit, remove files and folders, with drag and drop support
7 | - Basic code editing abilities: path IntelliSense, go to definition, etc.
8 | - Integrated Console: no need to press 'F12' to open DevTools
9 | - Prettier as the default code formatter
10 | - Keyboard shortcuts support: 'CTRL(CMD) + /' to show the shortcut list
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Code Studio
8 | Web Code Runner for JS and NodeJS
9 |
10 | ## Features
11 |
12 | - [x] Create projects from existing templates (10+ templates included) without cloning or installing anything locally
13 | - [x] Support import from [GitHub folder](https://code-studio.chenxiaoyao.cn/edit?source=https://github.com/chenxiaoyao6228/fe-notes/tree/main/React%E7%9B%B8%E5%85%B3/_demo/react-starter) or local folder
14 | - [x] Sync your project to Gist (if GitHub token is provided) or download as ZIP
15 | - [x] File Manager: easily create, edit, remove files and folders, with drag and drop support
16 | - [x] Basic code editing abilities: path IntelliSense, go to definition, etc.
17 | - [x] Integrated Console: no need to press 'F12' to open DevTools
18 | - [x] Prettier as the default code formatter
19 | - [x] Keyboard shortcuts support: 'CTRL(CMD) + /' to show the shortcut list
20 | - [ ] Various VSCode theme support
21 | - [ ] File Sharing
22 | - [ ] Diff editor
23 |
24 | You can experience code-studio through [this link](https://code-studio.chenxiaoyao.cn)
25 |
26 | ## Screenshots
27 |
28 | 
29 |
30 | 
31 |
32 | ## Development
33 |
34 | Code Studio uses the following tech stack:
35 |
36 | > Angular 18 + RxJS + WebContainers + Monaco Editor + XtermJS
37 |
38 | Run the following commands to get started:
39 |
40 | ```sh
41 | yarn install
42 | yarn start
43 | ```
44 |
45 | ## Contribute
46 |
47 | For anyone interested in the project, any kind of contribution is greatly appreciated.
48 |
49 | ## Reference
50 |
51 | - https://webcontainers.io
52 | - https://microsoft.github.io/monaco-editor/
53 | - https://angular.dev/
54 | - https://material.angular.io/
55 | - https://github.com/xtermjs/xterm.js
56 |
57 | Great thanks to these awesome open-source projects!
58 |
--------------------------------------------------------------------------------
/angular.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "version": 1,
4 | "newProjectRoot": "projects",
5 | "projects": {
6 | "CodeStudio": {
7 | "projectType": "application",
8 | "schematics": {
9 | "@schematics/angular:component": {
10 | "style": "scss"
11 | }
12 | },
13 | "root": "",
14 | "sourceRoot": "src",
15 | "prefix": "app",
16 | "architect": {
17 | "build": {
18 | "builder": "@angular-devkit/build-angular:application",
19 | "options": {
20 | "outputPath": "dist/code-studio",
21 | "index": "src/index.html",
22 | "browser": "src/main.ts",
23 | "tsConfig": "tsconfig.app.json",
24 | "inlineStyleLanguage": "scss",
25 | "assets": [
26 | "src/assets",
27 | {
28 | "glob": "**/*",
29 | "input": "public"
30 | },
31 | {
32 | "glob": "**/*",
33 | "input": "./node_modules/monaco-editor/min/vs",
34 | "output": "/assets/vs/"
35 | }
36 | ],
37 | "styles": [
38 | "@angular/material/prebuilt-themes/magenta-violet.css",
39 | "src/styles.scss"
40 | ]
41 | },
42 | "configurations": {
43 | "production": {
44 | "budgets": [
45 | {
46 | "type": "initial",
47 | "maximumWarning": "2mb",
48 | "maximumError": "5mb"
49 | // "maximumWarning": "500kB",
50 | // "maximumError": "1MB"
51 | },
52 | {
53 | "type": "anyComponentStyle",
54 | "maximumWarning": "2kB",
55 | "maximumError": "5kB"
56 | }
57 | ],
58 | "outputHashing": "all"
59 | },
60 | "development": {
61 | "optimization": false,
62 | "extractLicenses": false,
63 | "sourceMap": true,
64 | "fileReplacements": [
65 | {
66 | "replace": "src/environments/environment.ts",
67 | "with": "src/environments/environment.development.ts"
68 | }
69 | ]
70 | }
71 | },
72 | "defaultConfiguration": "production"
73 | },
74 | "serve": {
75 | "options": {
76 | "headers": {
77 | "Cross-Origin-Opener-Policy": "same-origin",
78 | "Cross-Origin-Embedder-Policy": "require-corp"
79 | }
80 | },
81 | "builder": "@angular-devkit/build-angular:dev-server",
82 | "configurations": {
83 | "production": {
84 | "buildTarget": "CodeStudio:build:production"
85 | },
86 | "development": {
87 | "buildTarget": "CodeStudio:build:development"
88 | }
89 | },
90 | "defaultConfiguration": "development"
91 | },
92 | "extract-i18n": {
93 | "builder": "@angular-devkit/build-angular:extract-i18n"
94 | },
95 | "test": {
96 | "builder": "@angular-devkit/build-angular:karma",
97 | "options": {
98 | "tsConfig": "tsconfig.spec.json",
99 | "inlineStyleLanguage": "scss",
100 | "assets": [
101 | "src/assets",
102 | {
103 | "glob": "**/*",
104 | "input": "public"
105 | },
106 | {
107 | "glob": "**/*",
108 | "input": "./node_modules/monaco-editor/min/vs",
109 | "output": "/assets/vs/"
110 | }
111 | ],
112 | "styles": [
113 | "@angular/material/prebuilt-themes/magenta-violet.css",
114 | "src/styles.scss"
115 | ]
116 | }
117 | },
118 | "lint": {
119 | "builder": "@angular-eslint/builder:lint",
120 | "options": {
121 | "lintFilePatterns": [
122 | "src/**/*.ts",
123 | "src/**/*.html"
124 | ]
125 | }
126 | }
127 | }
128 | }
129 | },
130 | "cli": {
131 | "analytics": "b001fd87-29ae-452b-8695-66d88a860d5c",
132 | "schematicCollections": [
133 | "@angular-eslint/schematics"
134 | ]
135 | }
136 | }
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import unusedImports from 'eslint-plugin-unused-imports';
2 | import path from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 | import js from '@eslint/js';
5 | import { FlatCompat } from '@eslint/eslintrc';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 | const compat = new FlatCompat({
10 | baseDirectory: __dirname,
11 | recommendedConfig: js.configs.recommended,
12 | allConfig: js.configs.all,
13 | });
14 |
15 | export default [
16 | ...compat
17 | .extends(
18 | 'eslint:recommended',
19 | 'plugin:@typescript-eslint/recommended',
20 | 'plugin:@typescript-eslint/stylistic',
21 | 'plugin:@angular-eslint/recommended',
22 | 'plugin:prettier/recommended'
23 | )
24 | .map((config) => ({
25 | ...config,
26 | files: ['**/*.ts'],
27 | })),
28 | {
29 | files: ['**/*.ts'],
30 |
31 | plugins: {
32 | 'unused-imports': unusedImports,
33 | },
34 |
35 | rules: {
36 | '@angular-eslint/directive-selector': [
37 | 'error',
38 | {
39 | type: 'attribute',
40 | prefix: 'app',
41 | style: 'camelCase',
42 | },
43 | ],
44 |
45 | '@angular-eslint/component-selector': [
46 | 'error',
47 | {
48 | type: 'element',
49 | prefix: 'app',
50 | style: 'kebab-case',
51 | },
52 | ],
53 |
54 | 'prettier/prettier': [
55 | 'error',
56 | {
57 | printWidth: 120, // Make sure this value matches the one in your .prettierrc file
58 | },
59 | ],
60 |
61 | '@typescript-eslint/triple-slash-reference': 'off',
62 | '@typescript-eslint/no-unused-vars': 'off',
63 | 'no-unused-vars': 'off',
64 | 'unused-imports/no-unused-imports': 'off',
65 | 'no-useless-constructor': 'off',
66 | 'no-useless-escape': 'off',
67 | '@typescript-eslint/no-explicit-any': 'off',
68 | '@typescript-eslint/no-empty-function': 'off',
69 | },
70 | },
71 | ...compat.extends('plugin:@angular-eslint/template/recommended').map((config) => ({
72 | ...config,
73 | files: ['**/*.html'],
74 | })),
75 | {
76 | files: ['**/*.html'],
77 | rules: {},
78 | },
79 | ...compat.extends('plugin:prettier/recommended').map((config) => ({
80 | ...config,
81 | files: ['**/*.html'],
82 | ignores: ['**/*inline-template-*.component.html'],
83 | })),
84 | {
85 | files: ['**/*.html'],
86 | ignores: ['**/*inline-template-*.component.html'],
87 |
88 | rules: {
89 | 'prettier/prettier': [
90 | 'error',
91 | {
92 | parser: 'angular',
93 | },
94 | ],
95 | },
96 | },
97 | ];
98 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "src/app/editor/features/main/ouput/console"
4 | ],
5 | "ext": "ts",
6 | "exec": "node scripts/genProxyScript.js"
7 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "code-studio",
3 | "version": "1.0.0",
4 | "author": "chenxiaoyao6228@163.com",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/chenxiaoyao6228/codestudio"
8 | },
9 | "scripts": {
10 | "ng": "ng",
11 | "start": "ng serve",
12 | "build": "ng build",
13 | "watch": "ng build --watch --configuration development",
14 | "build:proxy-console": "node scripts/genProxyScript.js",
15 | "watch:proxy-console": "nodemon",
16 | "test": "ng test",
17 | "lint": "npx eslint --fix",
18 | "prepare": "husky"
19 | },
20 | "private": true,
21 | "dependencies": {
22 | "@angular/animations": "^18.0.0",
23 | "@angular/cdk": "18.0.2",
24 | "@angular/common": "^18.0.0",
25 | "@angular/compiler": "^18.0.0",
26 | "@angular/core": "^18.0.0",
27 | "@angular/forms": "^18.0.0",
28 | "@angular/material": "18.0.2",
29 | "@angular/platform-browser": "^18.0.0",
30 | "@angular/platform-browser-dynamic": "^18.0.0",
31 | "@angular/router": "^18.0.0",
32 | "@octokit/rest": "^21.0.0",
33 | "@webcontainer/api": "^1.1.9",
34 | "@xterm/addon-fit": "^0.10.0",
35 | "@xterm/xterm": "^5.5.0",
36 | "hotkeys-js": "^3.13.7",
37 | "jszip": "^3.10.1",
38 | "lodash-es": "^4.17.21",
39 | "monaco-editor": "^0.50.0",
40 | "rxjs": "~7.8.0"
41 | },
42 | "devDependencies": {
43 | "@angular-devkit/build-angular": "^18.0.2",
44 | "@angular-eslint/eslint-plugin-template": "^18.1.0",
45 | "@angular-eslint/template-parser": "^18.1.0",
46 | "@angular/cli": "^18.0.2",
47 | "@angular/compiler-cli": "^18.0.0",
48 | "@babel/cli": "^7.24.8",
49 | "@babel/core": "^7.24.8",
50 | "@babel/preset-env": "^7.24.8",
51 | "@babel/preset-typescript": "^7.24.7",
52 | "@eslint/eslintrc": "^3.1.0",
53 | "@eslint/js": "^9.7.0",
54 | "@types/jasmine": "~5.1.0",
55 | "@types/lodash": "^4.17.6",
56 | "@typescript-eslint/eslint-plugin": "^7.16.1",
57 | "@typescript-eslint/parser": "^7.16.1",
58 | "adm-zip": "^0.5.14",
59 | "angular-eslint": "18.0.1",
60 | "eslint": "^9.3.0",
61 | "eslint-config-prettier": "^9.1.0",
62 | "eslint-plugin-prettier": "^5.2.1",
63 | "eslint-plugin-unused-imports": "^4.0.0",
64 | "husky": "^9.0.11",
65 | "jasmine-core": "~5.1.0",
66 | "karma": "~6.4.0",
67 | "karma-chrome-launcher": "~3.2.0",
68 | "karma-coverage": "~2.2.0",
69 | "karma-jasmine": "~5.1.0",
70 | "karma-jasmine-html-reporter": "~2.1.0",
71 | "lint-staged": "^15.2.7",
72 | "nodemon": "^3.1.4",
73 | "prettier": "^3.3.3",
74 | "prettier-eslint": "^16.3.0",
75 | "terser": "^5.31.2",
76 | "tslib": "^2.3.0",
77 | "typescript": "~5.4.2"
78 | }
79 | }
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | 404
7 |
8 |
9 | 404
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxiaoyao6228/code-studio/1c666275313afaa74532fad5d6631125bb8f3c90/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chenxiaoyao6228/code-studio/1c666275313afaa74532fad5d6631125bb8f3c90/public/logo.jpg
--------------------------------------------------------------------------------
/public/proxy-console/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Proxy console
7 |
8 |
9 | Proxy console
10 |
11 |
174 |
175 |
176 |
--------------------------------------------------------------------------------
/public/proxy-console/index.js:
--------------------------------------------------------------------------------
1 | // This file is auto-generated, please do not modify by hand
2 | "use strict";function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _slicedToArray(e,r){return _arrayWithHoles(e)||_iterableToArrayLimit(e,r)||_unsupportedIterableToArray(e,r)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,r){if(e){if("string"==typeof e)return _arrayLikeToArray(e,r);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,r):void 0}}function _arrayLikeToArray(e,r){(null==r||r>e.length)&&(r=e.length);for(var n=0,t=Array(r);n {
24 | if (err) {
25 | console.error("Error during Babel transformation:", err);
26 | return;
27 | }
28 |
29 | try {
30 | const minified = await minify(result.code, {
31 | output: {
32 | comments: false,
33 | },
34 | });
35 |
36 | const escapedCode = minified.code
37 | .replace(/\\/g, "\\\\") // Escape backslashes
38 | .replace(/`/g, "\\`") // Escape backticks
39 | .replace(/\$/g, "\\$") // Escape dollar signs
40 | .replace(/\r?\n|\r/g, "\\n"); // Escape newlines
41 | const outputString = `export const proxyConsoleScript = \`${escapedCode}\`;`;
42 |
43 | fs.writeFileSync(
44 | outputFilePath,
45 | `// This file is auto-generated, please do not modify by hand
46 | ${minified.code}`
47 | );
48 | fs.writeFileSync(
49 | outputFileStringPath,
50 | `// This file is auto-generated, please do not modify by hand
51 | ${outputString}`
52 | );
53 | console.log(
54 | "TypeScript file has been transpiled and output to public/proxy-console/index.js"
55 | );
56 | } catch (minifyErr) {
57 | console.error("Error during minification:", minifyErr);
58 | }
59 | });
60 |
--------------------------------------------------------------------------------
/scripts/zip.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const AdmZip = require('adm-zip');
4 |
5 | function deleteNodeModules(folderPath) {
6 | const files = fs.readdirSync(folderPath);
7 |
8 | files.forEach(file => {
9 | const filePath = path.join(folderPath, file);
10 | const stats = fs.statSync(filePath);
11 |
12 | if (stats.isDirectory()) {
13 | if (file === 'node_modules') {
14 | fs.rmSync(filePath, { recursive: true, force: true });
15 | console.log(`Deleted ${filePath}`);
16 | } else {
17 | deleteNodeModules(filePath);
18 | }
19 | }
20 | });
21 | }
22 |
23 | function zipFolderRecursive(folderPath, zip, baseFolder) {
24 | const files = fs.readdirSync(folderPath);
25 |
26 | files.forEach(file => {
27 | const filePath = path.join(folderPath, file);
28 | const relativePath = path.relative(baseFolder, filePath);
29 |
30 | const stats = fs.statSync(filePath);
31 | if (stats.isDirectory()) {
32 | zip.addFile(relativePath + '/', Buffer.alloc(0), '', 0o755);
33 | zipFolderRecursive(filePath, zip, baseFolder);
34 | } else {
35 | const fileData = fs.readFileSync(filePath);
36 | zip.addFile(relativePath, fileData, '', 0o644);
37 | }
38 | });
39 | }
40 |
41 | function zipFolder(sourceFolder, targetZip) {
42 | const zip = new AdmZip();
43 | deleteNodeModules(sourceFolder);
44 | zipFolderRecursive(sourceFolder, zip, sourceFolder);
45 | zip.writeZip(targetZip);
46 | console.log(`Zipped ${sourceFolder} to ${targetZip}`);
47 | }
48 |
49 | function unzipFolder(zipFilePath, targetFolder) {
50 | const zip = new AdmZip(zipFilePath);
51 | zip.extractAllTo(targetFolder, true);
52 | console.log(`Unzipped ${zipFilePath} to ${targetFolder}`);
53 | }
54 |
55 | const args = process.argv.slice(2);
56 |
57 | let name = '';
58 | let action = '';
59 |
60 | args.forEach((arg, index) => {
61 | if (arg === '--name' && args[index + 1]) {
62 | name = args[index + 1];
63 | } else if (arg === '--zip') {
64 | action = 'zip';
65 | } else if (arg === '--unzip') {
66 | action = 'unzip';
67 | }
68 | });
69 |
70 | if (!name) {
71 | console.log('Please provide a folder name using --name argument.');
72 | } else if (action === 'zip') {
73 | const sourceFolder = `/home/york/projects/CodeStudio/public/templates/${name}`;
74 | const targetZip = `${sourceFolder}.zip`;
75 | zipFolder(sourceFolder, targetZip);
76 | } else if (action === 'unzip') {
77 | const sourceZip = `/home/york/projects/CodeStudio/public/templates/${name}.zip`;
78 | const targetFolder = `/home/york/projects/CodeStudio/public/templates/${name}`;
79 | unzipFolder(sourceZip, targetFolder);
80 | } else {
81 | console.log('Please provide --zip or --unzip argument.');
82 | }
83 |
--------------------------------------------------------------------------------
/src/app/_shared/components/confirm-dialog/confirm-dialog.ts:
--------------------------------------------------------------------------------
1 | import { Component, Inject } from '@angular/core';
2 | import { MatButton, MatButtonModule } from '@angular/material/button';
3 | import {
4 | MatDialogRef,
5 | MAT_DIALOG_DATA,
6 | MatDialog,
7 | MatDialogTitle,
8 | MatDialogContent,
9 | MatDialogActions,
10 | MatDialogClose,
11 | } from '@angular/material/dialog';
12 |
13 | @Component({
14 | standalone: true,
15 | selector: 'app-confirm-dialog',
16 | template: `
17 | Confirm
18 |
19 |
{{ data.message }}
20 |
21 |
22 | No
23 | Yes
24 |
25 | `,
26 | imports: [MatButtonModule, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose],
27 | })
28 | export class ConfirmDialogComponent {
29 | constructor(
30 | public dialogRef: MatDialogRef,
31 | @Inject(MAT_DIALOG_DATA) public data: any
32 | ) {}
33 |
34 | onNoClick(): void {
35 | this.dialogRef.close(false);
36 | }
37 |
38 | onYesClick(): void {
39 | this.dialogRef.close(true);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/_shared/components/github-token-dialog/github-token-dialog.component.html:
--------------------------------------------------------------------------------
1 | Enter Your GitHub Token
2 |
3 |
4 | GitHub Token
5 |
6 |
7 |
8 | Code Studio use Gist as storage service. If you don't have a Github token yet, you can create a new one
9 | here
10 | . Make sure to select the gist scope in the scopes section.
11 |
12 |
13 |
14 | Cancel
15 | Save
16 |
17 |
--------------------------------------------------------------------------------
/src/app/_shared/components/github-token-dialog/github-token-dialog.component.scss:
--------------------------------------------------------------------------------
1 | mat-form-field {
2 | width: 100%;
3 | }
4 |
5 | .mat-mdc-dialog-content {
6 | padding-bottom: 16px;
7 | .custom-link {
8 | text-decoration: underline;
9 | color: #fff;
10 | font-weight: strong;
11 | }
12 | }
13 |
14 | .mat-dialog-actions {
15 | display: flex;
16 | justify-content: flex-end;
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/_shared/components/github-token-dialog/github-token-dialog.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { FormsModule } from '@angular/forms';
3 | import { MatButtonModule } from '@angular/material/button';
4 | import {
5 | MatDialogActions,
6 | MatDialogClose,
7 | MatDialogContent,
8 | MatDialogRef,
9 | MatDialogTitle,
10 | } from '@angular/material/dialog';
11 | import { MatFormFieldModule } from '@angular/material/form-field';
12 | import { MatInputModule } from '@angular/material/input';
13 |
14 | @Component({
15 | standalone: true,
16 | selector: 'app-github-token-dialog',
17 | templateUrl: './github-token-dialog.component.html',
18 | imports: [
19 | MatFormFieldModule,
20 | MatInputModule,
21 | FormsModule,
22 | MatButtonModule,
23 | MatDialogTitle,
24 | MatDialogContent,
25 | MatDialogActions,
26 | MatDialogClose,
27 | ],
28 | styleUrls: ['./github-token-dialog.component.scss'],
29 | })
30 | export class GitHubTokenDialogComponent {
31 | token = '';
32 |
33 | constructor(public dialogRef: MatDialogRef) {}
34 |
35 | onNoClick(): void {
36 | this.dialogRef.close();
37 | }
38 |
39 | onSave(): void {
40 | this.dialogRef.close(this.token);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/app/_shared/components/github-url-dialog/github-url-dialog.component.html:
--------------------------------------------------------------------------------
1 | Enter GitHub URL
2 |
29 |
--------------------------------------------------------------------------------
/src/app/_shared/components/github-url-dialog/github-url-dialog.component.scss:
--------------------------------------------------------------------------------
1 | mat-form-field {
2 | width: 100%;
3 | }
4 |
5 | .mat-mdc-dialog-content {
6 | padding-bottom: 16px;
7 | .custom-link {
8 | text-decoration: underline;
9 | color: #fff;
10 | font-weight: strong;
11 | }
12 | }
13 |
14 | .mat-dialog-actions {
15 | display: flex;
16 | justify-content: flex-end;
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/_shared/components/github-url-dialog/github-url-dialog.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, inject, Inject } from '@angular/core';
2 | import { FormsModule } from '@angular/forms';
3 | import { MatButtonModule } from '@angular/material/button';
4 | import {
5 | MatDialogRef,
6 | MAT_DIALOG_DATA,
7 | MatDialogTitle,
8 | MatDialogContent,
9 | MatDialogActions,
10 | MatDialogClose,
11 | } from '@angular/material/dialog';
12 | import { MatFormFieldModule } from '@angular/material/form-field';
13 | import { MatInputModule } from '@angular/material/input';
14 |
15 | @Component({
16 | selector: 'app-github-url-dialog',
17 | templateUrl: './github-url-dialog.component.html',
18 | styleUrls: ['./github-url-dialog.component.scss'],
19 | standalone: true,
20 | imports: [
21 | MatFormFieldModule,
22 | MatInputModule,
23 | FormsModule,
24 | MatButtonModule,
25 | MatDialogTitle,
26 | MatDialogContent,
27 | MatDialogActions,
28 | MatDialogClose,
29 | ],
30 | })
31 | export class GithubUrlDialogComponent {
32 | githubUrl = '';
33 | readonly dialogRef = inject(MatDialogRef);
34 |
35 | constructor(@Inject(MAT_DIALOG_DATA) public data: unknown) {}
36 |
37 | onNoClick(): void {
38 | this.dialogRef.close();
39 | }
40 |
41 | onSave(): void {
42 | if (this.githubUrl.trim()) {
43 | this.dialogRef.close(this.githubUrl);
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/_shared/components/template-modal/config.ts:
--------------------------------------------------------------------------------
1 | import { environment } from '@src/environments/environment';
2 |
3 | export interface ITemplateItem {
4 | name: string;
5 | url: string;
6 | terminal: string;
7 | icon: string;
8 | }
9 |
10 | const BASE_URL = environment.baseUrl;
11 |
12 | export const TEMPLATES_CONFIG = [
13 | {
14 | name: 'React',
15 | desc: 'Typescript',
16 | url: `${BASE_URL}/templates/react-ts.zip`,
17 | icon: '/assets/imgs/template/react.svg',
18 | terminal: 'dev',
19 | },
20 | {
21 | name: 'Vue3',
22 | desc: 'Typescript',
23 | url: `${BASE_URL}/templates/vue3-ts.zip`,
24 | icon: '/assets/imgs/template/vue.svg',
25 | terminal: 'dev',
26 | },
27 | {
28 | name: 'Solid',
29 | desc: 'Typescript',
30 | url: `${BASE_URL}/templates/solid-ts.zip`,
31 | icon: '/assets/imgs/template/solid.svg',
32 | terminal: 'dev',
33 | },
34 | {
35 | name: 'Angular',
36 | desc: 'Typescript',
37 | url: `${BASE_URL}/templates/angular.zip`,
38 | icon: '/assets/imgs/template/angular.svg',
39 | terminal: 'start',
40 | },
41 | {
42 | name: 'Vanilla',
43 | desc: 'Typescript',
44 | url: `${BASE_URL}/templates/vanilla.zip`,
45 | icon: '/assets/imgs/template/vanilla.svg',
46 | terminal: 'dev',
47 | },
48 | {
49 | name: 'Static',
50 | desc: 'HTML/CSS/JS',
51 | url: `${BASE_URL}/templates/static.zip`,
52 | icon: '/assets/imgs/template/static.svg',
53 | terminal: 'start',
54 | },
55 | {
56 | name: 'Node',
57 | desc: 'Blank project',
58 | url: `${BASE_URL}/templates/node.zip`,
59 | icon: '/assets/imgs/template/node.svg',
60 | terminal: 'dev',
61 | },
62 | {
63 | name: 'Next',
64 | desc: 'Node',
65 | url: `${BASE_URL}/templates/next.zip`,
66 | icon: '/assets/imgs/template/next.svg',
67 | terminal: 'dev',
68 | },
69 | ];
70 |
--------------------------------------------------------------------------------
/src/app/_shared/components/template-modal/template-modal.component.html:
--------------------------------------------------------------------------------
1 |
2 | Choose a template
3 |
4 |
5 | close
6 |
7 |
8 |
9 |
10 |
11 | @for (item of templateList(); track item.icon) {
12 |
13 |
14 |
15 |
{{ item.name }}
16 |
{{ item.desc }}
17 |
18 |
19 | }
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/app/_shared/components/template-modal/template-modal.component.scss:
--------------------------------------------------------------------------------
1 | .title {
2 | display: flex;
3 | align-items: center;
4 | }
5 | .mat-mdc-dialog-content {
6 | .template-list {
7 | overflow: auto;
8 |
9 | .template-item {
10 | width: 160px;
11 | height: 70px;
12 | display: flex;
13 | align-items: center;
14 | float: left;
15 | margin-right: 10px;
16 | margin-bottom: 20px;
17 | cursor: pointer;
18 | transition: all 0.3s;
19 | border-radius: 4px;
20 | &:hover {
21 | background-color: rgba(36, 36, 36, 0.1);
22 | border: 1px solid #fff;
23 | }
24 |
25 | .icon {
26 | width: 60px;
27 | height: 60px;
28 | background-size: contain;
29 | background-repeat: no-repeat;
30 | background-position: center;
31 | margin-left: 10px;
32 | }
33 | .wrap {
34 | margin-left: 20px;
35 | .name {
36 | overflow: hidden;
37 | font-weight: bold;
38 | font-size: 18px;
39 | }
40 | .desc {
41 | overflow: hidden;
42 | font-weight: 400;
43 | font-size: 85%;
44 | text-overflow: ellipsis;
45 | white-space: nowrap;
46 | opacity: 0.65;
47 | }
48 | }
49 | }
50 | }
51 | }
52 | .mat-mdc-dialog-actions {
53 | position: absolute;
54 | right: 0;
55 | .close {
56 | &:hover {
57 | cursor: pointer;
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/app/_shared/components/template-modal/template-modal.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
2 | import { ITemplateItem, TEMPLATES_CONFIG } from '@app/_shared/components/template-modal/config';
3 | import {
4 | MatDialog,
5 | MatDialogActions,
6 | MatDialogClose,
7 | MatDialogContent,
8 | MatDialogModule,
9 | MatDialogTitle,
10 | } from '@angular/material/dialog';
11 | import { Router } from '@angular/router';
12 | import { MatIcon } from '@angular/material/icon';
13 |
14 | @Component({
15 | selector: 'app-template-modal',
16 | standalone: true,
17 | imports: [MatDialogModule, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose, MatIcon],
18 | templateUrl: './template-modal.component.html',
19 | styleUrls: ['./template-modal.component.scss'],
20 | changeDetection: ChangeDetectionStrategy.OnPush,
21 | })
22 | export class TemplateModalComponent {
23 | dialog = inject(MatDialog);
24 | router = inject(Router); // Inject the Router
25 |
26 | templateList = signal(TEMPLATES_CONFIG);
27 |
28 | selectTemplate(item: ITemplateItem) {
29 | const queryString = `source=${encodeURIComponent(item.url)}&terminal=${encodeURIComponent(item.terminal)}`;
30 | window.location.href = `${window.location.origin}/edit/?${queryString}`;
31 | this.closeModal(); // Close the modal after selection
32 | }
33 |
34 | closeModal() {
35 | this.dialog.closeAll();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/_shared/constant.ts:
--------------------------------------------------------------------------------
1 | export const PROJECT_KEY = 'codestudio';
2 |
--------------------------------------------------------------------------------
/src/app/_shared/service/Emitter.ts:
--------------------------------------------------------------------------------
1 | export class EventEmitter {
2 | private events: { [K in keyof T]?: ((payload: T[K]) => void)[] } = {};
3 |
4 | on(event: K, listener: (payload: T[K]) => void): void {
5 | if (!this.events[event]) {
6 | this.events[event] = [];
7 | }
8 | this.events[event]!.push(listener);
9 | }
10 |
11 | off(event: K, listener: (payload: T[K]) => void): void {
12 | if (!this.events[event]) return;
13 | this.events[event] = this.events[event]!.filter(l => l !== listener);
14 | }
15 |
16 | emit(event: K, payload: T[K]): void {
17 | if (!this.events[event]) return;
18 | this.events[event]!.forEach(listener => listener(payload));
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/_shared/service/device-guard.ts:
--------------------------------------------------------------------------------
1 | import { inject, Injectable } from '@angular/core';
2 | import { CanActivate, Router } from '@angular/router';
3 | import { DeviceService } from './device.service';
4 | import { MatSnackBar } from '@angular/material/snack-bar';
5 |
6 | @Injectable({
7 | providedIn: 'root',
8 | })
9 | export class PcOnlyGuard implements CanActivate {
10 | private deviceService = inject(DeviceService);
11 | private snackBar = inject(MatSnackBar);
12 | constructor() {}
13 |
14 | canActivate(): boolean {
15 | if (this.deviceService.isPC()) {
16 | return true;
17 | } else {
18 | this.snackBar.open('This application can only be accessed on a PC.', 'Close', {
19 | duration: 5000,
20 | });
21 | return false;
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/app/_shared/service/device.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 |
3 | @Injectable({
4 | providedIn: 'root',
5 | })
6 | export class DeviceService {
7 | constructor() {}
8 |
9 | isPC(): boolean {
10 | const userAgent = navigator.userAgent.toLowerCase();
11 | const isMobile = /iphone|ipod|ipad|android|blackberry|windows phone|opera mini|iemobile/i.test(userAgent);
12 | return !isMobile;
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/_shared/service/gist.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, inject } from '@angular/core';
2 | import { Octokit } from '@octokit/rest';
3 | import { LocalStorageService } from './local-storage.service';
4 |
5 | export interface IGistFile {
6 | filename: string;
7 | type: string;
8 | language: string;
9 | raw_url: string;
10 | size: number;
11 | }
12 |
13 | export interface IGistItem {
14 | id: string;
15 | description: string;
16 | updated_at: string;
17 | url: string;
18 | files: Record;
19 | }
20 |
21 | @Injectable({
22 | providedIn: 'root',
23 | })
24 | export class GistService {
25 | private localStorageService = inject(LocalStorageService);
26 |
27 | private createOctokitInstance() {
28 | const token = this.localStorageService.getItem('githubToken');
29 | return new Octokit({ auth: token });
30 | }
31 |
32 | private handleError(error: any) {
33 | if (error.status === 401) {
34 | return {
35 | success: false,
36 | status: 401,
37 | message: 'Unauthorized. Please check your GitHub token.',
38 | };
39 | }
40 | return { success: false, status: error.status, message: error.message };
41 | }
42 |
43 | async addGist(
44 | params: { title: string; description: string; [key: string]: any },
45 | files: Record
46 | ) {
47 | try {
48 | const fullDescription = stringifyDescription(params);
49 | const octokit = this.createOctokitInstance();
50 | const response = await octokit.gists.create({
51 | description: fullDescription,
52 | public: true,
53 | files,
54 | });
55 | return { success: true, data: response.data };
56 | } catch (error) {
57 | return this.handleError(error);
58 | }
59 | }
60 | async deleteGist(params: { gistId: string }) {
61 | const octokit = this.createOctokitInstance();
62 | try {
63 | await octokit.gists.delete({ gist_id: params.gistId });
64 | return { success: true };
65 | } catch (error) {
66 | return this.handleError(error);
67 | }
68 | }
69 |
70 | async deleteMultipleGists(params: { gistIds: string[] }) {
71 | try {
72 | const deletePromises = params.gistIds.map(gistId => this.deleteGist({ gistId }));
73 | await Promise.all(deletePromises);
74 | return { success: true };
75 | } catch (error) {
76 | return this.handleError(error);
77 | }
78 | }
79 |
80 | async updateGist(
81 | gistId: string,
82 | params: { title: string; description: string; [key: string]: any },
83 | files: Record
84 | ) {
85 | const octokit = this.createOctokitInstance();
86 | try {
87 | const response = await octokit.gists.update({
88 | description: stringifyDescription(params),
89 | gist_id: gistId,
90 | files: files,
91 | });
92 | return { success: true, data: response.data };
93 | } catch (error) {
94 | return this.handleError(error);
95 | }
96 | }
97 |
98 | async getGists(params: { page: number; perPage: number }) {
99 | const octokit = this.createOctokitInstance();
100 | try {
101 | const response = await octokit.gists.list({
102 | page: params.page,
103 | per_page: params.perPage,
104 | });
105 | return { success: true, data: response.data };
106 | } catch (error) {
107 | return this.handleError(error);
108 | }
109 | }
110 | }
111 |
112 | /*
113 | * Gist doesn't support extra keys except description from list query,
114 | * so try to store them in description
115 | * so that we can get some extra information like title in the home page
116 | */
117 | export function stringifyDescription(params: Record): string {
118 | return Object.keys(params)
119 | .map(key => `${key}: ${params[key]}`)
120 | .join('\n');
121 | }
122 |
123 | export function parseDescription(description: string): Record {
124 | const lines = description.split('\n');
125 | const params: Record = {};
126 | lines.forEach(line => {
127 | const [key, ...rest] = line.split(': ');
128 | if (key) {
129 | params[key] = rest.join(': ');
130 | }
131 | });
132 | return params;
133 | }
134 |
--------------------------------------------------------------------------------
/src/app/_shared/service/local-storage.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 |
3 | @Injectable({
4 | providedIn: 'root',
5 | })
6 | export class LocalStorageService {
7 | constructor() {}
8 |
9 | setItem(key: string, value: any): void {
10 | localStorage.setItem(key, JSON.stringify(value));
11 | }
12 |
13 | getItem(key: string): any {
14 | const item = localStorage.getItem(key);
15 | return item ? JSON.parse(item) : null;
16 | }
17 |
18 | removeItem(key: string): void {
19 | localStorage.removeItem(key);
20 | }
21 |
22 | clear(): void {
23 | localStorage.clear();
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/_shared/service/redirect-guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { CanActivate, Router, ActivatedRouteSnapshot, UrlTree } from '@angular/router';
3 | import { Observable } from 'rxjs';
4 |
5 | @Injectable({
6 | providedIn: 'root',
7 | })
8 | export class RedirectGuard implements CanActivate {
9 | constructor(private router: Router) {}
10 |
11 | canActivate(
12 | route: ActivatedRouteSnapshot
13 | ): Observable | Promise | boolean | UrlTree {
14 | const source = route.queryParams['source'];
15 | if (source) {
16 | const queryParams = { ...route.queryParams };
17 | return this.router.createUrlTree(['/edit'], { queryParams });
18 | }
19 | return true;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/_shared/types/index.ts:
--------------------------------------------------------------------------------
1 | import { FileSystemTree } from '@webcontainer/api';
2 |
3 | export interface IStudioAsset {
4 | meta: Record;
5 | files: FileSystemTree;
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/_shared/utils/index.ts:
--------------------------------------------------------------------------------
1 | export function formatTime(timeString: string): string {
2 | const date = new Date(timeString);
3 | const options: Intl.DateTimeFormatOptions = {
4 | year: 'numeric',
5 | month: '2-digit',
6 | day: '2-digit',
7 | hour: '2-digit',
8 | minute: '2-digit',
9 | second: '2-digit',
10 | timeZoneName: 'short',
11 | };
12 | return date.toLocaleDateString('en-US', options);
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { IconResolver, MatIconModule, MatIconRegistry } from '@angular/material/icon';
3 | import { DomSanitizer } from '@angular/platform-browser';
4 | import { RouterOutlet } from '@angular/router';
5 |
6 | @Component({
7 | selector: 'app-root',
8 | standalone: true,
9 | imports: [RouterOutlet, MatIconModule],
10 | template: ' ',
11 | })
12 | export class AppComponent {
13 | title = 'CodeStudio';
14 | constructor(iconRegistry: MatIconRegistry, sanitizer: DomSanitizer) {
15 | const resolver: IconResolver = (name, namespace) => {
16 | return sanitizer.bypassSecurityTrustResourceUrl(`assets/imgs/${name}.svg`);
17 | };
18 | iconRegistry.addSvgIconResolver(resolver);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/app.config.ts:
--------------------------------------------------------------------------------
1 | import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
2 | import { provideRouter } from '@angular/router';
3 | import { Routes } from '@angular/router';
4 | import { EditorComponent } from './editor/editor.component';
5 | import { CommonModule } from '@angular/common';
6 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
7 | import { provideHttpClient, withFetch } from '@angular/common/http';
8 | import { FormsModule } from '@angular/forms';
9 | import { HomeComponent } from './home/home.component';
10 | import { RedirectGuard } from './_shared/service/redirect-guard';
11 | import { PcOnlyGuard } from './_shared/service/device-guard';
12 |
13 | export const routes: Routes = [
14 | { path: '', component: HomeComponent, canActivate: [RedirectGuard, PcOnlyGuard] },
15 | {
16 | path: 'edit',
17 | component: EditorComponent,
18 | canActivate: [PcOnlyGuard],
19 | },
20 | ];
21 |
22 | export const appConfig: ApplicationConfig = {
23 | providers: [
24 | provideExperimentalZonelessChangeDetection(),
25 | provideRouter(routes),
26 | CommonModule,
27 | FormsModule,
28 | provideAnimationsAsync(),
29 | provideHttpClient(withFetch()),
30 | RedirectGuard,
31 | ],
32 | };
33 |
--------------------------------------------------------------------------------
/src/app/editor/components/shortcut-dialog.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
3 | import { CommonModule } from '@angular/common';
4 | import { MatListModule } from '@angular/material/list';
5 | import { ShortcutService } from '../services/shortcut';
6 | import { MatIcon } from '@angular/material/icon';
7 |
8 | @Component({
9 | selector: 'app-shortcut-dialog',
10 | standalone: true,
11 | imports: [CommonModule, MatDialogModule, MatListModule, MatIcon],
12 | template: `
13 |
14 | Keyboard Shortcuts
15 |
16 |
17 | close
18 |
19 |
20 |
21 |
22 |
23 |
24 | {{ shortcut.shortcut }}: {{ shortcut.description }}
25 |
26 |
27 |
28 | `,
29 | styles: [
30 | `
31 | .title {
32 | display: flex;
33 | align-items: center;
34 | }
35 | .mat-mdc-dialog-actions {
36 | position: absolute;
37 | right: 0;
38 | .close {
39 | &:hover {
40 | cursor: pointer;
41 | }
42 | }
43 | }
44 | `,
45 | ],
46 | })
47 | export class ShortcutDialogComponent {
48 | shortcuts = this.shortcutService.getShortcuts();
49 |
50 | constructor(
51 | private shortcutService: ShortcutService,
52 | private dialogRef: MatDialogRef
53 | ) {}
54 |
55 | close() {
56 | this.dialogRef.close();
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/editor/constants/index.ts:
--------------------------------------------------------------------------------
1 | export enum StartupPhase {
2 | NOT_STARTED,
3 | BOOTING,
4 | LOADING_FILES,
5 | INSTALLING,
6 | STARTING_DEV_SERVER,
7 | READY,
8 | ERROR,
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/editor/editor.component.ts:
--------------------------------------------------------------------------------
1 | import { ShortcutService } from './services/shortcut';
2 | import { AfterViewInit, Component, inject, OnDestroy } from '@angular/core';
3 | import { HeaderComponent } from './features/header/header.component';
4 | import { FooterComponent } from './features/footer/footer.component';
5 | import { MainComponent } from './features/main/main.component';
6 | import { CommonModule } from '@angular/common';
7 | import { NodeContainerService } from './services/node-container.service';
8 | import { MatCheckbox } from '@angular/material/checkbox';
9 | import { ActivatedRoute } from '@angular/router';
10 | import { FileLoaderFactory } from './services/file-loader/loader-factory.service';
11 | import { EditorStateService } from './services/editor-state.service';
12 | import { StartupPhase } from './constants';
13 | import { CodeEditorService } from './features/main/edit/code-editor/code-editor.service';
14 | import { TypeLoaderService } from './features/main/edit/code-editor/type-loader.service';
15 | import { TemplateModalComponent } from '@app/_shared/components/template-modal/template-modal.component';
16 | import { MatDialog } from '@angular/material/dialog';
17 | import { ConfirmDialogComponent } from '../_shared/components/confirm-dialog/confirm-dialog';
18 | import { ShortcutDialogComponent } from './components/shortcut-dialog.component';
19 |
20 | export interface IRouteParams {
21 | source: string; // mock, local, template name, github folder , zip url
22 | terminal?: string;
23 | pkgManager?: 'npm' | 'yarn' | 'pnpm';
24 | }
25 |
26 | @Component({
27 | selector: 'app-editor',
28 | standalone: true,
29 | imports: [
30 | CommonModule,
31 | HeaderComponent,
32 | FooterComponent,
33 | MainComponent,
34 | MatCheckbox,
35 | TemplateModalComponent,
36 | ShortcutDialogComponent,
37 | ],
38 | providers: [],
39 | template: `
40 |
41 |
42 |
43 | `,
44 | styles: [
45 | `
46 | :host {
47 | height: 100%;
48 | position: relative;
49 | display: flex;
50 | flex-direction: column;
51 | }
52 | `,
53 | ],
54 | })
55 | export class EditorComponent implements AfterViewInit, OnDestroy {
56 | routeParams: IRouteParams = {
57 | source: 'local',
58 | };
59 | readonly dialog = inject(MatDialog);
60 | route = inject(ActivatedRoute);
61 | nodeContainerService = inject(NodeContainerService);
62 | fileLoaderService = inject(FileLoaderFactory);
63 | editorStateService = inject(EditorStateService);
64 | typeLoadingService = inject(TypeLoaderService);
65 | codeEditorService = inject(CodeEditorService);
66 | shortcutService = inject(ShortcutService);
67 |
68 | async ngAfterViewInit() {
69 | // support direct access from route params
70 | this.route.queryParams.subscribe((params: any) => {
71 | this.routeParams = params;
72 | this.handleQueryParamsChange(this.routeParams);
73 | });
74 | }
75 |
76 | async handleQueryParamsChange(params: IRouteParams) {
77 | if (!params.source) {
78 | const dialogRef = this.dialog.open(ConfirmDialogComponent, {
79 | width: '400px',
80 | data: {
81 | message:
82 | 'Source is required in the query params to passed to access this page, go to the page page to choose a template or import a project.',
83 | },
84 | });
85 | // throw new Error('Source is required');
86 | }
87 | // local files have already been loaded by user picker
88 | if (!(this.routeParams.source === 'local')) {
89 | this.editorStateService.setPhase(StartupPhase.LOADING_FILES);
90 | try {
91 | const fileTree = await this.fileLoaderService.loadFiles({
92 | source: this.routeParams.source || 'mock',
93 | });
94 | this.editorStateService.setFileTree(fileTree);
95 | } catch (error) {
96 | console.error('Failed to load files: ', error);
97 | throw error;
98 | }
99 | }
100 | try {
101 | await this.nodeContainerService.init(params);
102 | } catch (error) {
103 | console.log('Failed to init webcontainer', error);
104 | }
105 | }
106 |
107 | ngOnDestroy() {
108 | this.shortcutService.cleanup();
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/app/editor/features/footer/footer.component.html:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/app/editor/features/footer/footer.component.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | height: 20px;
3 | line-height: 20px;
4 | font-size: 12px;
5 | display: flex;
6 | justify-content: space-between;
7 | align-content: center;
8 | overflow: hidden;
9 | padding: 0 4px;
10 | }
11 | .left {
12 | }
13 | .middle {
14 | }
15 | .right {
16 | .console {
17 | }
18 | }
19 |
20 | .footer-btn {
21 | // reset all btn attribute
22 | outline: none;
23 | border: none;
24 | padding: 0;
25 | margin: 0;
26 | background: none;
27 | cursor: pointer;
28 |
29 | font-size: 12px;
30 | color: #fff;
31 | margin: 0 4px;
32 | &:hover {
33 | opacity: 0.8;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/editor/features/footer/footer.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
2 | import { MatButtonModule } from '@angular/material/button';
3 | import { MainService } from '../main/main.service';
4 |
5 | @Component({
6 | selector: 'app-editor-footer',
7 | standalone: true,
8 | imports: [MatButtonModule],
9 | templateUrl: './footer.component.html',
10 | styleUrl: './footer.component.scss',
11 | changeDetection: ChangeDetectionStrategy.OnPush,
12 | })
13 | export class FooterComponent {
14 | mainService = inject(MainService);
15 |
16 | toggleConsole() {
17 | this.mainService.toggleConsole();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/editor/features/header/header.component.html:
--------------------------------------------------------------------------------
1 |
31 |
--------------------------------------------------------------------------------
/src/app/editor/features/header/header.component.scss:
--------------------------------------------------------------------------------
1 | .header-wrap {
2 | display: flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | height: 40px;
6 | border-bottom: 1px solid #fff;
7 | background-color: var(--activityBar-background);
8 | box-shadow: 0 1px 3px #0000001a, 0 1px 2px #0000000f;
9 | .left {
10 | margin-left: 10px;
11 | display: flex;
12 | align-items: center;
13 | &:hover {
14 | cursor: pointer;
15 | }
16 | .logo {
17 | width: 20px;
18 | height: 20px;
19 | border-radius: 50%;
20 | margin-right: 6px;
21 | }
22 | .name {
23 | font-weight: 500;
24 | }
25 | }
26 | .middle {
27 | .project-name {
28 | border: none;
29 | background-color: var(--activityBar-background);
30 | height: 30px;
31 | padding: 2px 4px;
32 | color: #fff;
33 | outline: none;
34 | font-weight: 600;
35 | font-size: 16px;
36 | text-align: center;
37 | max-width: 300px;
38 | overflow: hidden;
39 | text-overflow: ellipsis;
40 | white-space: nowrap;
41 | }
42 | }
43 | .right {
44 | display: flex;
45 | align-items: center;
46 | .template {
47 | display: flex;
48 | align-items: center;
49 | margin-right: 10px;
50 | // height: 24px;
51 | &:hover {
52 | cursor: pointer;
53 | }
54 | .icon {
55 | // margin-right: 0px;
56 | }
57 | .text {
58 | // line-height: 24px;
59 | }
60 | }
61 | .save {
62 | margin-right: 10px;
63 | position: relative;
64 | .spinner {
65 | position: absolute;
66 | left: 0;
67 | right: 0;
68 | top: 0;
69 | bottom: 0;
70 | margin: auto;
71 | visibility: hidden;
72 | }
73 | &.loading {
74 | .spinner {
75 | visibility: visible;
76 | }
77 | .save-btn {
78 | opacity: 0.5;
79 | }
80 | }
81 | }
82 | .github {
83 | display: flex;
84 | align-items: center;
85 | &:hover {
86 | cursor: pointer;
87 | }
88 | .logo {
89 | width: 24px;
90 | height: 24px;
91 | border-radius: 50%;
92 | margin-right: 20px;
93 | border: none;
94 | }
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/app/editor/features/header/header.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
2 | import { MatButton } from '@angular/material/button';
3 | import { MatDialog } from '@angular/material/dialog';
4 | import { MatIcon } from '@angular/material/icon';
5 | import { TemplateModalComponent } from '@app/_shared/components/template-modal/template-modal.component';
6 | import { FileSaverService } from '../../services/file-saver/file-saver.service';
7 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
8 | import { MatSnackBar } from '@angular/material/snack-bar';
9 | import { GitHubTokenDialogComponent } from '@src/app/_shared/components/github-token-dialog/github-token-dialog.component';
10 | import { LocalStorageService } from '@src/app/_shared/service/local-storage.service';
11 | import { Router, ActivatedRoute } from '@angular/router';
12 | import { CodeEditorService } from '../main/edit/code-editor/code-editor.service';
13 | import { ConfirmDialogComponent } from '@src/app/_shared/components/confirm-dialog/confirm-dialog';
14 | import { NodeContainerService } from '../../services/node-container.service';
15 |
16 | const UNTITLED_NAME = 'untitled_project';
17 |
18 | @Component({
19 | selector: 'app-editor-header',
20 | standalone: true,
21 | imports: [MatIcon, MatButton, MatProgressSpinnerModule, ConfirmDialogComponent],
22 | templateUrl: './header.component.html',
23 | styleUrl: './header.component.scss',
24 | changeDetection: ChangeDetectionStrategy.OnPush,
25 | })
26 | export class HeaderComponent implements OnInit {
27 | readonly dialog = inject(MatDialog);
28 | readonly snackBar = inject(MatSnackBar);
29 | logoPath = 'assets/imgs/header-logo.png';
30 | githubLogoPath = 'assets/imgs/github.png';
31 | isSaving = signal(false);
32 | projectName = signal(UNTITLED_NAME);
33 | nodeContainerService = inject(NodeContainerService);
34 | codeEditorService = inject(CodeEditorService);
35 | fileSaverService = inject(FileSaverService);
36 | localStorageService = inject(LocalStorageService);
37 | router = inject(Router);
38 | activatedRoute = inject(ActivatedRoute);
39 | editId: string | null = null;
40 | editName: string | null = null;
41 | ngOnInit() {
42 | this.activatedRoute.queryParamMap.subscribe(params => {
43 | this.editId = params.get('editId');
44 | const editName = params.get('editName');
45 | if (editName) {
46 | this.projectName.set(editName);
47 | }
48 | });
49 |
50 | this.nodeContainerService.fileMounted$.subscribe(async fileMounted => {
51 | if (fileMounted && this.projectName() === UNTITLED_NAME) {
52 | const pkgContent = await this.nodeContainerService.readFile('package.json');
53 | const name = JSON.parse(pkgContent).name || UNTITLED_NAME;
54 | if (name) {
55 | this.projectName.set(name);
56 | }
57 | }
58 | });
59 | }
60 |
61 | goHome() {
62 | if (this.codeEditorService.hasEdit) {
63 | const dialogRef = this.dialog.open(ConfirmDialogComponent, {
64 | width: '400px',
65 | data: {
66 | message: 'You have unsaved changes. Do you really want to leave?',
67 | },
68 | });
69 |
70 | dialogRef.afterClosed().subscribe(async result => {
71 | if (result) {
72 | this._goHome();
73 | } else {
74 | await this.saveToGist();
75 | this.codeEditorService.hasEdit = false;
76 | }
77 | });
78 | } else {
79 | this._goHome();
80 | }
81 | }
82 | private _goHome() {
83 | window.location.href = '/'; // force reload to release resources
84 | }
85 |
86 | openTemplateModal() {
87 | this.dialog.open(TemplateModalComponent);
88 | }
89 |
90 | async saveToGist() {
91 | let token = this.localStorageService.getItem('githubToken');
92 | if (!token) {
93 | const dialogRef = this.dialog.open(GitHubTokenDialogComponent, {
94 | width: '400px',
95 | });
96 |
97 | dialogRef.afterClosed().subscribe(async result => {
98 | if (result) {
99 | token = result;
100 | this.localStorageService.setItem('githubToken', token);
101 | this.performSave();
102 | }
103 | });
104 | } else {
105 | this.performSave();
106 | }
107 | }
108 |
109 | async performSave() {
110 | try {
111 | this.isSaving.set(true);
112 | let name = UNTITLED_NAME;
113 | if (this.projectName() !== UNTITLED_NAME) {
114 | name = this.projectName();
115 | }
116 | if (!name) {
117 | const pkgContent = await this.nodeContainerService.readFile('package.json');
118 | name = JSON.parse(pkgContent).name;
119 | }
120 |
121 | await this.fileSaverService.uploadToGist(name, {
122 | editId: this.editId,
123 | });
124 | this.snackBar.open(
125 | 'File uploaded ! Please note that this is a async operation. It may take Gist some time to return the newest list.',
126 | 'Close',
127 | {
128 | duration: 8000,
129 | }
130 | );
131 | this.isSaving.set(false);
132 | } catch (error) {
133 | this.isSaving.set(false);
134 | this.snackBar.open('File upload failed. Please try again.', 'Close', {
135 | duration: 3000,
136 | });
137 | console.log('error', error);
138 | }
139 | }
140 |
141 | handleProjectNameInput($event: Event) {
142 | const target = $event.target as HTMLInputElement;
143 | this.projectName.set(target.value);
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/components/resizer/resize-container.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | display: flex;
3 | width: 100%;
4 | height: 100%;
5 | }
6 |
7 | :host-context(.row) {
8 | flex-direction: row;
9 | }
10 |
11 | :host-context(.col) {
12 | flex-direction: column;
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/components/resizer/resize-container.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChangeDetectionStrategy,
3 | Component,
4 | ContentChildren,
5 | ElementRef,
6 | Input,
7 | QueryList,
8 | SimpleChanges,
9 | inject,
10 | OnChanges,
11 | AfterContentInit,
12 | } from '@angular/core';
13 | import { ResizeManagerService } from './resize-manager.service';
14 | import { ResizerComponent } from './resizer';
15 |
16 | @Component({
17 | standalone: true,
18 | selector: 'app-resizer-container',
19 | template: ' ',
20 | styleUrls: ['./resize-container.scss'],
21 | providers: [ResizeManagerService],
22 | changeDetection: ChangeDetectionStrategy.OnPush,
23 | })
24 | export class ResizerContainerComponent implements OnChanges, AfterContentInit {
25 | @Input() direction: 'col' | 'row' = 'row';
26 | @ContentChildren(ResizerComponent) resizers!: QueryList;
27 |
28 | private el = inject(ElementRef);
29 | private resizeService = inject(ResizeManagerService);
30 |
31 | ngOnChanges(changes: SimpleChanges) {
32 | if (changes['direction']) {
33 | this.resizeService.setDirection(this.direction);
34 | }
35 | }
36 |
37 | ngAfterContentInit() {
38 | const resizerArray = this.resizers.toArray();
39 | // setFirst to get childIndex set for later resize
40 | this.resizeService.setResizelist(resizerArray.map(resizer => resizer.getOptions()));
41 |
42 | const initialRect = this.el.nativeElement.getBoundingClientRect();
43 | this.resizeService.setContainerSize({
44 | width: initialRect.right - initialRect.left,
45 | height: initialRect.bottom - initialRect.top,
46 | });
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/components/resizer/resize-manager.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { ResizerComponent } from './resizer';
3 |
4 | /*
5 | * just found this package after implementation: https://www.npmjs.com/package/angular-split
6 | */
7 |
8 | interface ResizeItem {
9 | id: string;
10 | minSize: number;
11 | maxSize: number;
12 | width: number;
13 | height: number;
14 | resizer: ResizerComponent;
15 | percentage: number;
16 | }
17 |
18 | type Direction = 'col' | 'row';
19 |
20 | @Injectable()
21 | export class ResizeManagerService {
22 | direction: Direction = 'row';
23 | resizeList: ResizeItem[] = [];
24 | containerSize: { width: number; height: number } = { width: 0, height: 0 };
25 |
26 | setDirection(dir: Direction) {
27 | this.direction = dir;
28 | }
29 |
30 | setContainerSize({ width, height }: { width: number; height: number }) {
31 | this.containerSize = { width, height };
32 | }
33 |
34 | setResizelist(list: ResizeItem[]) {
35 | this.resizeList = list;
36 |
37 | let percentage = 0;
38 | this.resizeList.forEach(item => {
39 | percentage += item.percentage;
40 | if (percentage > 100) {
41 | throw new Error('resizer percentage sum > 100');
42 | }
43 | item.width = this.direction === 'row' ? item.percentage : 100;
44 | item.height = this.direction === 'col' ? item.percentage : 100;
45 | item.resizer.updateSize('width', item.width);
46 | item.resizer.updateSize('height', item.height);
47 | });
48 | }
49 |
50 | calculateNewSize({ id, delta: _delta }: { id: string; delta: number }) {
51 | const delta = (_delta / this.containerSize[this.getSizeFlagFromDir()]) * 100;
52 |
53 | const index = this.resizeList.findIndex(i => i.id === id);
54 |
55 | if (index === -1) {
56 | return;
57 | }
58 |
59 | const sizeFlag = getSizeFlagFromDir(this.direction);
60 |
61 | const preEle = this.resizeList[index - 1];
62 | const nextEle = this.resizeList[index]; // firstElement does not have resize-bar
63 |
64 | const newSizeOfPrev = preEle[sizeFlag] + delta;
65 | const newSizeOfNext = nextEle[sizeFlag] - delta;
66 |
67 | if (newSizeOfPrev < preEle.minSize / preEle[sizeFlag] || newSizeOfNext < nextEle.minSize / nextEle[sizeFlag]) {
68 | return;
69 | }
70 |
71 | this.resizeList.forEach(item => {
72 | if (item.id === id) {
73 | item[sizeFlag] = newSizeOfNext;
74 | } else if (item.id === preEle.id) {
75 | item[sizeFlag] = newSizeOfPrev;
76 | }
77 | });
78 | this.resizeList.forEach(item => {
79 | item.resizer.updateSize(sizeFlag, item[sizeFlag]);
80 | });
81 |
82 | function getSizeFlagFromDir(dir: Direction) {
83 | return dir === 'row' ? 'width' : 'height';
84 | }
85 | }
86 | getSizeFlagFromDir() {
87 | return this.direction === 'row' ? 'width' : 'height';
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/components/resizer/resizer.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | display: flex;
3 | position: relative;
4 | flex-grow: 1;
5 | }
6 |
7 | :host-context(.row) {
8 | flex-direction: row;
9 | }
10 |
11 | :host-context(.col) {
12 | flex-direction: column;
13 | }
14 |
15 | .resizer-bar {
16 | opacity: 0;
17 | &:hover {
18 | background-color: rebeccapurple;
19 | opacity: 1;
20 | // transition: opacity 0.1s ease-out;
21 | }
22 | }
23 |
24 | .resizer-bar.row {
25 | cursor: col-resize;
26 | width: 5px;
27 | margin: 0px -1.5px;
28 | height: 100%;
29 | }
30 |
31 | .resizer-bar.col {
32 | width: 100%;
33 | height: 5px;
34 | margin: -1.5px 0px;
35 | cursor: row-resize;
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/components/resizer/resizer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | ElementRef,
4 | Renderer2,
5 | AfterViewInit,
6 | Input,
7 | inject,
8 | ChangeDetectionStrategy,
9 | OnDestroy,
10 | } from '@angular/core';
11 | import { ResizeManagerService } from './resize-manager.service';
12 |
13 | interface IPosition {
14 | x: number;
15 | y: number;
16 | }
17 |
18 | @Component({
19 | selector: 'app-resizer',
20 | standalone: true,
21 | template: `
22 | @if (!isFirstElement) {
23 |
28 | }
29 |
30 | `,
31 | styleUrls: ['./resizer.scss'],
32 | imports: [],
33 | changeDetection: ChangeDetectionStrategy.OnPush,
34 | })
35 | export class ResizerComponent implements AfterViewInit, OnDestroy {
36 | @Input() minSize = 0;
37 | @Input() maxSize = Infinity;
38 | @Input() isFirstElement = false;
39 | @Input() percentage = 0;
40 | private pointerDownPosition: IPosition | null = null;
41 | resizeService = inject(ResizeManagerService);
42 | id = `resizer-${Math.random()}`;
43 |
44 | private el = inject(ElementRef);
45 | private renderer = inject(Renderer2);
46 |
47 | interStyle = {
48 | width: 100,
49 | height: 100,
50 | };
51 |
52 | ngAfterViewInit() {
53 | this.initEvents();
54 | }
55 |
56 | ngOnDestroy() {
57 | this.stopResize();
58 | }
59 |
60 | initEvents() {
61 | this.renderer.listen(this.el.nativeElement, 'pointerdown', this.startResize);
62 | }
63 |
64 | cleanupEvents() {
65 | document.removeEventListener('pointermove', this.handleResize);
66 | document.removeEventListener('pointerup', this.stopResize);
67 | }
68 |
69 | startResize = (e: PointerEvent) => {
70 | const resizerBar = this.el.nativeElement.querySelector('.resizer-bar');
71 | if (resizerBar && resizerBar.contains(e.target as Node)) {
72 | // remember to stop only when needed
73 | this.stopEvent(e);
74 | this.pointerDownPosition = {
75 | x: e.clientX,
76 | y: e.clientY,
77 | };
78 | document.addEventListener('pointermove', this.handleResize);
79 | document.addEventListener('pointerup', this.stopResize);
80 |
81 | // disable selection
82 | const style = document.createElement('style');
83 | style.type = 'text/css';
84 | style.id = 'disable-select';
85 | style.innerHTML = `
86 | * {
87 | user-select: none !important;
88 | pointer-events: none !important;
89 | cursor: ${this.resizeService.direction === 'row' ? 'col-resize' : 'row-resize'} !important;
90 | }
91 | `;
92 | document.head.appendChild(style);
93 | } else {
94 | this.stopResize();
95 | }
96 | };
97 |
98 | handleResize = (e: PointerEvent) => {
99 | this.stopEvent(e);
100 | requestAnimationFrame(() => {
101 | if (!this.pointerDownPosition) return;
102 | const direction = this.resizeService.direction;
103 | const { clientX, clientY } = e;
104 | let delta = 0;
105 | if (direction === 'row') {
106 | delta = clientX - this.pointerDownPosition!.x;
107 | } else {
108 | delta = clientY - this.pointerDownPosition!.y;
109 | }
110 | // update pointer location
111 | this.pointerDownPosition = {
112 | x: clientX,
113 | y: clientY,
114 | };
115 |
116 | if (delta === 0) return;
117 |
118 | this.resizeService.calculateNewSize({
119 | id: this.id,
120 | delta: delta,
121 | });
122 | });
123 | };
124 |
125 | stopResize = () => {
126 | this.cleanupEvents();
127 | this.pointerDownPosition = null;
128 |
129 | // enable selection
130 | const style = document.getElementById('disable-select');
131 | if (style) {
132 | style.parentNode?.removeChild(style);
133 | }
134 |
135 | document.body.style.cursor = '';
136 | };
137 |
138 | stopEvent(e: Event) {
139 | e.preventDefault();
140 | e.stopPropagation();
141 | e.stopImmediatePropagation();
142 | }
143 |
144 | getOptions() {
145 | return {
146 | id: this.id,
147 | minSize: this.minSize,
148 | maxSize: this.maxSize,
149 | width: 0,
150 | height: 0,
151 | percentage: this.percentage,
152 | originPercentage: this.percentage,
153 | resizer: this,
154 | };
155 | }
156 |
157 | updateSize(sizeFlag: 'width' | 'height', size: number) {
158 | this.renderer.setStyle(this.el.nativeElement, sizeFlag, `${size}%`);
159 |
160 | this.interStyle[sizeFlag] = size;
161 |
162 | if (this.interStyle.width === 0 || this.interStyle.height === 0) {
163 | this.renderer.setStyle(this.el.nativeElement, 'display', 'none');
164 | } else {
165 | this.renderer.setStyle(this.el.nativeElement, 'display', 'flex');
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/code-editor/code-editor.component.scss:
--------------------------------------------------------------------------------
1 | .editor-container {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 100%;
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/code-editor/code-editor.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AfterViewInit,
3 | Component,
4 | ElementRef,
5 | EventEmitter,
6 | forwardRef,
7 | HostBinding,
8 | Input,
9 | OnDestroy,
10 | Output,
11 | Renderer2,
12 | ViewChild,
13 | inject,
14 | ChangeDetectionStrategy,
15 | } from '@angular/core';
16 | import { NG_VALUE_ACCESSOR } from '@angular/forms';
17 | import { debounceTime, fromEvent, Subject, takeUntil } from 'rxjs';
18 | import { CodeEditorService } from './code-editor.service';
19 |
20 | export interface EditorModel {
21 | content: string;
22 | language?: string;
23 | uri?: string;
24 | }
25 |
26 | @Component({
27 | standalone: true,
28 | selector: 'app-monaco-editor',
29 | template: `
`,
30 | styleUrls: ['./code-editor.component.scss'],
31 | imports: [],
32 | changeDetection: ChangeDetectionStrategy.OnPush,
33 | providers: [
34 | {
35 | provide: NG_VALUE_ACCESSOR,
36 | useExisting: forwardRef(() => AppEditorComponent),
37 | multi: true,
38 | },
39 | ],
40 | })
41 | export class AppEditorComponent implements AfterViewInit, OnDestroy {
42 | @ViewChild('editorContainer') editorContentRef!: ElementRef;
43 | @Input() @HostBinding('style.height') height = '100%';
44 |
45 | @Output() modelChange = new EventEmitter();
46 |
47 | private destroyRef$: Subject = new Subject();
48 | private editor: monaco.editor.IStandaloneCodeEditor | undefined;
49 |
50 | private disposables: monaco.IDisposable[] = [];
51 |
52 | options: monaco.editor.IStandaloneEditorConstructionOptions = {
53 | theme: 'vs-dark',
54 | language: 'javascript',
55 | fontSize: 16,
56 | wordWrap: 'on',
57 | automaticLayout: true,
58 | minimap: {
59 | enabled: false,
60 | },
61 | };
62 |
63 | codeEditorService = inject(CodeEditorService);
64 | renderer = inject(Renderer2);
65 |
66 | ngAfterViewInit(): void {
67 | this.codeEditorService
68 | .getScriptLoadSubject()
69 | .pipe(takeUntil(this.destroyRef$))
70 | .subscribe(isLoaded => {
71 | if (isLoaded) {
72 | this.initMonaco();
73 | }
74 | });
75 |
76 | fromEvent(window, 'resize')
77 | .pipe(debounceTime(50), takeUntil(this.destroyRef$))
78 | .subscribe(() => {
79 | if (this.editor) {
80 | this.editor.layout();
81 | }
82 | });
83 | }
84 |
85 | private initMonaco(): void {
86 | const options = this.options;
87 | const editorWrapper: HTMLDivElement = this.editorContentRef.nativeElement;
88 |
89 | if (!this.editor) {
90 | this.editor = this.codeEditorService.initEditor(editorWrapper, options);
91 | }
92 | this.renderer.setStyle(this.editorContentRef.nativeElement, 'height', this.height);
93 |
94 | this.editor.layout();
95 | }
96 |
97 | ngOnDestroy(): void {
98 | this.destroyRef$.next();
99 | this.destroyRef$.complete();
100 | if (this.editor) {
101 | this.editor.dispose();
102 | this.editor = undefined;
103 | }
104 | if (this.disposables.length) {
105 | this.disposables.forEach(disposable => disposable.dispose());
106 | this.disposables = [];
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/code-editor/deps-parsre.service.ts:
--------------------------------------------------------------------------------
1 | export enum ImportKind {
2 | Package = 'package',
3 | Relative = 'relative',
4 | Alias = 'alias',
5 | Reference = 'reference',
6 | }
7 |
8 | export interface ImportResult {
9 | kind: ImportKind;
10 | path: string;
11 | }
12 |
13 | export function parseImports(content: string) {
14 | const results: ImportResult[] = [];
15 |
16 | const packageImportRegex = /import\s.*\sfrom\s['"]([^'"]+)['"]/g;
17 | const commonJSImportRegex = /const\s.*=\srequire\(['"]([^'"]+)['"]/g;
18 | const referencePathRegex = /\/\/\/\s // TypeScript reference path
52 |
53 | // Relative imports
54 | import { LocalModule } from './local-module';
55 | import { LocalModuleInDir } from '../dir/local-module-in-dir';
56 |
57 | // TypeScript path alias imports
58 | import { AliasModule } from '@alias/alias-module';
59 | import { AnotherAliasModule } from '@another-alias/another-alias-module';
60 |
61 | // Sass file import
62 | import '../styles/main.scss';
63 | `;
64 | return source;
65 | }
66 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/code-editor/prettier.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { BehaviorSubject, Observable } from 'rxjs';
3 |
4 | declare const window: any;
5 |
6 | @Injectable({
7 | providedIn: 'root',
8 | })
9 | export class PrettierService {
10 | private prettier: any;
11 | private prettierPlugins: any;
12 | private isLoaded = new BehaviorSubject(false);
13 |
14 | loadPrettier(): Observable {
15 | if (this.isLoaded.value) {
16 | return this.isLoaded.asObservable();
17 | }
18 |
19 | window.define(
20 | 'Prettier',
21 | [
22 | '/assets/prettier/standalone.js',
23 | '/assets/prettier/parser-babel.js',
24 | '/assets/prettier/parser-html.js',
25 | '/assets/prettier/parser-postcss.js',
26 | '/assets/prettier/parser-typescript.js',
27 | ],
28 | (Prettier: any, ...args: any[]) => {
29 | this.prettier = Prettier;
30 | this.prettierPlugins = {
31 | babel: args[0],
32 | html: args[1],
33 | postcss: args[2],
34 | typescript: args[3],
35 | };
36 | this.isLoaded.next(true);
37 | }
38 | );
39 |
40 | return this.isLoaded.asObservable();
41 | }
42 |
43 | format(code: string, filepath: string): string {
44 | if (!this.prettier || !this.prettierPlugins) {
45 | console.error('Prettier or PrettierPlugins not loaded');
46 | return code;
47 | }
48 |
49 | const options = {
50 | filepath,
51 | plugins: this.prettierPlugins,
52 | singleQuote: true,
53 | tabWidth: 2,
54 | useTabs: false,
55 | semi: true,
56 | bracketSpacing: true,
57 | arrowParens: 'always',
58 | trailingComma: 'es5',
59 | printWidth: 80,
60 | };
61 |
62 | try {
63 | return this.prettier.format(code, options);
64 | } catch (error) {
65 | console.error('Prettier formatting error:', error);
66 | return code;
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/edit.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | @for (tab of editService.openedTabs(); track tab.filePath) {
4 |
5 |
6 | {{ tab.name }}
7 |
8 | @if (tab.isPendingWrite) {
9 |
12 | } @else {
13 |
✕
14 | }
15 |
16 | }
17 |
18 |
19 |
20 | @if (!isPreviewOpen()) {
21 | keyboard_tab
22 | }
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/edit.component.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | height: 100%;
3 | display: flex;
4 | flex-direction: column;
5 | }
6 | .top {
7 | border: 1px solid #fff;
8 | border-top: none;
9 | display: flex;
10 | justify-content: space-between;
11 | align-items: center;
12 | .tabs {
13 | display: flex;
14 | height: 30px;
15 | align-items: center;
16 | overflow: hidden;
17 | background-color: var(--sb-page-background);
18 | .tab-item {
19 | user-select: none;
20 | position: relative;
21 | display: inline-flex;
22 | align-items: center;
23 | justify-content: center;
24 | background-color: var(--tab-inactiveBackground);
25 | color: var(--sb-foreground-alt);
26 | flex-shrink: 0;
27 | font-size: 13px;
28 | font-weight: 400;
29 |
30 | padding: 0;
31 | padding-inline-end: 4px;
32 | padding-inline-start: 10px;
33 | cursor: pointer;
34 |
35 | &.active {
36 | background-color: var(--tab-activeBackground);
37 | }
38 | .text {
39 | min-width: 60px;
40 | max-width: 100px;
41 | white-space: nowrap;
42 | overflow: hidden;
43 | text-overflow: ellipsis;
44 | }
45 |
46 | .close,
47 | .pending {
48 | width: 20px;
49 | padding: 2px;
50 | text-align: center;
51 | cursor: pointer;
52 | &:hover {
53 | opacity: 0.8;
54 | }
55 | }
56 | .pending {
57 | display: flex;
58 | align-items: center;
59 | justify-content: center;
60 | .dot {
61 | width: 8px;
62 | height: 8px;
63 | border-radius: 50%;
64 | background-color: #fff;
65 | }
66 | }
67 | }
68 | }
69 | .functions {
70 | display: flex;
71 | align-items: center;
72 | margin-left: 4px;
73 | margin-right: 4px;
74 | .prettier {
75 | margin-right: 4px;
76 | font-size: 18px;
77 | width: 18px;
78 | height: 18px;
79 | cursor: pointer;
80 | }
81 | .open-preview {
82 | cursor: pointer;
83 | transform: rotate(180deg);
84 | flex-grow: 0;
85 | flex-shrink: 0;
86 | }
87 | }
88 | }
89 | .bottom {
90 | flex: 1;
91 | position: relative;
92 | overflow: hidden;
93 | background-color: #212121;
94 | }
95 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/edit.component.ts:
--------------------------------------------------------------------------------
1 | import { ChangeDetectionStrategy, Component, ViewChild, OnInit, OnDestroy, inject, computed } from '@angular/core';
2 | import { AppEditorComponent } from './code-editor/code-editor.component';
3 | import { EditService, ITabItem } from './edit.service';
4 | import { MatIcon } from '@angular/material/icon';
5 | import { MainService } from '../main.service';
6 | import { CodeEditorService } from './code-editor/code-editor.service';
7 |
8 | @Component({
9 | standalone: true,
10 | selector: 'app-edit',
11 | imports: [AppEditorComponent, MatIcon],
12 | templateUrl: './edit.component.html',
13 | styleUrls: ['./edit.component.scss'],
14 | changeDetection: ChangeDetectionStrategy.OnPush,
15 | })
16 | export class EditComponent implements OnInit, OnDestroy {
17 | @ViewChild(AppEditorComponent) editorComponent: AppEditorComponent | undefined;
18 |
19 | editService = inject(EditService);
20 | codeEditorService = inject(CodeEditorService);
21 | private mainService = inject(MainService);
22 |
23 | isPreviewOpen = computed(() => this.mainService.isPreviewOpen());
24 |
25 | constructor() {}
26 |
27 | ngOnInit() {
28 | document.addEventListener('keydown', this.handleKeyDown.bind(this));
29 |
30 | this.editService.initEvents();
31 | }
32 |
33 | ngOnDestroy() {
34 | document.removeEventListener('keydown', this.handleKeyDown.bind(this));
35 | }
36 |
37 | async handleKeyDown(event: KeyboardEvent) {
38 | if ((event.ctrlKey || event.metaKey) && event.key === 's') {
39 | event.preventDefault();
40 | await this.editService.saveFile();
41 | }
42 | }
43 |
44 | updateTabs(filePath: string) {
45 | this.editService.updateTabs(filePath);
46 | }
47 |
48 | selectTab(tab: ITabItem) {
49 | this.editService.selectTab(tab);
50 | }
51 |
52 | closeTab(tabItem: ITabItem, event: Event) {
53 | event.stopPropagation();
54 | this.editService.closeTab(tabItem.filePath, true);
55 | }
56 | openPreview() {
57 | this.mainService.openPreview();
58 | }
59 | formatCurrentFile() {
60 | this.codeEditorService.formatCurrentFile();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/edit/edit.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, inject } from '@angular/core';
2 | import { NodeContainerService } from '@app/editor/services/node-container.service';
3 | import { EditorStateService } from '@app/editor/services/editor-state.service';
4 | import { CodeEditorService } from './code-editor/code-editor.service';
5 | import { isFile } from '@app/editor/utils/file';
6 | import { WritableSignal, signal, effect } from '@angular/core';
7 |
8 | export interface ITabItem {
9 | filePath: string;
10 | name: string;
11 | active: boolean;
12 | isPendingWrite: boolean;
13 | }
14 |
15 | @Injectable({
16 | providedIn: 'root',
17 | })
18 | export class EditService {
19 | private readonly nodeContainerService = inject(NodeContainerService);
20 | private readonly editorStateService = inject(EditorStateService);
21 | private readonly codeEditorService = inject(CodeEditorService);
22 |
23 | openedTabs: WritableSignal = signal([]);
24 |
25 | constructor() {
26 | effect(async () => {
27 | const currentFilePath = this.editorStateService.geCurrentFilePath();
28 | const fileTree = this.editorStateService.getFileTree();
29 | if (currentFilePath && fileTree && isFile(fileTree, currentFilePath)) {
30 | try {
31 | const content = await this.nodeContainerService.readFile(currentFilePath);
32 | this.updateTabs(currentFilePath);
33 | if (content !== undefined) {
34 | this.codeEditorService.openOrCreateFile({
35 | content,
36 | filePath: currentFilePath,
37 | });
38 | }
39 | } catch (error) {
40 | console.log('error', error);
41 | }
42 | }
43 | });
44 | }
45 |
46 | async updateFileModels(filePaths: string[]) {
47 | await this.codeEditorService.ensureMonacoLoaded();
48 |
49 | filePaths.forEach(async filePath => {
50 | if (!this.codeEditorService.isModelExist(filePath)) {
51 | try {
52 | const content = await this.nodeContainerService.readFile(filePath);
53 | if (content) {
54 | this.codeEditorService.openOrCreateFile({
55 | filePath,
56 | content,
57 | });
58 | } else {
59 | console.log(`error: content of ${filePath} not exists`);
60 | }
61 | } catch (error) {
62 | console.log('updateFileModels error:', error);
63 | }
64 | }
65 | });
66 | }
67 |
68 | initEvents() {
69 | this.codeEditorService.on('contentChanged', ({ content, filePath }) => {
70 | this.openedTabs.update(tabs =>
71 | tabs.map(t => ({
72 | ...t,
73 | isPendingWrite: t.filePath === filePath ? true : t.isPendingWrite,
74 | }))
75 | );
76 | });
77 | this.codeEditorService.on('goToDefinition', ({ filePath }) => {
78 | this.editorStateService.setCurrentFilePath(filePath);
79 | });
80 | }
81 |
82 | updateTabs(filePath: string) {
83 | const isTabExist = this.openedTabs().find(t => t.filePath === filePath);
84 | if (!isTabExist) {
85 | const newTab = {
86 | filePath: filePath,
87 | name: this.extractFileName(filePath),
88 | isPendingWrite: false,
89 | active: true,
90 | };
91 | this.openedTabs.update(tabs => [
92 | ...tabs.map(t => ({
93 | ...t,
94 | active: false,
95 | })),
96 | newTab,
97 | ]);
98 | } else {
99 | this.openedTabs.update(tabs =>
100 | tabs.map(t => ({
101 | ...t,
102 | active: t.filePath === filePath,
103 | }))
104 | );
105 | }
106 | }
107 |
108 | selectTab(tab: ITabItem) {
109 | this.editorStateService.setCurrentFilePath(tab.filePath);
110 | }
111 |
112 | closeTab(filePath: string, preserveModel = false) {
113 | const openedTabs = this.openedTabs().slice();
114 | const findIndex = openedTabs.findIndex(t => t.filePath === filePath);
115 |
116 | if (findIndex === -1) {
117 | // If the specified tab is not found, return directly
118 | return;
119 | }
120 |
121 | // editor close model to release memory, update this when rename or delete file
122 | if (!preserveModel) {
123 | this.codeEditorService.closeFile(filePath);
124 | }
125 |
126 | const newTabs = openedTabs.filter(t => t.filePath !== filePath);
127 | this.openedTabs.set(newTabs);
128 |
129 | if (newTabs.length === 0) {
130 | // If there are no open tabs, set the current file path to an empty string
131 | this.editorStateService.setCurrentFilePath('');
132 | } else {
133 | // Calculate the new active tab index, focusing on the previous tab if possible
134 | const newActiveIndex = findIndex > 0 ? findIndex - 1 : 0;
135 | this.editorStateService.setCurrentFilePath(newTabs[newActiveIndex].filePath);
136 | }
137 | }
138 |
139 | async saveFile() {
140 | const filePath = this.editorStateService.geCurrentFilePath();
141 | if (filePath) {
142 | const content = this.codeEditorService.getCurrentFileContent();
143 | if (content) {
144 | this.nodeContainerService.writeFile(filePath, content);
145 |
146 | this.openedTabs.update(tabs =>
147 | tabs.map(t => ({
148 | ...t,
149 | isPendingWrite: t.filePath === filePath ? false : t.isPendingWrite,
150 | }))
151 | );
152 | }
153 | }
154 | }
155 |
156 | extractFileName(filePath: string): string {
157 | const match = filePath.match(/[^/\\]+$/);
158 |
159 | if (match) {
160 | return match[0];
161 | }
162 |
163 | return '';
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/main.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/main.component.scss:
--------------------------------------------------------------------------------
1 | $header-height: 40px;
2 | :host {
3 | height: calc(100vh - $header-height);
4 | flex: 1;
5 | display: flex;
6 | position: relative;
7 | border-bottom: 1px solid #fff;
8 |
9 | #sidebar,
10 | #edit,
11 | #output {
12 | height: 100%;
13 | overflow: auto;
14 | flex-grow: 1;
15 | }
16 |
17 | #sidebar {
18 | position: relative;
19 | }
20 |
21 | #edit {
22 | height: 100%;
23 | }
24 |
25 | #output {
26 | flex: 1;
27 | height: 100%;
28 | position: relative;
29 | overflow: hidden;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/main.component.ts:
--------------------------------------------------------------------------------
1 | import { AfterViewInit, ChangeDetectionStrategy, Component, inject, ViewChild } from '@angular/core';
2 | import { CommonModule } from '@angular/common';
3 | import { EditComponent } from './edit/edit.component';
4 | import { ResizerContainerComponent } from './components/resizer/resize-container';
5 | import { ResizerComponent } from './components/resizer/resizer';
6 | import { PreviewComponent } from './ouput/preview/preview.component';
7 | import { TerminalComponent } from './ouput/terminal/terminal.component';
8 | import { SidebarComponent } from './sidebar/sidebar.component';
9 | import { MainService } from './main.service';
10 | import { ConsoleComponent } from './ouput/console/console.component';
11 |
12 | @Component({
13 | selector: 'app-editor-main',
14 | standalone: true,
15 | imports: [
16 | CommonModule,
17 | EditComponent,
18 | ResizerContainerComponent,
19 | ResizerComponent,
20 | PreviewComponent,
21 | TerminalComponent,
22 | SidebarComponent,
23 | ConsoleComponent,
24 | ],
25 | providers: [],
26 | templateUrl: './main.component.html',
27 | styleUrl: './main.component.scss',
28 | changeDetection: ChangeDetectionStrategy.OnPush,
29 | })
30 | export class MainComponent implements AfterViewInit {
31 | @ViewChild('mainResizer') mainResizer!: ResizerComponent;
32 | @ViewChild('outputResizer') outputResizer!: ResizerComponent;
33 | @ViewChild('editResizer') editResizer!: ResizerComponent;
34 | mainService = inject(MainService);
35 |
36 | ngAfterViewInit() {
37 | this.mainService.setMainResizer(this.mainResizer);
38 | this.mainService.setOutputResizer(this.outputResizer);
39 | this.mainService.setEditResizer(this.editResizer);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/ouput/console/_console-script.js:
--------------------------------------------------------------------------------
1 | // This file is auto-generated, please do not modify by hand
2 | export const proxyConsoleScript = `"use strict";function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _slicedToArray(e,r){return _arrayWithHoles(e)||_iterableToArrayLimit(e,r)||_unsupportedIterableToArray(e,r)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,r){if(e){if("string"==typeof e)return _arrayLikeToArray(e,r);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array\$/.test(n)?_arrayLikeToArray(e,r):void 0}}function _arrayLikeToArray(e,r){(null==r||r>e.length)&&(r=e.length);for(var n=0,t=Array(r);n
2 |
3 | Console
4 |
5 |
6 | delete
7 | {{
8 | mainService.isConsoleOpen() ? 'expand_less' : 'expand_more'
9 | }}
10 |
11 |
12 |
13 |
14 | @for (log of consoleService.logs(); track log.args) {
15 |
16 |
17 | @for (content of log.args; track content.value) {
18 | @if (['array', 'object', 'set', 'map'].indexOf(content.type) > -1) {
19 |
20 | } @else {
21 |
22 | }
23 | }
24 |
25 | }
26 |
27 | @if (consoleService.errors() && consoleService.errors().length) {
28 |
29 | @for (error of consoleService.errors(); track error.message) {
30 |
31 |
32 |
33 |
{{ error.message }}
34 |
35 | @if (error.stacks && error.stacks.length) {
36 | @for (s of error.stacks; track s) {
37 |
{{ s }}
38 | }
39 | }
40 |
41 |
42 |
43 | }
44 |
45 | }
46 |
47 |
48 | navigate_next
49 |
56 |
57 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/ouput/console/console.component.scss:
--------------------------------------------------------------------------------
1 | :host {
2 | height: 100%;
3 | display: flex;
4 | flex-direction: column;
5 | background-color: #030301;
6 |
7 | --bg-warn: #fef6d5;
8 | --bg-error: #faaaa3;
9 | }
10 |
11 | .controls {
12 | border-bottom: 1px solid #fff;
13 | padding: 0 4px;
14 | height: 20px;
15 | display: flex;
16 | align-items: center;
17 | justify-content: space-between;
18 | .left {
19 | .title {
20 | font-weight: bold;
21 | font-size: 13px;
22 | }
23 | }
24 | .right {
25 | display: flex;
26 | align-items: center;
27 | .clear-btn {
28 | font-size: 16px;
29 | width: 16px;
30 | height: 16px;
31 | }
32 | .toggle-btn {
33 | }
34 | }
35 | }
36 |
37 | .command-line {
38 | display: flex;
39 | align-items: center;
40 | height: 24px;
41 | .icon {
42 | }
43 | .input {
44 | flex: 1;
45 | outline: none;
46 | border: none;
47 | resize: none;
48 | outline: none;
49 | background: inherit;
50 | color: #fff;
51 | caret-color: #fff;
52 | }
53 | }
54 |
55 | .console-wrap {
56 | flex: 1;
57 | overflow: auto;
58 | .console-entries {
59 | }
60 |
61 | .console-item,
62 | .error-item {
63 | padding: 0 6px;
64 | border-bottom: 1px solid #ccc;
65 | font-size: 13px;
66 | display: flex;
67 | align-items: center;
68 |
69 | .icon {
70 | width: 10px;
71 | height: 18px;
72 | background-repeat: no-repeat;
73 | background-position: 50% 50%;
74 | display: inline-block;
75 | margin-right: 6px;
76 | display: none;
77 | }
78 | }
79 |
80 | .console-item {
81 | &.info {
82 | }
83 | &.debug {
84 | }
85 | &.warn {
86 | background-color: var(--bg-warn);
87 | color: #000;
88 | .icon {
89 | display: block;
90 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACkSURBVChTbY7BCoJQFERn0Q/3BX1JuxQjsSCXiV8gtCgxhCIrKIRIqKDVzXl5w5cNHBjm6eGinXiAXu5inY2xYm/mbpIh+vcFhLA3sx0athNUhymEsP+10lAEEA17x8o/9wFuNGnYuVlWve0SQl7P0sBu3aq2R1Q/1JzSkYGd29eqNv2wjdnUuvNRciC/N+qe+7gidbA8zyHkOINsvA/sumcOkjcabcBmw2+mMgAAAABJRU5ErkJggg==");
91 | }
92 | }
93 |
94 | &.error {
95 | background-color: var(--bg-error);
96 | color: #000;
97 | .icon {
98 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADESURBVChTY4CB7ZI8tmfU5E6e01b+DMIgNkgMKg0BR9Vkux6YWPx/bemIgkFiIDmwogOaqrYPzazAEm8DwuGKYGyQHEgNw0VT05Mwib9v3v7/kJEHxiA2TDFIDcNNU4vPMFPACj58/P/v40cwGyYOUsNwy8IZRSFIEUgxskKQGoZrzp4ErQapYbgYHG371M4dLACTQGaD5EBqwD6/FpzQ9dTBE64IhkFiIDmwIhi4mlJqey8o4eR9r8jPIAxig8QgsgwMAFZz1YtGPXgjAAAAAElFTkSuQmCC");
99 | display: block;
100 | }
101 | }
102 | }
103 | .error-item {
104 | background-color: var(--bg-error);
105 | color: #000;
106 | display: flex;
107 | align-items: center;
108 | .icon {
109 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADESURBVChTY4CB7ZI8tmfU5E6e01b+DMIgNkgMKg0BR9Vkux6YWPx/bemIgkFiIDmwogOaqrYPzazAEm8DwuGKYGyQHEgNw0VT05Mwib9v3v7/kJEHxiA2TDFIDcNNU4vPMFPACj58/P/v40cwGyYOUsNwy8IZRSFIEUgxskKQGoZrzp4ErQapYbgYHG371M4dLACTQGaD5EBqwD6/FpzQ9dTBE64IhkFiIDmwIhi4mlJqey8o4eR9r8jPIAxig8QgsgwMAFZz1YtGPXgjAAAAAElFTkSuQmCC");
110 | }
111 | .msg {
112 | .stacks {
113 | line-height: 16px;
114 | margin-left: 10px;
115 | font-size: 11px;
116 | .stack {
117 | font-size: 10px;
118 | }
119 | }
120 | }
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/ouput/console/console.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, effect, ElementRef, inject, ViewChild } from '@angular/core';
2 | import { ConsoleService } from './console.service';
3 | import { MatIcon } from '@angular/material/icon';
4 | import { MainService } from '../../main.service';
5 | import { CompoundObjRendererComponent } from './compound-obj-renderer';
6 | import { CommonModule } from '@angular/common';
7 | import { PrimitiveRendererComponent } from './primitive-renderer.component';
8 |
9 | @Component({
10 | selector: 'app-console',
11 | templateUrl: './console.component.html',
12 | styleUrls: ['./console.component.scss'],
13 | standalone: true,
14 | imports: [MatIcon, CommonModule, CompoundObjRendererComponent, PrimitiveRendererComponent],
15 | })
16 | export class ConsoleComponent {
17 | @ViewChild('commandInput', { static: true })
18 | commandInput!: ElementRef;
19 | @ViewChild('consoleWrap', { static: true })
20 | consoleWrap!: ElementRef;
21 | consoleService = inject(ConsoleService);
22 | mainService = inject(MainService);
23 |
24 | constructor() {
25 | window.addEventListener('message', this.consoleService.handleMessage.bind(this.consoleService));
26 |
27 | effect(() => {
28 | if (this.mainService.isConsoleOpen()) {
29 | this.commandInput.nativeElement.focus();
30 | }
31 | });
32 |
33 | effect(() => {
34 | const logs = this.consoleService.logs();
35 | const errors = this.consoleService.errors();
36 | if (logs.length || errors.length) {
37 | this.consoleWrap.nativeElement.scrollTop = this.consoleWrap.nativeElement.scrollHeight;
38 | }
39 | });
40 | }
41 |
42 | toggleConsole() {
43 | this.mainService.toggleConsole();
44 | }
45 |
46 | clearConsole() {
47 | this.consoleService.clearConsole();
48 | }
49 |
50 | executeCommand(event: Event) {
51 | event.preventDefault();
52 | const textarea = this.commandInput.nativeElement;
53 | const code = textarea.value.trim();
54 |
55 | if (code) {
56 | this.consoleService.addCommandToHistory(code);
57 | const previewIframe = document.querySelector('#preview-panel')?.querySelector('iframe');
58 | if (previewIframe && previewIframe.contentWindow) {
59 | previewIframe.contentWindow.postMessage({ type: 'execute', code }, '*');
60 | textarea.value = '';
61 | }
62 | }
63 |
64 | textarea.focus();
65 | }
66 |
67 | handleKeyUp(event: KeyboardEvent) {
68 | const textarea = this.commandInput.nativeElement;
69 | if (event.key === 'ArrowUp') {
70 | const previousCommand = this.consoleService.getPreviousCommand();
71 | if (previousCommand !== null) {
72 | textarea.value = previousCommand;
73 | textarea.focus();
74 | }
75 | } else if (event.key === 'ArrowDown') {
76 | const nextCommand = this.consoleService.getNextCommand();
77 | if (nextCommand !== null) {
78 | textarea.value = nextCommand;
79 | textarea.focus();
80 | } else {
81 | textarea.value = '';
82 | }
83 | }
84 | }
85 |
86 | getItemType(type: string) {
87 | return type;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/ouput/console/console.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, signal, WritableSignal } from '@angular/core';
2 |
3 | export interface dataType {
4 | type: string;
5 | value: string;
6 | }
7 |
8 | interface IConsoleMessage {
9 | type: 'console';
10 | method: 'log' | 'error' | 'warn' | 'info' | 'debug';
11 | args: dataType[];
12 | }
13 |
14 | interface IError {
15 | type: 'error';
16 | message: string;
17 | codeInfo: string;
18 | stacks: string[];
19 | }
20 |
21 | @Injectable({
22 | providedIn: 'root',
23 | })
24 | export class ConsoleService {
25 | logs: WritableSignal = signal([]);
26 | errors: WritableSignal = signal([]);
27 | commandHistory: string[] = [];
28 | historyIndex = -1;
29 |
30 | clearConsole() {
31 | this.logs.set([]);
32 | this.errors.set([]);
33 | this.commandHistory = [];
34 | this.historyIndex = -1;
35 | }
36 |
37 | addCommandToHistory(command: string) {
38 | this.commandHistory.push(command);
39 | this.historyIndex = this.commandHistory.length;
40 | }
41 |
42 | getPreviousCommand(): string | null {
43 | if (this.historyIndex > 0) {
44 | this.historyIndex--;
45 | return this.commandHistory[this.historyIndex];
46 | }
47 | return null;
48 | }
49 |
50 | getNextCommand(): string | null {
51 | if (this.historyIndex === 0 && this.commandHistory.length === 1) {
52 | return this.commandHistory[0];
53 | }
54 | if (this.historyIndex < this.commandHistory.length - 1) {
55 | this.historyIndex++;
56 | return this.commandHistory[this.historyIndex];
57 | }
58 | return null;
59 | }
60 |
61 | handleMessage(event: MessageEvent) {
62 | if (!['webcontainer', 'localhost'].some(h => event.origin.includes(h))) {
63 | return;
64 | }
65 |
66 | const { type } = event.data;
67 | if (type === 'console') {
68 | const { method, args } = event.data;
69 | if (['log', 'error', 'warn', 'info', 'debug'].includes(method)) {
70 | this.logs.set([...this.logs(), { type, method, args }]);
71 | }
72 | } else if (type === 'error') {
73 | this.errors.set([...this.errors(), event.data]);
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/app/editor/features/main/ouput/console/getProxyConsoleScript.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error skip
2 | import { proxyConsoleScript } from './_console-script.js';
3 | /**
4 | * due to "CROS policy", we can't directly inject script to iframe
5 | * cuz the domain of iframe are not the same as the main
6 | * so we have to some how inject the script to the index.html
7 | * before the project loaded to webContainer
8 | */
9 | function getProxyConsoleScript() {
10 | return ``;
15 | }
16 |
17 | function uint8ArrayToString(array: Uint8Array): string {
18 | return new TextDecoder().decode(array);
19 | }
20 |
21 | // rewrite html and inject script
22 | export function injectProxyScriptToEntryHTML(contents: string | Uint8Array) {
23 | const proxyScript = getProxyConsoleScript();
24 |
25 | if (contents instanceof Uint8Array) {
26 | contents = uint8ArrayToString(contents);
27 | }
28 |
29 | // Inject script before the closing tag
30 | return contents.replace('', `${proxyScript}`);
31 | }
32 |
33 | /**
34 | * for simplicity, we assume there is only on index.html file
35 | * usually index.html , src/index.html or build/index.html
36 | * for other scenarios like webpack multi-page, we just don't handle that
37 | */
38 | export function isEntryFile(path: string) {
39 | return path.endsWith('index.html');
40 | }
41 |
42 | export function removeProxyScriptOfEntryHTML(contents: string | Uint8Array): string {
43 | if (contents instanceof Uint8Array) {
44 | contents = uint8ArrayToString(contents);
45 | }
46 |
47 | const proxyScriptRegex = /