diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..78c6dde --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/JacLy.iml b/.idea/JacLy.iml deleted file mode 100644 index 24643cc..0000000 --- a/.idea/JacLy.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 71b7a82..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml deleted file mode 100644 index 4ea72a9..0000000 --- a/.idea/copilot.data.migration.agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml deleted file mode 100644 index 7ef04e2..0000000 --- a/.idea/copilot.data.migration.ask.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml deleted file mode 100644 index 1f2ea11..0000000 --- a/.idea/copilot.data.migration.ask2agent.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml deleted file mode 100644 index 8648f94..0000000 --- a/.idea/copilot.data.migration.edit.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index bff03dc..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index d9c9aee..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml deleted file mode 100644 index b0c1c68..0000000 --- a/.idea/prettier.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/terminals.json b/.vscode/terminals.json index d9e29ec..1f574d6 100644 --- a/.vscode/terminals.json +++ b/.vscode/terminals.json @@ -8,6 +8,13 @@ "icon": "globe", "command": "cd [workspaceFolder]/apps/web && pnpm dev --force" }, + { + "name": "Web Dev Server - Lint", + "description": "Development server for web app", + "focus": false, + "icon": "globe", + "command": "cd [workspaceFolder]/apps/web && pnpm lint" + }, { "name": "Jacly Build Watch", "description": "Build watcher for Jacly package", @@ -26,7 +33,7 @@ ] }, { - "name": "Registry Server", + "name": "Registry Server - Serve", "description": "Registry server for testing", "focus": true, "icon": "broadcast", @@ -34,6 +41,16 @@ "cd ../Jaculus-libraries", "jaculus-registry serve ." ] + }, + { + "name": "Registry Server - Watch", + "description": "Registry server for testing", + "focus": true, + "icon": "broadcast", + "commands": [ // Multiple commands to run + "cd ../Jaculus-libraries", + "jaculus-registry build-watch ." + ] } ] } diff --git a/apps/web/.github/workflows/playwright.yml b/apps/web/.github/workflows/playwright.yml deleted file mode 100644 index f8fb191..0000000 --- a/apps/web/.github/workflows/playwright.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install -g pnpm && pnpm install - - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps - - name: Run Playwright tests - run: pnpm exec playwright test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/apps/web/.vscode/extensions.json b/apps/web/.vscode/extensions.json deleted file mode 100644 index 8cf06c2..0000000 --- a/apps/web/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["inlang.vs-code-extension"] -} diff --git a/apps/web/README.md b/apps/web/README.md index c987b94..954e796 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,73 +1 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x'; -import reactDom from 'eslint-plugin-react-dom'; - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]); -``` +# Jacly Web Application diff --git a/apps/web/components.json b/apps/web/components.json index 6d1247d..b0c2e0c 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -1,22 +1,24 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "radix-nova", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.ts", - "css": "src/index.css", - "baseColor": "slate", + "css": "src/app/index.css", + "baseColor": "gray", "cssVariables": true, "prefix": "" }, "iconLibrary": "lucide", "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", + "components": "@/features/shared/components", + "utils": "@/lib/utils/cn", + "ui": "@/features/shared/components/ui", "lib": "@/lib", - "hooks": "@/hooks" + "hooks": "@/features/shared/hooks" }, + "menuColor": "default", + "menuAccent": "subtle", "registries": {} } diff --git a/apps/web/index.html b/apps/web/index.html index d8eb222..198b45f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,6 @@
- + diff --git a/apps/web/package.json b/apps/web/package.json index 1fc9e47..7d89ad2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "prebuild": "node --experimental-strip-types scripts/runTsScripts.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -19,70 +18,59 @@ "test:playwright:ui": "pnpm exec playwright test --ui", "preview": "vite preview", "clean": "rm -rf dist *.tsbuildinfo .intlayer", - "clean-node": "rm -rf node_modules", - "machine-translate": "inlang machine translate --project project.inlang" + "clean-node": "rm -rf node_modules" }, "dependencies": { + "@base-ui/react": "^1.0.0", + "@fontsource-variable/inter": "^5.2.8", "@jaculus/common": "workspace:*", "@jaculus/device": "workspace:*", "@jaculus/jacly": "workspace:*", "@jaculus/link": "workspace:*", "@jaculus/project": "workspace:*", "@monaco-editor/react": "^4.7.0", - "@obsidize/tar-browserify": "^6.3.2", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.4", - "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-router": "^1.139.1", - "@tanstack/react-router-devtools": "^1.139.1", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-router": "^1.144.0", + "@tanstack/react-router-devtools": "^1.144.0", "@zenfs/archives": "^1.3.1", "@zenfs/core": "^2.4.4", - "@zenfs/dom": "^1.2.5", + "@zenfs/dom": "^1.2.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.1.1", + "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", + "esptool-js": "^0.5.7", + "fflate": "^0.8.2", "flexlayout-react": "^0.8.17", - "lucide-react": "^0.554.0", + "lucide-react": "^0.562.0", "nanoid": "^5.1.6", "notistack": "^3.0.2", - "pako": "^2.1.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "radix-ui": "^1.4.3", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "shadcn": "^3.6.2", "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.17", - "tailwindcss-animate": "^1.0.7", - "vite": "^7.2.4", - "wokwi-client-js": "../../../wokwi-cli/packages/wokwi-client-js/" + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "vite": "^7.3.0", + "zustand": "^5.0.9" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@playwright/test": "^1.56.1", - "@tanstack/router-plugin": "^1.139.1", - "@types/archiver": "^7.0.0", - "@types/chai": "^5.2.3", - "@types/mocha": "^10.0.10", - "@types/node": "^24.10.1", - "@types/pako": "^2.0.4", - "@types/react": "^19.2.6", + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.57.0", + "@tanstack/router-plugin": "^1.145.2", + "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/w3c-web-serial": "^1.0.8", - "@vitejs/plugin-react": "^5.1.1", - "archiver": "^7.0.1", - "chai": "^6.2.1", - "eslint": "^9.39.1", + "@vitejs/plugin-react": "^5.1.2", + "chai": "^6.2.2", + "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", + "eslint-plugin-react-refresh": "^0.4.26", "mocha": "^11.7.5", - "tsx": "^4.20.6", - "tw-animate-css": "^1.4.0", + "tsx": "^4.21.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.47.0", + "typescript-eslint": "^8.51.0", "vite-plugin-node-polyfills": "^0.24.0" } } diff --git a/apps/web/public/bin/jaculus.uf2 b/apps/web/public/bin/jaculus.uf2 deleted file mode 100644 index 5e279c2..0000000 Binary files a/apps/web/public/bin/jaculus.uf2 and /dev/null differ diff --git a/apps/web/scripts/generateSchema.ts b/apps/web/scripts/generateSchema.ts deleted file mode 100644 index bb7bf5e..0000000 --- a/apps/web/scripts/generateSchema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { projectJsonSchema } from '@jaculus/project'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -export function generateSchema() { - const workspaceRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..' - ); - const outputSchema = path.join( - workspaceRoot, - 'public', - 'schema', - 'project.json' - ); - if (!fs.existsSync(path.dirname(outputSchema))) { - fs.mkdirSync(path.dirname(outputSchema), { recursive: true }); - } - - const schema = projectJsonSchema(); - fs.writeFileSync(outputSchema, JSON.stringify(schema, null, 2), 'utf-8'); - console.log(`Generated schema at ${outputSchema}`); -} diff --git a/apps/web/scripts/generateTarGz.ts b/apps/web/scripts/generateTarGz.ts deleted file mode 100644 index 3c2022b..0000000 --- a/apps/web/scripts/generateTarGz.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Archive } from '@obsidize/tar-browserify'; -import pako from 'pako'; -import * as fs from 'fs'; -import * as path from 'path'; - -export async function generateTarGz(sourceDir: string, outPath: string) { - const archive = new Archive(); - - function addFilesToArchive(dir: string, baseDir: string = dir) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - const relativePath = path.relative(baseDir, fullPath); - const tarPath = path.join(relativePath); - - if (entry.isDirectory()) { - archive.addDirectory(tarPath); - addFilesToArchive(fullPath, baseDir); - } else if (entry.isFile()) { - const content = fs.readFileSync(fullPath); - archive.addBinaryFile(tarPath, content); - } - } - } - - addFilesToArchive(sourceDir); - - const tarData = archive.toUint8Array(); - const gzData = pako.gzip(tarData); - - // Ensure output directory exists - const outDir = path.dirname(outPath); - if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }); - } - - fs.writeFileSync(outPath, gzData); - console.log(`Generated tar.gz at ${outPath}`); -} diff --git a/apps/web/scripts/generateTsLibsZip.ts b/apps/web/scripts/generateTsLibsZip.ts deleted file mode 100644 index 09aee0b..0000000 --- a/apps/web/scripts/generateTsLibsZip.ts +++ /dev/null @@ -1,64 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; -import archiver from 'archiver'; - -const require = createRequire(import.meta.url); - -type FileEntry = { name: string; content: string }; - -function getTypescriptLibFiles(typescriptLibPath: string): FileEntry[] { - const files = fs.readdirSync(typescriptLibPath); - if (files.length === 0) { - throw new Error( - `TSVFS: Could not find the TypeScript lib files at ${typescriptLibPath}. Please ensure that TypeScript is installed.` - ); - } - - return files - .filter( - (lib: string) => - !lib.startsWith('lib.webworker.') && - lib.startsWith('lib.') && - lib.endsWith('.d.ts') - ) - .map((lib: string) => ({ - name: lib, - content: fs.readFileSync(path.join(typescriptLibPath, lib), 'utf8'), - })); -} - -function createZipArchive(outPath: string, files: FileEntry[]): Promise { - const output = fs.createWriteStream(outPath); - const archive = archiver('zip', { zlib: { level: 9 } }); - - return new Promise((resolve, reject) => { - output.on('close', () => { - console.log( - `Created TS Library ${outPath} (${archive.pointer()} total bytes)` - ); - resolve(); - }); - archive.on('error', reject); - archive.pipe(output); - - for (const file of files) { - archive.append(file.content, { name: file.name }); - } - - archive.finalize(); - }); -} - -export async function generateTsLibsZip(): Promise { - const workspaceRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..' - ); - const outZip = path.join(workspaceRoot, 'public', 'tsLibs.zip'); - const typescriptLibPath = path.dirname(require.resolve('typescript')); - - const files = getTypescriptLibFiles(typescriptLibPath); - await createZipArchive(outZip, files); -} diff --git a/apps/web/scripts/runShellScript.sh b/apps/web/scripts/runShellScript.sh deleted file mode 100755 index df5da04..0000000 --- a/apps/web/scripts/runShellScript.sh +++ /dev/null @@ -1,62 +0,0 @@ - -#!/bin/bash - -# Pack a directory into a tar.gz archive -function generateTarGz() { - local sourceDir=$1 - local outTarGzPath=$2 - - if [ ! -d "$sourceDir" ]; then - echo "Error: Source directory does not exist: $sourceDir" - return 1 - fi - - # Create output directory if it doesn't exist - local outDir=$(dirname "$outTarGzPath") - mkdir -p "$outDir" - - # Create tar.gz archive (contents only, without the directory wrapper) - # Exclude macOS metadata files - tar -czf "$outTarGzPath" -C "$sourceDir" \ - --exclude='._*' \ - --exclude='.DS_Store' \ - --exclude='__MACOSX' \ - . - - if [ $? -eq 0 ]; then - echo "Created tar.gz archive $outTarGzPath" - else - echo "Error: Failed to create tar.gz archive" - return 1 - fi -} - -# Pack a directory into a zip archive -function generateZip() { - local sourceDir=$1 - local outZipPath=$2 - - if [ ! -d "$sourceDir" ]; then - echo "Error: Source directory does not exist: $sourceDir" - return 1 - fi - - # Create output directory if it doesn't exist - local outDir=$(dirname "$outZipPath") - mkdir -p "$outDir" - - # Create zip archive (recursively including all files) - # Exclude macOS metadata files - zip -r "$outZipPath" "$sourceDir" \ - -x '*/._*' '*.DS_Store' '*/__MACOSX' - - if [ $? -eq 0 ]; then - echo "Created zip archive $outZipPath" - else - echo "Error: Failed to create zip archive" - return 1 - fi -} - -generateZip "../../test/data/test-project" "public/project.zip" -generateTarGz "../../test/data/test-project" "public/project.tar.gz" diff --git a/apps/web/scripts/runTsScripts.ts b/apps/web/scripts/runTsScripts.ts deleted file mode 100644 index 9ca4754..0000000 --- a/apps/web/scripts/runTsScripts.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { generateSchema } from './generateSchema.ts'; -import { generateTsLibsZip } from './generateTsLibsZip.ts'; -import { generateTarGz } from './generateTarGz.ts'; - -generateSchema(); -generateTsLibsZip(); -generateTarGz('../../test/data/test-project', 'public/project.tar.gz'); diff --git a/apps/web/src/index.css b/apps/web/src/app/index.css similarity index 80% rename from apps/web/src/index.css rename to apps/web/src/app/index.css index a64a6a1..6bb6e28 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/app/index.css @@ -1,6 +1,5 @@ @import 'tailwindcss'; - -@plugin "tailwindcss-animate"; +@import 'tw-animate-css'; @custom-variant dark (&:is([data-theme="dark"] *)); @@ -46,33 +45,33 @@ --radius: 0.625rem; /* Light theme: neutral cool grey with subtle blue tint */ - --background: oklch(0.95 0.015 250); /* soft off-white, slightly cool */ - --foreground: oklch(0.22 0.03 252); /* dark neutral text */ + --background: oklch(0.97 0.01 250); /* soft off-white, slightly cool */ + --foreground: oklch(0.2 0.03 252); /* dark neutral text */ - --card: oklch(0.99 0.005 250); /* almost white card */ - --card-foreground: oklch(0.22 0.03 252); + --card: oklch(0.995 0.005 250); /* almost white card */ + --card-foreground: oklch(0.2 0.03 252); - --popover: oklch(0.98 0.01 250); - --popover-foreground: oklch(0.22 0.03 252); + --popover: oklch(0.99 0.008 250); + --popover-foreground: oklch(0.2 0.03 252); /* Keep blue only for actions, not whole UI */ --primary: oklch(0.67 0.15 245); /* matches dark theme’s blue */ --primary-foreground: oklch(0.99 0.01 252); - --secondary: oklch(0.92 0.02 250); - --secondary-foreground: oklch(0.28 0.03 252); + --secondary: oklch(0.94 0.02 250); + --secondary-foreground: oklch(0.25 0.03 252); - --muted: oklch(0.93 0.015 250); - --muted-foreground: oklch(0.55 0.02 252); + --muted: oklch(0.95 0.015 250); + --muted-foreground: oklch(0.5 0.02 252); - --accent: oklch(0.94 0.03 245); /* subtle highlight, not full blue panel */ - --accent-foreground: oklch(0.25 0.03 252); + --accent: oklch(0.95 0.025 245); /* subtle highlight, not full blue panel */ + --accent-foreground: oklch(0.22 0.03 252); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.86 0.02 250); /* neutral grey border */ - --input: oklch(0.98 0.01 250); - --ring: oklch(0.75 0.1 245); + --border: oklch(0.78 0.03 250); /* more visible grey border */ + --input: oklch(0.67 0.15 245); /* white input background for contrast */ + --ring: oklch(0.67 0.15 245); /* match primary for consistency */ --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); @@ -81,17 +80,17 @@ --chart-5: oklch(0.769 0.188 70.08); /* Sidebar: same neutral base, blue only for active / primary */ - --sidebar: oklch(0.93 0.02 250); - --sidebar-foreground: oklch(0.22 0.03 252); + --sidebar: oklch(0.96 0.015 250); + --sidebar-foreground: oklch(0.2 0.03 252); --sidebar-primary: oklch(0.67 0.15 245); --sidebar-primary-foreground: oklch(0.99 0.01 252); - --sidebar-accent: oklch(0.94 0.03 245); - --sidebar-accent-foreground: oklch(0.25 0.03 252); + --sidebar-accent: oklch(0.95 0.025 245); + --sidebar-accent-foreground: oklch(0.22 0.03 252); - --sidebar-border: oklch(0.86 0.02 250); - --sidebar-ring: oklch(0.75 0.1 245); + --sidebar-border: oklch(0.78 0.03 250); + --sidebar-ring: oklch(0.67 0.15 245); } [data-theme='dark'] { diff --git a/apps/web/src/app/main.tsx b/apps/web/src/app/main.tsx new file mode 100644 index 0000000..721d7ac --- /dev/null +++ b/apps/web/src/app/main.tsx @@ -0,0 +1,22 @@ +import { StrictMode } from 'react'; +import ReactDOM from 'react-dom/client'; +import { RouterProvider } from '@tanstack/react-router'; +import { makeRouterContext } from '@/router/router-context'; +import { makeRouter } from '@/router/router'; +import { AppProviders } from '@/providers/app-provider'; +import '@/app/index.css'; + +async function bootstrap() { + const context = makeRouterContext(); + const router = makeRouter(context); + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + ); +} + +bootstrap(); diff --git a/apps/web/src/app/root-layout.tsx b/apps/web/src/app/root-layout.tsx new file mode 100644 index 0000000..5775aae --- /dev/null +++ b/apps/web/src/app/root-layout.tsx @@ -0,0 +1,30 @@ +import { AppRouterProviders } from '@/providers/app-router-provider'; +import { GeneralHeader } from '@/features/shared/components/custom/general-header'; +import { Outlet, useMatches } from '@tanstack/react-router'; + +export function RootLayout() { + const matches = useMatches(); + // Don't show GeneralHeader on /editor/:projectId routes + const isEditorProjectPage = matches.some( + match => match.routeId === '/project/$projectId' + ); + + return ( + +
+ {!isEditorProjectPage && } +
+ +
+ + {/* */} +
+
+ ); +} diff --git a/apps/web/src/components/404/404.tsx b/apps/web/src/components/404/404.tsx deleted file mode 100644 index d8e0fb9..0000000 --- a/apps/web/src/components/404/404.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Page404() { - return
404 - Page Not Found
; -} diff --git a/apps/web/src/components/device/connect.tsx b/apps/web/src/components/device/connect.tsx deleted file mode 100644 index 3bca805..0000000 --- a/apps/web/src/components/device/connect.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Unplug } from 'lucide-react'; -import { ConnectionSelector } from './connection-selector'; -import { useTerminal } from '@/hooks/terminal-store'; -import { useJacProject } from '@/providers/jac-project-provider'; - -export function JacConnection() { - const terminal = useTerminal(); - const { device, setDevice } = useJacProject(); - - return ( -
-
- -
-

No device connected

-

- Connect your Jaculus device to get started -

-
- -
-
- ); -} diff --git a/apps/web/src/components/device/connection-selector.tsx b/apps/web/src/components/device/connection-selector.tsx deleted file mode 100644 index 8807908..0000000 --- a/apps/web/src/components/device/connection-selector.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { AlertCircle } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { enqueueSnackbar } from 'notistack'; -import { useJacProject } from '@/providers/jac-project-provider'; -import { - connectDevice, - getAvailableConnectionTypes, - type ConnectionType, -} from '@/lib/device/connection'; -import { type AddToTerminal } from '@/hooks/terminal-store'; -import type { JacDevice } from '@jaculus/device'; - -interface ConnectionSelectorProps { - onConnect?: () => void; - className?: string; - oneLine?: boolean; - defaultConnection?: ConnectionType; - addToTerminal: AddToTerminal; - device: JacDevice | null; - setDevice: (device: JacDevice | null) => void; -} - -export function ConnectionSelector({ - onConnect, - className, - oneLine = true, - defaultConnection = 'serial', - addToTerminal, - setDevice, -}: ConnectionSelectorProps) { - const { device, project } = useJacProject(); - - const availableConnections = getAvailableConnectionTypes(); - - // Function to get connection from URL - const getConnectionFromUrl = (): ConnectionType | undefined => { - if (typeof window === 'undefined') return undefined; - - const urlParams = new URLSearchParams(window.location.search); - const connectionFromUrl = urlParams.get('connection') as ConnectionType; - - // Validate that the connection from URL is available - if ( - connectionFromUrl && - availableConnections.some(conn => conn.type === connectionFromUrl) - ) { - return connectionFromUrl; - } - - return undefined; - }; - - // Function to save connection to URL - const saveConnectionToUrl = (connection: ConnectionType | undefined) => { - if (typeof window === 'undefined') return; - - const url = new URL(window.location.href); - - if (connection) { - url.searchParams.set('connection', connection); - } else { - url.searchParams.delete('connection'); - } - - // Update URL without triggering a page reload - window.history.replaceState({}, '', url.toString()); - }; - - // Initialize selected connection with priority: URL > default > first available - const initializeConnection = (): ConnectionType | undefined => { - const urlConnection = getConnectionFromUrl(); - if (urlConnection) return urlConnection; - - if ( - defaultConnection && - availableConnections.some(conn => conn.type === defaultConnection) - ) { - return defaultConnection; - } - - return availableConnections[0]?.type; - }; - - const [selectedConnection, setSelectedConnection] = useState< - ConnectionType | undefined - >(initializeConnection()); - const [isConnecting, setIsConnecting] = useState(false); - const [error, setError] = useState(null); - - // Save to URL when selection changes - useEffect(() => { - saveConnectionToUrl(selectedConnection); - }, [selectedConnection]); - - // Handle selection change - const handleConnectionChange = (value: string) => { - const connection = value as ConnectionType; - setSelectedConnection(connection); - }; - - async function handleConnect() { - if (!selectedConnection) return; - - setIsConnecting(true); - setError(null); - - try { - const newDevice = await connectDevice( - selectedConnection, - project, - addToTerminal - ); - setDevice(newDevice); - onConnect?.(); - enqueueSnackbar('Device connected successfully', { variant: 'success' }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to connect'; - setError(errorMessage); - console.error('Connection failed:', error); - enqueueSnackbar(`Connection failed`, { variant: 'error' }); - } finally { - setIsConnecting(false); - } - } - - if (device) { - return; - } - - // No connections available - if (availableConnections.length === 0) { - return ( -
- - No connection methods available -
- ); - } - - const selectElement = ( - - ); - - const connectButton = ( - - ); - - const errorElement = error && ( -
- - {error} -
- ); - - if (oneLine) { - return ( -
- {selectElement} - {connectButton} -
- ); - } - - return ( -
-
- {selectElement} - {connectButton} - {errorElement} -
-
- ); -} diff --git a/apps/web/src/components/editor/header-actions.tsx b/apps/web/src/components/editor/header-actions.tsx deleted file mode 100644 index 6fcb954..0000000 --- a/apps/web/src/components/editor/header-actions.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { Upload } from 'lucide-react'; -import { ConnectionSelector } from '../device/connection-selector'; -import type { AddToTerminal } from '@/hooks/terminal-store'; -import type { JacDevice } from '@jaculus/device'; - -interface EditorHeaderActionsProps { - onBuildFlashMonitor?: () => void; - addToTerminal: AddToTerminal; - device: JacDevice | null; - setDevice: (device: JacDevice | null) => void; -} - -export function EditorHeaderActions({ - onBuildFlashMonitor: onBuildFlashMonitor, - addToTerminal, - device, - setDevice, -}: EditorHeaderActionsProps) { - return ( - <> - - - - ); -} diff --git a/apps/web/src/components/editor/panels/blockly/index.tsx b/apps/web/src/components/editor/panels/blockly/index.tsx deleted file mode 100644 index 1c1c650..0000000 --- a/apps/web/src/components/editor/panels/blockly/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useJacProject } from '@/providers/jac-project-provider'; -import { useTheme } from '@/providers/theme-provider'; -import { JaclyEditor } from '@jaculus/jacly/editor'; -import { JaclyBlocks } from '@jaculus/jacly/blocks'; -import './index.css'; -import { fs } from '@zenfs/core'; -import { getProjectFsRoot } from '@/lib/projects/project-manager'; -import type { FSInterface } from '@jaculus/project/fs'; -import { useEffect } from 'react'; -import { enqueueSnackbar } from 'notistack'; - -export function BlocklyEditorPanel() { - const { themeNormalized } = useTheme(); - const { projectInstance, project } = useJacProject(); - const jaclyBlocks = new JaclyBlocks( - getProjectFsRoot(project.id), - fs as unknown as FSInterface, - projectInstance.getJacLyFiles() - ); - - // on load install dependencies using - useEffect(() => { - projectInstance.install().then(() => { - enqueueSnackbar('Project dependencies installed.', { variant: 'info' }); - }); - }, [projectInstance]); - - return ; -} diff --git a/apps/web/src/components/editor/panels/file-explorer/index.tsx b/apps/web/src/components/editor/panels/file-explorer/index.tsx deleted file mode 100644 index 5e294ac..0000000 --- a/apps/web/src/components/editor/panels/file-explorer/index.tsx +++ /dev/null @@ -1,577 +0,0 @@ -import { useEffect, useState, useRef, type JSX, useCallback } from 'react'; -import { Folder, FolderOpen, ChevronRight, ChevronDown } from 'lucide-react'; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from '@/components/ui/context-menu'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { cn } from '@/lib/utils'; -import { enqueueSnackbar } from 'notistack'; -import path from 'path'; -import type { JaclyProject } from '@/lib/projects/project-manager'; -import { fs } from '@zenfs/core'; -import { getFileIcon } from './file-helper'; -import logger from '@/lib/logger'; -import { useEditor } from '@/providers/editor-provider'; - -const fsp = fs.promises; - -export interface FileSystemItem { - name: string; - path: string; - isDirectory: boolean; - children?: FileSystemItem[]; -} - -interface FileExplorerProps { - project: JaclyProject; -} - -export function FileExplorerPanel({ project }: FileExplorerProps) { - const [fileTree, setFileTree] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedItem, setSelectedItem] = useState(null); - const [renameDialogOpen, setRenameDialogOpen] = useState(false); - const [renameValue, setRenameValue] = useState(''); - const [contextItem, setContextItem] = useState(null); - const [expandedFolders, setExpandedFolders] = useState>( - new Set() - ); - const initialLoadRef = useRef(true); - const watchTimeoutRef = useRef(null); - const { addPanelSourceCode } = useEditor(); - - const sortItems = useCallback((items: FileSystemItem[]): FileSystemItem[] => { - return items.sort((a, b) => { - if (a.isDirectory && !b.isDirectory) return -1; - if (!a.isDirectory && b.isDirectory) return 1; - return a.name.localeCompare(b.name); - }); - }, []); - - const waitForDirectory = async ( - dirPath: string, - maxAttempts = 10, - delayMs = 100 - ): Promise => { - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - await fsp.stat(dirPath); - return true; - } catch { - if (attempt < maxAttempts - 1) { - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - } - return false; - }; - - const buildFileTreeForEffect = useCallback( - async (dirPath: string): Promise => { - if (!fsp) return []; - - try { - const items = await fsp.readdir(dirPath); - const treeItems: FileSystemItem[] = []; - - for (const item of items) { - const itemPath = `${dirPath}/${item}`; - - try { - const stat = await fsp.stat(itemPath); - const isDirectory = stat.isDirectory(); - - treeItems.push({ - name: item, - path: itemPath, - isDirectory, - children: isDirectory ? [] : undefined, - }); - } catch (error) { - console.warn(`Error checking ${itemPath}:`, error); - } - } - - return sortItems(treeItems); - } catch (error) { - console.error(`Error reading directory ${dirPath}:`, error); - return []; - } - }, - [sortItems] - ); - - const applyFolderStructure = useCallback( - ( - items: FileSystemItem[], - expandedSet: Set - ): Promise => { - // Use an inner recursive function so we don't reference the outer - // `applyFolderStructure` variable while it's being defined (avoids - // "accessed before it is declared" errors). - const inner = async ( - currentItems: FileSystemItem[], - set: Set - ): Promise => { - const processedItems: FileSystemItem[] = []; - - for (const item of currentItems) { - const processedItem: FileSystemItem = { - ...item, - }; - - if (item.isDirectory && set.has(item.path) && fsp) { - try { - const children = await buildFileTreeForEffect(item.path); - processedItem.children = await inner(children, set); - } catch (error) { - console.error(`Error loading children for ${item.path}:`, error); - processedItem.children = []; - } - } else if (item.isDirectory) { - processedItem.children = []; - } - - processedItems.push(processedItem); - } - - return processedItems; - }; - - return inner(items, expandedSet); - }, - [buildFileTreeForEffect] - ); - - const buildFileTree = useCallback( - async (dirPath: string): Promise => { - try { - const items = await fsp.readdir(dirPath); - const treeItems: FileSystemItem[] = []; - - for (const item of items) { - const itemPath = path.join(dirPath, item); - - try { - const isDirectory = (await fsp.stat(itemPath)).isDirectory(); - treeItems.push({ - name: item, - path: itemPath, - isDirectory, - children: isDirectory ? [] : undefined, - }); - } catch (error) { - console.warn(`Error checking ${itemPath}:`, error); - } - } - - return sortItems(treeItems); - } catch (error) { - console.error(`Error reading directory ${dirPath}:`, error); - return []; - } - }, - [sortItems] - ); - - // Load initial file tree - useEffect(() => { - const loadFileTree = async () => { - setLoading(true); - try { - const projectRoot = `/${project.id}`; - - const isReady = await waitForDirectory(projectRoot); - if (!isReady) { - logger.error(`Directory ${projectRoot} not ready after retries`); - setLoading(false); - return; - } - - const tree = await buildFileTreeForEffect(projectRoot); - - // Initialize expandedSet from project.folderStructure or existing expandedFolders - let expandedSet = expandedFolders; - if (project.folderStructure && initialLoadRef.current) { - // Convert folderStructure Record to Set - expandedSet = new Set(); - Object.entries(project.folderStructure).forEach( - ([path, isExpanded]) => { - if (isExpanded) { - expandedSet.add(path); - } - } - ); - } - - const restoredTree = await applyFolderStructure(tree, expandedSet); - setFileTree(restoredTree); - setExpandedFolders(expandedSet); - initialLoadRef.current = false; - } catch (error) { - console.error('Error loading file tree:', error); - } - setLoading(false); - }; - - const watchFileSystem = async () => { - try { - const projectRoot = `/${project.id}`; - for await (const change of fsp.watch(projectRoot, { - recursive: true, - })) { - if (change.eventType === 'change' || change.eventType === 'rename') { - // File system has changed, refresh the tree with debounce - if (watchTimeoutRef.current) { - clearTimeout(watchTimeoutRef.current); - } - - watchTimeoutRef.current = setTimeout(async () => { - const tree = await buildFileTreeForEffect(projectRoot); - const restoredTree = await applyFolderStructure( - tree, - expandedFolders - ); - setFileTree(restoredTree); - }, 300); // Debounce for 300ms - } - } - } catch (error) { - console.error('Error watching file system:', error); - } - }; - - initialLoadRef.current = true; - loadFileTree(); - watchFileSystem(); - - return () => { - if (watchTimeoutRef.current) { - clearTimeout(watchTimeoutRef.current); - } - }; - }, [ - applyFolderStructure, - buildFileTreeForEffect, - expandedFolders, - project.folderStructure, - project.id, - ]); - - const updateTreeItemExpanded = ( - items: FileSystemItem[], - targetPath: string - ): FileSystemItem[] => { - return items.map(treeItem => { - if (treeItem.path === targetPath) { - return { - ...treeItem, - children: - !expandedFolders.has(targetPath) && - (!treeItem.children || treeItem.children.length === 0) - ? [] - : treeItem.children, - }; - } - if (treeItem.children) { - return { - ...treeItem, - children: updateTreeItemExpanded(treeItem.children, targetPath), - }; - } - return treeItem; - }); - }; - - const addChildrenToTreeItem = ( - items: FileSystemItem[], - targetPath: string, - children: FileSystemItem[] - ): FileSystemItem[] => { - return items.map(treeItem => { - if (treeItem.path === targetPath) { - return { ...treeItem, children }; - } - if (treeItem.children) { - return { - ...treeItem, - children: addChildrenToTreeItem( - treeItem.children, - targetPath, - children - ), - }; - } - return treeItem; - }); - }; - - // Toggle directory expansion - const toggleDirectory = async (item: FileSystemItem) => { - if (!item.isDirectory) return; - - const newTree = updateTreeItemExpanded(fileTree, item.path); - setFileTree(newTree); - - // Update expanded folders set - const newExpandedFolders = new Set(expandedFolders); - if (expandedFolders.has(item.path)) { - newExpandedFolders.delete(item.path); - } else { - newExpandedFolders.add(item.path); - } - setExpandedFolders(newExpandedFolders); - - // Load children if expanding and not loaded yet - if ( - !expandedFolders.has(item.path) && - (!item.children || item.children.length === 0) - ) { - const children = await buildFileTree(item.path); - const finalTree = addChildrenToTreeItem(newTree, item.path, children); - setFileTree(finalTree); - } - }; - - // Handle context menu actions - const handleRename = () => { - if (contextItem) { - setRenameValue(contextItem.name); - setRenameDialogOpen(true); - } - }; - - const handleDelete = async () => { - if (!contextItem) return; - - try { - if (contextItem.isDirectory) { - await fsp.rmdir(contextItem.path); - } else { - await fsp.rm(contextItem.path); - } - - // Refresh the parent directory - const tree = await buildFileTree(`/${project.id}`); - setFileTree(tree); - setContextItem(null); - - enqueueSnackbar( - `${contextItem.isDirectory ? 'Folder' : 'File'} deleted successfully`, - { variant: 'success' } - ); - } catch (error) { - console.error(`Error deleting ${contextItem.path}:`, error); - } - }; - - const handleOpen = async (item: FileSystemItem) => { - if (item.isDirectory) { - toggleDirectory(item); - } else { - addPanelSourceCode(item.path); - } - }; - - const handleShowInTerminal = (item: FileSystemItem) => { - const command = item.isDirectory ? `cd ${item.path}` : `cat ${item.path}`; - console.log(`Terminal command: ${command}`); - }; - - const confirmRename = async () => { - const renameValueTrimmed = renameValue.trim(); - - if (!contextItem || !renameValueTrimmed) return; - - try { - const newPath = path.join( - path.dirname(contextItem.path), - renameValueTrimmed - ); - await fsp.rename(contextItem.path, newPath); - - // Refresh the parent directory - const tree = await buildFileTree(`/${project.id}`); - setFileTree(tree); - - setRenameDialogOpen(false); - setContextItem(null); - enqueueSnackbar( - `${contextItem.isDirectory ? 'Folder' : 'File'} renamed to ${renameValueTrimmed}`, - { variant: 'success' } - ); - } catch (error) { - console.error(`Error renaming ${contextItem.path}:`, error); - } - }; - - // Render file tree recursively - function renderFileTree(items: FileSystemItem[], depth = 0): JSX.Element[] { - return items.map(item => ( -
- - -
{ - setSelectedItem(item.path); - await handleOpen(item); - }} - > - {item.isDirectory && ( - - )} - - {item.isDirectory ? ( - expandedFolders.has(item.path) ? ( - - ) : ( - - ) - ) : ( - getFileIcon(item.name, false) - )} - - {item.name} -
-
- - - await handleOpen(item)}> - {item.isDirectory ? 'Open' : 'Open File'} - - handleShowInTerminal(item)}> - Show in Terminal - - - { - setContextItem(item); - handleRename(); - }} - > - Rename - - { - setContextItem(item); - handleDelete(); - }} - className="text-red-600" - > - Delete - - -
- - {item.isDirectory && - expandedFolders.has(item.path) && - item.children && ( -
{renderFileTree(item.children, depth + 1)}
- )} -
- )); - } - - if (loading) { - return ( -
- Loading files... -
- ); - } - - return ( -
-
- {fileTree.length === 0 ? ( -
- No files found -
- ) : ( - <> - - {renderFileTree(fileTree)} - - )} -
- - {/* Rename Dialog */} - - - - - Rename {contextItem?.isDirectory ? 'Folder' : 'File'} - - -
- setRenameValue(e.target.value)} - placeholder="Enter new name" - onKeyDown={e => { - if (e.key === 'Enter') { - confirmRename(); - } - }} - /> -
- - - - -
-
-
- ); -} diff --git a/apps/web/src/components/editor/panels/generated-code.tsx b/apps/web/src/components/editor/panels/generated-code.tsx deleted file mode 100644 index 8c8d6f9..0000000 --- a/apps/web/src/components/editor/panels/generated-code.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { getBuildPath } from '@/lib/projects'; -import path from 'path'; -import { CodePanel } from './code'; -import type { JacProject } from '@/components/projects/projects-list'; - -export function GeneratedCodePanel({ project }: { project: JacProject }) { - const generatedCodePath = path.join(getBuildPath(project), 'index.js'); - return ( - - ); -} diff --git a/apps/web/src/components/editor/panels/jaculus/index.tsx b/apps/web/src/components/editor/panels/jaculus/index.tsx deleted file mode 100644 index 0d1e8f3..0000000 --- a/apps/web/src/components/editor/panels/jaculus/index.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { JacConnection } from '@/components/device/connect'; -import { ConnectionSelector } from '@/components/device/connection-selector'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { useJacProject } from '@/providers/jac-project-provider'; -import { useTerminal } from '@/hooks/terminal-store'; -import { - Usb, - Unplug, - Settings, - Play, - Square, - Terminal as TerminalIcon, - Zap, - Upload, - Monitor, - Eye, - Wifi, -} from 'lucide-react'; -import type { JaclyProject } from '@/lib/projects/project-manager'; -import { jacCompile } from '@/lib/device/jaculus'; - -interface JaculusPanelProps { - project: JaclyProject; -} - -export function JaculusPanel({ project }: JaculusPanelProps) { - const { device, setDevice } = useJacProject(); - const terminal = useTerminal(); - - const handleStop = async () => { - if (device) { - try { - await device.controller.stop(); - } catch (error) { - console.error('Failed to stop program:', error); - } - } - }; - - if (!device) { - return ; - } - - return ( -
-
-

Jaculus Control Panel

-
-
- Connected -
-
- - {/* Connection */} - - - - - Connection - - - -
- - - {device && ( -
- -
- )} -
-
-
- - {/* Build & Flash */} - - - - - Build & Flash - - - -
- - - -
-
-
- - {/* Device Control */} - - - - - Device Control - - - -
- - - - - - -
-
-
- - {/* WiFi Configuration */} - - - - - WiFi Configuration - - - -
- - -
-
-
- - {/* Settings */} - - - - - Settings - - - -
- - - -
-
-
-
- ); -} diff --git a/apps/web/src/components/editor/panels/packages.tsx b/apps/web/src/components/editor/panels/packages.tsx deleted file mode 100644 index b825867..0000000 --- a/apps/web/src/components/editor/panels/packages.tsx +++ /dev/null @@ -1,437 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { useJacProject } from '@/providers/jac-project-provider'; -import { - Package, - Plus, - Trash2, - RefreshCw, - Search, - Loader2, - ChevronsUpDown, - Check, -} from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { enqueueSnackbar } from 'notistack'; - -export function PackagesPanel() { - const { projectInstance } = useJacProject(); - const [libsRegistry, setLibsRegistry] = useState([]); - const [libsInstalled, setLibsInstalled] = useState>( - {} - ); - const [availableVersions, setAvailableVersions] = useState([]); - const [installing, setInstalling] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedLibrary, setSelectedLibrary] = useState( - undefined - ); - const [selectedVersion, setSelectedVersion] = useState( - undefined - ); - const [openLibraryCombo, setOpenLibraryCombo] = useState(false); - const [openVersionCombo, setOpenVersionCombo] = useState(false); - const [error, setError] = useState(null); - - const loadLibraries = useCallback(async () => { - try { - setError(null); - if (projectInstance?.registry) { - const registryLibs = await projectInstance.registry.list(); - setLibsRegistry(registryLibs || []); - } - if (projectInstance) { - const installed = await projectInstance.installedLibraries(); - setLibsInstalled(installed); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load libraries'); - console.error('Error loading libraries:', err); - } - }, [projectInstance]); - - const fetchVersions = useCallback( - async (libraryName: string) => { - try { - setError(null); - if (projectInstance?.registry) { - const versions = - await projectInstance.registry.listVersions(libraryName); - setAvailableVersions(versions); - setSelectedVersion(versions.length > 0 ? versions[0] : ''); - } - } catch (err) { - setError( - err instanceof Error - ? err.message - : `Failed to fetch versions for ${libraryName}` - ); - console.error('Error fetching versions:', err); - setAvailableVersions([]); - } - }, - [projectInstance] - ); - - // Load libraries on mount - useEffect(() => { - loadLibraries(); - }, [loadLibraries]); - - // Fetch versions when library is selected - useEffect(() => { - if (selectedLibrary && projectInstance?.registry) { - fetchVersions(selectedLibrary); - } - }, [selectedLibrary, projectInstance, fetchVersions]); - - const handleInstall = useCallback(async () => { - try { - setInstalling(true); - setError(null); - if (projectInstance) { - await projectInstance.install(); - enqueueSnackbar('Packages installed successfully', { - variant: 'success', - }); - await loadLibraries(); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Installation failed'); - console.error('Error during installation:', err); - } finally { - setInstalling(false); - } - }, [projectInstance, loadLibraries]); - - const handleAddLibrary = useCallback(async () => { - if (!selectedLibrary) { - setError('Please select a library'); - return; - } - - try { - setError(null); - if (selectedVersion) { - await projectInstance.addLibraryVersion( - selectedLibrary.trim(), - selectedVersion.trim() - ); - enqueueSnackbar(`Added ${selectedLibrary}@${selectedVersion}`, { - variant: 'success', - }); - } else { - await projectInstance.addLibrary(selectedLibrary.trim()); - enqueueSnackbar(`Added ${selectedLibrary} (latest)`, { - variant: 'success', - }); - } - setSelectedLibrary(undefined); - setSelectedVersion(undefined); - await loadLibraries(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add library'); - console.error('Error adding library:', err); - } - }, [selectedLibrary, selectedVersion, projectInstance, loadLibraries]); - - const handleRemoveLibrary = useCallback( - async (libraryName: string) => { - if (!confirm(`Remove ${libraryName}?`)) return; - - try { - setError(null); - if (projectInstance) { - await projectInstance.removeLibrary(libraryName); - enqueueSnackbar(`Removed ${libraryName}`, { variant: 'info' }); - await loadLibraries(); - } - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to remove library' - ); - console.error('Error removing library:', err); - } - }, - [projectInstance, loadLibraries] - ); - - const filteredLibraries = libsInstalled - ? Object.entries(libsInstalled).filter(([name]) => - name.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : []; - - return ( -
- {/* Header */} -
-
- -

- Packages -

-
- - {/* Install/Update Button */} - -
- - {/* Error Message */} - {error && ( -
- {error} -
- )} - - {/* Scrollable Content */} -
- {/* Add New Package Section */} -
-
- -

- Add New Package -

-
- -
- {/* Library Combobox */} -
- - - - - - - - - - - No packages found - - - {libsRegistry - .filter( - lib => !libsInstalled || !(lib in libsInstalled) - ) - .map(lib => ( - { - setSelectedLibrary( - currentValue === selectedLibrary - ? '' - : currentValue - ); - setSelectedVersion('latest'); - setOpenLibraryCombo(false); - }} - className="text-slate-900 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-700" - > - - {lib} - - ))} - - - - - -
- - {/* Version Combobox */} -
- - - - - - - - - - - No versions found - - - {availableVersions.map(version => ( - { - setSelectedVersion( - currentValue === selectedVersion - ? '' - : currentValue - ); - setOpenVersionCombo(false); - }} - className="text-slate-900 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-700" - > - - {version} - - ))} - - - - - -
- - {/* Add Button */} - -
-
- - {/* Divider */} -
- - {/* Installed Packages Section */} -
-
- -

