diff --git a/package-lock.json b/package-lock.json index eed599f..fe2f813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,24 @@ { "name": "browsercode", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browsercode", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "@iconify-json/mingcute": "^1.2.7", "@iconify/svelte": "^5.2.1", "@leaningtech/browserpod": "3.0.1", + "@shikijs/langs": "^4.4.3", + "@shikijs/monaco": "^4.4.3", + "@shikijs/themes": "^4.4.3", "@sveltejs/adapter-static": "^3.0.10", "fflate": "^0.8.3", "monaco-editor": "^0.55.1", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "shiki": "^4.4.3" }, "devDependencies": { "@eslint/compat": "^2.0.2", @@ -1148,6 +1152,120 @@ "win32" ] }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/monaco": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/monaco/-/monaco-4.4.3.tgz", + "integrity": "sha512-xyXoWFtqVsN33RhGbZReHFKOoxGbDEJ5mgGKdUbQLe6GF0/DDv7C4kgxkNwbWwBo7zhkICbqIqCfGjKKdF8RzQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1534,6 +1652,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1541,6 +1668,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", @@ -1567,6 +1703,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", @@ -1849,6 +1991,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1974,6 +2122,16 @@ "node": ">=6" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1991,6 +2149,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2045,6 +2223,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2132,6 +2320,15 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2148,6 +2345,19 @@ "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", @@ -2651,6 +2861,52 @@ "node": ">=8" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3130,6 +3386,116 @@ "node": ">= 18" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3214,6 +3580,23 @@ ], "license": "MIT" }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3584,6 +3967,16 @@ } } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3625,6 +4018,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3755,6 +4172,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3778,6 +4214,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3792,6 +4238,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3958,6 +4418,16 @@ "node": ">=6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4037,6 +4507,74 @@ "devOptional": true, "license": "MIT" }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4054,6 +4592,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -4304,6 +4870,16 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 3b7ab53..9505c3d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "browsercode", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite dev", @@ -39,9 +39,13 @@ "@iconify-json/mingcute": "^1.2.7", "@iconify/svelte": "^5.2.1", "@leaningtech/browserpod": "3.0.1", + "@shikijs/langs": "^4.4.3", + "@shikijs/monaco": "^4.4.3", + "@shikijs/themes": "^4.4.3", "@sveltejs/adapter-static": "^3.0.10", "fflate": "^0.8.3", "monaco-editor": "^0.55.1", - "qrcode": "^1.5.4" + "qrcode": "^1.5.4", + "shiki": "^4.4.3" } } diff --git a/src/lib/agents/credential-gate.svelte.ts b/src/lib/agents/credential-gate.svelte.ts new file mode 100644 index 0000000..2ff30e7 --- /dev/null +++ b/src/lib/agents/credential-gate.svelte.ts @@ -0,0 +1,75 @@ +import type { CredentialSpec } from '$lib/config/tools'; +import { describeError } from './boot'; + +/** `idle` renders no overlay; every other stage covers the terminal. */ +export type GateStage = 'idle' | 'loading' | 'signin' | 'error'; + +export type CredentialGateOptions = { + /** A newly saved secret only reaches the CLI through a fresh launch. */ + onRestart: () => void; +}; + +/** + * Drives the boot overlay for a CLI that declares a `CredentialSpec`. It surfaces a failed boot + * itself because the terminal `bootCLI` wrote that failure into sits behind this overlay. + */ +export class CredentialGate { + stage = $state('idle'); + error = $state(''); + /** Assumed until `begin()` reads storage, so the loading card does not flash a false prompt. */ + hasCredential = $state(true); + /** Not a stage: the toolbar can open this over an idle gate, after a boot has finished. */ + changeOpen = $state(false); + + private resolveSignIn: ((value: string) => void) | null = null; + + constructor( + private credential: CredentialSpec, + private options: CredentialGateOptions + ) {} + + get overlayVisible(): boolean { + return this.stage !== 'idle' || this.changeOpen; + } + + /** Call before `bootCLI`, so the overlay is up before the image starts streaming. */ + begin = (): void => { + this.hasCredential = this.credential.get() !== null; + this.stage = 'loading'; + }; + + /** + * Blocks the launch until a secret is stored, rather than overlaying a CLI already running + * without one, which could not be handed it afterwards. + */ + beforeLaunch = async (): Promise => { + if (!this.credential.get()) { + this.stage = 'signin'; + this.credential.set(await new Promise((resolve) => (this.resolveSignIn = resolve))); + this.hasCredential = true; + } + this.stage = 'idle'; + }; + + submit = (value: string): void => { + this.resolveSignIn?.(value); + }; + + saveAndRestart = (value: string): void => { + this.credential.set(value); + this.options.onRestart(); + }; + + reportBootFailure = (error: unknown): void => { + this.error = describeError(error); + this.stage = 'error'; + }; + + openChange = (): void => { + this.changeOpen = true; + }; + + closeChange = (): void => { + this.changeOpen = false; + }; +} diff --git a/src/lib/agents/session.svelte.ts b/src/lib/agents/session.svelte.ts new file mode 100644 index 0000000..035ddd8 --- /dev/null +++ b/src/lib/agents/session.svelte.ts @@ -0,0 +1,98 @@ +import type { PortalUpdate } from '$lib/pod/portals'; +import { + cliConfigs, + isEnabledTool, + resolveToolId, + toolItems, + type CredentialSpec, + type ToolId, + type ToolItem +} from '$lib/config/tools'; +import { + installLeaveGuard, + markIntentionalNavigation, + navigateWithLeaveGuard +} from '$lib/stores/leaveWarning.svelte'; +import { requestSingleTabLock } from '$lib/utils/tabLock'; +import { bootCLI } from './boot'; +import { CredentialGate } from './credential-gate.svelte'; + +/** `pending` covers the wait on the lock, which resolves after a grace period rather than at once. */ +export type LockState = 'pending' | 'held' | 'taken'; + +/** Owns one agent CLI session. */ +export class AgentSession { + readonly id: ToolId; + readonly tool: ToolItem; + readonly credential: CredentialSpec | undefined; + /** Null for a CLI that declares no credential; those boot with no overlay at all. */ + readonly gate: CredentialGate | null; + + lock = $state('pending'); + + private releaseLock: () => void = () => {}; + private disposeLeaveGuard: () => void = () => {}; + + constructor(requestedTool: string | undefined) { + this.id = resolveToolId(requestedTool); + // resolveToolId only ever returns an id that is in toolItems, so this always resolves. + this.tool = toolItems.find((item) => item.id === this.id)!; + // Mirrors bootCLI's own resolution, so the gate matches the config that actually launches. + this.credential = (cliConfigs[this.id] ?? cliConfigs.claude).credential; + this.gate = this.credential + ? new CredentialGate(this.credential, { onRestart: this.restart }) + : null; + } + + async boot( + terminalEl: HTMLElement, + onPortalUpdate: (update: PortalUpdate) => void + ): Promise { + const lock = requestSingleTabLock(`agent-session:${this.id}`); + this.releaseLock = lock.release; + + if (!(await lock.acquired)) { + this.lock = 'taken'; + return; + } + this.lock = 'held'; + + // Only warn on tab close/refresh/back-button once there is work here to lose. + this.disposeLeaveGuard = installLeaveGuard(); + + // Covers pod boot, the image streaming in, and any warm-up probe. + this.gate?.begin(); + + try { + await bootCLI( + this.id, + terminalEl, + onPortalUpdate, + this.gate ? { beforeLaunch: this.gate.beforeLaunch } : undefined + ); + } catch (error) { + // bootCLI already logged this and wrote it into the terminal, but a gated boot's overlay + // covers that terminal until it is told to show the failure. + this.gate?.reportBootFailure(error); + } + } + + /** The CLI reads its credential at launch, so both a new secret and a retry need a fresh load. */ + restart = (): void => { + markIntentionalNavigation(); + window.location.reload(); + }; + + /** Always confirms: there is a live session here to tear down. */ + switchTo = (id: string): void => { + if (isEnabledTool(id)) navigateWithLeaveGuard(`/agents/${id}`, true); + }; + + /** No confirmation, unlike `switchTo` */ + leave = (): void => navigateWithLeaveGuard('/agents', false); + + shutdown(): void { + this.disposeLeaveGuard(); + this.releaseLock(); + } +} diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte index b40a86a..334f14f 100644 --- a/src/lib/components/Portal.svelte +++ b/src/lib/components/Portal.svelte @@ -1,194 +1,443 @@ -{#if portals.length > 0} +{#if portal.portals.length > 0}
- +
-
- - Preview -
+ {#if onCollapse} + + + {/if} - {#if src} -
- {#if portals.length > 1} -
- -
+
+ + {#if hasChoice} + + {/if} + + + {#if portal.showPorts} +
+ {#each portal.portals as item (item.port)} + + {/each}
{/if} +
+ + + + + +
- {#if showMenu} -
+ {#if portal.showMenu} +
- -
{/if}
+ {#if portal.showInfo} +
+
+ Scan the QR code + +
+ + {#if portal.qrError} +

{portal.qrError}

+ {/if} + + + + + {portal.url} + + +
+ {/if} + {/if} + + {#if sweeping} + {#key sweepId} +
+ {/key} {/if}
- {#if src} + {#if portal.url}
- {#if frameStatus !== 'waiting'} + {#if portal.frameStatus !== 'waiting'} {/if} - - {#if showInfo} -
- - -
- -
- - {#if qrError} -
{qrError}
- {:else} - -
- {src} -
- {/if} -
- {/if}
{/if}
{/if} + + diff --git a/src/lib/components/agents/CodexErrorCard.svelte b/src/lib/components/agents/AgentErrorCard.svelte similarity index 86% rename from src/lib/components/agents/CodexErrorCard.svelte rename to src/lib/components/agents/AgentErrorCard.svelte index c4e839b..de8d0a6 100644 --- a/src/lib/components/agents/CodexErrorCard.svelte +++ b/src/lib/components/agents/AgentErrorCard.svelte @@ -1,11 +1,14 @@ + + + +
+ +
+ +
+ + + {#if !isMobile} + + {/if} + + + {#if session.gate && session.credential} + + + {#if session.gate.overlayVisible} + + {/if} + {/if} + + + {#if showTerminalTip && !(isMobile && activeMobileView !== 'terminal')} + + {/if} + + {#if !isMobile && portal.portals.length > 0 && isPortalVisible} + + +
+ +
+ {:else if isMobile && portal.portals.length > 0 && activeMobileView === 'preview'} +
+ +
+ {/if} +
+ +{#if isMobile} + {#if showToolMenu} + (showToolMenu = false)} + /> + {/if} + + +{/if} diff --git a/src/lib/components/agents/CodexSignInCard.svelte b/src/lib/components/agents/CredentialCard.svelte similarity index 59% rename from src/lib/components/agents/CodexSignInCard.svelte rename to src/lib/components/agents/CredentialCard.svelte index f3dce40..ae15cf5 100644 --- a/src/lib/components/agents/CodexSignInCard.svelte +++ b/src/lib/components/agents/CredentialCard.svelte @@ -1,24 +1,32 @@ @@ -27,43 +35,41 @@ class="glass-panel w-full max-w-[520px] rounded-[14px] border border-bc-mist/15 p-8 shadow-2xl" >
- +
- {mode === 'boot' ? 'Sign in to Codex' : 'Change your API key'} + {mode === 'boot' ? `Sign in to ${tool.label}` : `Change your ${credential.label}`} {mode === 'boot' - ? "ChatGPT sign-in isn't available here, so an API key is required." - : 'Codex reads the key at launch, so saving a new one restarts your session. Anything running in the terminal stops.'} + ? credential.signInReason + : `${tool.label} reads it at launch, so saving a new one restarts your session. Anything running in the terminal stops.`}
-
+
diff --git a/src/lib/components/agents/TerminalTip.svelte b/src/lib/components/agents/TerminalTip.svelte new file mode 100644 index 0000000..bcd8606 --- /dev/null +++ b/src/lib/components/agents/TerminalTip.svelte @@ -0,0 +1,28 @@ + + +
+
+ + + +
+ This is a real terminal. + Click into it and use your keyboard. Mouse clicks alone won't do much here. +
+ +
+
diff --git a/src/lib/components/agents/ToolMenuSheet.svelte b/src/lib/components/agents/ToolMenuSheet.svelte new file mode 100644 index 0000000..08e1854 --- /dev/null +++ b/src/lib/components/agents/ToolMenuSheet.svelte @@ -0,0 +1,93 @@ + + + +
+ +
+
+
+
+ CLI Tool + +
+
+ {#each toolItems as item (item.id)} + + {/each} +
+ +
+
diff --git a/src/lib/components/ide/EditorPane.svelte b/src/lib/components/ide/EditorPane.svelte index 1119ab1..730ed05 100644 --- a/src/lib/components/ide/EditorPane.svelte +++ b/src/lib/components/ide/EditorPane.svelte @@ -3,8 +3,10 @@ import { SvelteMap } from 'svelte/reactivity'; import Icon from '@iconify/svelte'; import { fileIcon } from '$lib/ide/file-icons'; + import ImageViewer from '$lib/components/ide/ImageViewer.svelte'; import type * as Monaco from 'monaco-editor'; import type { IdeSession } from '$lib/ide/session.svelte'; + import { activeEditorThemeId } from '$lib/stores/editor-settings.svelte'; let { session }: { session: IdeSession } = $props(); @@ -18,6 +20,8 @@ const viewStates = new SvelteMap(); let renderedPath = ''; + let activeFile = $derived(session.openFiles.find((file) => file.path === session.selectedFile)); + // Responsive font const FONT_QUERY = '(min-width: 640px)'; const fontSizeFor = (desktop: boolean) => (desktop ? 12.8 : 11.5); @@ -27,13 +31,15 @@ const onMediaChange = () => editor?.updateOptions({ fontSize: fontSizeFor(media.matches) }); media.addEventListener('change', onMediaChange); - // Load Monaco lazily - void import('./monaco').then((mod) => { + // Load Monaco lazily. Highlighting must be installed first: it patches `editor.create`. + void (async () => { + const mod = await import('./monaco'); + await mod.highlightingReady; if (destroyed || !container) return; monacoMod = mod; editor = mod.monaco.editor.create(container, { model: null, - theme: 'browsercode-dark', + theme: activeEditorThemeId(), automaticLayout: true, fontSize: fontSizeFor(media.matches), fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace", @@ -55,17 +61,19 @@ entry.content = value; entry.preview = false; }); - }); + })(); return () => media.removeEventListener('change', onMediaChange); }); - /** Returns (creating if needed) the Monaco model that backs `path`. */ + /** + * Returns (creating if needed) the Monaco model that backs `path`. Passing no language lets + * Monaco resolve it from the URI. + */ function modelFor(mod: typeof import('./monaco'), path: string, content: string) { const uri = mod.monaco.Uri.file(path); return ( - mod.monaco.editor.getModel(uri) ?? - mod.monaco.editor.createModel(content, mod.languageFor(path), uri) + mod.monaco.editor.getModel(uri) ?? mod.monaco.editor.createModel(content, undefined, uri) ); } @@ -82,11 +90,12 @@ // Show the active tab: park the outgoing view state, attach the incoming // model, restore its cursor/scroll. $effect(() => { - const entry = session.openFiles.find((file) => file.path === session.selectedFile); + const entry = activeFile; if (!editor || !monacoMod) return; // Track the reveal request so a jump to the already-open file still re-runs this effect. void session.revealRequest; - if (!entry) { + // Detaching on an image tab stops the previous file's text showing through under it. + if (!entry || entry.image) { if (renderedPath) viewStates.set(renderedPath, editor.saveViewState()); editor.setModel(null); renderedPath = ''; @@ -109,6 +118,12 @@ consumeReveal(entry.path); }); + // Theme switching repaints only; no model or view state is touched. + $effect(() => { + const themeId = activeEditorThemeId(); + monacoMod?.applyTheme(themeId); + }); + // Dispose models and view states whose tab has been closed (or renamed away). $effect(() => { const open = new Set(session.openFiles.map((file) => file.path)); @@ -198,6 +213,11 @@
+ {#if activeFile?.image} +
+ +
+ {/if} {#if session.openFiles.length === 0 && !session.loading && editor}
No file open diff --git a/src/lib/components/ide/IdeShell.svelte b/src/lib/components/ide/IdeShell.svelte index 60fb0cd..34b8c0e 100644 --- a/src/lib/components/ide/IdeShell.svelte +++ b/src/lib/components/ide/IdeShell.svelte @@ -7,56 +7,37 @@ import SearchPanel from '$lib/components/ide/SearchPanel.svelte'; import TerminalTabs from '$lib/components/ide/TerminalTabs.svelte'; import LoadingScene from '$lib/components/ide/LoadingScene.svelte'; + import SettingsMenu from '$lib/components/ide/SettingsMenu.svelte'; import { fade } from 'svelte/transition'; import type { BootStage, IdeSession } from '$lib/ide/session.svelte'; import { downloadProject } from '$lib/ide/download'; import { PortalState } from '$lib/stores/portals.svelte'; - import type { PortalUpdate } from '$lib/pod/portals'; import { installLeaveGuard } from '$lib/stores/leaveWarning.svelte'; + import { startDrag } from '$lib/utils/drag'; import { watchIsMobile } from '$lib/utils/viewport'; import { bugReportUrl } from '$lib/utils/bug-report'; import { trackEvent } from '$lib/utils/useLazyTracking'; import ZenToggle from '$lib/components/ZenToggle.svelte'; import { zenState } from '$lib/stores/zen.svelte'; - // Boot-log lines the preview loader streams, per boot mode. - const BOOT_LOG: Record<'framework' | 'github', string[]> = { - framework: [ - 'booting BrowserPod', - 'copying project files', - 'installing dependencies', - 'starting dev server' - ], - github: [ - 'booting BrowserPod', - 'cloning repository', - 'installing dependencies', - 'starting dev server' - ] - }; // Which boot-log line is the *active* (spinning) one for each real stage. const STAGE_LINE: Record = { booting: 0, - copying: 1, - cloning: 1, + hydrating: 1, installing: 2, starting: 3 }; - // The route owns the session and decides how it boots (curated framework vs GitHub clone); - // the shell is mode-agnostic — it just drives `boot` once the terminals are mounted and - // renders from session state. Switching projects happens by navigating away (the sidebar's - // Ide flyout or an /ide/github URL), not from here. - let { - session, - boot - }: { - session: IdeSession; - boot: ( - terminalEl: HTMLElement, - onPortalUpdate: (update: PortalUpdate) => void - ) => Promise; - } = $props(); + // The route picks the project source, so the shell never learns how the project arrived. + let { session }: { session: IdeSession } = $props(); + + // Only the hydrate step differs per source. + let bootLines = $derived([ + 'booting BrowserPod', + session.source.hydrateLabel, + 'installing dependencies', + 'starting dev server' + ]); let isCompatibleBrowser = $state(true); let downloading = $state(false); @@ -76,12 +57,26 @@ let activePanel = $state<'files' | 'search' | null>('files'); let fileTree = $state<{ startCreate: (kind: 'file' | 'folder') => void } | null>(null); - // Frameworks with a declared app port keep the preview pinned to it; other - // ports stay reachable through the port selector. - const portal = new PortalState({ preferredPort: () => session.appPort }); + // ── Mobile state ────────────────────────────────────────────────────────── + let isMobile = $state(false); + let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor'); + + // A source with a declared app port keeps the preview pinned to it; other + // ports stay reachable through the toolbar's port menu. + const portal = new PortalState({ preferredPort: () => session.source.appPort }); + + let isPreviewVisible = $state(true); + let previewCollapsed = $derived(!isPreviewVisible && !isMobile); + + /** Collapses to the stub without unmounting: a remount would lose the previewed app's route. */ + function togglePreview(): void { + isPreviewVisible = !isPreviewVisible; + // xterm only refits on a resize event. + setTimeout(() => fitTerminals(), 0); + } // Recomputed as the preview moves ports, so a report always carries the live portal URL. - let bugReportHref = $derived(bugReportUrl({ repo: session.repo, previewUrl: portal.url })); + let bugReportHref = $derived(bugReportUrl({ repo: session.source.repo, previewUrl: portal.url })); // Live once the framed document loaded, so the loader covers the server's start and first paint. let previewLive = $derived(portal.frameStatus === 'ready'); @@ -90,12 +85,11 @@ if (!previewLive) loaderVisible = true; }); - let bootLines = $derived(BOOT_LOG[session.mode]); let activeLine = $derived(STAGE_LINE[session.bootStage]); - // ── Mobile state ────────────────────────────────────────────────────────── - let isMobile = $state(false); - let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor'); + /** Editor floor, so dragging the terminal up leaves a usable strip of it. */ + const MIN_EDITOR_FRACTION = 0.1; + const MAX_EDITOR_FRACTION = 0.85; // ── Resize state ────────────────────────────────────────────────────────── let filePanelWidth = $state(208); @@ -111,14 +105,8 @@ window.dispatchEvent(new Event('resize')); } - function startDrag(which: 'file' | 'col' | 'row', e: MouseEvent) { - e.preventDefault(); + function startPaneDrag(which: 'file' | 'col' | 'row', event: MouseEvent) { dragging = which; - document.body.classList.add('dragging'); - document.body.style.cursor = which === 'row' ? 'row-resize' : 'col-resize'; - - const startX = e.clientX; - const startY = e.clientY; const startFileW = filePanelWidth; const startEditorFrac = editorFraction; const startLeftW = leftColEl?.clientWidth ?? 0; @@ -126,43 +114,30 @@ // 40px = icon rail width const startTotalW = bodyEl ? bodyEl.clientWidth - 40 - (activePanel ? filePanelWidth : 0) : 1; - function onMove(ev: MouseEvent) { - const dx = ev.clientX - startX; - const dy = ev.clientY - startY; - - if (which === 'file') { - const requested = startFileW + dx; - if (requested < 100) { - // Dragged shut — collapse the panel instead of pinning to min width - activePanel = null; - onUp(); - return; + startDrag(event, { + cursor: which === 'row' ? 'row-resize' : 'col-resize', + move: (dx, dy, stop) => { + if (which === 'file') { + const requested = startFileW + dx; + if (requested < 100) { + // Dragged shut — collapse the panel instead of pinning to min width + activePanel = null; + stop(); + return; + } + filePanelWidth = Math.max(140, Math.min(480, requested)); + } else if (which === 'col') { + leftColFraction = Math.max(0.25, Math.min(0.8, (startLeftW + dx) / startTotalW)); + } else if (which === 'row') { + editorFraction = Math.max( + MIN_EDITOR_FRACTION, + Math.min(MAX_EDITOR_FRACTION, (startLeftH * startEditorFrac + dy) / startLeftH) + ); } - filePanelWidth = Math.max(140, Math.min(480, requested)); - } else if (which === 'col') { - leftColFraction = Math.max(0.25, Math.min(0.8, (startLeftW + dx) / startTotalW)); - } else if (which === 'row') { - // Cap the terminal at 600px by bumping the editor's minimum fraction - const maxTerminalPx = 600; - const minEditorFrac = startLeftH > 0 ? Math.max(0.2, 1 - maxTerminalPx / startLeftH) : 0.2; - editorFraction = Math.max( - minEditorFrac, - Math.min(0.85, (startLeftH * startEditorFrac + dy) / startLeftH) - ); - } - fitTerminals(); - } - - function onUp() { - dragging = null; - document.body.classList.remove('dragging'); - document.body.style.cursor = ''; - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - } - - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); + fitTerminals(); + }, + end: () => (dragging = null) + }); } // ── Mobile detection ────────────────────────────────────────────────────── @@ -193,7 +168,7 @@ await tick(); if (!outputEl) throw new Error('Terminal container is not ready yet'); try { - await boot(outputEl, portal.apply); + await session.boot(outputEl, portal.apply); } catch (error) { console.error('Failed initializing BrowserPod:', error); session.loading = false; @@ -215,7 +190,7 @@ >
- {session.displayLabel} + {session.source.label} {#if session.selectedFile} / {session.selectedFile} @@ -255,6 +230,11 @@
+ trackEvent('Clicked Report Bug', { mode: session.mode })} + onclick={() => trackEvent('Clicked Report Bug', { mode: session.source.id })} class="flex items-center justify-center rounded p-1.5 text-zinc-600 transition hover:bg-bc-coral/10 hover:text-bc-coral" > @@ -357,7 +337,7 @@ type="button" class="divider divider-col" class:active={dragging === 'file'} - onmousedown={(e) => startDrag('file', e)} + onmousedown={(e) => startPaneDrag('file', e)} aria-label="Resize side panel" >
@@ -369,14 +349,18 @@
@@ -388,7 +372,7 @@ type="button" class="divider divider-row" class:active={dragging === 'row'} - onmousedown={(e) => startDrag('row', e)} + onmousedown={(e) => startPaneDrag('row', e)} aria-label="Resize terminal panel" >
@@ -396,22 +380,22 @@ {/if}
- {#if !isMobile} + {#if !isMobile && isPreviewVisible} + {/if} + +
+ {#if !isCompatibleBrowser}
- +
+ +
+

Incompatible Browser

+

+ Requires Atomics.waitAsync (Chrome, Edge, Safari + 16.4+). +

-

Incompatible Browser

-

- Requires Atomics.waitAsync (Chrome, Edge, Safari - 16.4+). -

-
- {:else} - {#if portal.portals.length > 0} - - {/if} - - {#if loaderVisible} -
- (loaderVisible = false)} + {:else} + {#if portal.portals.length > 0} + session.saveAll()} + onCollapse={isMobile ? undefined : togglePreview} /> -
+ {/if} + + {#if loaderVisible} +
+ (loaderVisible = false)} + /> +
+ {/if} {/if} - {/if} +
@@ -516,14 +510,6 @@ diff --git a/src/lib/components/ide/SettingsMenu.svelte b/src/lib/components/ide/SettingsMenu.svelte new file mode 100644 index 0000000..ea77c84 --- /dev/null +++ b/src/lib/components/ide/SettingsMenu.svelte @@ -0,0 +1,130 @@ + + +
+ + + {#if open} +
+

Editor theme

+ {#each themes as theme (theme.id)} + + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/ide/monaco.ts b/src/lib/components/ide/monaco.ts index 4f6dd08..4eb4726 100644 --- a/src/lib/components/ide/monaco.ts +++ b/src/lib/components/ide/monaco.ts @@ -5,6 +5,31 @@ import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; +import { shikiToMonaco, textmateThemeToMonacoTheme } from '@shikijs/monaco'; +import { createHighlighterCore } from 'shiki/core'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; +import type { ThemeRegistrationResolved } from 'shiki'; +import css from '@shikijs/langs/css'; +import docker from '@shikijs/langs/docker'; +import graphql from '@shikijs/langs/graphql'; +import html from '@shikijs/langs/html'; +import ini from '@shikijs/langs/ini'; +import javascript from '@shikijs/langs/javascript'; +import json from '@shikijs/langs/json'; +import less from '@shikijs/langs/less'; +import markdown from '@shikijs/langs/markdown'; +import python from '@shikijs/langs/python'; +import scss from '@shikijs/langs/scss'; +import shellscript from '@shikijs/langs/shellscript'; +import sql from '@shikijs/langs/sql'; +import svelte from '@shikijs/langs/svelte'; +import toml from '@shikijs/langs/toml'; +import typescript from '@shikijs/langs/typescript'; +import vue from '@shikijs/langs/vue'; +import xml from '@shikijs/langs/xml'; +import yaml from '@shikijs/langs/yaml'; +import type { EditorThemeId } from '$lib/config/editor-themes'; +import { THEME_DATA } from './themes/theme-data'; /** * Monaco bootstrap for the playground editor. This module (and EditorPane, which @@ -26,61 +51,68 @@ self.MonacoEnvironment = { for (const defaults of [monacoTs.typescriptDefaults, monacoTs.javascriptDefaults]) defaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: false }); -monaco.editor.defineTheme('browsercode-dark', { - base: 'vs-dark', - inherit: true, - rules: [], - colors: { - 'editor.background': '#09090b', - 'editor.foreground': '#d9d9d9', - 'editorCursor.foreground': '#10b981', - 'editor.selectionBackground': '#10b9812e', - 'editor.inactiveSelectionBackground': '#10b9811a', - 'editor.lineHighlightBackground': '#ffffff05', - 'editorLineNumber.foreground': '#ffffff40', - 'editorLineNumber.activeForeground': '#ffffff8c', - 'editorGutter.background': '#09090b', - 'editorIndentGuide.background1': '#ffffff0a', - 'editorIndentGuide.activeBackground1': '#ffffff1f', - 'editorWidget.background': '#111111', - 'editorWidget.border': '#ffffff14', - 'scrollbarSlider.background': '#ffffff1f', - 'scrollbarSlider.hoverBackground': '#ffffff38', - 'scrollbarSlider.activeBackground': '#ffffff38', - 'editorOverviewRuler.border': '#00000000' - } -}); +// Fallback for a failed install below: Monaco resolves an unknown theme name to vs-light, and +// silently, so every theme has to exist even without TextMate. +for (const [id, theme] of Object.entries(THEME_DATA)) + monaco.editor.defineTheme( + id, + textmateThemeToMonacoTheme( + theme as ThemeRegistrationResolved + ) as monaco.editor.IStandaloneThemeData + ); -// Svelte and Vue have no Monaco grammar, so they fall back to HTML. -const LANGUAGE_BY_EXT: Record = { - svelte: 'html', - vue: 'html', - html: 'html', - htm: 'html', - ts: 'typescript', - tsx: 'typescript', - mts: 'typescript', - cts: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - css: 'css', - scss: 'scss', - less: 'less', - json: 'json', - md: 'markdown', - markdown: 'markdown', - yaml: 'yaml', - yml: 'yaml', - xml: 'xml', - svg: 'xml' -}; +/* Replaces Monarch with TextMate grammars */ +async function installHighlighting(): Promise { + // Not Monaco languages, so shikiToMonaco would skip them and they would fall back to + // HTML (.svelte/.vue) or plaintext (.toml). Registering the extensions also lets Monaco + // infer them in `modelFor`. + monaco.languages.register({ id: 'svelte', extensions: ['.svelte'] }); + monaco.languages.register({ id: 'vue', extensions: ['.vue'] }); + monaco.languages.register({ id: 'toml', extensions: ['.toml'] }); -// Maps a file path to a Monaco language id by its extension; unknown types are plaintext. -export function languageFor(file: string): string { - const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase(); - return LANGUAGE_BY_EXT[ext] ?? 'plaintext'; + const highlighter = await createHighlighterCore({ + themes: Object.values(THEME_DATA), + langs: [ + css, + docker, + graphql, + html, + ini, + javascript, + json, + less, + markdown, + python, + scss, + shellscript, + sql, + svelte, + toml, + typescript, + vue, + xml, + yaml + ], + // Covers every grammar loaded here, and keeps the oniguruma wasm out of the bundle. + engine: createJavaScriptRegexEngine() + }); + + // Reads the theme list once, and re-running would wrap `setTheme` twice. + shikiToMonaco(highlighter, monaco); +} + +/** Await before creating an editor: `shikiToMonaco` patches `create` and `setTheme`. */ +export const highlightingReady: Promise = installHighlighting().catch((error) => { + console.error('Failed to install TextMate highlighting:', error); +}); + +/** Global; no editor or model is rebuilt. */ +export function applyTheme(id: EditorThemeId): void { + try { + monaco.editor.setTheme(id); + } catch (error) { + console.error(`Failed to apply the ${id} editor theme:`, error); + } } export { monaco }; diff --git a/src/lib/components/ide/themes/browsercode-dark.ts b/src/lib/components/ide/themes/browsercode-dark.ts new file mode 100644 index 0000000..8d5b317 --- /dev/null +++ b/src/lib/components/ide/themes/browsercode-dark.ts @@ -0,0 +1,93 @@ +import type { ThemeRegistration } from 'shiki'; + +/** + * The house theme, in TextMate form so Shiki loads it alongside the bundled ones. Token colors come + * from the `bc-*` palette in `routes/layout.css`. + */ +export const browsercodeDark: ThemeRegistration = { + name: 'browsercode-dark', + type: 'dark', + colors: { + 'editor.background': '#09090b', + 'editor.foreground': '#d9d9d9', + 'editorCursor.foreground': '#10b981', + 'editor.selectionBackground': '#10b9812e', + 'editor.inactiveSelectionBackground': '#10b9811a', + 'editor.lineHighlightBackground': '#ffffff05', + 'editorLineNumber.foreground': '#ffffff40', + 'editorLineNumber.activeForeground': '#ffffff8c', + 'editorGutter.background': '#09090b', + 'editorIndentGuide.background1': '#ffffff0a', + 'editorIndentGuide.activeBackground1': '#ffffff1f', + 'editorWidget.background': '#111111', + 'editorWidget.border': '#ffffff14', + 'scrollbarSlider.background': '#ffffff1f', + 'scrollbarSlider.hoverBackground': '#ffffff38', + 'scrollbarSlider.activeBackground': '#ffffff38', + 'editorOverviewRuler.border': '#00000000' + }, + tokenColors: [ + { + scope: ['comment', 'punctuation.definition.comment'], + settings: { foreground: '#5c6473', fontStyle: 'italic' } + }, + { + scope: ['string', 'string.quoted', 'string.template'], + settings: { foreground: '#b7cdff' } + }, + { + scope: ['constant.character.escape', 'string.regexp'], + settings: { foreground: '#ffd633' } + }, + { + scope: ['constant.numeric', 'constant.language', 'support.constant'], + settings: { foreground: '#c73da6' } + }, + { + scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], + settings: { foreground: '#4a7dff' } + }, + { + scope: ['keyword.operator', 'punctuation', 'meta.brace'], + settings: { foreground: '#7d8595' } + }, + { + scope: ['entity.name.function', 'support.function', 'meta.function-call'], + settings: { foreground: '#ffd633' } + }, + { + scope: [ + 'entity.name.type', + 'entity.name.class', + 'entity.other.inherited-class', + 'support.type', + 'support.class' + ], + settings: { foreground: '#8fb0ff' } + }, + { + scope: ['variable', 'variable.other', 'meta.definition.variable'], + settings: { foreground: '#d9d9d9' } + }, + { + scope: ['variable.parameter', 'meta.object-literal.key', 'support.variable.property'], + settings: { foreground: '#a9b4c8' } + }, + { + scope: ['entity.name.tag', 'punctuation.definition.tag'], + settings: { foreground: '#ff6161' } + }, + { + scope: ['entity.other.attribute-name'], + settings: { foreground: '#ffd633' } + }, + { + scope: ['markup.heading', 'markup.bold'], + settings: { foreground: '#b7cdff', fontStyle: 'bold' } + }, + { + scope: ['invalid', 'invalid.illegal'], + settings: { foreground: '#ff6161' } + } + ] +}; diff --git a/src/lib/components/ide/themes/colorblind-safe.ts b/src/lib/components/ide/themes/colorblind-safe.ts new file mode 100644 index 0000000..086eefd --- /dev/null +++ b/src/lib/components/ide/themes/colorblind-safe.ts @@ -0,0 +1,93 @@ +import type { ThemeRegistration } from 'shiki'; + +/** + * Built on the Okabe–Ito palette, which stays separable under protanopia, deuteranopia and + * tritanopia. Every color clears WCAG AA (4.5:1) on this background. + */ +export const colorblindSafe: ThemeRegistration = { + name: 'colorblind-safe', + type: 'dark', + colors: { + 'editor.background': '#0f1319', + 'editor.foreground': '#e6edf3', + 'editorCursor.foreground': '#56b4e9', + 'editor.selectionBackground': '#56b4e93d', + 'editor.inactiveSelectionBackground': '#56b4e921', + 'editor.lineHighlightBackground': '#ffffff08', + 'editorLineNumber.foreground': '#ffffff4d', + 'editorLineNumber.activeForeground': '#ffffff99', + 'editorGutter.background': '#0f1319', + 'editorIndentGuide.background1': '#ffffff0f', + 'editorIndentGuide.activeBackground1': '#ffffff2b', + 'editorWidget.background': '#161b22', + 'editorWidget.border': '#ffffff1f', + 'scrollbarSlider.background': '#ffffff26', + 'scrollbarSlider.hoverBackground': '#ffffff40', + 'scrollbarSlider.activeBackground': '#ffffff40', + 'editorOverviewRuler.border': '#00000000' + }, + tokenColors: [ + { + scope: ['comment', 'punctuation.definition.comment'], + settings: { foreground: '#8d96a0', fontStyle: 'italic' } + }, + { + scope: ['string', 'string.quoted', 'string.template'], + settings: { foreground: '#f0e442' } + }, + { + scope: ['constant.character.escape', 'string.regexp'], + settings: { foreground: '#f5793a' } + }, + { + scope: ['constant.numeric', 'constant.language', 'support.constant'], + settings: { foreground: '#e69f00' } + }, + { + scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], + settings: { foreground: '#cc79a7' } + }, + { + scope: ['keyword.operator', 'punctuation', 'meta.brace'], + settings: { foreground: '#7d8895' } + }, + { + scope: ['entity.name.function', 'support.function', 'meta.function-call'], + settings: { foreground: '#56b4e9' } + }, + { + scope: [ + 'entity.name.type', + 'entity.name.class', + 'entity.other.inherited-class', + 'support.type', + 'support.class' + ], + settings: { foreground: '#9fe6ff' } + }, + { + scope: ['variable', 'variable.other', 'meta.definition.variable'], + settings: { foreground: '#e6edf3' } + }, + { + scope: ['variable.parameter', 'meta.object-literal.key', 'support.variable.property'], + settings: { foreground: '#b8c4d0' } + }, + { + scope: ['entity.name.tag', 'punctuation.definition.tag'], + settings: { foreground: '#cc79a7' } + }, + { + scope: ['entity.other.attribute-name'], + settings: { foreground: '#56b4e9' } + }, + { + scope: ['markup.heading', 'markup.bold'], + settings: { foreground: '#e6edf3', fontStyle: 'bold' } + }, + { + scope: ['invalid', 'invalid.illegal'], + settings: { foreground: '#f5793a', fontStyle: 'underline' } + } + ] +}; diff --git a/src/lib/components/ide/themes/theme-data.ts b/src/lib/components/ide/themes/theme-data.ts new file mode 100644 index 0000000..c47b274 --- /dev/null +++ b/src/lib/components/ide/themes/theme-data.ts @@ -0,0 +1,25 @@ +import type { ThemeRegistration } from 'shiki'; +import catppuccinMocha from '@shikijs/themes/catppuccin-mocha'; +import dracula from '@shikijs/themes/dracula'; +import githubDarkDefault from '@shikijs/themes/github-dark-default'; +import nightOwl from '@shikijs/themes/night-owl'; +import oneDarkPro from '@shikijs/themes/one-dark-pro'; +import tokyoNight from '@shikijs/themes/tokyo-night'; +import type { EditorThemeId } from '$lib/config/editor-themes'; +import { browsercodeDark } from './browsercode-dark'; +import { colorblindSafe } from './colorblind-safe'; + +/** + * Colors for every id in `config/editor-themes.ts`, which holds the metadata the menu and store + * read. Kept apart from it so this data stays behind the lazy Monaco import. + */ +export const THEME_DATA: Record = { + 'browsercode-dark': browsercodeDark, + 'colorblind-safe': colorblindSafe, + dracula, + 'tokyo-night': tokyoNight, + 'night-owl': nightOwl, + 'one-dark-pro': oneDarkPro, + 'github-dark-default': githubDarkDefault, + 'catppuccin-mocha': catppuccinMocha +}; diff --git a/src/lib/config/editor-themes.ts b/src/lib/config/editor-themes.ts new file mode 100644 index 0000000..77f4607 --- /dev/null +++ b/src/lib/config/editor-themes.ts @@ -0,0 +1,44 @@ +/** + * Editor theme metadata. + */ + +export type EditorThemeVariant = 'dark' | 'light'; + +export type EditorThemeId = + | 'browsercode-dark' + | 'colorblind-safe' + | 'dracula' + | 'tokyo-night' + | 'night-owl' + | 'one-dark-pro' + | 'github-dark-default' + | 'catppuccin-mocha'; + +export type EditorThemeConfig = { + id: EditorThemeId; + label: string; + variant: EditorThemeVariant; +}; + +/** Ids are Shiki theme names, which `shikiToMonaco` registers with Monaco verbatim. */ +export const editorThemes: EditorThemeConfig[] = [ + { id: 'browsercode-dark', label: 'BrowserCode Dark', variant: 'dark' }, + { id: 'colorblind-safe', label: 'Colorblind Safe', variant: 'dark' }, + { id: 'dracula', label: 'Dracula', variant: 'dark' }, + { id: 'tokyo-night', label: 'Tokyo Night', variant: 'dark' }, + { id: 'night-owl', label: 'Night Owl', variant: 'dark' }, + { id: 'one-dark-pro', label: 'One Dark Pro', variant: 'dark' }, + { id: 'github-dark-default', label: 'GitHub Dark', variant: 'dark' }, + { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', variant: 'dark' } +]; + +/** Stands in for any variant with nothing stored, including light until one ships. */ +export const defaultEditorThemeId: EditorThemeId = 'browsercode-dark'; + +export function editorThemesFor(variant: EditorThemeVariant): EditorThemeConfig[] { + return editorThemes.filter((theme) => theme.variant === variant); +} + +export function isEditorThemeId(value: string | null): value is EditorThemeId { + return value !== null && editorThemes.some((theme) => theme.id === value); +} diff --git a/src/lib/config/tools.ts b/src/lib/config/tools.ts index 414767f..e7732e4 100644 --- a/src/lib/config/tools.ts +++ b/src/lib/config/tools.ts @@ -1,5 +1,11 @@ import type { BrowserPod } from '@leaningtech/browserpod'; -import { codexEnv, prepareCodexPod, CODEX_BIN_PATH } from '$lib/agents/codex'; +import { + codexEnv, + getCodexApiKey, + prepareCodexPod, + setCodexApiKey, + CODEX_BIN_PATH +} from '$lib/agents/codex'; export type ToolId = 'claude' | 'antigravity' | 'codex' | 'opencode'; @@ -8,8 +14,6 @@ export type ToolItem = { icon: string | null; label: string; disabled: boolean; - /** Set what the tool needs before booting, shown under its label on the agents landing. */ - requirement?: string; /** Tailwind classes for the icon badge when the tool is available (ignored while disabled). */ accentClass: string; /** Solid Tailwind background class for the small "this one is running" status dot. */ @@ -31,7 +35,6 @@ export const toolItems: ToolItem[] = [ icon: 'hugeicons:chat-gpt', label: 'Codex CLI', disabled: false, - requirement: 'OpenAI API key', accentClass: 'bg-bc-orchid/10 text-bc-orchid', dotClass: 'bg-bc-orchid' }, @@ -53,6 +56,39 @@ export const toolItems: ToolItem[] = [ } ]; +/** Where an unknown or not-yet-shipped tool id falls back to. */ +export const defaultToolId: ToolId = toolItems.find((item) => !item.disabled)?.id ?? 'claude'; + +/** A disabled tool has nothing to boot, so it is not a routable id either. */ +export function isEnabledTool(id: string | undefined): id is ToolId { + return toolItems.some((item) => item.id === id && !item.disabled); +} + +/** Route params are user input, so an id that cannot boot resolves to the default instead. */ +export function resolveToolId(param: string | undefined): ToolId { + return isEnabledTool(param) ? param : defaultToolId; +} + +/** + * A secret the CLI can only be handed at process launch, so declaring one switches the tool to a + * gated boot: an overlay blocks until it is stored. Copy lives here as data, so the cards render + * any provider without knowing which. + */ +export type CredentialSpec = { + label: string; + placeholder: string; + /** Where the user creates one, not where it is documented. */ + consoleUrl: string; + /** Quoted from the provider's own console so it is findable there. */ + createLabel: string; + /** Why the usual sign-in is unavailable. */ + signInReason: string; + /** Where the value is kept and how it is billed. */ + rationale: string; + get: () => string | null; + set: (value: string) => void; +}; + export type CLIConfig = { /** Prebuilt disk image, mounted at /home. */ userImage?: string; @@ -65,6 +101,7 @@ export type CLIConfig = { prepare?: (pod: BrowserPod) => Promise; /** Extra env for the CLI process, resolved at launch. */ env?: () => string[]; + credential?: CredentialSpec; }; export const cliConfigs: Record = { @@ -95,6 +132,18 @@ export const cliConfigs: Record = { args: [], projectFile: '/project/codex/AGENTS.md', prepare: prepareCodexPod, - env: codexEnv + env: codexEnv, + credential: { + label: 'OpenAI API key', + placeholder: 'sk-proj-…', + consoleUrl: 'https://platform.openai.com/api-keys', + createLabel: 'Create new secret key', + signInReason: "ChatGPT sign-in isn't available here, so an API key is required.", + rationale: + "It's saved in this browser so you only paste it once, and sent to OpenAI when Codex runs. " + + 'Usage is billed per token to your OpenAI account, not to a ChatGPT plan.', + get: getCodexApiKey, + set: setCodexApiKey + } } }; diff --git a/src/lib/ide/download.ts b/src/lib/ide/download.ts index 6b7254e..20d4239 100644 --- a/src/lib/ide/download.ts +++ b/src/lib/ide/download.ts @@ -45,7 +45,7 @@ export async function downloadProject(session: IdeSession): Promise { files[path] = await readPodBinaryFile(pod, `${session.workdir}/${path}`); } - triggerDownload(zipSync(files), zipFilename(session.displayLabel), 'application/zip'); + triggerDownload(zipSync(files), zipFilename(session.source.label), 'application/zip'); } /** Downloads a single project-relative file under its own name. */ diff --git a/src/lib/ide/media.ts b/src/lib/ide/media.ts new file mode 100644 index 0000000..d950591 --- /dev/null +++ b/src/lib/ide/media.ts @@ -0,0 +1,57 @@ +/** + * Image tabs: pod files have no URL an `` can reach, so the bytes are read once and + * republished as an object URL, owned by the tab that holds it. + */ +import type { BrowserPod } from '@leaningtech/browserpod'; +import { readPodBinaryFile } from '$lib/pod/fs'; + +/** `url` stays valid until {@link releaseImage}. */ +export type ImagePayload = { url: string; bytes: number }; + +/** Extensions a browser renders in an ``. SVG is absent so it stays editable as text; */ +const IMAGE_MIME: Record = { + png: 'image/png', + apng: 'image/apng', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + jpe: 'image/jpeg', + jif: 'image/jpeg', + jfif: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + bmp: 'image/bmp', + ico: 'image/x-icon' +}; + +const extensionOf = (path: string): string => path.slice(path.lastIndexOf('.') + 1).toLowerCase(); + +/** True when `path` opens as an image tab rather than in the text editor. */ +export function isImagePath(path: string): boolean { + return extensionOf(path) in IMAGE_MIME; +} + +/** Reads an image out of the pod and publishes it as an object URL. */ +export async function loadPodImage(pod: BrowserPod, absPath: string): Promise { + const bytes = await readPodBinaryFile(pod, absPath); + const blob = new Blob([bytes], { type: IMAGE_MIME[extensionOf(absPath)] }); + return { url: URL.createObjectURL(blob), bytes: bytes.byteLength }; +} + +/** Releases the object URL; a leaked one pins the whole image in memory. */ +export function releaseImage(image: ImagePayload | undefined): void { + if (image) URL.revokeObjectURL(image.url); +} + +const BYTE_UNITS = ['B', 'KB', 'MB']; + +/** Byte count for the viewer's status line, e.g. `24.1 KB`. */ +export function formatBytes(bytes: number): string { + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < BYTE_UNITS.length - 1) { + value /= 1024; + unit++; + } + return `${unit === 0 ? value : value.toFixed(1)} ${BYTE_UNITS[unit]}`; +} diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts index db42cca..5b838f2 100644 --- a/src/lib/ide/native-deps.ts +++ b/src/lib/ide/native-deps.ts @@ -11,6 +11,9 @@ type Manifest = { /** What the repo declares, package name to version spec, dependencies and devDependencies. */ type DeclaredDeps = Map; +/** Reads the repo's own value for an entry, never an earlier patch's, so rule order cannot matter. */ +type CurrentValue = (section: string, name: string) => string | undefined; + type SectionPatch = { /** Top-level manifest key holding a name to value map. */ section: string; @@ -21,7 +24,7 @@ type SectionPatch = { type FrameworkRule = { applies: (deps: DeclaredDeps) => boolean; - patches: SectionPatch[]; + patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[]; }; /** Ours wins: for where the repo's own value is the thing that breaks the pod. */ @@ -42,24 +45,46 @@ const DEP_SECTIONS = new Set([ 'peerDependencies' ]); +/** Native binaries the pod cannot execute. */ const WASM_BUNDLERS = { - esbuild: 'npm:esbuild-wasm@0.25.11', - rollup: 'npm:@rollup/wasm-node@4.52.4' + esbuild: 'npm:esbuild-wasm@*', + rollup: 'npm:@rollup/wasm-node@*' }; const FRAMEWORK_RULES: FrameworkRule[] = [ - // Vite 8+ + // Vite 8.2+ { - applies: (deps) => majorAtLeast(deps, 'vite', 8), - patches: [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })] + applies: (deps) => minorAtLeast(deps, 'vite', 8, 2), + patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })] }, // Vite 7 and earlier { applies: (deps) => majorBelow(deps, 'vite', 8), - patches: [force('overrides', WASM_BUNDLERS)] + patches: () => [force('overrides', WASM_BUNDLERS)] + }, + // Next.js + { + applies: (deps) => deps.has('next'), + patches: (deps, current) => { + const dev = nextDevWithWebpack(current('scripts', 'dev'), deps); + return dev ? [force('scripts', { dev })] : []; + } } ]; +/** + * Turbopack needs native bindings the pod cannot execute. Dropping its flags is enough through Next + * 15; 16 defaults to it and needs `--webpack` to opt out. + */ +function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): string | undefined { + if (!script) return undefined; + const withoutTurbopack = script.replace(/ --turbo(?:pack)?\b/g, ''); + if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) { + return withoutTurbopack; + } + return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack'); +} + /** Highest major the spec could install. Null means no ceiling at all. */ function highestMajor(spec: string): number | null { if (spec.includes('>') && !spec.includes('<')) return null; @@ -68,6 +93,27 @@ function highestMajor(spec: string): number | null { return Math.max(...versions.map((version) => Number.parseInt(version, 10))); } +/** Highest minor the spec could install within `major`, Infinity when it leaves the minor free. */ +function highestMinor(spec: string, major: number): number { + // A caret or a bare major floats the minor: `^8.1.1` installs 8.2 today, so it reads as 8.x. + if (spec.includes('^')) return Infinity; + const minors = (spec.match(/\d+(?:\.\d+)*/g) ?? []) + .map((version) => version.split('.').map(Number)) + .filter(([declared]) => declared === major) + .map(([, minor]) => minor ?? Infinity); + return minors.length > 0 ? Math.max(...minors) : Infinity; +} + +/** True when `name` is declared and can install `major.minor` or newer. */ +function minorAtLeast(deps: DeclaredDeps, name: string, major: number, minor: number): boolean { + const spec = deps.get(name); + if (spec === undefined) return false; + const highest = highestMajor(spec); + if (highest === null) return true; + if (highest !== major) return highest > major; + return highestMinor(spec, major) >= minor; +} + /** True when `name` is declared and can install `major` or newer, an unversioned spec included. */ function majorAtLeast(deps: DeclaredDeps, name: string, major: number): boolean { const spec = deps.get(name); @@ -116,6 +162,10 @@ export function patchClonedManifest( const deps: DeclaredDeps = new Map( Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) ); + const currentValue: CurrentValue = (section, name) => { + const held = manifest[section]; + return isRecord(held) ? held[name] : undefined; + }; // Working copies, seeded from the manifest the first time a rule touches the section. A section // holding anything other than a map is treated as absent; npm would reject it anyway. const sections = new Map>(); @@ -132,7 +182,7 @@ export function patchClonedManifest( const notes: string[] = []; for (const rule of FRAMEWORK_RULES) { if (!rule.applies(deps)) continue; - for (const { section: sectionName, mode, entries } of rule.patches) { + for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) { const section = workingCopy(sectionName); for (const [name, value] of Object.entries(entries)) { if (mode === 'fill' && alreadyPresent(section, sectionName, name, deps)) continue; diff --git a/src/lib/ide/project-source.ts b/src/lib/ide/project-source.ts new file mode 100644 index 0000000..4151b4e --- /dev/null +++ b/src/lib/ide/project-source.ts @@ -0,0 +1,49 @@ +/** + * How a project gets into the pod. `IdeSession` runs one boot pipeline over these hooks. + */ +import type { BrowserPod } from '@leaningtech/browserpod'; + +/** Pod-side handles the hooks work against; the session owns the terminal and the boot token. */ +export type BootContext = { + pod: BrowserPod; + workdir: string; + /** Writes raw text to the Output terminal. */ + write: (data: string) => void; + run: ( + command: string, + args: string[], + options?: { cwd?: string; color?: boolean } + ) => Promise; + /** True once the boot was superseded or the session unmounted; a hook must return early. */ + cancelled: () => boolean; +}; + +export type ProjectSource = { + /** Carried into analytics and bug reports as the boot mode. */ + id: string; + /** Doubles as the filename the project downloads under. */ + label: string; + /** Names the hydrate step in the preview loader's boot log. */ + hydrateLabel: string; + /** Where the project lands; pod paths resolve against it. */ + workdir: string; + /** Preview is pinned to this port when set. Unknown for an arbitrary repo. */ + appPort?: number; + /** What this session cloned, for the bug report's repo field. */ + repo?: { url: string; ref: string }; + /** Fetched before the pod boots, so the file tree renders while the project arrives. */ + listFiles: () => Promise; + hydrate: (ctx: BootContext, files: string[]) => Promise; + /** Runs once the pod is readable, before the first tab opens. */ + prepare?: (ctx: BootContext) => Promise; + initialFile: (files: string[]) => string | undefined; + /** npm invocations run before the dev server. */ + installCommands: () => string[][]; + /** + * npm invocation that starts the dev server, or null when there is nothing to run. A source + * returning null has already said so on the terminal. + */ + startCommand: (ctx: BootContext) => Promise; + /** Called once the pod is readable, so a boot that dies earlier is not counted. */ + trackBoot: () => void; +}; diff --git a/src/lib/ide/repo-source.ts b/src/lib/ide/repo-source.ts new file mode 100644 index 0000000..56c3e29 --- /dev/null +++ b/src/lib/ide/repo-source.ts @@ -0,0 +1,81 @@ +/** Project source for a shallow GitHub clone. */ +import { fetchRepoTree } from '$lib/github/api'; +import { POD_HOME, readPodFile, writePodFile } from '$lib/pod/fs'; +import { trackEvent } from '$lib/utils/useLazyTracking'; +import { patchClonedManifest } from './native-deps'; +import { ANSI } from './shell-rc'; +import type { BootContext, ProjectSource } from './project-source'; + +export type RepoRef = { owner: string; repo: string; ref: string; dir: string }; + +export function repoSource({ owner, repo, ref, dir }: RepoRef): ProjectSource { + const url = `https://github.com/${owner}/${repo}`; + const repoDir = `${POD_HOME}/${repo}`; + + return { + id: 'github', + label: dir ? `${owner}/${repo}/${dir}` : `${owner}/${repo}`, + hydrateLabel: 'cloning repository', + workdir: dir ? `${repoDir}/${dir}` : repoDir, + repo: { url, ref }, + listFiles: async (): Promise => { + try { + return await fetchRepoTree(owner, repo, ref, dir); + } catch (error) { + // The tree only renders the file list early; the clone still produces it. + console.error('Failed to fetch repo file tree:', error); + return []; + } + }, + hydrate: (ctx: BootContext): Promise => + ctx.run('git', ['clone', '--depth', '1', '--branch', ref, `${url}.git`], { + cwd: POD_HOME, + color: false + }), + // Patched before the first tab opens, so the editor shows the manifest install will see. + prepare: patchManifest, + initialFile: (files: string[]) => files[0], + installCommands: () => [['install']], + startCommand: resolveStartScript, + trackBoot: () => trackEvent('Booted Playground GitHub', { repo: `${owner}/${repo}` }) + }; +} + +async function patchManifest(ctx: BootContext): Promise { + const manifestPath = `${ctx.workdir}/package.json`; + try { + const raw = await readPodFile(ctx.pod, manifestPath); + const result = patchClonedManifest(raw); + if (!result) { + ctx.write(`\r\n${ANSI.dim}No dependency patches apply to this repo.${ANSI.reset}\r\n`); + return; + } + await writePodFile(ctx.pod, manifestPath, result.patched); + // Header takes the leading blank line; the changes follow indented under it as one block. + ctx.write(`\r\n${ANSI.dim}Modified package.json${ANSI.reset}\r\n`); + for (const note of result.notes) ctx.write(`${ANSI.dim} ${note}${ANSI.reset}\r\n`); + } catch (error) { + ctx.write( + `\r\n${ANSI.coral}Could not patch package.json; installing the repo as cloned.${ANSI.reset}\r\n` + ); + console.warn('Could not patch the cloned manifest:', error); + } +} + +async function resolveStartScript(ctx: BootContext): Promise { + let scripts: Record = {}; + try { + const raw = await readPodFile(ctx.pod, `${ctx.workdir}/package.json`); + scripts = (JSON.parse(raw) as { scripts?: Record }).scripts ?? {}; + } catch (error) { + console.error('Failed to read package.json:', error); + } + const script = scripts.dev ? 'dev' : scripts.start ? 'start' : null; + if (!script) { + ctx.write( + `\r\n${ANSI.coral}No "dev" or "start" script in package.json — nothing to run.${ANSI.reset}\r\n` + ); + return null; + } + return ['run', script]; +} diff --git a/src/lib/ide/session.svelte.ts b/src/lib/ide/session.svelte.ts index b0d218f..5e4d3f0 100644 --- a/src/lib/ide/session.svelte.ts +++ b/src/lib/ide/session.svelte.ts @@ -1,26 +1,10 @@ import type { BrowserPod, Terminal } from '@leaningtech/browserpod'; import { SvelteSet } from 'svelte/reactivity'; -import { - frameworkConfigs, - defaultFrameworkId, - type FrameworkConfig, - type FrameworkId -} from '$lib/config/frameworks'; -import { - POD_HOME, - readPodFile, - writePodFile, - writePodBinaryFile, - writeToTerminal -} from '$lib/pod/fs'; -import { ANSI, BP_RC, BP_RC_PATH } from './shell-rc'; -import { patchClonedManifest } from './native-deps'; -import { fetchRepoTree } from '$lib/github/api'; -import { trackEvent } from '$lib/utils/useLazyTracking'; +import { readPodFile, writePodFile, writeToTerminal } from '$lib/pod/fs'; import type { PortalUpdate } from '$lib/pod/portals'; - -// Re-exported so the boot-owning routes keep a single import site for session types. -export type { PortalUpdate }; +import { ANSI, BP_RC, BP_RC_PATH } from './shell-rc'; +import { isImagePath, loadPodImage, releaseImage, type ImagePayload } from './media'; +import type { BootContext, ProjectSource } from './project-source'; /** Force color: npm/vite print plain text with no TTY in the pod. */ const COLOR_ENV = ['FORCE_COLOR=3', 'COLORTERM=truecolor']; @@ -29,20 +13,30 @@ const COLOR_ENV = ['FORCE_COLOR=3', 'COLORTERM=truecolor']; * A file open as an editor tab. A `preview` tab (opened by single-click) * is reused by the next preview open; double-clicking or editing pins it. */ -export type OpenFile = { path: string; content: string; savedContent: string; preview: boolean }; - -/** Where the boot pipeline currently is; drives the loader's progress readout. `copying` is - * framework-only, `cloning` GitHub-only. */ -export type BootStage = 'booting' | 'copying' | 'cloning' | 'installing' | 'starting'; +export type OpenFile = { + path: string; + content: string; + savedContent: string; + preview: boolean; + /** Set on image tabs, which render as a picture and never save. */ + image?: ImagePayload; +}; + +/** Where the boot pipeline currently is; drives the loader's progress readout. */ +export type BootStage = 'booting' | 'hydrating' | 'installing' | 'starting'; /** - * Owns the BrowserPod lifecycle for the playground IDE: boots the pod, hydrates - * the selected framework template into its filesystem, starts the dev server and - * exposes file/terminal/portal state. UI components render from this; the editor - * implementation (Monaco today, VS Code Web later) stays swappable on top. + * Owns the BrowserPod lifecycle for the playground IDE: boots the pod, hydrates the project its + * `ProjectSource` describes, starts the dev server and exposes file/terminal/portal state. UI + * components render from this; it holds no editor state of its own, so Monaco lives entirely in + * the component layer. */ export class IdeSession { - framework = $state(defaultFrameworkId); + /** How this session's project gets in, and everything the UI reads about it. */ + readonly source: ProjectSource; + /** Directory the project lives in inside the pod; pod file paths resolve against it. */ + readonly workdir: string; + projectFiles = $state([]); /** Explicitly-known directories (from UI folder actions); lets empty folders render in the tree. */ projectDirs = $state([]); @@ -59,15 +53,6 @@ export class IdeSession { /** Current boot pipeline step; drives the preview loader's stage readout. */ bootStage = $state('booting'); - /** Which boot path produced this session; drives the label, appPort and workdir. */ - mode = $state<'framework' | 'github'>('framework'); - /** Directory the project lives in inside the pod; pod file paths resolve against it. */ - workdir = POD_HOME; - private githubSlug = $state(''); - /** Repo URL this session clones from: `git clone` appends `.git`, bug reports link it as-is. */ - private githubUrl = $state(''); - private githubRef = $state(''); - pod: BrowserPod | null = null; outputTerminal: Terminal | null = null; /** Lazily created hidden terminal that carries UI-initiated fs commands. */ @@ -83,24 +68,9 @@ export class IdeSession { private bootToken = 0; private booting = false; - get config(): FrameworkConfig { - return frameworkConfigs[this.framework]; - } - - /** Label shown in the IDE shell; framework label, or owner/repo[/dir] in GitHub mode. */ - get displayLabel(): string { - return this.mode === 'github' ? this.githubSlug : this.config.label; - } - - /** What this session cloned; null in framework mode. Carried into bug reports. */ - get repo(): { url: string; ref: string } | null { - if (this.mode !== 'github' || !this.githubUrl) return null; - return { url: this.githubUrl, ref: this.githubRef }; - } - - /** Preview is pinned to this port when set. Unknown for arbitrary GitHub repos. */ - get appPort(): number | undefined { - return this.mode === 'github' ? undefined : this.config.appPort; + constructor(source: ProjectSource) { + this.source = source; + this.workdir = source.workdir; } private get activeFile(): OpenFile | undefined { @@ -117,12 +87,8 @@ export class IdeSession { return this.unmounted || token !== this.bootToken; } - /** - * Boots a pod from a curated framework template: hydrate the template files, - * `npm install` (per the framework's setup commands), then the dev server. - */ + /** The source fills in each step; the pipeline itself is the same for all of them. */ async boot( - framework: FrameworkId, terminalEl: HTMLElement, onPortalUpdate: (update: PortalUpdate) => void ): Promise { @@ -130,133 +96,60 @@ export class IdeSession { this.booting = true; const token = ++this.bootToken; try { - this.framework = framework; this.bootStage = 'booting'; - this.projectFiles = await fetchManifest(this.config); - if (this.cancelled(token)) return; - - const pod = await this.bootPod(terminalEl, onPortalUpdate, token); - if (!pod) return; - - this.bootStage = 'copying'; - for (const file of this.projectFiles) { - if (this.cancelled(token)) return; - const content = await fetchTemplateFile(this.config, file); - if (this.cancelled(token)) return; - await writePodBinaryFile(pod, `${this.workdir}/${file}`, content); - } - if (this.cancelled(token)) return; - - this.podReady = true; - const initialFile = this.projectFiles.includes(this.config.defaultFile) - ? this.config.defaultFile - : this.projectFiles[0]; - if (initialFile) await this.openFile(initialFile); - - trackEvent('Booted Playground', { framework: this.config.label }); - - this.bootStage = 'installing'; - for (const setupCommandArgs of this.config.setupCommandArgs ?? [['install']]) { - if (this.cancelled(token)) return; - await this.runInOutput('npm', setupCommandArgs); - } - + this.projectFiles = await this.source.listFiles(); if (this.cancelled(token)) return; - this.bootStage = 'starting'; - await this.runInOutput('npm', this.config.devCommandArgs ?? ['run', 'dev']); - } finally { - this.booting = false; - } - } - - /** - * Boots a pod from a GitHub repo: shallow clone, `npm install`, then the - * project's dev/start script. - * The file tree is fetched up front so it renders while the clone runs. - */ - async bootFromGitHub( - owner: string, - repo: string, - ref: string, - dir: string, - terminalEl: HTMLElement, - onPortalUpdate: (update: PortalUpdate) => void - ): Promise { - if (this.booting || this.pod) return; - this.booting = true; - this.mode = 'github'; - this.githubSlug = dir ? `${owner}/${repo}/${dir}` : `${owner}/${repo}`; - this.githubUrl = `https://github.com/${owner}/${repo}`; - this.githubRef = ref; - const token = ++this.bootToken; - try { - this.bootStage = 'booting'; - // File list up front (before the pod) so the tree renders while we clone. - try { - const files = await fetchRepoTree(owner, repo, ref, dir); - if (this.cancelled(token)) return; - this.projectFiles = files; - } catch (error) { - console.error('Failed to fetch repo file tree:', error); - } const pod = await this.bootPod(terminalEl, onPortalUpdate, token); if (!pod) return; + const ctx = this.bootContext(pod, token); - const repoDir = `${POD_HOME}/${repo}`; - this.workdir = dir ? `${repoDir}/${dir}` : repoDir; - - // Plain shallow clone: fetch only the tip commit (--depth 1) of the requested branch - // and check out the full working tree. For a subdir boot we just point `workdir` at - // the subtree afterwards. (BrowserPod 2.12+ handles the packfile memory footprint, so - // the earlier --filter=blob:none / --sparse workaround is no longer needed.) - this.bootStage = 'cloning'; - // git manages its own colors (no TTY here, so none) — forcing COLOR_ENV would change nothing. - await this.runInOutput( - 'git', - ['clone', '--depth', '1', '--branch', ref, `${this.githubUrl}.git`], - { cwd: POD_HOME, color: false } - ); + this.bootStage = 'hydrating'; + await this.source.hydrate(ctx, this.projectFiles); if (this.cancelled(token)) return; - // The working tree exists now — let the editor read from the pod. + // The project exists on disk now, so the editor may read from the pod. this.podReady = true; - - // Patch package.json before the first tab opens, so the editor shows the - // manifest install will actually see. - await this.applyManifestPatches(); + await this.source.prepare?.(ctx); if (this.cancelled(token)) return; - const initialFile = this.projectFiles[0]; + const initialFile = this.source.initialFile(this.projectFiles); if (initialFile) await this.openFile(initialFile); else this.loading = false; - trackEvent('Booted Playground GitHub', { repo: `${owner}/${repo}` }); + this.source.trackBoot(); this.bootStage = 'installing'; - await this.runInOutput('npm', ['install']); + for (const args of this.source.installCommands()) { + if (this.cancelled(token)) return; + await this.runInOutput('npm', args); + } if (this.cancelled(token)) return; - const script = await this.resolveStartScript(); - if (this.cancelled(token)) return; - if (!script) { - this.termWrite( - `\r\n${ANSI.coral}No "dev" or "start" script in package.json — nothing to run.${ANSI.reset}\r\n` - ); - return; - } + const startArgs = await this.source.startCommand(ctx); + if (this.cancelled(token) || !startArgs) return; this.bootStage = 'starting'; - await this.runInOutput('npm', ['run', script]); + await this.runInOutput('npm', startArgs); } finally { this.booting = false; } } + /** All a source gets of the pod; `cancelled` stays bound to the boot that handed it over. */ + private bootContext(pod: BrowserPod, token: number): BootContext { + return { + pod, + workdir: this.workdir, + write: (data) => this.termWrite(data), + run: (command, args, options) => this.runInOutput(command, args, options), + cancelled: () => this.cancelled(token) + }; + } + /** - * Shared front half of both boot paths: dynamic-import BrowserPod, boot the pod, - * attach the Output terminal (with the banner and branded shell rc) and wire portal - * events through to the host. Returns null when the boot was cancelled in flight. + * Boots the pod and attaches the Output terminal, with the banner and branded shell rc. + * Returns null when the boot was cancelled in flight. */ private async bootPod( terminalEl: HTMLElement, @@ -337,43 +230,6 @@ export class IdeSession { } } - private async applyManifestPatches(): Promise { - if (!this.pod) return; - const manifestPath = `${this.workdir}/package.json`; - try { - const raw = await readPodFile(this.pod, manifestPath); - const result = patchClonedManifest(raw); - if (!result) { - this.termWrite(`\r\n${ANSI.dim}No dependency patches apply to this repo.${ANSI.reset}\r\n`); - return; - } - await writePodFile(this.pod, manifestPath, result.patched); - // Header takes the leading blank line; the changes follow indented under it as one block. - this.termWrite(`\r\n${ANSI.dim}Modified package.json${ANSI.reset}\r\n`); - for (const note of result.notes) this.termWrite(`${ANSI.dim} ${note}${ANSI.reset}\r\n`); - } catch (error) { - this.termWrite( - `\r\n${ANSI.coral}Could not patch package.json; installing the repo as cloned.${ANSI.reset}\r\n` - ); - console.warn('Could not patch the cloned manifest:', error); - } - } - - /** Pick the dev-server script from the cloned package.json: prefer `dev`, then `start`. */ - private async resolveStartScript(): Promise { - if (!this.pod) return null; - try { - const raw = await readPodFile(this.pod, `${this.workdir}/package.json`); - const scripts = (JSON.parse(raw) as { scripts?: Record }).scripts ?? {}; - if (scripts.dev) return 'dev'; - if (scripts.start) return 'start'; - return null; - } catch (error) { - console.error('Failed to read package.json:', error); - return null; - } - } - /** True if the tree already knows an entry at `path` (or something nested under it). */ private entryExists(path: string): boolean { const under = (p: string) => p === path || p.startsWith(`${path}/`); @@ -461,6 +317,7 @@ export class IdeSession { const gone = (p: string) => p === path || p.startsWith(`${path}/`); this.projectFiles = this.projectFiles.filter((p) => !gone(p)); this.projectDirs = this.projectDirs.filter((p) => !gone(p)); + for (const file of this.openFiles) if (gone(file.path)) releaseImage(file.image); this.openFiles = this.openFiles.filter((file) => !gone(file.path)); if (gone(this.selectedFile)) this.selectedFile = this.openFiles.at(-1)?.path ?? ''; return null; @@ -486,20 +343,28 @@ export class IdeSession { this.loading = true; this.selectedFile = path; try { - const content = await readPodFile(this.pod, `${this.workdir}/${path}`); - if (this.unmounted || this.openFiles.some((file) => file.path === path)) return; + const absPath = `${this.workdir}/${path}`; + const image = isImagePath(path) ? await loadPodImage(this.pod, absPath) : undefined; + const content = image ? '' : await readPodFile(this.pod, absPath); + if (this.unmounted || this.openFiles.some((file) => file.path === path)) { + releaseImage(image); + return; + } // A pin that arrived while the read was in flight wins over the preview flag. const entry: OpenFile = { path, content, savedContent: content, - preview: preview && !this.pendingPins.delete(path) + preview: preview && !this.pendingPins.delete(path), + image }; const previewIndex = entry.preview ? this.openFiles.findIndex((file) => file.preview) : -1; - this.openFiles = - previewIndex >= 0 - ? this.openFiles.map((file, i) => (i === previewIndex ? entry : file)) - : [...this.openFiles, entry]; + if (previewIndex >= 0) { + releaseImage(this.openFiles[previewIndex].image); + this.openFiles = this.openFiles.map((file, i) => (i === previewIndex ? entry : file)); + } else { + this.openFiles = [...this.openFiles, entry]; + } } catch (error) { console.error('Failed to load file:', error); this.pendingPins.delete(path); @@ -531,6 +396,7 @@ export class IdeSession { if (index < 0) return; const entry = this.openFiles[index]; if (entry.content !== entry.savedContent) void this.saveEntry(entry); + releaseImage(entry.image); this.openFiles = this.openFiles.filter((file) => file.path !== path); if (this.selectedFile === path) this.selectedFile = (this.openFiles[index] ?? this.openFiles[index - 1])?.path ?? ''; @@ -554,6 +420,8 @@ export class IdeSession { } private async saveEntry(entry: OpenFile): Promise { + // An image tab carries no text, so writing its content back would truncate the file. + if (entry.image) return; // Saving only makes sense once the dev server is reachable; earlier writes // would race the template hydration. if (!this.pod || !this.hasPortal || this.unmounted) return; @@ -594,6 +462,7 @@ export class IdeSession { /** Tears down the pod and cancels any in-flight boot. */ shutdown(): void { this.unmounted = true; + for (const file of this.openFiles) releaseImage(file.image); this.bootToken += 1; if (this.pod) void shutdownPod(this.pod); } @@ -604,30 +473,6 @@ function shQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -async function fetchManifest(config: FrameworkConfig): Promise { - const response = await fetch(`${config.sourceRoot}/manifest.txt`); - if (!response.ok) { - throw new Error( - `Failed to load manifest for ${config.id}: ${response.status} ${response.statusText}` - ); - } - const text = await response.text(); - return text - .split('\n') - .map((line) => line.trim()) - .filter(Boolean); -} - -async function fetchTemplateFile(config: FrameworkConfig, fileName: string): Promise { - const response = await fetch(`${config.sourceRoot}/${fileName}`); - if (!response.ok) { - throw new Error( - `Failed to fetch ${fileName} for ${config.id}: ${response.status} ${response.statusText}` - ); - } - return response.arrayBuffer(); -} - // shutdown() is not part of the published type definitions yet. async function shutdownPod(pod: BrowserPod): Promise { try { diff --git a/src/lib/ide/template-source.ts b/src/lib/ide/template-source.ts new file mode 100644 index 0000000..bd4f7f6 --- /dev/null +++ b/src/lib/ide/template-source.ts @@ -0,0 +1,57 @@ +/** + * Project source for the curated framework templates served out of `static/templates//`. + */ +import { frameworkConfigs, type FrameworkConfig, type FrameworkId } from '$lib/config/frameworks'; +import { POD_HOME, writePodBinaryFile } from '$lib/pod/fs'; +import { trackEvent } from '$lib/utils/useLazyTracking'; +import type { BootContext, ProjectSource } from './project-source'; + +export function templateSource(framework: FrameworkId): ProjectSource { + const config = frameworkConfigs[framework]; + + return { + id: 'framework', + label: config.label, + hydrateLabel: 'copying project files', + workdir: POD_HOME, + appPort: config.appPort, + listFiles: () => fetchManifest(config), + hydrate: async (ctx: BootContext, files: string[]): Promise => { + for (const file of files) { + if (ctx.cancelled()) return; + const content = await fetchTemplateFile(config, file); + if (ctx.cancelled()) return; + await writePodBinaryFile(ctx.pod, `${ctx.workdir}/${file}`, content); + } + }, + initialFile: (files: string[]) => + files.includes(config.defaultFile) ? config.defaultFile : files[0], + installCommands: () => config.setupCommandArgs ?? [['install']], + startCommand: async () => config.devCommandArgs ?? ['run', 'dev'], + trackBoot: () => trackEvent('Booted Playground', { framework: config.label }) + }; +} + +async function fetchManifest(config: FrameworkConfig): Promise { + const response = await fetch(`${config.sourceRoot}/manifest.txt`); + if (!response.ok) { + throw new Error( + `Failed to load manifest for ${config.id}: ${response.status} ${response.statusText}` + ); + } + const text = await response.text(); + return text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +async function fetchTemplateFile(config: FrameworkConfig, fileName: string): Promise { + const response = await fetch(`${config.sourceRoot}/${fileName}`); + if (!response.ok) { + throw new Error( + `Failed to fetch ${fileName} for ${config.id}: ${response.status} ${response.statusText}` + ); + } + return response.arrayBuffer(); +} diff --git a/src/lib/stores/editor-settings.svelte.ts b/src/lib/stores/editor-settings.svelte.ts new file mode 100644 index 0000000..dc21a48 --- /dev/null +++ b/src/lib/stores/editor-settings.svelte.ts @@ -0,0 +1,45 @@ +import { + defaultEditorThemeId, + isEditorThemeId, + type EditorThemeId, + type EditorThemeVariant +} from '$lib/config/editor-themes'; + +const STORAGE_KEY = 'browsercode:editor-theme'; + +export const appearance: EditorThemeVariant = 'dark'; + +type ThemeChoices = Record; + +/** Unknown ids are dropped, so a stale value can't reach Monaco. */ +function loadChoices(): ThemeChoices { + const choices: ThemeChoices = { dark: null, light: null }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return choices; + const stored = JSON.parse(raw) as Partial>; + for (const variant of ['dark', 'light'] as const) { + const value = stored[variant]; + if (typeof value === 'string' && isEditorThemeId(value)) choices[variant] = value; + } + } catch (error) { + console.warn('Could not read the stored editor theme:', error); + } + return choices; +} + +const choices = $state(loadChoices()); + +export function activeEditorThemeId(): EditorThemeId { + return choices[appearance] ?? defaultEditorThemeId; +} + +/** Persists the pick for the current appearance. */ +export function setEditorTheme(id: EditorThemeId): void { + choices[appearance] = id; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(choices)); + } catch (error) { + console.warn('Could not persist the editor theme:', error); + } +} diff --git a/src/lib/stores/portals.svelte.ts b/src/lib/stores/portals.svelte.ts index 98a1d33..96318c0 100644 --- a/src/lib/stores/portals.svelte.ts +++ b/src/lib/stores/portals.svelte.ts @@ -35,6 +35,7 @@ export class PortalState { url = $state(''); frameStatus = $state('waiting'); showMenu = $state(false); + showPorts = $state(false); showInfo = $state(false); copied = $state(false); qrError = $state(''); @@ -76,11 +77,8 @@ export class PortalState { if (next.length === 0) this.options.onEmpty?.(); }; - /** Change handler for Portal.svelte's port