- Installed Packages ({filteredLibraries.length}) -

-
- -
- - setSearchQuery(e.target.value)} - className="pl-9 bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-700 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500" - /> -
- - {filteredLibraries.length === 0 ? ( -
- {libsInstalled && Object.keys(libsInstalled).length === 0 - ? 'No packages installed yet' - : 'No packages match your search'} -
- ) : ( -
- {filteredLibraries.map(([name, version]) => ( -
-
- -
-
- {name} -
-
- v{version} -
-
-
- -
- ))} -
- )} -
-
-
- ); -} diff --git a/apps/web/src/components/editor/panels/terminal.tsx b/apps/web/src/components/editor/panels/terminal.tsx deleted file mode 100644 index 8099a52..0000000 --- a/apps/web/src/components/editor/panels/terminal.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { Button } from '@/components/ui/button'; -import { - useTerminal, - getStreamInfo, - type TerminalStreamType, -} from '@/hooks/terminal-store'; -import { Trash2, Filter } from 'lucide-react'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuCheckboxItem, - DropdownMenuSeparator, - DropdownMenuLabel, -} from '@/components/ui/dropdown-menu'; - -const formatTime = (date: Date) => { - return date.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - fractionalSecondDigits: 3, - }); -}; - -export default function TerminalPanel() { - const terminal = useTerminal(); - const scrollRef = useRef(null); - - // Auto-scroll to bottom when new entries are added - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [terminal.filteredEntries]); - - const streamTypes: TerminalStreamType[] = [ - 'serial-in', - 'serial-out', - 'compiler-stdout', - 'compiler-stderr', - 'runtime-stdout', - 'runtime-stderr', - 'system', - 'debug', - ]; - - const visibleCount = Object.values(terminal.visibleTypes).filter( - Boolean - ).length; - const allVisible = visibleCount === streamTypes.length; - const noneVisible = visibleCount === 0; - - const toggleAll = () => { - const newState = !allVisible; - streamTypes.forEach(type => { - terminal.setTypeVisible(type, newState); - }); - }; - - return ( -
- {/* Header with controls */} -
-
-

Terminal

- - ({terminal.filteredEntries.length} / {terminal.entries.length}) - -
- -
- {/* Filter dropdown */} - - - - - - Stream Filters - - - - {allVisible ? 'Hide All' : 'Show All'} - - - - - {streamTypes.map(type => { - const info = getStreamInfo(type); - const isVisible = terminal.visibleTypes[type]; - - return ( - - terminal.setTypeVisible(type, !!checked) - } - > -
-
e.type === type) - ?.color || '#6b7280', - }} - /> -
- {info.name} - - {info.description} - -
-
- - ); - })} - - - - {/* Clear button */} - -
-
- - {/* Terminal content */} -
- {terminal.filteredEntries.length === 0 ? ( -
- {noneVisible ? 'No streams selected' : 'No messages'} -
- ) : ( - terminal.filteredEntries.map(entry => ( -
- - {formatTime(entry.timestamp)} - - - {getStreamInfo(entry.type).name} - - - {entry.content} - -
- )) - )} -
-
- ); -} diff --git a/apps/web/src/components/editor/panels/wokwi.tsx b/apps/web/src/components/editor/panels/wokwi.tsx deleted file mode 100644 index eb4255b..0000000 --- a/apps/web/src/components/editor/panels/wokwi.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useRef } from 'react'; -import { useJacProject } from '@/providers/jac-project-provider'; - -export function WokwiPanel() { - const { device } = useJacProject(); - const iframeRef = useRef(null); - - // useEffect(() => { - // // Load code from localStorage if available - // const path = getProjectFsRoot(project.id) + '/code.js'; - // if (!fs.existsSync(path)) { - // fs.writeFileSync(path, defaultJsCode, 'utf-8'); - // } - - // const handleMessage = async (event: MessageEvent) => { - // if (event.data && event.data.port) { - // console.log('Received MessagePort from iframe'); - - // const transport = new MessagePortTransport(event.data.port); - // const client = new APIClient(transport); - // clientRef.current = client; - - // await client.connected; - - // client.onConnected = async helloMessage => { - // console.log('Wokwi client connected', helloMessage); - // await loadDiagram(); - - // const firmwareResponse = await fetch('/bin/jaculus.uf2'); - // const jaculusBinContent = await firmwareResponse.arrayBuffer(); - // await client.fileUpload( - // 'jaculus.uf2', - // new Uint8Array(jaculusBinContent) - // ); - - // window.dispatchEvent( - // new CustomEvent('wokwi-port-available', { - // detail: { port: event.data.port }, - // }) - // ); - // }; - - // client.listen( - // 'serial-monitor:data', - // (event: APIEvent) => { - // const rawBytes = new Uint8Array(event.payload.bytes); - // const text = new TextDecoder().decode(rawBytes); - // setOutput(prev => prev + text); - // } - // ); - - // client.listen('ui:clickStart', () => { - // handleStart(); - // }); - - // client.onEvent = event => { - // console.log('Wokwi event:', event); - // }; - - // client.onError = error => { - // console.error('Wokwi error:', error); - // }; - // } - // }; - - // window.addEventListener('message', handleMessage); - // console.log('Wokwi ESP32 MicroPython script loaded'); - - // return () => { - // window.removeEventListener('message', handleMessage); - // }; - // }, []); - - // const loadDiagram = async () => { - // if (clientRef.current) { - // const client = clientRef.current; - - // const diagramPath = getProjectFsRoot(project.id) + '/diagram.json'; - // if (!fs.existsSync(diagramPath)) { - // fs.writeFileSync(diagramPath, diagramDefault, 'utf-8'); - // } - // const diagramFromFs = fs.readFileSync(diagramPath, 'utf-8'); - // await client.fileUpload('diagram.json', diagramFromFs); - // } - // }; - - // const handleStart = async () => { - // if (clientRef.current) { - // const client = clientRef.current; - // await client.serialMonitorListen(); - // await loadDiagram(); - - // await client.simStart({ - // firmware: 'jaculus.uf2', - // }); - // } - // }; - - if (!device) { - return ( -
-

- No device connected. Please connect a Wokwi ESP32 device to use the - simulator. -

-
- ); - } - - return ( -
- -
- ); -} diff --git a/apps/web/src/components/locale/locale-switcher.tsx b/apps/web/src/components/locale/locale-switcher.tsx deleted file mode 100644 index 75cbdc4..0000000 --- a/apps/web/src/components/locale/locale-switcher.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// import { Button } from '@/components/ui/button'; -// import { -// DropdownMenu, -// DropdownMenuContent, -// DropdownMenuItem, -// DropdownMenuTrigger, -// } from '@/components/ui/dropdown-menu'; - -// export function LocaleSwitcher() { -// const { locale, availableLocales, setLocale } = useLocale(); - -// return ( -// -// -// -// -// -// {availableLocales.map(localeItem => ( -// setLocale(localeItem)} -// > -// {getLocaleName(localeItem)} -// -// ))} -// -// -// ); -// } diff --git a/apps/web/src/components/projects/new-project-button.tsx b/apps/web/src/components/projects/new-project-button.tsx deleted file mode 100644 index 38d0cb5..0000000 --- a/apps/web/src/components/projects/new-project-button.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { useNavigate } from '@tanstack/react-router'; - -export function NewProjectButton() { - const navigate = useNavigate(); - - return ( - - ); -} diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx deleted file mode 100644 index fab34e1..0000000 --- a/apps/web/src/components/ui/accordion.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; -import * as AccordionPrimitive from '@radix-ui/react-accordion'; -import { ChevronDown } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const Accordion = AccordionPrimitive.Root; - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AccordionItem.displayName = 'AccordionItem'; - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180', - className - )} - {...props} - > - {children} - - - -)); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
{children}
-
-)); -AccordionContent.displayName = AccordionPrimitive.Content.displayName; - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx deleted file mode 100644 index 5788dd1..0000000 --- a/apps/web/src/components/ui/button.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '@/lib/utils'; - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: - 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', - outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', - secondary: - 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: - 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', - link: 'text-primary underline-offset-4 hover:underline', - }, - size: { - default: 'h-9 px-4 py-1 has-[>svg]:px-3', - sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', - lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', - icon: 'size-9', - 'icon-sm': 'size-8', - 'icon-lg': 'size-10', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - } -); - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean; - }) { - const Comp = asChild ? Slot : 'button'; - - return ( - - ); -} - -export { Button, buttonVariants }; diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx deleted file mode 100644 index 4a59372..0000000 --- a/apps/web/src/components/ui/command.tsx +++ /dev/null @@ -1,153 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { type DialogProps } from '@radix-ui/react-dialog'; -import { Command as CommandPrimitive } from 'cmdk'; -import { Search } from 'lucide-react'; - -import { cn } from '@/lib/utils'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; - -const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Command.displayName = CommandPrimitive.displayName; - -const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - - - - {children} - - - - ); -}; - -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)); - -CommandInput.displayName = CommandPrimitive.Input.displayName; - -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandList.displayName = CommandPrimitive.List.displayName; - -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)); - -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -CommandShortcut.displayName = 'CommandShortcut'; - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -}; diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx deleted file mode 100644 index f1d9b69..0000000 --- a/apps/web/src/components/ui/context-menu.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import * as React from 'react'; -import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const ContextMenu = ContextMenuPrimitive.Root; - -const ContextMenuTrigger = ContextMenuPrimitive.Trigger; - -const ContextMenuGroup = ContextMenuPrimitive.Group; - -const ContextMenuPortal = ContextMenuPrimitive.Portal; - -const ContextMenuSub = ContextMenuPrimitive.Sub; - -const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; - -const ContextMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; - -const ContextMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; - -const ContextMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; - -const ContextMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; - -const ContextMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -ContextMenuCheckboxItem.displayName = - ContextMenuPrimitive.CheckboxItem.displayName; - -const ContextMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; - -const ContextMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; - -const ContextMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; - -const ContextMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -ContextMenuShortcut.displayName = 'ContextMenuShortcut'; - -export { - ContextMenu, - ContextMenuTrigger, - ContextMenuContent, - ContextMenuItem, - ContextMenuCheckboxItem, - ContextMenuRadioItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuGroup, - ContextMenuPortal, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuRadioGroup, -}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx deleted file mode 100644 index d2f3382..0000000 --- a/apps/web/src/components/ui/input.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -function Input({ className, type, ...props }: React.ComponentProps<'input'>) { - return ( - - ); -} - -export { Input }; diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx deleted file mode 100644 index a9ba6f3..0000000 --- a/apps/web/src/components/ui/popover.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import * as PopoverPrimitive from '@radix-ui/react-popover'; - -import { cn } from '@/lib/utils'; - -const Popover = PopoverPrimitive.Root; - -const PopoverTrigger = PopoverPrimitive.Trigger; - -const PopoverAnchor = PopoverPrimitive.Anchor; - -const PopoverContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - - -)); -PopoverContent.displayName = PopoverPrimitive.Content.displayName; - -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx deleted file mode 100644 index 8e6889c..0000000 --- a/apps/web/src/components/ui/select.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import * as React from 'react'; -import * as SelectPrimitive from '@radix-ui/react-select'; -import { Check, ChevronDown, ChevronUp } from 'lucide-react'; - -import { cn } from '@/lib/utils'; - -const Select = SelectPrimitive.Root; - -const SelectGroup = SelectPrimitive.Group; - -const SelectValue = SelectPrimitive.Value; - -const SelectTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - span]:line-clamp-1', - className - )} - {...props} - > - {children} - - - - -)); -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; - -const SelectScrollUpButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; - -const SelectScrollDownButton = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)); -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; - -const SelectContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, position = 'popper', ...props }, ref) => ( - - - - - {children} - - - - -)); -SelectContent.displayName = SelectPrimitive.Content.displayName; - -const SelectLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - -const SelectItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -SelectItem.displayName = SelectPrimitive.Item.displayName; - -const SelectSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; - -export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, -}; diff --git a/apps/web/src/components/editor/panels/code.tsx b/apps/web/src/features/code-editor/components/code-editor-basic.tsx similarity index 61% rename from apps/web/src/components/editor/panels/code.tsx rename to apps/web/src/features/code-editor/components/code-editor-basic.tsx index 5c3c390..80bc1b6 100644 --- a/apps/web/src/components/editor/panels/code.tsx +++ b/apps/web/src/features/code-editor/components/code-editor-basic.tsx @@ -1,51 +1,38 @@ -import { fs } from '@zenfs/core'; -import { useEffect, useState } from 'react'; +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { useTheme } from '@/features/theme/components/theme-provider'; import Editor from '@monaco-editor/react'; -import { useTheme } from '@/providers/theme-provider'; - -const fsp = fs.promises; +import { useEffect, useState } from 'react'; +import { inferLanguageFromPath } from '../lib/language'; -interface CodePanelProps { +interface CodeEditorReadOnlyProps { readonly filePath: string; readonly readOnly?: boolean; readonly ifNotExists: 'create' | 'loading' | 'error'; readonly loadingMessage?: string; } -function CodeLoadingSpinner({ loadingMessage }: { loadingMessage?: string }) { - return ( -
-
- {/* Animated spinner */} -
-
-
-
-

- {loadingMessage} -

-
-
- ); -} - -export function CodePanel({ +export function CodeEditorBasic({ filePath, readOnly = false, ifNotExists, loadingMessage = 'Loading file...', -}: CodePanelProps) { +}: CodeEditorReadOnlyProps) { + const { fsp, projectPath } = useActiveProject(); const { themeNormalized } = useTheme(); + const [code, setCode] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const fullPath = `${projectPath}/${filePath}`; + const readOnlyInternal = filePath.startsWith('build/') ? true : readOnly; + useEffect(() => { async function loadFile() { try { setLoading(true); setError(null); - const data = await fsp.readFile(filePath, 'utf-8'); + const data = await fsp.readFile(fullPath, 'utf-8'); setCode(data); } catch (error) { console.error('Error loading file:', error); @@ -54,7 +41,7 @@ export function CodePanel({ // Handle ifNotExists logic if (ifNotExists === 'create') { setCode(''); // Create empty file - await fsp.writeFile(filePath, '', 'utf-8').catch(err => { + await fsp.writeFile(fullPath, '', 'utf-8').catch(err => { console.error('Error creating file:', err); }); } else if (ifNotExists === 'error') { @@ -67,10 +54,10 @@ export function CodePanel({ async function watchFile() { try { - for await (const change of fsp.watch(filePath)) { + for await (const change of fsp.watch(fullPath)) { if (change.eventType === 'change') { // File has changed, reload it - const data = await fsp.readFile(filePath, 'utf-8'); + const data = await fsp.readFile(fullPath, 'utf-8'); setCode(data); } } @@ -81,18 +68,18 @@ export function CodePanel({ loadFile(); watchFile(); - }, [filePath, ifNotExists]); + }, [filePath, ifNotExists, fsp, fullPath]); async function handleEditorChange(value: string | undefined) { - if (value !== undefined && !readOnly) { + if (value !== undefined && !readOnlyInternal) { setCode(value); - await fsp.writeFile(filePath, value, 'utf-8'); + await fsp.writeFile(fullPath, value, 'utf-8'); } } // Show loading spinner when code is undefined or still loading if (code === undefined || loading) { - return ; + return
{loadingMessage}
; } // Show error state if there's an error and ifNotExists is 'error' @@ -108,16 +95,15 @@ export function CodePanel({
); } - return ( + ); +} diff --git a/apps/web/src/features/code-editor/components/index.tsx b/apps/web/src/features/code-editor/components/index.tsx new file mode 100644 index 0000000..044cf42 --- /dev/null +++ b/apps/web/src/features/code-editor/components/index.tsx @@ -0,0 +1,60 @@ +import Editor, { type OnMount } from '@monaco-editor/react'; +import { debounce } from '@/lib/utils/debouncer'; +import { useMemo, useRef } from 'react'; +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { inferLanguageFromPath } from '../lib/language'; + +interface CodeEditorProps { + readonly filePath?: string; + readonly readOnly?: boolean; +} + +export function CodeEditor({ filePath, readOnly = false }: CodeEditorProps) { + const { fsp, projectPath } = useActiveProject(); + const saveFile = useMemo( + () => + debounce(async (path: string, content: string) => { + try { + await fsp.writeFile(`${projectPath}/${path}`, content, 'utf-8'); + console.log(`File saved: ${path}`); + } catch (error) { + console.error('Error saving file:', error); + } + }, 500), + [projectPath, fsp] + ); + + const editorRef = useRef[0] | null>(null); + + function handleEditorChange(value: string | undefined) { + if (value && filePath) { + saveFile(filePath, value); + } + } + + const handleEditorMount: OnMount = editor => { + editorRef.current = editor; + }; + + if (!filePath) { + return
No file selected.
; + } + + return ( + + ); +} diff --git a/apps/web/src/features/code-editor/lib/language.ts b/apps/web/src/features/code-editor/lib/language.ts new file mode 100644 index 0000000..e493dd0 --- /dev/null +++ b/apps/web/src/features/code-editor/lib/language.ts @@ -0,0 +1,17 @@ +export function inferLanguageFromPath(path: string): string { + if (!path) return 'plaintext'; + const extension = path.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'ts': + case 'tsx': + return 'typescript'; + case 'js': + case 'jsx': + case 'jacly': + return 'javascript'; + case 'json': + return 'json'; + default: + return 'plaintext'; + } +} diff --git a/apps/web/src/features/code-editor/lib/loader.ts b/apps/web/src/features/code-editor/lib/loader.ts new file mode 100644 index 0000000..03ea6b6 --- /dev/null +++ b/apps/web/src/features/code-editor/lib/loader.ts @@ -0,0 +1,33 @@ +export interface FileEntry { + path: string; + content: string; +} + +export async function loadProjectFiles( + projectPath: string, + fsp: typeof import('fs').promises +): Promise { + const fileEntries: FileEntry[] = []; + + async function traverseDirectory(currentPath: string) { + const entries = await fsp.readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = `${currentPath}/${entry.name}`; + + if (entry.isDirectory()) { + await traverseDirectory(fullPath); + } else if (entry.isFile()) { + const content = await fsp.readFile(fullPath, 'utf-8'); + // Remove leading slash from relative path + const relativePath = fullPath + .replace(projectPath, '') + .replace(/^\//, ''); + fileEntries.push({ path: relativePath, content }); + } + } + } + + await traverseDirectory(projectPath); + return fileEntries; +} diff --git a/apps/web/src/features/code-editor/lib/project-indexer.ts b/apps/web/src/features/code-editor/lib/project-indexer.ts new file mode 100644 index 0000000..5e2b033 --- /dev/null +++ b/apps/web/src/features/code-editor/lib/project-indexer.ts @@ -0,0 +1,127 @@ +import { useMonaco } from '@monaco-editor/react'; +import { enqueueSnackbar } from 'notistack'; +import { inferLanguageFromPath } from './language'; + +export async function indexMonacoFiles( + monaco: ReturnType, + projectPath: string, + fsp: typeof import('fs').promises +) { + // console.log( + // 'Indexing Monaco files for project at:', + // projectPath, + // monaco, + // fsp + // ); + + if (!monaco || !projectPath || !fsp) return; + + // monaco.languages.typescript.typescriptDefaults.setCompilerOptions() + + monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true); + monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true); + + try { + const { loadProjectFiles } = await import( + '@/features/code-editor/lib/loader' + ); + const fileEntries = await loadProjectFiles(projectPath, fsp); + + fileEntries.forEach(({ path, content }) => { + // create valid Monaco URI + const uri = monaco.Uri.file(path); + // console.log('Indexing file in Monaco:', path, uri.toString(), content); + + const existingModel = monaco.editor.getModel(uri); + if (existingModel) { + existingModel.setValue(content); + } else { + monaco.editor.createModel(content, inferLanguageFromPath(path), uri); + } + }); + } catch (error) { + console.error('Error indexing project files:', error); + enqueueSnackbar('Error indexing project files', { variant: 'error' }); + } +} + +export function watchMonacoFiles( + monaco: ReturnType, + projectPath: string, + fs: typeof import('fs') +): () => void { + if (!monaco || !projectPath || !fs) { + return () => {}; + } + + const watcher = fs.watch( + projectPath, + { recursive: true }, + async (eventType, filename) => { + if (!filename) return; + + // Construct full path - handle both forward and backward slashes + const fullPath = `${projectPath}/${filename}`.replace(/\\/g, '/'); + const uri = monaco.Uri.file(fullPath); + + try { + if (eventType === 'change') { + // File was modified - read and update content + const content = await fs.promises.readFile(fullPath, 'utf-8'); + const existingModel = monaco.editor.getModel(uri); + + if (existingModel) { + existingModel.setValue(content); + } else { + // File was created but model doesn't exist yet + monaco.editor.createModel( + content, + inferLanguageFromPath(fullPath), + uri + ); + } + } else if (eventType === 'rename') { + // 'rename' event fires for both creation and deletion + const exists = fs.existsSync(fullPath); + + if (exists) { + // File was created or renamed to this path + try { + const content = await fs.promises.readFile(fullPath, 'utf-8'); + const existingModel = monaco.editor.getModel(uri); + + if (existingModel) { + existingModel.setValue(content); + } else { + monaco.editor.createModel( + content, + inferLanguageFromPath(fullPath), + uri + ); + } + } catch { + // File might be a directory or unreadable + console.debug('Could not read file:', filename); + } + } else { + // File was deleted or renamed away + const existingModel = monaco.editor.getModel(uri); + if (existingModel) { + existingModel.dispose(); + } + } + } + } catch (error) { + console.error('Error updating Monaco model for file:', filename, error); + enqueueSnackbar(`Error updating file: ${filename}`, { + variant: 'error', + }); + } + } + ); + + // Return cleanup function to stop watching + return () => { + watcher.close(); + }; +} diff --git a/apps/web/src/features/jac-device/components/build-flash.tsx b/apps/web/src/features/jac-device/components/build-flash.tsx new file mode 100644 index 0000000..3022821 --- /dev/null +++ b/apps/web/src/features/jac-device/components/build-flash.tsx @@ -0,0 +1,57 @@ +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { Button } from '@/features/shared/components/ui/button'; +import { ButtonGroup } from '@/features/shared/components/ui/button-group'; +import { SquareArrowRightIcon } from 'lucide-react'; +import { enqueueSnackbar } from 'notistack'; +import { compileProject } from '../lib/compilation'; +import { flashProject } from '../lib/flash'; +import { useJacDevice } from '../provider/jac-device-provider'; +import { useTerminal } from '@/features/terminal/provider/terminal-provider'; + +export function BuildFlash() { + const { projectPath, fs } = useActiveProject(); + const { addEntry } = useTerminal(); + const { device, jacProject } = useJacDevice(); + + if (!device || !jacProject) { + return; + } + + async function handleBuildAndFlash() { + if (!device) { + enqueueSnackbar('No device connected', { variant: 'error' }); + return; + } + + try { + if (!(await compileProject(projectPath, fs, addEntry))) { + enqueueSnackbar('Compilation failed', { variant: 'error' }); + return; + } + const files = await jacProject!.getFlashFiles(); + console.log(`Files to flash: ${Object.keys(files).length}`); + for (const [filePath, content] of Object.entries(files)) { + console.log(`File: ${filePath}, Content: ${content.toString()}`); + } + await flashProject(files, device); + } catch (error) { + enqueueSnackbar( + error instanceof Error ? error.message : 'Build & Flash failed', + { variant: 'error' } + ); + } + } + + return ( + + + + ); +} diff --git a/apps/web/src/features/jac-device/components/build.tsx b/apps/web/src/features/jac-device/components/build.tsx new file mode 100644 index 0000000..279a1ac --- /dev/null +++ b/apps/web/src/features/jac-device/components/build.tsx @@ -0,0 +1,51 @@ +import { Button } from '@/features/shared/components/ui/button'; +import { ButtonGroup } from '@/features/shared/components/ui/button-group'; +import { HammerIcon } from 'lucide-react'; +import { enqueueSnackbar } from 'notistack'; +import { compileProject } from '../lib/compilation'; +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { useJacDevice } from '../provider/jac-device-provider'; +import { useTerminal } from '@/features/terminal/provider/terminal-provider'; + +export function Build() { + const { projectPath, fs } = useActiveProject(); + const { jacProject } = useJacDevice(); + const { addEntry } = useTerminal(); + + if (jacProject == null) { + return; + } + + async function handleBuild() { + try { + const files = await jacProject!.getFlashFiles(); + console.log(`Files to flash: ${Object.keys(files).length}`); + for (const [filePath, content] of Object.entries(files)) { + console.log(`File: ${filePath}, Content: ${content.toString()}`); + } + if (!(await compileProject(projectPath, fs, addEntry))) { + enqueueSnackbar('Compilation failed', { variant: 'error' }); + return; + } + enqueueSnackbar('Build succeeded', { variant: 'success' }); + } catch (error) { + enqueueSnackbar( + error instanceof Error ? error.message : 'Build & Flash failed', + { variant: 'error' } + ); + } + } + + return ( + + + + ); +} diff --git a/apps/web/src/features/jac-device/components/connection-selector.tsx b/apps/web/src/features/jac-device/components/connection-selector.tsx new file mode 100644 index 0000000..fe7685b --- /dev/null +++ b/apps/web/src/features/jac-device/components/connection-selector.tsx @@ -0,0 +1,97 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/features/shared/components/ui/select'; +import { useState } from 'react'; +import { + connectDevice, + getAvailableConnectionTypes, + UnknownConnectionTypeError, +} from '../lib/connection'; +import { Button } from '@/features/shared/components/ui/button'; +import type { ConnectionType } from '../types/connection'; +import { enqueueSnackbar } from 'notistack'; +import { ButtonGroup } from '@/features/shared/components/ui/button-group'; +import { useJacDevice } from '../provider/jac-device-provider'; +import { useTerminal } from '@/features/terminal/provider/terminal-provider'; + +export function ConnectionSelector() { + const availableConnections = getAvailableConnectionTypes(); + const { addEntry } = useTerminal(); + const { setDevice } = useJacDevice(); + + const [selectedConnection, setSelectedConnection] = useState( + availableConnections[0].type + ); + const [isConnected, setIsConnected] = useState(false); + + async function handleConnection() { + if (isConnected) { + setDevice(null); + setIsConnected(false); + return; + } else { + await handleConnect(); + } + } + + function onDisconnect() { + setDevice(null); + setIsConnected(false); + enqueueSnackbar('Device disconnected.', { variant: 'warning' }); + } + + async function handleConnect() { + try { + setDevice( + await connectDevice(selectedConnection, addEntry, onDisconnect) + ); + } catch (error) { + if (error instanceof UnknownConnectionTypeError) { + enqueueSnackbar(error.message, { variant: 'error' }); + } else { + enqueueSnackbar('Failed to connect to device.', { variant: 'error' }); + } + return; + } + setIsConnected(true); + } + + return ( + + + + + + ); +} diff --git a/apps/web/src/features/jac-device/components/console-selector.tsx b/apps/web/src/features/jac-device/components/console-selector.tsx new file mode 100644 index 0000000..a1a591a --- /dev/null +++ b/apps/web/src/features/jac-device/components/console-selector.tsx @@ -0,0 +1,30 @@ +import { Button } from '@/features/shared/components/ui/button'; +import { ButtonGroup } from '@/features/shared/components/ui/button-group'; +import { useEditor } from '@/features/project/provider/project-editor-provider'; +import { CableIcon } from 'lucide-react'; +import { useJacDevice } from '../provider/jac-device-provider'; + +export function ConsoleSelector() { + const { device } = useJacDevice(); + const { controlPanel } = useEditor(); + + if (!device) { + return; + } + + return ( + + + + ); +} diff --git a/apps/web/src/features/jac-device/lib/compilation.ts b/apps/web/src/features/jac-device/lib/compilation.ts new file mode 100644 index 0000000..8534d0f --- /dev/null +++ b/apps/web/src/features/jac-device/lib/compilation.ts @@ -0,0 +1,14 @@ +import { createWritableStream } from '@/features/terminal/lib/stream'; +import { type AddToTerminal } from '@/features/terminal/provider/terminal-provider'; +import { compile } from '@jaculus/project/compiler'; + +export async function compileProject( + projectPath: string, + fs: typeof import('fs'), + addEntry: AddToTerminal +): Promise { + const outStream = createWritableStream('compiler-stdout', addEntry); + const errStream = createWritableStream('compiler-stderr', addEntry); + + return compile(fs, projectPath, 'build', outStream, errStream, '/tsLibs'); +} diff --git a/apps/web/src/features/jac-device/lib/connection.ts b/apps/web/src/features/jac-device/lib/connection.ts new file mode 100644 index 0000000..b0e2a83 --- /dev/null +++ b/apps/web/src/features/jac-device/lib/connection.ts @@ -0,0 +1,118 @@ +import { BluetoothIcon, MonitorIcon, UsbIcon } from 'lucide-react'; +import type { ConnectionInfo, ConnectionType } from '../types/connection'; +import { JacDevice } from '@jaculus/device'; +import logger from './logger'; +import { JacSerialStream } from './jac-stream'; +import type { Duplex } from '@jaculus/link/stream'; +import type { AddToTerminal } from '@/features/terminal/provider/terminal-provider'; + +export function getAvailableConnectionTypes(): ConnectionInfo[] { + const types: ConnectionInfo[] = []; + if (isWebSerialAvailable()) { + types.push({ type: 'serial', name: 'Web Serial', icon: UsbIcon }); + } + if (isWebBLEAvailable()) { + types.push({ type: 'ble', name: 'Web Bluetooth', icon: BluetoothIcon }); + } + if (isWokwiAvailable()) { + types.push({ type: 'wokwi', name: 'Wokwi Simulator', icon: MonitorIcon }); + } + return types; +} + +// create custom Error class +export class UnknownConnectionTypeError extends Error { + constructor(type: ConnectionType) { + super(`Unknown connection type: ${type}`); + this.name = 'UnknownConnectionTypeError'; + } +} + +export async function connectDevice( + type: ConnectionType, + addToTerminal: AddToTerminal, + onDisconnect: () => void +): Promise { + switch (type) { + case 'serial': + return connectDeviceWebSerial(addToTerminal, onDisconnect); + // case 'ble': + // return connectDeviceWebBLE(); + // case 'wokwi': + // return connectDeviceWokwiSimulator(project, addToTerminal); + default: + return Promise.reject(new UnknownConnectionTypeError(type)); + } +} + +function setupJacDevice( + stream: Duplex, + addToTerminal: AddToTerminal +): JacDevice { + const device = new JacDevice(stream, logger); + + device.programOutput.onData(data => { + const msg = String.fromCharCode(...data); + addToTerminal('console-out', msg); + }); + + device.programError.onData(data => { + const msg = String.fromCharCode(...data); + addToTerminal('console-err', msg); + }); + + return device; +} + +export function sendToDevice( + device: JacDevice, + input: Uint8Array, + addToTerminal: AddToTerminal +): void { + addToTerminal('console-in', new TextDecoder().decode(input)); + device.programInput.write(input); +} + +export function sendToDeviceStr( + device: JacDevice, + input: string, + addToTerminal: AddToTerminal +): void { + addToTerminal('console-in', input); + device.programInput.write(new TextEncoder().encode(input)); +} + +// WEB SERIAL + +export function isWebSerialAvailable(): boolean { + return 'serial' in navigator; +} + +export async function connectDeviceWebSerial( + addToTerminal: AddToTerminal, + onDisconnect: () => void +): Promise { + const port = await navigator.serial.requestPort(); + await port.open({ baudRate: 921600 }); + const stream = new JacSerialStream(port, logger); + + navigator.serial.addEventListener('disconnect', event => { + if (event.target === port) { + onDisconnect(); + } + }); + + return setupJacDevice(stream, addToTerminal); +} + +// WEB BLE + +export function isWebBLEAvailable(): boolean { + return 'bluetooth' in navigator; +} + +// WOKWI SIMULATOR + +export function isWokwiAvailable(): boolean { + return true; +} diff --git a/apps/web/src/features/jac-device/lib/flash.ts b/apps/web/src/features/jac-device/lib/flash.ts new file mode 100644 index 0000000..acc8d88 --- /dev/null +++ b/apps/web/src/features/jac-device/lib/flash.ts @@ -0,0 +1,67 @@ +import type { JacDevice } from '@jaculus/device'; +import { enqueueSnackbar } from 'notistack'; +import logger from './logger'; +import { dirname } from 'path'; + +export async function flashProject( + files: Record, + device: JacDevice +) { + try { + await device.controller.lock().catch((err: unknown) => { + logger.verbose('Error locking device: ' + err); + throw 1; + }); + + await device.controller.stop().catch((err: unknown) => { + logger.verbose('Error stopping device: ' + err); + }); + + try { + logger.info('Getting current data hashes'); + const dataHashes = await device.uploader + .getDirHashes('code') + .catch((err: unknown) => { + logger.verbose('Error getting data hashes: ' + err); + throw err; + }); + + await device.uploader.uploadIfDifferent(dataHashes, files, 'code'); + } catch { + logger.info('Deleting old code'); + await device.uploader.deleteDirectory('code').catch((err: unknown) => { + logger.verbose('Error deleting directory: ' + err); + }); + + for (const [filePath, content] of Object.entries(files)) { + const fullPath = `code/${filePath}`; + const dirPath = dirname(fullPath); + if (dirPath) { + await device.uploader + .createDirectory(dirPath) + .catch((err: unknown) => { + logger.verbose('Error creating directory: ' + err); + }); + } + await device.uploader + .writeFile(fullPath, content) + .catch((err: unknown) => { + logger.verbose('Error writing file: ' + err); + }); + } + } + + await device.controller.start('index.js').catch((err: unknown) => { + logger.verbose('Error starting program: ' + err); + throw 1; + }); + + await device.controller.unlock().catch((err: unknown) => { + logger.verbose('Error unlocking device: ' + err); + throw 1; + }); + } catch (error) { + logger.error(`Error flashing device: ${error}`); + enqueueSnackbar('Flashing failed', { variant: 'error' }); + } +} diff --git a/apps/web/src/lib/device/jac-stream.ts b/apps/web/src/features/jac-device/lib/jac-stream.ts similarity index 100% rename from apps/web/src/lib/device/jac-stream.ts rename to apps/web/src/features/jac-device/lib/jac-stream.ts diff --git a/apps/web/src/lib/logger.ts b/apps/web/src/features/jac-device/lib/logger.ts similarity index 100% rename from apps/web/src/lib/logger.ts rename to apps/web/src/features/jac-device/lib/logger.ts diff --git a/apps/web/src/features/jac-device/provider/jac-device-provider.tsx b/apps/web/src/features/jac-device/provider/jac-device-provider.tsx new file mode 100644 index 0000000..fc59383 --- /dev/null +++ b/apps/web/src/features/jac-device/provider/jac-device-provider.tsx @@ -0,0 +1,90 @@ +import { createContext, use, useMemo, useState, type ReactNode } from 'react'; +import { loadPackageJsonSync, Project, Registry } from '@jaculus/project'; +import { JacDevice } from '@jaculus/device'; +import { getRequest } from '@jaculus/jacly/project'; +import { Writable } from 'node:stream'; +import path from 'path'; +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { createWritableStream } from '@/features/terminal/lib/stream'; +import { useTerminal } from '@/features/terminal/provider/terminal-provider'; +import { enqueueSnackbar } from 'notistack'; + +export interface JacDeviceContextValue { + jacProject: Project | null; + device: JacDevice | null; + setDevice: (device: JacDevice | null) => void; + outStream?: Writable; + errStream?: Writable; +} + +export const JacDeviceContext = createContext( + null +); + +interface JacDeviceProviderProps { + children: ReactNode; +} + +export function JacDeviceProvider({ children }: JacDeviceProviderProps) { + const { fs, projectPath } = useActiveProject(); + const { addEntry } = useTerminal(); + const [device, setDevice] = useState(null); + + const jacProject = useMemo(() => { + const packageJsonPath = path.join(projectPath, 'package.json'); + try { + if (!fs.existsSync(packageJsonPath)) { + enqueueSnackbar( + 'No package.json found in the project. Please initialize a Jacly project.', + { variant: 'warning' } + ); + return null; + } + const pkg = loadPackageJsonSync(fs, packageJsonPath); + const registry = new Registry(pkg.registry, getRequest); + + return new Project( + fs, + projectPath, + createWritableStream('runtime-stdout', addEntry), + createWritableStream('runtime-stderr', addEntry), + registry + ); + } catch (error) { + console.error( + `Failed to load Jacly project at ${packageJsonPath}:`, + error + ); + enqueueSnackbar( + `Failed to load Jacly project. Please ensure it is a valid Jacly project.`, + { variant: 'error' } + ); + return null; + } + }, [fs, projectPath, addEntry]); + + const contextValue: JacDeviceContextValue = { + jacProject, + device, + setDevice: (newDevice: JacDevice | null) => { + if (device) { + device.destroy(); + } + setDevice(newDevice); + }, + }; + + return ( + + {children} + + ); +} + +export function useJacDevice() { + const context = use(JacDeviceContext); + if (!context) { + throw new Error('useJacDevice must be used within a JacDeviceProvider'); + } + return context; +} diff --git a/apps/web/src/features/jac-device/types/connection.ts b/apps/web/src/features/jac-device/types/connection.ts new file mode 100644 index 0000000..d2c0c59 --- /dev/null +++ b/apps/web/src/features/jac-device/types/connection.ts @@ -0,0 +1,7 @@ +export type ConnectionType = 'serial' | 'ble' | 'wokwi'; + +export type ConnectionInfo = { + type: ConnectionType; + name: string; + icon: React.FC>; +}; diff --git a/apps/web/src/features/jac-device/types/stream.ts b/apps/web/src/features/jac-device/types/stream.ts new file mode 100644 index 0000000..f178e4f --- /dev/null +++ b/apps/web/src/features/jac-device/types/stream.ts @@ -0,0 +1,16 @@ +import { Writable } from 'node:stream'; + +export type JacStream = { + name: string; + enabled: boolean; + outStream: Writable; + errStream: Writable; +}; + +export type JacStreamType = 'console' | 'compiler' | 'upload' | 'project'; + +export interface JacStreamState { + streams: Record; + clearStream: (type: JacStreamType) => void; + addToStream: (type: JacStreamType, data: string) => void; +} diff --git a/apps/web/src/features/jacly-editor/components/index.tsx b/apps/web/src/features/jacly-editor/components/index.tsx new file mode 100644 index 0000000..cae8aeb --- /dev/null +++ b/apps/web/src/features/jacly-editor/components/index.tsx @@ -0,0 +1,107 @@ +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { useTheme } from '@/features/theme/components/theme-provider'; +import { JaclyEditor, JaclyLoading } from '@jaculus/jacly/editor'; +import { enqueueSnackbar } from 'notistack'; +import { useState, useEffect } from 'react'; +import { dirname } from 'path'; +import { useJacDevice } from '@/features/jac-device/provider/jac-device-provider'; +import type { JaclyBlocksFiles } from '@jaculus/project'; + +export function BlocklyEditorComponent() { + const { themeNormalized } = useTheme(); + const { fs, fsp, getFileName } = useActiveProject(); + const { jacProject } = useJacDevice(); + + const [initialJson, setInitialJson] = useState(null); + const [jaclyBlockFiles, setJaclyBlockFiles] = + useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + if (!jacProject) { + enqueueSnackbar('No Jacly project loaded.', { variant: 'error' }); + setInitialJson({}); + setJaclyBlockFiles({}); + return; + } + + setIsLoading(true); + + // Load initial JSON + const jaclyFile = getFileName('JACLY_INDEX'); + const dirnamePath = dirname(jaclyFile); + if (!fs.existsSync(dirnamePath)) { + await fsp.mkdir(dirnamePath, { recursive: true }); + } + + let jsonData; + if (fs.existsSync(jaclyFile)) { + const data = fs.readFileSync(jaclyFile, 'utf-8'); + jsonData = JSON.parse(data); + } else { + await fsp.writeFile(jaclyFile, JSON.stringify({}, null, 2), 'utf-8'); + jsonData = {}; + } + setInitialJson(jsonData); + setJaclyBlockFiles(await jacProject.getJaclyBlockFiles()); + } catch (error) { + console.error('Failed to load editor data:', error); + enqueueSnackbar('Failed to load editor data.', { variant: 'error' }); + setInitialJson({}); + setJaclyBlockFiles({}); + } finally { + setIsLoading(false); + } + })(); + }, [fs, fsp, getFileName, jacProject]); + + async function handleJsonChange(workspaceJson: object) { + try { + const filePath = getFileName('JACLY_INDEX'); + const dirnamePath = dirname(filePath); + if (!fs.existsSync(dirnamePath)) { + await fsp.mkdir(dirnamePath, { recursive: true }); + } + + await fsp.writeFile( + filePath, + JSON.stringify(workspaceJson, null, 2), + 'utf-8' + ); + } catch (error) { + enqueueSnackbar('Failed to save JSON.', { variant: 'error' }); + console.error('Failed to save JSON:', error); + } + } + + async function handleGeneratedCode(code: string) { + try { + const filePath = getFileName('GENERATED_CODE'); + const dirnamePath = dirname(filePath); + if (!fs.existsSync(dirnamePath)) { + await fsp.mkdir(dirnamePath, { recursive: true }); + } + + await fsp.writeFile(filePath, code, 'utf-8'); + } catch (error) { + enqueueSnackbar('Failed to save generated code.', { variant: 'error' }); + console.error('Failed to save generated code:', error); + } + } + + if (isLoading || !initialJson || !jaclyBlockFiles) { + return ; + } + + return ( + + ); +} diff --git a/apps/web/src/features/jacly-editor/lib/jacly.ts b/apps/web/src/features/jacly-editor/lib/jacly.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/features/project/components/.gitignore b/apps/web/src/features/project/components/.gitignore new file mode 100644 index 0000000..31fc8c3 --- /dev/null +++ b/apps/web/src/features/project/components/.gitignore @@ -0,0 +1 @@ +!logs diff --git a/apps/web/src/components/editor/flex-layout/flexlayout.css b/apps/web/src/features/project/components/flex-layout/flexlayout.css similarity index 100% rename from apps/web/src/components/editor/flex-layout/flexlayout.css rename to apps/web/src/features/project/components/flex-layout/flexlayout.css diff --git a/apps/web/src/features/project/components/index.tsx b/apps/web/src/features/project/components/index.tsx new file mode 100644 index 0000000..2f33f91 --- /dev/null +++ b/apps/web/src/features/project/components/index.tsx @@ -0,0 +1,5 @@ +import { ProjectEditorProvider } from '@/features/project/provider/project-editor-provider'; + +export function ProjectEditorComponent() { + return ; +} diff --git a/apps/web/src/components/editor/panels/wrapper/index.tsx b/apps/web/src/features/project/components/panel-warpper.tsx similarity index 100% rename from apps/web/src/components/editor/panels/wrapper/index.tsx rename to apps/web/src/features/project/components/panel-warpper.tsx diff --git a/apps/web/src/components/editor/panels/blockly/index.css b/apps/web/src/features/project/components/panels/blockly/index.css similarity index 100% rename from apps/web/src/components/editor/panels/blockly/index.css rename to apps/web/src/features/project/components/panels/blockly/index.css diff --git a/apps/web/src/features/project/components/panels/blockly/index.tsx b/apps/web/src/features/project/components/panels/blockly/index.tsx new file mode 100644 index 0000000..8f49ab6 --- /dev/null +++ b/apps/web/src/features/project/components/panels/blockly/index.tsx @@ -0,0 +1,6 @@ +import { BlocklyEditorComponent } from '@/features/jacly-editor/components'; +import './index.css'; + +export function BlocklyEditorPanel() { + return ; +} diff --git a/apps/web/src/features/project/components/panels/code/index.tsx b/apps/web/src/features/project/components/panels/code/index.tsx new file mode 100644 index 0000000..19c6d7d --- /dev/null +++ b/apps/web/src/features/project/components/panels/code/index.tsx @@ -0,0 +1,9 @@ +import { CodeEditorBasic } from '@/features/code-editor/components/code-editor-basic'; + +interface CodePanelProps { + filePath: string; +} + +export function CodePanel({ filePath }: CodePanelProps) { + return ; +} diff --git a/apps/web/src/features/project/components/panels/console/index.tsx b/apps/web/src/features/project/components/panels/console/index.tsx new file mode 100644 index 0000000..b6f9923 --- /dev/null +++ b/apps/web/src/features/project/components/panels/console/index.tsx @@ -0,0 +1,5 @@ +import { TerminalConsole } from '@/features/terminal/components/terminal-console'; + +export function ConsolePanel() { + return ; +} diff --git a/apps/web/src/features/project/components/panels/file-explorer/file-tree-node.tsx b/apps/web/src/features/project/components/panels/file-explorer/file-tree-node.tsx new file mode 100644 index 0000000..fb3bf73 --- /dev/null +++ b/apps/web/src/features/project/components/panels/file-explorer/file-tree-node.tsx @@ -0,0 +1,111 @@ +import { cn } from '@/lib/utils/cn'; +import { ChevronDown, ChevronRight, FolderOpen, Folder } from 'lucide-react'; +import { memo } from 'react'; +import { getFileIcon } from './helper'; +import type { FileTreeNodeProps } from './types'; + +import { + ContextMenu, + ContextMenuTrigger, +} from '@/features/shared/components/ui/context-menu'; + +export const FileTreeNode = memo( + ({ + item, + depth, + expandedFolders, + selectedItem, + onToggle, + onOpen, + onSelect, + ContextMenuComponent, + }: FileTreeNodeProps) => { + const isExpanded = expandedFolders.has(item.path); + const isSelected = selectedItem === item.path; + + return ( +
+ + +
{ + onSelect(item.path); + onOpen(item); + }} + onDoubleClick={e => { + e.stopPropagation(); + onToggle(item); + }} + > + {/* Expand/Collapse Chevron */} +
{ + e.stopPropagation(); + onToggle(item); + }} + > + {item.isDirectory && + (isExpanded ? ( + + ) : ( + + ))} +
+ + {/* Icon */} + {item.isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + {getFileIcon(item.name, false)} + + )} + + {/* Filename */} + {item.name} +
+
+ +
+ + {/* Recursive Render */} + {item.isDirectory && isExpanded && item.children && ( +
+ {item.children.map(child => ( + + ))} +
+ )} +
+ ); + } +); + +FileTreeNode.displayName = 'FileTreeNode'; diff --git a/apps/web/src/components/editor/panels/file-explorer/file-helper.tsx b/apps/web/src/features/project/components/panels/file-explorer/helper.tsx similarity index 56% rename from apps/web/src/components/editor/panels/file-explorer/file-helper.tsx rename to apps/web/src/features/project/components/panels/file-explorer/helper.tsx index 289aafa..7ceeeda 100644 --- a/apps/web/src/components/editor/panels/file-explorer/file-helper.tsx +++ b/apps/web/src/features/project/components/panels/file-explorer/helper.tsx @@ -7,6 +7,50 @@ import { FileAudio, Blocks, } from 'lucide-react'; +import type { FileSystemItem } from './types'; +import type fs from 'fs'; + +export async function buildFileTree( + fsp: typeof fs.promises, + path: string +): Promise { + try { + const entries = await fsp.readdir(path, { withFileTypes: true }); + const items: FileSystemItem[] = []; + + for (const entry of entries) { + const name = entry.name; + const itemPath = path === '/' ? `/${name}` : `${path}/${name}`; + const isDirectory = entry.isDirectory(); + + items.push({ + isRoot: false, + name, + path: itemPath, + isDirectory, + children: isDirectory ? [] : undefined, + }); + } + + return items.sort((a, b) => { + if (a.isDirectory === b.isDirectory) { + return a.name.localeCompare(b.name); + } + return a.isDirectory ? -1 : 1; + }); + } catch (error) { + console.error('Error building file tree:', error); + return []; + } +} + +export async function loadDirectoryChildren( + fsp: typeof fs.promises, + item: FileSystemItem +): Promise { + if (!item.isDirectory) return []; + return buildFileTree(fsp, item.path); +} export function getFileIcon(fileName: string, isDirectory: boolean) { if (isDirectory) return null; // Will be handled separately diff --git a/apps/web/src/features/project/components/panels/file-explorer/index.tsx b/apps/web/src/features/project/components/panels/file-explorer/index.tsx new file mode 100644 index 0000000..d9d2a30 --- /dev/null +++ b/apps/web/src/features/project/components/panels/file-explorer/index.tsx @@ -0,0 +1,325 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/features/shared/components/ui/context-menu'; +import { + CopyIcon, + FilePlusIcon, + FolderMinusIcon, + FolderPenIcon, + FolderPlusIcon, +} from 'lucide-react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { FileSystemItem } from './types'; +import { buildFileTree, loadDirectoryChildren } from './helper'; +import { useEditor } from '@/features/project/provider/project-editor-provider'; +import { enqueueSnackbar } from 'notistack'; +import { useActiveProject } from '@/features/project/provider/active-project-provider'; +import { debounce } from '@/lib/utils/debouncer'; +import { FileTreeNode } from './file-tree-node'; + +export function FileExplorerPanel() { + const { fsp, projectPath } = useActiveProject(); + const { openPanel } = useEditor(); + + const [fileTree, setFileTree] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const [loading, setLoading] = useState(true); + + // We keep expandedFolders in state for UI updates, and a Ref for the background watcher + const [expandedFolders, setExpandedFolders] = useState>( + new Set() + ); + const expandedFoldersRef = useRef(expandedFolders); + + const rootFileItem: FileSystemItem = { + isRoot: true, + name: projectPath, + path: projectPath, + isDirectory: true, + children: [], + }; + + // Sync Ref with State automatically + useEffect(() => { + expandedFoldersRef.current = expandedFolders; + }, [expandedFolders]); + + // Update tree helper to ensure React sees data changes + const updateTreeItem = useCallback( + ( + nodes: FileSystemItem[], + targetPath: string, + updater: (node: FileSystemItem) => FileSystemItem + ): FileSystemItem[] => { + return nodes.map(node => { + if (node.path === targetPath) { + return updater(node); + } + if (node.children) { + const newChildren = updateTreeItem( + node.children, + targetPath, + updater + ); + if (newChildren !== node.children) { + return { ...node, children: newChildren }; + } + } + return node; + }); + }, + [] + ); + + const loadTreeWithExpansion = useCallback( + async ( + path: string, + expandedSet: Set + ): Promise => { + try { + const items = await buildFileTree(fsp, path); + // Recursively load children for expanded folders + await Promise.all( + items.map(async item => { + if (item.isDirectory && expandedSet.has(item.path)) { + item.children = await loadTreeWithExpansion( + item.path, + expandedSet + ); + } + }) + ); + return items; + } catch (error) { + console.error(`Error loading tree at ${path}:`, error); + return []; + } + }, + [fsp] + ); + + const refreshTree = useCallback( + async (isBackground = false) => { + if (!isBackground) setLoading(true); + try { + const tree = await loadTreeWithExpansion( + projectPath, + expandedFoldersRef.current + ); + setFileTree(tree); + } catch (error) { + console.error('Failed to load file tree:', error); + } finally { + if (!isBackground) setLoading(false); + } + }, + [projectPath, loadTreeWithExpansion] + ); + + // Initial Load + useEffect(() => { + refreshTree(false); + }, [refreshTree]); + + useEffect(() => { + if (!projectPath || !fsp.watch) return; + + let aborted = false; + const abortController = new AbortController(); + + const debouncedRefresh = debounce(() => { + if (!aborted) { + refreshTree(true); + } + }, 300); + + (async () => { + try { + const watcher = fsp.watch(projectPath, { + recursive: true, + signal: abortController.signal, + }); + for await (const event of watcher) { + void event; + if (aborted) break; + debouncedRefresh(); + } + } catch (error) { + if (!aborted) console.error('Error watching files:', error); + } + })(); + + return () => { + aborted = true; + abortController.abort(); + }; + }, [projectPath, fsp, refreshTree]); + + const handleOpen = useCallback( + async (item: FileSystemItem) => { + if (!item.isDirectory) { + const path = item.path.replace(`${projectPath}/`, ''); + openPanel('code', { filePath: path }); + } + }, + [projectPath, openPanel] + ); + + const toggleDirectory = useCallback( + async (item: FileSystemItem) => { + setExpandedFolders(prev => { + const newExpanded = new Set(prev); + if (newExpanded.has(item.path)) { + newExpanded.delete(item.path); + } else { + newExpanded.add(item.path); + } + return newExpanded; + }); + + // If we are opening and children are missing, load them + if ( + !expandedFoldersRef.current.has(item.path) && + (!item.children || item.children.length === 0) + ) { + const children = await loadDirectoryChildren(fsp, item); + + setFileTree(prevTree => + updateTreeItem(prevTree, item.path, node => ({ + ...node, + children: children, + })) + ); + } + }, + [fsp, updateTreeItem] + ); + + const createNewFile = async (item: FileSystemItem) => { + const fileName = prompt( + `Creating new file under: ${item.path}\nEnter file name:` + ); + if (!fileName) return; + try { + await fsp.writeFile(`${item.path}/${fileName}`, '', 'utf-8'); + openPanel('code', { + filePath: `${item.path}/${fileName}`.replace(`${projectPath}/`, ''), + }); + } catch { + enqueueSnackbar('Failed to create file', { variant: 'error' }); + } + }; + + const createNewDirectory = async (item: FileSystemItem) => { + const dirName = prompt( + `Creating new folder under: ${item.path}\nEnter folder name:` + ); + if (!dirName) return; + try { + await fsp.mkdir(`${item.path}/${dirName}`); + } catch { + enqueueSnackbar('Failed to create folder', { variant: 'error' }); + } + }; + + const renameItem = async (item: FileSystemItem) => { + const newName = prompt(`Enter new name for "${item.name}":`, item.name); + if (!newName || newName === item.name) return; + try { + const newPath = item.path.replace(/[^/]+$/, newName); + await fsp.rename(item.path, newPath); + } catch { + enqueueSnackbar('Failed to rename', { variant: 'error' }); + } + }; + + const removeItem = async (item: FileSystemItem) => { + if (!confirm(`Delete ${item.name}?`)) return; + try { + if (item.isDirectory) + await fsp.rm(item.path, { recursive: true, force: true }); + else await fsp.unlink(item.path); + } catch { + enqueueSnackbar('Failed to delete', { variant: 'error' }); + } + }; + + const NodeContextMenu = useCallback( + ({ item }: { item: FileSystemItem }) => ( + + {item.isDirectory && ( + <> + createNewFile(item)}> + New File + + createNewDirectory(item)}> + New Folder + + + )} + {!item.isRoot && ( + <> + renameItem(item)}> + Rename + + removeItem(item)} + className="text-red-500" + > + Delete + + + )} + + navigator.clipboard.writeText( + item.path.replace(`${projectPath}`, '') + ) + } + > + Copy Path + + + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [projectPath] + ); + + return ( + + +
+ {loading && fileTree.length === 0 ? ( +
+ Loading files... +
+ ) : fileTree.length === 0 ? ( +
+ No files found +
+ ) : ( +
+ {fileTree.map(item => ( + + ))} +
+ )} +
+
+ +
+ ); +} diff --git a/apps/web/src/features/project/components/panels/file-explorer/types.ts b/apps/web/src/features/project/components/panels/file-explorer/types.ts new file mode 100644 index 0000000..c2129a2 --- /dev/null +++ b/apps/web/src/features/project/components/panels/file-explorer/types.ts @@ -0,0 +1,20 @@ +import type { JSX } from 'react'; + +export interface FileSystemItem { + isRoot: boolean; + name: string; + path: string; + isDirectory: boolean; + children?: FileSystemItem[]; +} + +export interface FileTreeNodeProps { + item: FileSystemItem; + depth: number; + expandedFolders: Set; + selectedItem: string | null; + onToggle: (item: FileSystemItem) => void; + onOpen: (item: FileSystemItem) => void; + onSelect: (path: string) => void; + ContextMenuComponent: (props: { item: FileSystemItem }) => JSX.Element; +} diff --git a/apps/web/src/features/project/components/panels/generated-code/index.tsx b/apps/web/src/features/project/components/panels/generated-code/index.tsx new file mode 100644 index 0000000..ab60aa7 --- /dev/null +++ b/apps/web/src/features/project/components/panels/generated-code/index.tsx @@ -0,0 +1,6 @@ +import { CodeEditorReadOnly } from '@/features/code-editor/components/code-editor-read'; + +export function GeneratedCode() { + const filePath = 'build/index.js'; + return ; +} diff --git a/apps/web/src/features/project/components/panels/jaculus/index.tsx b/apps/web/src/features/project/components/panels/jaculus/index.tsx new file mode 100644 index 0000000..9eb4075 --- /dev/null +++ b/apps/web/src/features/project/components/panels/jaculus/index.tsx @@ -0,0 +1,3 @@ +export function JaculusPanel() { + return

Jaculus Panel

; +} diff --git a/apps/web/src/features/project/components/panels/logs/index.tsx b/apps/web/src/features/project/components/panels/logs/index.tsx new file mode 100644 index 0000000..4d45a35 --- /dev/null +++ b/apps/web/src/features/project/components/panels/logs/index.tsx @@ -0,0 +1,7 @@ +import { TerminalLogs } from "@/features/terminal/components/terminal-logs"; + +export function LogsPanel() { + return ( + + ) +} \ No newline at end of file diff --git a/apps/web/src/features/project/components/panels/packages/index.tsx b/apps/web/src/features/project/components/panels/packages/index.tsx new file mode 100644 index 0000000..2ff2c8b --- /dev/null +++ b/apps/web/src/features/project/components/panels/packages/index.tsx @@ -0,0 +1,338 @@ +import logger from '@/features/jac-device/lib/logger'; +import { useJacDevice } from '@/features/jac-device/provider/jac-device-provider'; +import type { Dependencies } from 'node_modules/@jaculus/project/dist/src/project/package'; +import { enqueueSnackbar } from 'notistack'; +import { useEffect, useState } from 'react'; +import { Button } from '@/features/shared/components/ui/button'; +import { Input } from '@/features/shared/components/ui/input'; +import { Card } from '@/features/shared/components/ui/card'; +import { Badge } from '@/features/shared/components/ui/badge'; +import { Separator } from '@/features/shared/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/features/shared/components/ui/select'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/features/shared/components/ui/alert-dialog'; +import { RefreshCw, Plus, Trash2, Package, Search } from 'lucide-react'; + +export function PackagesPanel() { + const { jacProject } = useJacDevice(); + const [installedLibs, setInstalledLibs] = useState({}); + const [availableLibs, setAvailableLibs] = useState([]); + const [availableLibVersions, setAvailableLibVersions] = useState( + [] + ); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedLib, setSelectedLib] = useState(null); + const [selectedLibVersion, setSelectedLibVersion] = useState( + null + ); + + const [isInstalling, setIsInstalling] = useState(false); + + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + setError(null); + if (jacProject == null || jacProject.registry == null) return; + setAvailableLibs(await jacProject.registry.list()); + setInstalledLibs(await jacProject.installedLibraries()); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load libraries' + ); + logger.error('Error loading libraries:' + err); + } + })(); + }, [jacProject]); + + useEffect(() => { + (async () => { + try { + setError(null); + if ( + jacProject == null || + selectedLib == null || + jacProject.registry == null + ) { + setAvailableLibVersions([]); + return; + } + const versions = await jacProject.registry.listVersions(selectedLib); + setAvailableLibVersions(versions); + + if (versions.length == 1) { + setSelectedLibVersion(versions[0]); + } else { + setSelectedLibVersion(null); + } + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to load library versions' + ); + logger.error('Error loading library versions:' + err); + } + })(); + }, [selectedLib, jacProject]); + + if (jacProject == null) { + return ( +
+ No project loaded +
+ ); + } + + async function handleInstall() { + try { + setIsInstalling(true); + setError(null); + await jacProject!.install(); + setInstalledLibs(await jacProject!.installedLibraries()); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to install library' + ); + logger.error('Error installing library:' + err); + } finally { + setIsInstalling(false); + } + } + + async function handleAddLibrary() { + try { + setIsInstalling(true); + setError(null); + if (selectedLib == null || availableLibVersions.length === 0) { + setError('No library or version selected'); + return; + } + const versionToInstall = selectedLibVersion ?? availableLibVersions[0]; + await jacProject!.addLibraryVersion(selectedLib, versionToInstall); + await jacProject!.install(); + setInstalledLibs(await jacProject!.installedLibraries()); + enqueueSnackbar( + `Library '${selectedLib}@${versionToInstall}' added successfully`, + { variant: 'success' } + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add library'); + logger.error('Error adding library:' + err); + } finally { + setIsInstalling(false); + setSelectedLib(null); + setSelectedLibVersion(null); + } + } + + async function handleRemoveLibrary(library: string) { + try { + setIsInstalling(true); + setError(null); + await jacProject!.removeLibrary(library); + await jacProject!.install(); + setInstalledLibs(await jacProject!.installedLibraries()); + enqueueSnackbar(`Library '${library}' removed successfully`, { + variant: 'success', + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove library'); + logger.error('Error removing library:' + err); + } finally { + setIsInstalling(false); + } + } + + // show only available libraries that are not installed yet + const filteredAvailableLibs = availableLibs + .filter(lib => !(lib in installedLibs)) + .filter(lib => lib.toLowerCase().includes(searchQuery.toLowerCase())); + + return ( +
+ {/* Install/Update Button */} + + + {/* Error Display */} + {error && ( + +

{error}

+
+ )} + + {/* Add New Package Section */} + +
+ +

Add New Package

+
+ +
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+
+
+ {filteredAvailableLibs.length === 0 ? ( +
+ No packages found +
+ ) : ( + filteredAvailableLibs.map(lib => ( + + {lib} + + )) + )} +
+ + +
+ +
+ + +
+ + + + + + + + {/* Installed Packages Section */} + +
+ +

+ Installed Packages ({Object.keys(installedLibs).length}) +

+
+ + {Object.keys(installedLibs).length === 0 ? ( +
+ No packages installed yet +
+ ) : ( +
+ {Object.entries(installedLibs).map(([name, version]) => ( +
+
+ {name} + {version} +
+ + + + + + + Remove Package + + Are you sure you want to remove '{name}'? This action + cannot be undone. + + + + Cancel + handleRemoveLibrary(name)} + > + Remove + + + + +
+ ))} +
+ )} +
+ + ); +} diff --git a/apps/web/src/features/project/components/project-editor-header.tsx b/apps/web/src/features/project/components/project-editor-header.tsx new file mode 100644 index 0000000..a78d6c6 --- /dev/null +++ b/apps/web/src/features/project/components/project-editor-header.tsx @@ -0,0 +1,37 @@ +import { Build } from '@/features/jac-device/components/build'; +import { BuildFlash } from '@/features/jac-device/components/build-flash'; +import { ConnectionSelector } from '@/features/jac-device/components/connection-selector'; +import { ConsoleSelector } from '@/features/jac-device/components/console-selector'; +import { ThemeToggle } from '@/features/theme/components/theme-toggle'; +import { Link } from '@tanstack/react-router'; +import { HouseIcon } from 'lucide-react'; + +export function ProjectEditorHeader() { + return ( +
+
+
+ {/* Navigation */} + + + {/* Theme switcher */} +
+ + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/features/project/components/project-load-error.tsx b/apps/web/src/features/project/components/project-load-error.tsx new file mode 100644 index 0000000..28fbdb6 --- /dev/null +++ b/apps/web/src/features/project/components/project-load-error.tsx @@ -0,0 +1,48 @@ +import { AlertCircleIcon, ArrowLeftIcon } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; + +interface EditorLoadErrorProps { + error: Error; +} + +export function ProjectLoadError({ error }: EditorLoadErrorProps) { + return ( +
+
+ {/* Error icon */} +
+ +
+ + {/* Error message */} +
+

+ Failed to Load Project Filesystem +

+

+ {error.message || + 'An unknown error occurred while mounting the filesystem'} +

+
+ + {/* Actions */} +
+ + + Back to Projects + + + +
+
+
+ ); +} diff --git a/apps/web/src/features/project/components/project-loading.tsx b/apps/web/src/features/project/components/project-loading.tsx new file mode 100644 index 0000000..46e82b6 --- /dev/null +++ b/apps/web/src/features/project/components/project-loading.tsx @@ -0,0 +1,53 @@ +import { Loader2Icon, ArrowLeftIcon } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; + +interface EditorMountLoadingProps { + message?: string; +} + +export function ProjectLoadingIndicator({ message }: EditorMountLoadingProps) { + return ( +
+
+ {/* Multiple spinning loaders */} +
+ {/* Outer ring - slowest */} +
+ + {/* Middle ring - medium speed */} +
+ + {/* Inner ring - fastest */} +
+ + {/* Center icon */} + +
+ + {/* Text */} +
+

Loading Project

+

+ {message} +

+
+ + {/* Animated dots */} +
+ + + +
+ + {/* Back to projects link */} + + + Back to Projects + +
+
+ ); +} diff --git a/apps/web/src/features/project/lib/download.ts b/apps/web/src/features/project/lib/download.ts new file mode 100644 index 0000000..5684132 --- /dev/null +++ b/apps/web/src/features/project/lib/download.ts @@ -0,0 +1,77 @@ +import type { FSInterface } from '@jaculus/project/fs'; +import { zipSync } from 'fflate'; +import { enqueueSnackbar } from 'notistack'; + +/** + * Recursively collect all files from a directory + */ +async function collectFiles( + fs: FSInterface, + dirPath: string, + basePath: string = '' +): Promise> { + const files: Record = {}; + const items = await fs.promises.readdir(dirPath, { withFileTypes: true }); + + for (const item of items) { + const fullPath = `${dirPath}/${item.name}`; + const relativePath = basePath ? `${basePath}/${item.name}` : item.name; + + if (item.isDirectory()) { + // Recursively collect files from subdirectory + const subFiles = await collectFiles(fs, fullPath, relativePath); + Object.assign(files, subFiles); + } else if (item.isFile()) { + // Read file content + const content = await fs.promises.readFile(fullPath); + files[relativePath] = + content instanceof Uint8Array ? content : new Uint8Array(content); + } + } + + return files; +} + +/** + * Download a project as a ZIP file + */ +export async function downloadProjectAsZip( + fs: FSInterface, + projectPath: string, + projectName: string +): Promise { + try { + // Collect all files from the project directory + const files = await collectFiles(fs, projectPath); + + if (Object.keys(files).length === 0) { + console.warn('No files found in project'); + enqueueSnackbar('No files found in project to download', { + variant: 'warning', + }); + } + + // Create ZIP using fflate + const zipData = zipSync(files, { level: 6 }); + + // Trigger download + const blob = new Blob([new Uint8Array(zipData)], { + type: 'application/zip', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${projectName}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + console.log( + `Downloaded ${Object.keys(files).length} files as ${projectName}.zip` + ); + } catch (error) { + console.error('Failed to download project as ZIP:', error); + throw error; + } +} diff --git a/apps/web/src/features/project/lib/flexlayout-components.tsx b/apps/web/src/features/project/lib/flexlayout-components.tsx new file mode 100644 index 0000000..f2bb10c --- /dev/null +++ b/apps/web/src/features/project/lib/flexlayout-components.tsx @@ -0,0 +1,57 @@ +import * as FlexLayout from 'flexlayout-react'; +import type { PanelType } from '@/features/project/types/flexlayout-type'; +import { PanelWrapper } from '@/features/project/components/panel-warpper'; +import { FileExplorerPanel } from '../components/panels/file-explorer'; +import { CodePanel } from '../components/panels/code'; +import { GeneratedCode } from '../components/panels/generated-code'; +import { ConsolePanel } from '../components/panels/console'; +import { LogsPanel } from '../components/panels/logs'; +import { PackagesPanel } from '../components/panels/packages'; +import { BlocklyEditorPanel } from '../components/panels/blockly'; +import { JaculusPanel } from '../components/panels/jaculus'; + +// Component registry - map panel types to component factory functions +const PANEL_COMPONENTS: Record< + PanelType, + (config?: Record) => React.ReactNode +> = { + blockly: () => , + console: () => , + 'file-explorer': () => , + code: config => , + 'generated-code': () => , + wokwi: () => <>wokwi, + packages: () => , + logs: () => , + jaculus: () => , +}; + +export function factory(node: FlexLayout.TabNode) { + const component = node.getComponent() as PanelType; + const tabName = node.getName(); + const isInBorder = node.getParent() instanceof FlexLayout.BorderNode; + const isHighlighted = false; + + const config = node.getConfig(); + const wrapComponent = ( + children: React.ReactNode, + showName: boolean = false, + highlight: boolean = false + ) => ( + + {children} + + ); + + const panelFactory = PANEL_COMPONENTS[component]; + const panelContent = panelFactory ? ( + panelFactory(config) + ) : ( +
Unknown component: {component}
+ ); + + return wrapComponent(panelContent, isInBorder, isHighlighted); +} diff --git a/apps/web/src/features/project/lib/flexlayout-defaults.ts b/apps/web/src/features/project/lib/flexlayout-defaults.ts new file mode 100644 index 0000000..6ee86a0 --- /dev/null +++ b/apps/web/src/features/project/lib/flexlayout-defaults.ts @@ -0,0 +1,97 @@ +import * as FlexLayout from 'flexlayout-react'; + +export const defaultGlobalSettings: FlexLayout.IGlobalAttributes = { + tabEnableClose: false, + tabEnableRename: false, + tabSetEnableMaximize: true, + tabSetEnableDrop: true, + tabSetEnableDrag: true, + tabSetEnableTabStrip: true, +}; + +export const defaultLayout: FlexLayout.IJsonRowNode = { + type: 'row', + weight: 100, + children: [ + { + type: 'tabset', + weight: 100, + id: 'main-tabset', + children: [ + { + type: 'tab', + name: 'Blockly Editor', + component: 'blockly', + id: 'blockly', + }, + ], + }, + ], +}; + +export const defaultBorderLayout: FlexLayout.IJsonBorderNode[] = [ + { + type: 'border', + location: 'left', + size: 250, + selected: -1, + children: [ + { + type: 'tab', + name: 'File Explorer', + component: 'file-explorer', + id: 'file-explorer', + enableClose: false, + }, + { + type: 'tab', + name: 'Packages', + component: 'packages', + id: 'packages', + enableClose: false, + }, + { + type: 'tab', + name: 'Jaculus', + component: 'jaculus', + id: 'jaculus', + enableClose: false, + }, + ], + }, + { + type: 'border', + location: 'right', + size: 400, + selected: -1, + children: [ + { + type: 'tab', + name: 'Device Console', + component: 'console', + id: 'console', + enableClose: false, + }, + { + type: 'tab', + name: 'Generated Code', + component: 'generated-code', + id: 'generated-code', + enableClose: false, + }, + { + type: 'tab', + name: 'Logs', + component: 'logs', + id: 'logs', + enableClose: false, + }, + ], + }, +]; + +export const flexLayoutDefaultJson: FlexLayout.IJsonModel = { + global: defaultGlobalSettings, + borders: defaultBorderLayout, + layout: defaultLayout, +}; diff --git a/apps/web/src/features/project/lib/flexlayout.ts b/apps/web/src/features/project/lib/flexlayout.ts new file mode 100644 index 0000000..fa4d61e --- /dev/null +++ b/apps/web/src/features/project/lib/flexlayout.ts @@ -0,0 +1,269 @@ +import * as FlexLayout from 'flexlayout-react'; +import type { + FlexLayoutAttributes, + PanelAction, + PanelType, +} from '@/features/project/types/flexlayout-type'; +import { flexLayoutDefaultJson } from '@/features/project/lib/flexlayout-defaults'; + +export function processAllTabs( + model: FlexLayout.IJsonModel, + callback: (tab: FlexLayoutAttributes) => void +) { + model.borders?.forEach(border => { + border.children?.forEach(tabNode => { + callback(tabNode as FlexLayoutAttributes); + }); + }); + + function processNode(node: FlexLayout.IJsonRowNode) { + node.children?.forEach(child => { + if (child.type === 'tab') { + const tabNode = child as FlexLayout.IJsonTabSetNode; + callback(tabNode as FlexLayoutAttributes); + } else if (child.type === 'tabset') { + processNode(child as FlexLayout.IJsonRowNode); + } + }); + } + + if (model.layout) { + processNode(model.layout); + } +} + +export function findAllTabIds(model: FlexLayout.IJsonModel): Set { + const tabIds = new Set(); + processAllTabs(model, tab => { + tabIds.add(tab.id); + }); + return tabIds; +} + +export function getUpdatedLayoutModel( + json: FlexLayout.IJsonModel | null +): FlexLayout.IJsonModel { + if (!json) { + return flexLayoutDefaultJson; + } + + const defaultTabIds = findAllTabIds(flexLayoutDefaultJson); + const currentTabIds = findAllTabIds(json); + + // Find tabs to add (in default but not in current) + const tabsToAdd = new Set(); + defaultTabIds.forEach(id => { + if (!currentTabIds.has(id)) { + tabsToAdd.add(id); + } + }); + + // Clone the current model (keep all existing tabs, even if removed from default) + const updatedModel: FlexLayout.IJsonModel = structuredClone(json); + + // Ensure borders structure exists + if (!updatedModel.borders && flexLayoutDefaultJson.borders) { + updatedModel.borders = flexLayoutDefaultJson.borders.map(border => ({ + ...border, + children: [], + })); + } else if (updatedModel.borders && flexLayoutDefaultJson.borders) { + // Ensure all default borders exist + flexLayoutDefaultJson.borders.forEach((defaultBorder, borderIndex) => { + if (!updatedModel.borders![borderIndex]) { + updatedModel.borders![borderIndex] = { + ...defaultBorder, + children: [], + }; + } + }); + } + + // Add missing tabs from default layout + if (tabsToAdd.size > 0) { + flexLayoutDefaultJson.borders?.forEach((defaultBorder, borderIndex) => { + defaultBorder.children?.forEach(defaultTab => { + const tabNode = defaultTab as FlexLayoutAttributes; + if (tabsToAdd.has(tabNode.id) && updatedModel.borders) { + if (!updatedModel.borders[borderIndex].children) { + updatedModel.borders[borderIndex].children = []; + } + updatedModel.borders[borderIndex].children?.push( + structuredClone(defaultTab) + ); + } + }); + }); + + // Add missing tabs from main layout + flexLayoutDefaultJson.layout.children?.forEach(child => { + if (child.type === 'tabset') { + const tabsetNode = child as FlexLayout.IJsonTabSetNode; + const missingTabsForThisTabset: FlexLayout.IJsonTabNode[] = []; + + // Collect all missing tabs for this tabset + tabsetNode.children?.forEach(defaultTab => { + const tabNode = defaultTab as FlexLayoutAttributes; + if (tabsToAdd.has(tabNode.id)) { + missingTabsForThisTabset.push(structuredClone(defaultTab)); + } + }); + + if (missingTabsForThisTabset.length === 0) return; + + // Try to find corresponding tabset in updatedModel + let tabsetFound = false; + + function findAndAddTabs(node: FlexLayout.IJsonRowNode): boolean { + let found = false; + node.children?.forEach(child => { + if (child.type === 'tabset') { + const currentTabset = child as FlexLayout.IJsonTabSetNode; + if (currentTabset.id === tabsetNode.id) { + if (!currentTabset.children) { + currentTabset.children = []; + } + currentTabset.children.push(...missingTabsForThisTabset); + found = true; + } + } else if (child.type === 'row' || child.type === 'column') { + if (findAndAddTabs(child as FlexLayout.IJsonRowNode)) { + found = true; + } + } + }); + return found; + } + + if (updatedModel.layout) { + tabsetFound = findAndAddTabs(updatedModel.layout); + } + + // If tabset not found, add the entire tabset from default + if (!tabsetFound && updatedModel.layout) { + const newTabset = structuredClone(tabsetNode); + // Only include the missing tabs + newTabset.children = missingTabsForThisTabset; + + if (!updatedModel.layout.children) { + updatedModel.layout.children = []; + } + updatedModel.layout.children.push(newTabset); + } + } + }); + } + + return updatedModel; +} + +export function controlPanel( + model: FlexLayout.Model, + type: PanelType, + action: PanelAction +) { + const node = model.getNodeById(type); + if (!node) { + console.warn(`Panel '${type}' not found in layout`); + return; + } + + const parent = node.getParent(); + const isInBorder = parent instanceof FlexLayout.BorderNode; + + switch (action) { + case 'close': + model.doAction(FlexLayout.Actions.deleteTab(node.getId())); + break; + case 'expand': + if (isInBorder) { + // For border panels, just select them to make them visible + model.doAction(FlexLayout.Actions.selectTab(node.getId())); + } else { + model.doAction(FlexLayout.Actions.selectTab(node.getId())); + model.doAction( + FlexLayout.Actions.updateNodeAttributes(node.getId(), { + size: 100, + }) + ); + } + break; + case 'collapse': + if (isInBorder) { + // For border panels, unselect by selecting -1 to hide the border + const border = parent as FlexLayout.BorderNode; + model.doAction( + FlexLayout.Actions.updateNodeAttributes(border.getId(), { + selected: -1, + }) + ); + } else { + model.doAction( + FlexLayout.Actions.updateNodeAttributes(node.getId(), { + size: 0, + }) + ); + } + break; + case 'focus': + model.doAction(FlexLayout.Actions.selectTab(node.getId())); + break; + } +} + +export function openPanel( + model: FlexLayout.Model, + type: PanelType, + props?: { filePath?: string } +) { + if (!model) return; + + switch (type) { + case 'code': { + const panelId = `source-code-${props?.filePath}`; + const node = model.getNodeById(panelId); + if (node) { + // Panel already exists, just focus it + model.doAction(FlexLayout.Actions.selectTab(panelId)); + return; + } + + let tabset = model.getNodeById('main-tabset'); + if (!tabset) { + model.doAction( + FlexLayout.Actions.addNode( + { + type: 'tabset', + id: 'main-tabset', + }, + model.getRoot().getId(), + FlexLayout.DockLocation.CENTER, + -1 + ) + ); + tabset = model.getNodeById('main-tabset') as FlexLayout.TabSetNode; + } + + const toNode: FlexLayout.IJsonTabNode = { + type: 'tab', + name: props?.filePath?.split('/').pop() || 'Unnamed', + component: 'code', + id: panelId, + enableClose: true, + config: { filePath: props?.filePath }, + }; + model.doAction( + FlexLayout.Actions.addNode( + toNode, + tabset.getId(), + FlexLayout.DockLocation.CENTER, + -1 + ) + ); + + break; + } + default: + console.warn(`openPanel: Unsupported panel type '${type}'`); + } +} diff --git a/apps/web/src/lib/projects/request.ts b/apps/web/src/features/project/lib/request.ts similarity index 98% rename from apps/web/src/lib/projects/request.ts rename to apps/web/src/features/project/lib/request.ts index 7af2650..9e1c5d6 100644 --- a/apps/web/src/lib/projects/request.ts +++ b/apps/web/src/features/project/lib/request.ts @@ -31,8 +31,6 @@ export async function loadPackageUri( throw new Error(`Unsupported URI scheme or missing fs for ${pkgUri}`); } - console.log(gz); - const dirs: string[] = []; const files: Record = {}; diff --git a/apps/web/src/features/project/provider/active-project-provider.tsx b/apps/web/src/features/project/provider/active-project-provider.tsx new file mode 100644 index 0000000..653d5cc --- /dev/null +++ b/apps/web/src/features/project/provider/active-project-provider.tsx @@ -0,0 +1,114 @@ +import { createContext, use, useEffect, useState, type ReactNode } from 'react'; +import * as fs from 'fs'; +import type { IDbProject } from '@/types/project'; +import { + ProjectFsService, + type ProjectFsInterface, +} from '@/services/project-fs-service'; +import { ProjectLoadingIndicator } from '@/features/project/components/project-loading'; +import { ProjectLoadError } from '@/features/project/components/project-load-error'; +import { JaclyFiles } from '../types/jacly-files'; +// import { useMonaco } from '@monaco-editor/react'; +export interface ActiveProjectContextValue { + fs: typeof fs; + fsp: typeof fs.promises; + dbProject: IDbProject; + projectPath: string; + getFileName(fileType: keyof typeof JaclyFiles): string; +} + +export const ActiveProjectContext = + createContext(null); + +interface ActiveProjectProviderProps { + dbProject: IDbProject; + projectFsService: ProjectFsService; + children: ReactNode; +} + +export function ActiveProjectProvider({ + dbProject: project, + projectFsService, + children, +}: ActiveProjectProviderProps) { + const [fsInterface, setFsInterface] = useState( + null + ); + const [error, setError] = useState(null); + + // const monaco = useMonaco(); + + useEffect(() => { + // if (!monaco) return; + + let mounted = true; + + async function mountFs() { + try { + const result = await projectFsService.mount(project.id); + if (mounted) { + // indexMonacoFiles( + // monaco, + // result.projectPath, + // result.fs.promises as unknown as typeof fs.promises + // ); + // watchMonacoFiles( + // monaco, + // result.projectPath, + // result.fs as unknown as typeof fs + // ); + + setFsInterface(result); + } + } catch (err) { + if (mounted) { + setError( + err instanceof Error ? err : new Error('Failed to mount filesystem') + ); + } + } + } + + mountFs(); + + return () => { + mounted = false; + // Unmount when leaving the project + projectFsService.unmount(project.id); + }; + }, [project.id, projectFsService /*monaco */]); + + if (error) { + return ; + } + + if (!fsInterface) { + return ; + } + + const contextValue: ActiveProjectContextValue = { + fs: fsInterface.fs as unknown as typeof fs, + fsp: fsInterface.fs.promises as unknown as typeof fs.promises, + dbProject: project, + projectPath: fsInterface.projectPath, + getFileName(fileType) { + return `${fsInterface.projectPath}/${JaclyFiles[fileType]}`; + }, + }; + + return ( + + {children} + + ); +} + +export function useActiveProject(): ActiveProjectContextValue { + const context = use(ActiveProjectContext); + if (!context) { + throw new Error( + 'useActiveProject must be used within an ActiveProjectProvider' + ); + } + return context; +} diff --git a/apps/web/src/features/project/provider/project-editor-provider.tsx b/apps/web/src/features/project/provider/project-editor-provider.tsx new file mode 100644 index 0000000..a34535e --- /dev/null +++ b/apps/web/src/features/project/provider/project-editor-provider.tsx @@ -0,0 +1,95 @@ +import { createContext, use, useState, useEffect } from 'react'; +import * as FlexLayout from 'flexlayout-react'; +import { Route } from '@/routes/__root'; +import { ProjectLoadingIndicator } from '@/features/project/components/project-loading'; +import '@/features/project/components/flex-layout/flexlayout.css'; +import { flexLayoutDefaultJson } from '@/features/project/lib/flexlayout-defaults'; +import { + controlPanel, + getUpdatedLayoutModel, + openPanel, +} from '@/features/project/lib/flexlayout'; +import type { + NewPanelProps, + PanelAction, + PanelType, +} from '@/features/project/types/flexlayout-type'; +import { enqueueSnackbar } from 'notistack'; +import { factory } from '@/features/project/lib/flexlayout-components'; +import { ProjectEditorHeader } from '../components/project-editor-header'; + +export interface EditorContextValue { + controlPanel: (type: PanelType, action: PanelAction) => void; + openPanel: { + (type: 'code', props?: NewPanelProps['code']): void; + }; +} + +const initialState: EditorContextValue = { + controlPanel: () => {}, + openPanel: () => {}, +}; + +export const EditorContext = createContext(initialState); + +export function ProjectEditorProvider() { + const { settingsService } = Route.useRouteContext(); + const [model, setModel] = useState(null); + + useEffect(() => { + const loadLayout = async () => { + try { + const settings = await settingsService.getSettings(); + const savedLayout = settings.flexLayoutModel; + setModel(FlexLayout.Model.fromJson(getUpdatedLayoutModel(savedLayout))); + } catch (error) { + console.error('Failed to load layout settings:', error); + enqueueSnackbar('Failed to load layout settings, using default.', { + variant: 'info', + }); + setModel(FlexLayout.Model.fromJson(flexLayoutDefaultJson)); + } + }; + + loadLayout(); + }, [settingsService]); + + async function handleModelChange(newModel: FlexLayout.Model) { + setModel(newModel); + try { + await settingsService.setSettings('flexLayoutModel', newModel.toJson()); + } catch (_error) { + console.error('Error saving layout model:', _error); + } + } + + const value: EditorContextValue = { + controlPanel: controlPanel.bind(null, model!), + openPanel: openPanel.bind(null, model!), + }; + + if (!model) { + return ; + } + + return ( + + +
+ +
+
+ ); +} + +export function useEditor() { + const context = use(EditorContext); + if (context === undefined) { + throw new Error('useEditor must be used within a EditorProvider'); + } + return context; +} diff --git a/apps/web/src/features/project/types/flexlayout-type.ts b/apps/web/src/features/project/types/flexlayout-type.ts new file mode 100644 index 0000000..ee6116b --- /dev/null +++ b/apps/web/src/features/project/types/flexlayout-type.ts @@ -0,0 +1,23 @@ +export type FlexLayoutAttributes = { + type: string; + name: string; + id: string; + enableClose: boolean; +}; + +export type PanelType = + | 'blockly' + | 'console' + | 'file-explorer' + | 'code' + | 'generated-code' + | 'wokwi' + | 'packages' + | 'logs' + | 'jaculus'; + +export type PanelAction = 'close' | 'expand' | 'collapse' | 'focus'; + +export type NewPanelProps = { + code: { filePath?: string }; +}; diff --git a/apps/web/src/features/project/types/jacly-files.ts b/apps/web/src/features/project/types/jacly-files.ts new file mode 100644 index 0000000..fd6f31e --- /dev/null +++ b/apps/web/src/features/project/types/jacly-files.ts @@ -0,0 +1,5 @@ +export const JaclyFiles = { + JACLY_INDEX: 'src/index.jacly', + GENERATED_CODE: 'build/index.js', + PACKAGE_JSON: 'package.json', +} as const; diff --git a/apps/web/src/features/shared/components/custom/error-boundary.tsx b/apps/web/src/features/shared/components/custom/error-boundary.tsx new file mode 100644 index 0000000..1929bd9 --- /dev/null +++ b/apps/web/src/features/shared/components/custom/error-boundary.tsx @@ -0,0 +1,33 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface Props { + fallback: ReactNode; + children?: ReactNode; +} + +interface State { + hasError: boolean; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(/* _: Error */): State { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return this.props.fallback; + } + + return this.props.children; + } +} diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/features/shared/components/custom/general-header.tsx similarity index 70% rename from apps/web/src/components/layout/header.tsx rename to apps/web/src/features/shared/components/custom/general-header.tsx index aedfeac..1b9de62 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/features/shared/components/custom/general-header.tsx @@ -1,13 +1,10 @@ +import { ThemeToggle } from '@/features/theme/components/theme-toggle'; import { Link } from '@tanstack/react-router'; -import { ModeToggle } from '@/components/theme/mode-toggle'; -import { useHeaderActions } from '@/providers/header-provider'; - -export function Header() { - const { actions } = useHeaderActions(); +export function GeneralHeader() { const links = [ { name: 'Home' as string, path: '/' }, - { name: 'Editor' as string, path: '/editor/' }, + { name: 'Projects' as string, path: '/project/' }, ]; return ( @@ -26,13 +23,9 @@ export function Header() { ))} - - {/* Dynamic actions injected from current page */} - {actions &&
{actions}
} - {/* Theme switcher */}
- + {/* */}
diff --git a/apps/web/src/components/projects/project-card.tsx b/apps/web/src/features/shared/components/custom/project-card.tsx similarity index 91% rename from apps/web/src/components/projects/project-card.tsx rename to apps/web/src/features/shared/components/custom/project-card.tsx index 41702d8..bf7e64f 100644 --- a/apps/web/src/components/projects/project-card.tsx +++ b/apps/web/src/features/shared/components/custom/project-card.tsx @@ -4,8 +4,8 @@ import { CardDescription, CardHeader, CardTitle, -} from '@/components/ui/card'; -import { cn } from '@/lib/utils'; +} from '@/features/shared/components/ui/card'; +import { cn } from '@/lib/utils/cn'; interface ProjectCardProps { title: string; diff --git a/apps/web/src/features/shared/components/ui/accordion.tsx b/apps/web/src/features/shared/components/ui/accordion.tsx new file mode 100644 index 0000000..260e189 --- /dev/null +++ b/apps/web/src/features/shared/components/ui/accordion.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils/cn'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; + +function Accordion({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/web/src/features/shared/components/ui/alert-dialog.tsx b/apps/web/src/features/shared/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..b4d5270 --- /dev/null +++ b/apps/web/src/features/shared/components/ui/alert-dialog.tsx @@ -0,0 +1,197 @@ +import * as React from 'react'; +import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; + +import { cn } from '@/lib/utils/cn'; +import { Button } from '@/features/shared/components/ui/button'; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'default' | 'sm'; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = 'default', + size = 'default', + ...props +}: React.ComponentProps & + Pick, 'variant' | 'size'>) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = 'outline', + size = 'default', + ...props +}: React.ComponentProps & + Pick, 'variant' | 'size'>) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/apps/web/src/features/shared/components/ui/badge.tsx b/apps/web/src/features/shared/components/ui/badge.tsx new file mode 100644 index 0000000..599bb0a --- /dev/null +++ b/apps/web/src/features/shared/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; + +import { cn } from '@/lib/utils/cn'; + +const badgeVariants = cva( + 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: + 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20', + outline: + 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: + 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +function Badge({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : 'span'; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/features/shared/components/ui/button-group.tsx b/apps/web/src/features/shared/components/ui/button-group.tsx new file mode 100644 index 0000000..d254ca0 --- /dev/null +++ b/apps/web/src/features/shared/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; + +import { cn } from '@/lib/utils/cn'; +import { Separator } from '@/features/shared/components/ui/separator'; + +const buttonGroupVariants = cva( + "has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none', + vertical: + '[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none', + }, + }, + defaultVariants: { + orientation: 'horizontal', + }, + } +); + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<'div'> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot.Root : 'div'; + + return ( + + ); +} + +function ButtonGroupSeparator({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +}; diff --git a/apps/web/src/features/shared/components/ui/button.tsx b/apps/web/src/features/shared/components/ui/button.tsx new file mode 100644 index 0000000..a7f505d --- /dev/null +++ b/apps/web/src/features/shared/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Slot } from 'radix-ui'; + +import { cn } from '@/lib/utils/cn'; + +const buttonVariants = cva( + "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + outline: + 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground', + destructive: + 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', + icon: 'size-8', + 'icon-xs': + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +function Button({ + className, + variant = 'default', + size = 'default', + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot.Root : 'button'; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/features/shared/components/ui/card.tsx similarity index 55% rename from apps/web/src/components/ui/card.tsx rename to apps/web/src/features/shared/components/ui/card.tsx index dbcf8b3..d02d633 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/features/shared/components/ui/card.tsx @@ -1,13 +1,18 @@ import * as React from 'react'; -import { cn } from '@/lib/utils'; +import { cn } from '@/lib/utils/cn'; -function Card({ className, ...props }: React.ComponentProps<'div'>) { +function Card({ + className, + size = 'default', + ...props +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { return (
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', className )} {...props} @@ -20,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
) { return (
); @@ -65,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<'div'>) { return (
); @@ -75,7 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
); diff --git a/apps/web/src/features/shared/components/ui/combobox.tsx b/apps/web/src/features/shared/components/ui/combobox.tsx new file mode 100644 index 0000000..42bb79e --- /dev/null +++ b/apps/web/src/features/shared/components/ui/combobox.tsx @@ -0,0 +1,302 @@ +'use client'; + +import * as React from 'react'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; + +import { cn } from '@/lib/utils/cn'; +import { Button } from '@/features/shared/components/ui/button'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from '@/features/shared/components/ui/input-group'; +import { ChevronDownIcon, XIcon, CheckIcon } from 'lucide-react'; + +const Combobox = ComboboxPrimitive.Root; + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return ; +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + + + + )} + {showClear && } + + {children} + + ); +} + +function ComboboxContent({ + className, + side = 'bottom', + sideOffset = 6, + align = 'start', + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor' + >) { + return ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ); +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ); +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( +