diff --git a/.gitignore b/.gitignore index 6bb5149..5b8fe02 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ build/ out/ apps/desktop-app/release/ +Projects/ *.tsbuildinfo # Logs @@ -29,4 +30,4 @@ pnpm-lock.yaml # Secrets / environment .env *.env -apps/desktop-app/.env \ No newline at end of file +apps/desktop-app/.env diff --git a/Projects/hello_ros/docker-compose.yml b/Projects/hello_ros/docker-compose.yml index 7d80b73..b5d14e5 100644 --- a/Projects/hello_ros/docker-compose.yml +++ b/Projects/hello_ros/docker-compose.yml @@ -1,9 +1,11 @@ services: - bros_helloros: - image: ros:humble - container_name: bros_helloros + bros2_helloros: + image: bros2/ros2-humble:latest + container_name: bros2_helloros command: bash -lc "sleep infinity" working_dir: /workspace tty: true volumes: - - "/Users/trieutran/BROS2/Projects/hello_ros/workspace:/workspace" + - "/Users/noahhathout/BROS2/Projects/hello_ros/workspace:/workspace" + ports: + - "9090:9090" diff --git a/README.md b/README.md index f5b918e..d39a7af 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

BROS2 logo

@@ -23,6 +23,51 @@ Verify Docker access before continuing: docker ps ``` +## Quick Dev Loop + +1. Select Node 20.19.x + + ```bash + source ~/.nvm/nvm.sh + nvm use 20.19.0 + ``` + +2. Refresh deps (clean if things feel stale): + + ```bash + pnpm install -r + pnpm -r clean # optional, only if builds seem out of date + ``` + + If you did clean, run the workspace builds in the Daily Development section (step 3) before moving on below. + +3. Build the ROS image once per machine (safe to rerun): + + ```bash + pnpm --filter ./apps/desktop-app ros:build-image + ``` + +4. Regenerate the Electron main + preload bundle (needed after a clean): + + ```bash + pnpm --filter ./apps/desktop-app build:main + ``` + +5. Launch the desktop dev stack (Electron main + Vite renderer): + + ```bash + pnpm --filter ./apps/desktop-app dev + ``` + +6. In Electron DevTools, bring up ROS 2 + rosbridge when you need it: + + ```js + await window.runner.up("hello_ros"); + await window.runner.exec('bash -lc "source /opt/ros/humble/setup.bash && ros2 launch rosbridge_server rosbridge_websocket_launch.xml"'); + ``` + +Skip to the sections below for the full workflow details and tests. + ## First-Time Setup ```bash @@ -48,13 +93,13 @@ It launches the packaged app once everything compiles. If the script adds an `nv nvm use 20.19.0 ``` -2. **Refresh dependencies** after pulling changes: +2. **Refresh dependencies** after pulling changes (rerun `pnpm -r clean` first if builds look stale): ```bash pnpm install -r ``` -3. **Build the workspace libraries** so their `.d.ts` files exist for the Electron main process. Run each filter separately from the repo root (brace expansion is not supported): +3. **Build the workspace libraries** (run this after changing any of these packages so their `.d.ts` files stay fresh for Electron main): ```bash pnpm --filter @bros2/runtime build @@ -64,8 +109,14 @@ It launches the packaged app once everything compiles. If the script adds an `nv pnpm --filter @bros2/runner build ``` -4. **Emit the desktop main + preload bundle** (run from the repo root). - _Do not skip this step after running `pnpm -r clean`; it regenerates the preload bridges and the runtime registry that power `window.runtime`._ +4. **Keep the ROS runner image up to date** (once per machine, rerun after touching `packages/services/runner/images/ros2-humble`): + + ```bash + pnpm --filter ./apps/desktop-app ros:build-image + ``` + +5. **Emit the desktop main + preload bundle** (run from the repo root). +_Do not skip this step after running `pnpm -r clean`; it regenerates the preload bridges and the runtime registry that power `window.runtime`._ ```bash pnpm --filter ./apps/desktop-app build:main @@ -77,7 +128,7 @@ It launches the packaged app once everything compiles. If the script adds an `nv pnpm --filter ./apps/desktop-app build:renderer ``` -5. **Start the dev environment** (Electron main + Vite renderer): +6. **Start the dev environment** (Electron main + Vite renderer): ```bash pnpm --filter ./apps/desktop-app dev @@ -116,7 +167,7 @@ await window.runner.exec("ros2 pkg list | head -n 5"); await window.runner.down(); ``` -This spins up the `bros_hello_ros` container defined in `Projects/hello_ros` and exercises the ROS 2 CLI. +This spins up the `bros2_hello_ros` container defined in `Projects/hello_ros` and exercises the ROS 2 CLI. ### IR build + validation example @@ -153,6 +204,21 @@ window.runtime.list(); // ["ArrowKeyPub_1", "ConsoleSub_1"] If `window.runtime` is missing, run `pnpm --filter ./apps/desktop-app build:main` again to regenerate the preload bridges. +### API cheatsheet (DevTools) + +- `window.runner.up(projectName)` – start/update the Docker container `bros2_` backed by `bros2/ros2-humble:latest`. +- `window.runner.exec(command)` – run commands like `ros2 topic list` or launching rosbridge: + + ```js + await window.runner.exec('bash -lc "source /opt/ros/humble/setup.bash && ros2 launch rosbridge_server rosbridge_websocket_launch.xml"'); + ``` + +- `window.runner.down()` – stop/remove the ROS 2 container. +- `window.runtime.create(type, config)` – instantiate nodes registered in `apps/desktop-app/src/renderer/runtime/registry.ts` (`ArrowKeyPub`, `ConsoleSub`, `RosbridgeBridge`, `Forwarder`). +- `window.runtime.start(id)`, `window.runtime.stop(id)`, `window.runtime.stopAll()` – control renderer-runtime nodes. +- `window.ir.build(...)` / `window.ir.validate(...)` – convert block graphs to IR and run validators. +- `globalThis.__rosbridge__` – dev-only handle populated by `RosbridgeBridge` with helpers like `publishRos(topic, msg)`. + ## Cleaning & Full Rebuild 1. Remove build outputs everywhere (this clears `dist/` folders and `tsconfig.main.tsbuildinfo`, ensuring the desktop main bundle re-emits `dist/main.js`): @@ -184,7 +250,10 @@ If `window.runtime` is missing, run `pnpm --filter ./apps/desktop-app build:main pnpm -r build ``` - You may ignore macOS code-sign warnings on local development machines. +## Supporting docs + +- [`apps/desktop-app/README.md`](apps/desktop-app/README.md) – ROS 2 quickstart snippet, DevTools walkthrough, and desktop-specific scripts. +- [`packages/services/runner/images/ros2-humble/README.md`](packages/services/runner/images/ros2-humble/README.md) – maintenance notes for the Docker image used by `window.runner`. ## Tips - Keep Docker running whenever you use `window.runner.*`; the runner manages containers in `Projects/`. diff --git a/apps/desktop-app/README.md b/apps/desktop-app/README.md new file mode 100644 index 0000000..2d1e4a8 --- /dev/null +++ b/apps/desktop-app/README.md @@ -0,0 +1,11 @@ +# BROS2 Desktop + +## ROS2 Quickstart (Dev) + +Build the local ROS 2 image once before launching the desktop app: + +```bash +pnpm --filter ./apps/desktop-app ros:build-image +``` + +After the app is running (`pnpm -r build` then `pnpm --filter ./apps/desktop-app dev`), open DevTools and execute the runner/runtime snippet from the acceptance checklist to spin up `window.runner`, create a `RosbridgeBridge`, start `ArrowKeyPub`, add a `Forwarder`, and interact with ROS 2 topics. \ No newline at end of file diff --git a/apps/desktop-app/package.json b/apps/desktop-app/package.json index 282a322..681b030 100644 --- a/apps/desktop-app/package.json +++ b/apps/desktop-app/package.json @@ -14,12 +14,14 @@ "build:renderer": "vite build --config src/renderer/vite.config.ts", "start": "cross-env NODE_ENV=production electron ./dist/main.js", "clean": "rimraf dist release tsconfig.main.tsbuildinfo", + "ros:build-image": "docker build -t bros2/ros2-humble:latest ../../packages/services/runner/images/ros2-humble", "typecheck": "pnpm tsc -b tsconfig.main.json && pnpm tsc --noEmit -p tsconfig.renderer.json", "postinstall": "electron-builder install-app-deps" }, "build": { "appId": "com.bros2.desktop", "productName": "BROS2 Desktop", + "icon": "../../assets/logos/bros-logo-icon.ico", "directories": { "output": "release" }, @@ -29,6 +31,16 @@ "package.json" ], "asar": true, + "extraResources": [ + { + "from": "../../assets/logos/bros-logo-icon.ico", + "to": "bros-logo-icon.ico" + }, + { + "from": "../../assets/logos/BROS2-logo.PNG", + "to": "BROS2-logo.PNG" + } + ], "mac": { "category": "public.app-category.developer-tools", "target": [ @@ -60,6 +72,7 @@ "cross-env": "^10.1.0", "electron": "^39.0.0", "electron-builder": "^26.0.12", + "electron-devtools-installer": "^4.0.0", "rimraf": "^6.0.1", "ts-node": "^10.9.2", "typescript": "^5.6.3", @@ -71,6 +84,7 @@ "@bros2/shared": "workspace:*", "@bros2/ui": "workspace:*", "@bros2/validation": "workspace:*", + "@xyflow/react": "^12.9.3", "bootstrap": "^5.3.8", "dotenv": "^17.2.3", "express": "^5.1.0", diff --git a/apps/desktop-app/src/assets/BROS2-logo-long.png b/apps/desktop-app/src/assets/BROS2-logo-long.png new file mode 100644 index 0000000..c02ee7c Binary files /dev/null and b/apps/desktop-app/src/assets/BROS2-logo-long.png differ diff --git a/apps/desktop-app/src/assets/BROS2-logo.PNG b/apps/desktop-app/src/assets/BROS2-logo.PNG new file mode 100644 index 0000000..5dd5865 Binary files /dev/null and b/apps/desktop-app/src/assets/BROS2-logo.PNG differ diff --git a/apps/desktop-app/src/main.ts b/apps/desktop-app/src/main.ts index 00971e3..9c9e5ce 100644 --- a/apps/desktop-app/src/main.ts +++ b/apps/desktop-app/src/main.ts @@ -1,8 +1,10 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; import * as electron from "electron"; // ✅ NOTE: namespace import import type { BrowserWindow } from "electron"; +import type { NativeImage } from "electron"; import express from "express"; import dotenv from "dotenv"; @@ -11,7 +13,7 @@ import type { IR } from "@bros2/shared"; import type { Runner as RunnerInstance } from "@bros2/runner"; import type { WorkspaceDocument, WorkspaceSummary } from "./shared/workspace"; -const { app, ipcMain, shell, BrowserWindow: BrowserWindowCtor } = electron; +const { app, ipcMain, shell, BrowserWindow: BrowserWindowCtor, nativeImage, session } = electron; // --- Dynamic module loaders --- type RunnerCtor = typeof import("@bros2/runner")["Runner"]; @@ -23,6 +25,116 @@ let runnerProjectKey: string | null = null; let mainWindow: BrowserWindow | null = null; let workspaceRoot: string | null = null; +let trashRoot: string | null = null; + +const APP_ICON_CANDIDATES = [ + "bros-logo-icon.icns", + "BROS2-logo.PNG", + "bros-logo-icon.ico", +]; +const REACT_DEVTOOLS_IDS = [ + // MV3 (new) React DevTools + "nkigjnjahdpfgmkaammbpohkfccginfo", + // MV2 (legacy) React DevTools + "fmkadmapgofadopljbjfkapdkoienihi", +]; + +const resolveAppIconPath = (): string | undefined => { + const roots = [process.resourcesPath, path.join(__dirname, "..", "..", "..", "assets", "logos")]; + for (const root of roots) { + for (const filename of APP_ICON_CANDIDATES) { + const candidate = path.join(root, filename); + if (fs.existsSync(candidate)) return candidate; + } + } + return undefined; +}; + +const getAppIcon = (): NativeImage | undefined => { + const iconPath = resolveAppIconPath(); + if (!iconPath) { + console.warn("[app] icon not found; using default Electron icon"); + return undefined; + } + const image = nativeImage.createFromPath(iconPath); + if (image.isEmpty()) { + console.warn("[app] icon loaded but empty; path=", iconPath); + return undefined; + } + return image; +}; + +const resolveReactDevtoolsFromChrome = async (): Promise => { + const explicit = process.env.REACT_DEVTOOLS_PATH; + if (explicit && fs.existsSync(explicit)) { + return explicit; + } + + const home = os.homedir(); + const candidates: string[] = []; + + const ids = REACT_DEVTOOLS_IDS; + + if (process.platform === "darwin") { + for (const id of ids) { + candidates.push( + path.join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Extensions", id) + ); + } + } else if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"); + for (const id of ids) { + candidates.push( + path.join(localAppData, "Google", "Chrome", "User Data", "Default", "Extensions", id) + ); + } + } else { + const configHome = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"); + const linuxBases = ["google-chrome", "google-chrome-beta", "google-chrome-canary", "chromium"]; + for (const base of linuxBases) { + for (const id of ids) { + candidates.push(path.join(configHome, base, "Default", "Extensions", id)); + } + } + } + + for (const base of candidates) { + try { + const entries = await fs.promises.readdir(base, { withFileTypes: true }); + const versions = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); + if (!versions.length) continue; + versions.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + return path.join(base, versions[versions.length - 1]); + } catch { + continue; + } + } + return null; +}; + +const installReactDevtools = async () => { + if (process.env.NODE_ENV !== "development") return; + try { + const extensionPath = await resolveReactDevtoolsFromChrome(); + if (!extensionPath) { + console.warn( + "[devtools] React DevTools not found in Chrome profile. Install the React DevTools Chrome extension (MV3: nkigjnjahdpfgmkaammbpohkfccginfo or MV2: fmkadmapgofadopljbjfkapdkoienihi) and restart the app." + ); + return; + } + + const extensionsApi = session.defaultSession.extensions; + if (!extensionsApi?.loadExtension || !extensionsApi?.getAllExtensions) { + throw new Error("session.extensions APIs are unavailable"); + } + + const loaded = await extensionsApi.loadExtension(extensionPath, { allowFileAccess: true }); + const names = extensionsApi.getAllExtensions().map((ext: any) => ext?.name ?? ext); + console.log("[devtools] React DevTools loaded:", loaded?.name ?? loaded, names); + } catch (err) { + console.error("[devtools] Failed to load React DevTools:", err); + } +}; function resolveWorkspaceRoot(): string { if (workspaceRoot) return workspaceRoot; @@ -58,8 +170,203 @@ function resolveWorkspaceRoot(): string { ); } -function workspaceFilePath(id: string) { - return path.join(resolveWorkspaceRoot(), `${id}.json`); +function resolveTrashRoot(): string { + if (trashRoot) return trashRoot; + + const candidates = [ + path.join(app.getPath("documents"), "BROS2", "trash"), + path.join(app.getPath("userData"), "trash"), + ]; + + for (const candidate of candidates) { + try { + fs.mkdirSync(candidate, { recursive: true }); + trashRoot = candidate; + if (candidate !== candidates[0]) { + console.warn( + `[workspace] Falling back to userData trash directory: ${candidate}. Documents directory was not accessible.` + ); + } + return trashRoot; + } catch (err: any) { + if (err?.code === "EACCES" || err?.code === "EPERM") { + console.warn( + `[workspace] Cannot access ${candidate} (permission denied). Trying next fallback.` + ); + continue; + } + throw err; + } + } + + throw new Error("Unable to create trash directory. Please check filesystem permissions."); +} + +const sanitizeWorkspaceName = (value?: string | null) => { + const cleaned = (value ?? "").replace(/[\\/:*?"<>|]/g, "-").replace(/\s+/g, " ").trim(); + return cleaned || "Untitled Workspace"; +}; + +const workspaceFileName = (name?: string | null, suffix?: number) => { + const base = sanitizeWorkspaceName(name); + return `${base}${suffix && suffix > 0 ? ` (${suffix})` : ""}.json`; +}; + +async function workspaceFilePathWithFolder( + id: string, + folder?: string | null, + inTrash = false, + name?: string | null +): Promise { + const base = inTrash ? resolveTrashRoot() : resolveWorkspaceRoot(); + const safeFolder = folder ? folder.split(path.sep).join(path.posix.sep) : ""; + const segments = safeFolder ? safeFolder.split("/") : []; + const dir = path.join(base, ...segments); + await fileSystem.mkdir(dir, { recursive: true }); + + let counter = 0; + while (true) { + const candidateName = workspaceFileName(name, counter); + const target = path.join(dir, candidateName); + try { + const stat = await fileSystem.stat(target); + if (!stat.isFile()) { + counter += 1; + continue; + } + const raw = await fileSystem.readFile(target, "utf-8"); + const doc = JSON.parse(raw) as WorkspaceDocument; + if (doc.id === id) return target; + } catch (err: any) { + if (err?.code === "ENOENT") return target; + if (err?.name === "SyntaxError") { + counter += 1; + continue; + } + throw err; + } + counter += 1; + } +} + +async function listWorkspaceSummaries(): Promise { + const activeDir = resolveWorkspaceRoot(); + const trashDir = resolveTrashRoot(); + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + const summaries: WorkspaceSummary[] = []; + + const scanDir = async (dir: string, isTrash: boolean, folderRel = "") => { + const entries = await fileSystem.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const nextFolderRel = folderRel ? path.join(folderRel, entry.name) : entry.name; + if (entry.isDirectory()) { + await scanDir(fullPath, isTrash, nextFolderRel); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const raw = await fileSystem.readFile(fullPath, "utf-8"); + try { + const doc = JSON.parse(raw) as WorkspaceDocument; + if (isTrash) { + const trashedAt = doc.meta && (doc.meta as any).trashedAt; + if (trashedAt) { + const age = Date.now() - new Date(trashedAt).getTime(); + if (age > sevenDaysMs) { + await fileSystem.unlink(fullPath); + continue; + } + } + } + summaries.push({ + id: doc.id, + name: doc.name, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + meta: { + ...(doc.meta ?? {}), + ...(folderRel ? { folder: folderRel } : {}), + ...(isTrash ? { tags: Array.from(new Set([...(doc.meta?.tags ?? []), "trash"])) } : {}), + }, + }); + } catch (err) { + console.warn(`[workspace] Failed to parse ${entry.name}:`, err); + } + } + }; + + await scanDir(activeDir, false); + await scanDir(trashDir, true); + summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + return summaries; +} + +async function ensureUniqueWorkspaceName( + desiredName: string | undefined, + folder: string | null | undefined, + currentId?: string +): Promise { + const base = sanitizeWorkspaceName(desiredName); + const existing = await listWorkspaceSummaries(); + const folderKey = folder?.trim() ?? ""; + const names = new Set( + existing + .filter( + (ws) => + !ws.meta?.tags?.includes?.("trash") && + (ws.meta?.folder ?? "") === folderKey && + ws.id !== currentId + ) + .map((ws) => ws.name) + ); + if (!names.has(base)) return base; + let counter = 2; + while (true) { + const candidate = `${base} (${counter})`; + if (!names.has(candidate)) return candidate; + counter += 1; + } +} + +async function resolveWorkspaceFile(id: string): Promise<{ filePath: string; inTrash: boolean }> { + const searchDirs = [ + { dir: resolveWorkspaceRoot(), inTrash: false }, + { dir: resolveTrashRoot(), inTrash: true }, + ]; + + const findInDir = async (dir: string): Promise => { + const entries = await fileSystem.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = await findInDir(full); + if (found) return found; + continue; + } + if (entry.isFile() && entry.name.endsWith(".json")) { + try { + const raw = await fileSystem.readFile(full, "utf-8"); + const doc = JSON.parse(raw) as WorkspaceDocument; + if (doc.id === id) return full; + } catch { + continue; + } + } + } + return null; + }; + + for (const { dir, inTrash } of searchDirs) { + try { + const found = await findInDir(dir); + if (found) return { filePath: found, inTrash }; + } catch { + continue; + } + } + + const fallback = path.join(resolveWorkspaceRoot(), workspaceFileName(id)); + return { filePath: fallback, inTrash: false }; } dotenv.config(); @@ -80,11 +387,11 @@ async function getValidateIr(): Promise { // --- Helpers --- function resolvePreloadPath(): string { - // Prefer the built preload that imports all bridges (dist/preload.js) + // Prefer source preload in dev so changes are picked up without rebuild; fall back to dist. const candidates = [ + path.join(__dirname, "preload.js"), path.join(__dirname, "..", "dist", "preload.js"), path.join(app.getAppPath(), "dist", "preload.js"), - path.join(__dirname, "preload.js"), path.join(__dirname, "remote", "preload.cjs"), path.join(__dirname, "remote", "ir-bridge.cjs"), // legacy single-bridge fallback path.join(app.getAppPath(), "dist", "remote", "preload.cjs"), @@ -114,6 +421,7 @@ function createWindow() { mainWindow = new BrowserWindowCtor({ width: 1000, height: 700, + icon: resolveAppIconPath(), webPreferences: { preload: preloadPath, contextIsolation: true, @@ -121,6 +429,8 @@ function createWindow() { sandbox: false, }, }); + const prefs = mainWindow.webContents.getLastWebPreferences?.(); + console.info("[window] webPreferences", prefs); mainWindow.maximize(); if (process.env.NODE_ENV === "development") { @@ -132,6 +442,13 @@ function createWindow() { } } +app.on("ready", () => { + if (process.platform === "darwin") { + const appIcon = getAppIcon(); + if (appIcon && app.dock) app.dock.setIcon(appIcon); + } +}); + // --- IPC: Runner + IR --- ipcMain.handle("runner:up", async (_event, projectName: string) => { const r = await ensureRunner(projectName); @@ -165,31 +482,116 @@ ipcMain.handle("ir:validate", async (_event, irData: IR) => { // --- IPC: Workspace storage --- ipcMain.handle("workspace:list", async () => { - const dir = resolveWorkspaceRoot(); try { - const entries = await fileSystem.readdir(dir); - const summaries: WorkspaceSummary[] = []; - for (const fileName of entries) { + return await listWorkspaceSummaries(); + } catch (err) { + console.error("[workspace:list] failed:", err); + throw err; + } +}); + +ipcMain.handle("workspace:storageList", async () => { + const activeDir = resolveWorkspaceRoot(); + const trashDir = resolveTrashRoot(); + const entries: Array<{ id: string; name: string; path: string; bytes: number }> = []; + + const scanDir = async (dir: string) => { + const files = await fileSystem.readdir(dir); + for (const fileName of files) { if (!fileName.endsWith(".json")) continue; - const raw = await fileSystem.readFile(path.join(dir, fileName), "utf-8"); + const fullPath = path.join(dir, fileName); try { + const stat = await fileSystem.stat(fullPath); + const raw = await fileSystem.readFile(fullPath, "utf-8"); const doc = JSON.parse(raw) as WorkspaceDocument; - summaries.push({ + entries.push({ id: doc.id, name: doc.name, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, + path: fullPath, + bytes: stat.size, }); } catch (err) { - console.warn(`[workspace] Failed to parse ${fileName}:`, err); + console.warn("[workspace:storageList] failed to read", fullPath, err); } } - summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - return summaries; - } catch (err) { - console.error("[workspace:list] failed:", err); - throw err; + }; + + await scanDir(activeDir); + await scanDir(trashDir); + return entries; +}); + +ipcMain.handle("folder:list", async () => { + const dir = resolveWorkspaceRoot(); + const folders: Array<{ name: string; path: string; fullPath: string }> = []; + + const scan = async (current: string, rel: string) => { + const entries = await fileSystem.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const fullPath = path.join(current, entry.name); + const relPath = rel ? path.join(rel, entry.name) : entry.name; + folders.push({ name: entry.name, path: relPath.replace(/\\/g, "/"), fullPath }); + await scan(fullPath, relPath); + } + }; + + await scan(dir, ""); + return folders; +}); + +ipcMain.handle("folder:create", async (_event, payload: { name?: string; parent?: string | null } | string) => { + const name = + typeof payload === "string" ? payload.trim() : payload?.name?.trim(); + if (!name) throw new Error("Folder name is required"); + const parent = + typeof payload === "string" ? "" : payload?.parent?.trim() ?? ""; + const segments = parent ? parent.split("/").filter(Boolean) : []; + const dir = path.join(resolveWorkspaceRoot(), ...segments, name); + await fileSystem.mkdir(dir, { recursive: true }); + const relPath = path.join(parent, name).replace(/\\/g, "/"); + return { name, path: relPath, fullPath: dir }; +}); + +ipcMain.handle("folder:open", async (_event, folderPath: string) => { + if (!folderPath) throw new Error("Folder path is required"); + await shell.openPath(folderPath); + return true; +}); + +ipcMain.handle("folder:rename", async (_event, payload: { oldPath: string; newName: string }) => { + const { oldPath, newName } = payload; + if (!oldPath || !newName?.trim()) throw new Error("oldPath and newName are required"); + const base = path.dirname(oldPath); + const target = path.join(base, newName.trim()); + await fileSystem.rename(oldPath, target); + return { name: newName.trim(), path: target }; +}); + +ipcMain.handle("folder:trash", async (_event, folderPath: string) => { + if (!folderPath) throw new Error("Folder path is required"); + const folderName = path.basename(folderPath); + const baseTarget = path.join(resolveTrashRoot(), folderName); + await fileSystem.mkdir(resolveTrashRoot(), { recursive: true }); + + // Avoid collisions by suffixing when a folder with the same name already exists in trash. + let target = baseTarget; + let counter = 1; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await fileSystem.stat(target); + target = `${baseTarget}-${counter}`; + counter += 1; + } catch (err: any) { + if (err?.code === "ENOENT") break; + throw err; + } } + + await fileSystem.mkdir(resolveTrashRoot(), { recursive: true }); + await fileSystem.rename(folderPath, target); + return { path: target }; }); ipcMain.handle( @@ -204,16 +606,21 @@ ipcMain.handle( const template = payload?.template ?? null; + const name = await ensureUniqueWorkspaceName( + payload?.name?.trim() || template?.name?.trim() || "Untitled Workspace", + payload?.meta?.folder ?? template?.meta?.folder ?? null + ); + const baseDoc: WorkspaceDocument = { id, - name: payload?.name?.trim() || template?.name?.trim() || "Untitled Workspace", + name, createdAt: now, updatedAt: now, nodes: template?.nodes ?? [], meta: template?.meta ?? payload?.meta ?? undefined, }; - const filePath = workspaceFilePath(id); + const filePath = await workspaceFilePathWithFolder(id, baseDoc.meta?.folder ?? null, false, baseDoc.name); await fileSystem.writeFile(filePath, JSON.stringify(baseDoc, null, 2), "utf-8"); return baseDoc; } @@ -221,7 +628,7 @@ ipcMain.handle( ipcMain.handle("workspace:load", async (_event, id: string) => { if (!id) throw new Error("workspace:load requires an id"); - const filePath = workspaceFilePath(id); + const { filePath } = await resolveWorkspaceFile(id); const raw = await fileSystem.readFile(filePath, "utf-8"); return JSON.parse(raw) as WorkspaceDocument; }); @@ -232,14 +639,45 @@ ipcMain.handle( const { id, data } = payload || ({} as { id: string; data: WorkspaceDocument }); if (!id || !data) throw new Error("workspace:save requires an id and data payload"); + const hasTrashTag = (data.meta?.tags ?? []).includes("trash"); + const trashedAt = + hasTrashTag ? (data.meta as any)?.trashedAt ?? new Date().toISOString() : (data.meta as any)?.trashedAt; + const nextDoc: WorkspaceDocument = { ...data, id, updatedAt: new Date().toISOString(), + meta: { + ...(data.meta ?? {}), + ...(hasTrashTag ? { tags: Array.from(new Set([...(data.meta?.tags ?? []), "trash"])) } : {}), + ...(trashedAt && hasTrashTag ? { trashedAt } : {}), + ...(!hasTrashTag ? { trashedAt: undefined } : {}), + }, }; - const filePath = workspaceFilePath(id); - await fileSystem.writeFile(filePath, JSON.stringify(nextDoc, null, 2), "utf-8"); + nextDoc.name = await ensureUniqueWorkspaceName(nextDoc.name, nextDoc.meta?.folder ?? null, id); + + const targetPath = await workspaceFilePathWithFolder( + id, + nextDoc.meta?.folder ?? null, + hasTrashTag, + nextDoc.name + ); + const previousResolved = await resolveWorkspaceFile(id); + const previousPath = previousResolved.filePath; + + await fileSystem.writeFile(targetPath, JSON.stringify(nextDoc, null, 2), "utf-8"); + + if (previousPath !== targetPath) { + try { + await fileSystem.unlink(previousPath); + } catch (err: any) { + if (err?.code !== "ENOENT") { + console.warn(`[workspace] failed to remove old workspace file at ${previousPath}`, err); + } + } + } + return nextDoc; } ); @@ -337,9 +775,10 @@ ipcMain.handle("oauth-login-google", async () => { // --- App lifecycle --- -app.whenReady().then(() => { +app.whenReady().then(async () => { resolveWorkspaceRoot(); - createWindow(); + createWindow(); // ensure a renderer exists before installing devtools + await installReactDevtools(); app.on("activate", () => { if (BrowserWindowCtor.getAllWindows().length === 0) createWindow(); diff --git a/apps/desktop-app/src/preload.js b/apps/desktop-app/src/preload.js index 1c502e9..c718f3c 100644 --- a/apps/desktop-app/src/preload.js +++ b/apps/desktop-app/src/preload.js @@ -1,6 +1,18 @@ +const { contextBridge, ipcRenderer } = require("electron"); const path = require("path"); const fs = require("fs"); -const { contextBridge, ipcRenderer } = require("electron"); + +function safeExpose(key, api) { + try { + contextBridge.exposeInMainWorld(key, api); + } catch (err) { + if (err && err.message && err.message.includes("Cannot bind an API on top of an existing property")) { + console.warn(`[preload] Skipping expose for ${key}; already defined.`); + return; + } + throw err; + } +} function loadBridge(filename) { const candidates = [ @@ -26,7 +38,23 @@ loadBridge("ir-bridge.cjs"); loadBridge("runner-bridge.cjs"); loadBridge("runtime-bridge.cjs"); -contextBridge.exposeInMainWorld("electron", { +safeExpose("electron", { login: () => ipcRenderer.invoke("oauth-login"), loginGoogle: () => ipcRenderer.invoke("oauth-login-google"), }); + +safeExpose("workspace", { + list: () => ipcRenderer.invoke("workspace:list"), + create: (payload = {}) => ipcRenderer.invoke("workspace:create", payload), + load: (id) => ipcRenderer.invoke("workspace:load", id), + save: (id, data) => ipcRenderer.invoke("workspace:save", { id, data }), + storageList: () => ipcRenderer.invoke("workspace:storageList"), +}); + +safeExpose("folder", { + list: () => ipcRenderer.invoke("folder:list"), + create: (name, parent = null) => ipcRenderer.invoke("folder:create", { name, parent }), + open: (folderPath) => ipcRenderer.invoke("folder:open", folderPath), + rename: (payload) => ipcRenderer.invoke("folder:rename", payload), + trash: (folderPath) => ipcRenderer.invoke("folder:trash", folderPath), +}); diff --git a/apps/desktop-app/src/preload.ts b/apps/desktop-app/src/preload.ts index 454691a..837f65f 100644 --- a/apps/desktop-app/src/preload.ts +++ b/apps/desktop-app/src/preload.ts @@ -5,38 +5,9 @@ // 1) Load side-effect bridges (CJS) so window.ir, window.runner, window.runtime are defined. // These modules execute their contextBridge.exposeInMainWorld(...) calls. -import path from "path"; -import fs from "fs"; - -function loadBridge(filename: string) { - const candidates = [ - path.join(__dirname, "remote", filename), - path.join(__dirname, "..", "dist", "remote", filename), - path.join(__dirname, "..", "src", "remote", filename), - ]; - - for (const candidate of candidates) { - if (!fs.existsSync(candidate)) continue; - try { - require(candidate); - return; - } catch (err: any) { - // Electron throws when a bridge tries to overwrite an existing property (e.g., runner). - if (err?.message?.includes("Cannot bind an API on top of an existing property")) { - return; - } - if (err?.code !== "MODULE_NOT_FOUND") { - console.warn(`[preload] failed loading ${candidate}:`, err); - return; - } - } - } - - console.warn(`[preload] bridge ${filename} not found; tried`, candidates); -} - -loadBridge("ir-bridge.cjs"); -loadBridge("runtime-bridge.cjs"); +import "./remote/ir-bridge.cjs"; +import "./remote/runner-bridge.cjs"; +import "./remote/runtime-bridge.cjs"; // 2) Keep your existing OAuth helpers under window.electron import { contextBridge, ipcRenderer } from "electron"; @@ -71,4 +42,23 @@ safeExpose("workspace", { load: (id: string): Promise => ipcRenderer.invoke("workspace:load", id), save: (id: string, data: WorkspaceDocument): Promise => ipcRenderer.invoke("workspace:save", { id, data }), + storageList: (): Promise< + Array<{ + id: string; + name: string; + path: string; + bytes: number; + }> + > => ipcRenderer.invoke("workspace:storageList"), +}); + +safeExpose("folder", { + list: (): Promise> => + ipcRenderer.invoke("folder:list"), + create: (name: string, parent?: string | null): Promise<{ name: string; path: string; fullPath: string }> => + ipcRenderer.invoke("folder:create", { name, parent }), + open: (folderPath: string): Promise => ipcRenderer.invoke("folder:open", folderPath), + rename: (payload: { oldPath: string; newName: string }): Promise<{ name: string; path: string }> => + ipcRenderer.invoke("folder:rename", payload), + trash: (folderPath: string): Promise<{ path: string }> => ipcRenderer.invoke("folder:trash", folderPath), }); diff --git a/apps/desktop-app/src/remote/ir-bridge.cjs b/apps/desktop-app/src/remote/ir-bridge.cjs index 1de00f1..cfc4b10 100644 --- a/apps/desktop-app/src/remote/ir-bridge.cjs +++ b/apps/desktop-app/src/remote/ir-bridge.cjs @@ -14,7 +14,19 @@ const electronBridge = { login: () => electron_1.ipcRenderer.invoke("oauth-login"), loginGoogle: () => electron_1.ipcRenderer.invoke("oauth-login-google"), }; -electron_1.contextBridge.exposeInMainWorld("runner", runnerBridge); -electron_1.contextBridge.exposeInMainWorld("ir", irBridge); -electron_1.contextBridge.exposeInMainWorld("electron", electronBridge); +function safeExpose(key, api) { + try { + electron_1.contextBridge.exposeInMainWorld(key, api); + } + catch (err) { + if ((err === null || err === void 0 ? void 0 : err.message) && err.message.includes("Cannot bind an API on top of an existing property")) { + console.warn(`[ir-bridge] ${key} already defined, skipping.`); + return; + } + throw err; + } +} +safeExpose("runner", runnerBridge); +safeExpose("ir", irBridge); +safeExpose("electron", electronBridge); console.info("[preload] runner + IR bridge loaded"); diff --git a/apps/desktop-app/src/remote/ir-bridge.cts b/apps/desktop-app/src/remote/ir-bridge.cts index 7ef19c5..fb8061b 100644 --- a/apps/desktop-app/src/remote/ir-bridge.cts +++ b/apps/desktop-app/src/remote/ir-bridge.cts @@ -1,26 +1,14 @@ import { contextBridge, ipcRenderer } from "electron"; -import type { ExecResult } from "@bros2/runner"; import type { IR } from "@bros2/shared"; import type { BlockGraph } from "@bros2/ui"; import type { ValidationResult } from "@bros2/validation"; - -interface RunnerBridge { - up(projectName: string): Promise; - exec(command: string): Promise; - down(): Promise; -} +import type { ExecResult } from "@bros2/runner"; interface IRBridge { build(graph: BlockGraph): Promise<{ ir: IR; issues: string[] }>; validate(ir: IR): Promise; } -const runnerBridge: RunnerBridge = { - up: (projectName: string) => ipcRenderer.invoke("runner:up", projectName), - exec: (command: string) => ipcRenderer.invoke("runner:exec", command), - down: () => ipcRenderer.invoke("runner:down"), -}; - const irBridge: IRBridge = { build: (graph: BlockGraph) => ipcRenderer.invoke("ir:build", graph), validate: (ir: IR) => ipcRenderer.invoke("ir:validate", ir), @@ -33,8 +21,21 @@ const electronBridge = { loginGoogle: (): Promise => ipcRenderer.invoke("oauth-login-google"), }; -contextBridge.exposeInMainWorld("runner", runnerBridge); -contextBridge.exposeInMainWorld("ir", irBridge); -contextBridge.exposeInMainWorld("electron", electronBridge); +function safeExpose(key: string, api: Record) { + try { + contextBridge.exposeInMainWorld(key, api); + } catch (err: any) { + if (err?.message?.includes("Cannot bind an API on top of an existing property")) return; + throw err; + } +} + +safeExpose("runner", { + up: (projectName: string) => ipcRenderer.invoke("runner:up", projectName), + exec: (command: string) => ipcRenderer.invoke("runner:exec", command), + down: () => ipcRenderer.invoke("runner:down"), +}); +safeExpose("ir", irBridge as unknown as Record); +safeExpose("electron", electronBridge as unknown as Record); console.info("[preload] runner + IR bridge loaded"); diff --git a/apps/desktop-app/src/remote/runner-bridge.cjs b/apps/desktop-app/src/remote/runner-bridge.cjs index 95d4683..5b9ab17 100644 --- a/apps/desktop-app/src/remote/runner-bridge.cjs +++ b/apps/desktop-app/src/remote/runner-bridge.cjs @@ -6,5 +6,17 @@ const runnerBridge = { exec: (command) => electron_1.ipcRenderer.invoke("runner:exec", command), down: () => electron_1.ipcRenderer.invoke("runner:down") }; -electron_1.contextBridge.exposeInMainWorld("runner", runnerBridge); +function safeExpose(key, api) { + try { + electron_1.contextBridge.exposeInMainWorld(key, api); + } + catch (err) { + if ((err === null || err === void 0 ? void 0 : err.message) && err.message.includes("Cannot bind an API on top of an existing property")) { + console.warn(`[runner-bridge] ${key} already defined, skipping.`); + return; + } + throw err; + } +} +safeExpose("runner", runnerBridge); console.info("[preload] runner bridge loaded"); diff --git a/apps/desktop-app/src/remote/runner-bridge.cts b/apps/desktop-app/src/remote/runner-bridge.cts index 9097a2b..d93def5 100644 --- a/apps/desktop-app/src/remote/runner-bridge.cts +++ b/apps/desktop-app/src/remote/runner-bridge.cts @@ -1,6 +1,15 @@ import { contextBridge, ipcRenderer } from "electron"; import type { ExecResult } from "@bros2/runner"; +function safeExpose(key: string, api: Record) { + try { + contextBridge.exposeInMainWorld(key, api); + } catch (err: any) { + if (err?.message?.includes("Cannot bind an API on top of an existing property")) return; + throw err; + } +} + type RunnerBridge = { up(projectName: string): Promise; exec(command: string): Promise; @@ -13,5 +22,5 @@ const runnerBridge: RunnerBridge = { down: () => ipcRenderer.invoke("runner:down") }; -contextBridge.exposeInMainWorld("runner", runnerBridge); +safeExpose("runner", runnerBridge); console.info("[preload] runner bridge loaded"); diff --git a/apps/desktop-app/src/remote/runtime-bridge.cjs b/apps/desktop-app/src/remote/runtime-bridge.cjs index c38b46b..074209e 100644 --- a/apps/desktop-app/src/remote/runtime-bridge.cjs +++ b/apps/desktop-app/src/remote/runtime-bridge.cjs @@ -1,7 +1,19 @@ const { contextBridge } = require("electron"); const { runtime } = require("../renderer/runtime/registry"); -contextBridge.exposeInMainWorld("runtime", { +function safeExpose(key, api) { + try { + contextBridge.exposeInMainWorld(key, api); + } catch (err) { + if (err && err.message && err.message.includes("Cannot bind an API on top of an existing property")) { + console.warn(`[runtime-bridge] ${key} already defined, skipping.`); + return; + } + throw err; + } +} + +safeExpose("runtime", { create: (type, config) => runtime.create(type, config).id, start: (id) => runtime.start(id), stop: (id) => runtime.stop(id), diff --git a/apps/desktop-app/src/remote/runtime-bridge.cts b/apps/desktop-app/src/remote/runtime-bridge.cts index f135f60..64778da 100644 --- a/apps/desktop-app/src/remote/runtime-bridge.cts +++ b/apps/desktop-app/src/remote/runtime-bridge.cts @@ -3,11 +3,21 @@ const { contextBridge } = require("electron"); // Import the runtime instance from the renderer registry const { runtime } = require("../renderer/runtime/registry"); -contextBridge.exposeInMainWorld("runtime", { +function safeExpose(key: string, api: Record) { + try { + contextBridge.exposeInMainWorld(key, api); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("Cannot bind an API on top of an existing property")) return; + throw err; + } +} + +safeExpose("runtime", { create: (type: string, config?: any) => runtime.create(type, config).id, start: (id: string) => runtime.start(id), stop: (id: string) => runtime.stop(id), startAll: () => runtime.startAll(), stopAll: () => runtime.stopAll(), list: () => runtime.list(), -}); \ No newline at end of file +}); diff --git a/apps/desktop-app/src/renderer/pages/Dashboard.tsx b/apps/desktop-app/src/renderer/pages/Dashboard.tsx index 73dafc6..b65314d 100644 --- a/apps/desktop-app/src/renderer/pages/Dashboard.tsx +++ b/apps/desktop-app/src/renderer/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { FiHome, FiClock, @@ -10,6 +11,13 @@ import { FiGrid, FiChevronDown, FiLogOut, + FiSettings, + FiMenu, + FiChevronLeft, + FiSun, + FiMoon, + FiCheck, + FiFileText, } from "react-icons/fi"; import { useNavigate } from "react-router-dom"; import type { WorkspaceDocument, WorkspaceNode, WorkspaceSummary } from "../../shared/workspace"; @@ -26,14 +34,15 @@ type WorkspaceCard = { color: string; isRecent?: boolean; isTrashed?: boolean; + meta?: WorkspaceDocument["meta"]; }; -type FolderRecord = { +type StorageEntry = { id: string; name: string; - location: string; - color: string; - isTrashed?: boolean; + path: string; + bytes: number; + folder?: string; }; const tabs = [ @@ -42,13 +51,6 @@ const tabs = [ { id: "trash", label: "Trash", icon: FiTrash2 }, ] as const; -const folderData: FolderRecord[] = [ - { id: "f-1", name: "BROS2 Roadmap", location: "In My Drive", color: "#5b7fff" }, - { id: "f-2", name: "Launch Assets", location: "Shared with me", color: "#f4b400" }, - { id: "f-3", name: "Sprint Docs", location: "In My Drive", color: "#0f9d58" }, - { id: "f-4", name: "Archived Concepts", location: "In My Drive", color: "#ab47bc", isTrashed: true }, -]; - const badgeLabel: Record = { doc: "Doc", sheet: "Sheet", @@ -106,56 +108,122 @@ const workspaceSeedTemplates: Array<{ createNodes: () => WorkspaceDocument["nodes"]; }> = [ { - name: "Autonomy Sandbox", + name: "ROS Workspace Starter", meta: { - description: "Sensors → decisions → motion. Perfect starting point for robotics flows.", - tags: ["sample", "autonomy"], + description: "Basic rosbridge, keyboard publisher, and console subscriber.", + tags: ["sample", "ros"], }, createNodes: () => [ - makeNode("entry", "Start", { x: 96, y: 80 }), - makeNode("sensor", "Read Sensors", { x: 260, y: 180 }), - makeNode("logic", "Decision Engine", { x: 460, y: 120 }), - makeNode("actuator", "Motor Control", { x: 640, y: 220 }), - ], - }, - { - name: "Perception Pipeline", - meta: { - description: "Camera stream with preprocessing, detection, and overlay output.", - tags: ["sample", "perception"], - }, - createNodes: () => [ - makeNode("input", "Camera Feed", { x: 120, y: 140 }), - makeNode("transform", "Preprocess", { x: 320, y: 80 }), - makeNode("model", "Object Detector", { x: 520, y: 140 }), - makeNode("visualize", "HUD Overlay", { x: 700, y: 220 }), - ], - }, - { - name: "Mission Planner", - meta: { - description: "Waypoint planner combining mapping, costmaps, and navigation goals.", - tags: ["sample", "planning"], - }, - createNodes: () => [ - makeNode("map", "Map Loader", { x: 140, y: 100 }), - makeNode("costmap", "Costmap Builder", { x: 340, y: 200 }), - makeNode("planner", "Route Planner", { x: 540, y: 120 }), - makeNode("goal", "Dispatch Goals", { x: 720, y: 240 }), + makeNode( + "RosbridgeBridge", + "Rosbridge", + { x: 140, y: 120 }, + { urls: ["ws://localhost:9090", "ws://127.0.0.1:9090"], retryMs: 2500 } + ), + makeNode("ArrowKeyPub", "Arrow Key Publisher", { x: 380, y: 120 }, { topic: "keys/arrows" }), + makeNode("ConsoleSub", "Console Subscriber", { x: 620, y: 120 }, { topic: "keys/arrows" }), + makeNode( + "Forwarder", + "ROS Forwarder", + { x: 900, y: 120 }, + { from: "keys/arrows", to: "/keys/arrows" } + ), ], }, ]; const Dashboard: React.FC = () => { + const getStoredTheme = () => { + if (typeof window === "undefined") return "dark" as const; + const stored = window.localStorage?.getItem("bros2-theme"); + return stored === "light" ? "light" : "dark"; + }; + const [activeTab, setActiveTab] = useState<(typeof tabs)[number]["id"]>("home"); const [searchQuery, setSearchQuery] = useState(""); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [workspaces, setWorkspaces] = useState([]); + const [folders, setFolders] = useState>([]); const [loadingWorkspaces, setLoadingWorkspaces] = useState(false); const [workspaceError, setWorkspaceError] = useState(null); + const [storageUsedBytes, setStorageUsedBytes] = useState(0); + const [isStorageModalOpen, setIsStorageModalOpen] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false); + const [theme, setTheme] = useState<"dark" | "light">(getStoredTheme); + const [typeFilter, setTypeFilter] = useState<{ folders: boolean; workspaces: boolean }>({ + folders: true, + workspaces: true, + }); + const [locationFilter, setLocationFilter] = useState<"home" | "trash">("home"); + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + workspaceId: string | null; + }>({ visible: false, x: 0, y: 0, workspaceId: null }); + const contextMenuRef = useRef(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingWorkspace, setEditingWorkspace] = useState(null); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editType, setEditType] = useState(""); + const [storageEntries, setStorageEntries] = useState([]); + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); + const [createFolderName, setCreateFolderName] = useState(""); + const [isCreateFolderClosing, setIsCreateFolderClosing] = useState(false); + const [isStorageClosing, setIsStorageClosing] = useState(false); + const newActionRef = useRef(null); + const newActionMenuRef = useRef(null); + const typeFilterRef = useRef(null); + const typeFilterMenuRef = useRef(null); + const locationFilterRef = useRef(null); + const locationFilterMenuRef = useRef(null); + const folderActionRef = useRef(null); + const folderActionMenuRef = useRef(null); + const [folderActionMenu, setFolderActionMenu] = useState<{ visible: boolean; x: number; y: number }>({ + visible: false, + x: 0, + y: 0, + }); + const [folderContextMenu, setFolderContextMenu] = useState<{ + visible: boolean; + x: number; + y: number; + name: string | null; + path: string | null; + fullPath: string | null; + }>({ visible: false, x: 0, y: 0, name: null, path: null, fullPath: null }); + const folderContextRef = useRef(null); + const [pendingFolderDelete, setPendingFolderDelete] = useState<{ + name: string; + path: string; + fullPath: string | null; + childFolders: number; + childWorkspaces: number; + } | null>(null); + const [newActionMenu, setNewActionMenu] = useState<{ visible: boolean; x: number; y: number }>({ + visible: false, + x: 0, + y: 0, + }); + const [typeFilterMenu, setTypeFilterMenu] = useState<{ visible: boolean; x: number; y: number }>({ + visible: false, + x: 0, + y: 0, + }); + const [locationFilterMenu, setLocationFilterMenu] = useState<{ visible: boolean; x: number; y: number }>({ + visible: false, + x: 0, + y: 0, + }); + const [createFolderMode, setCreateFolderMode] = useState<"create" | "rename">("create"); + const [renameFolderTarget, setRenameFolderTarget] = useState(null); + const [currentFolder, setCurrentFolder] = useState(""); const accountRef = useRef(null); const navigate = useNavigate(); + const createFolderCloseRef = useRef(null); + const storageCloseRef = useRef(null); const seedWorkspaces = useCallback(async () => { if (!window.workspace) { @@ -201,6 +269,23 @@ const Dashboard: React.FC = () => { list = window.workspace ? await window.workspace.list() : []; } setWorkspaces(list); + if (window.folder) { + const folderList = await window.folder.list(); + setFolders(folderList); + } + + // Estimate storage by loading each workspace and summing serialized bytes. + const used = await (async () => { + try { + const encoder = new TextEncoder(); + const docs = await Promise.all(list.map((ws) => window.workspace!.load(ws.id))); + return docs.reduce((sum, doc) => sum + encoder.encode(JSON.stringify(doc)).length, 0); + } catch (err) { + console.warn("[dashboard] failed to calculate storage", err); + return 0; + } + })(); + setStorageUsedBytes(used); } catch (err) { console.error("[dashboard] failed to load workspaces", err); setWorkspaceError( @@ -232,41 +317,91 @@ const Dashboard: React.FC = () => { color, isRecent: computeIsRecent(workspace.updatedAt), isTrashed: workspace.meta?.tags?.includes("trash") ?? false, + meta: workspace.meta, }; }); }, [workspaces]); const visibleFolders = useMemo(() => { const query = searchQuery.toLowerCase(); - - return folderData.filter((folder) => { - if (activeTab === "trash" && !folder.isTrashed) return false; - if (activeTab !== "trash" && folder.isTrashed) return false; - - return folder.name.toLowerCase().includes(query); + if (activeTab === "trash") return []; + const parentOf = (p: string) => { + const parts = p.split("/").filter(Boolean); + parts.pop(); + return parts.join("/"); + }; + if (!typeFilter.folders) return []; + return folders.filter((folder) => { + const folderParent = parentOf(folder.path); + return folderParent === (currentFolder || "") && folder.name.toLowerCase().includes(query); }); - }, [activeTab, searchQuery]); + }, [activeTab, currentFolder, folders, searchQuery, typeFilter.folders]); const visibleFiles = useMemo(() => { const query = searchQuery.toLowerCase(); - + const folderFilter = currentFolder || ""; + if (!typeFilter.workspaces) return []; return workspaceCards.filter((file) => { if (activeTab === "trash" && !file.isTrashed) return false; if (activeTab === "recent" && !file.isRecent) return false; if (activeTab === "home" && file.isTrashed) return false; + if (activeTab !== "trash") { + const fileFolder = file.meta?.folder ?? ""; + if (fileFolder !== folderFilter) return false; + } return ( file.name.toLowerCase().includes(query) || file.description.toLowerCase().includes(query) ); }); - }, [workspaceCards, activeTab, searchQuery]); + }, [workspaceCards, activeTab, searchQuery, currentFolder, typeFilter.workspaces]); const emptyStateMessage = activeTab === "trash" ? "Your trash is empty." : "No workspaces yet. Create a new one to get started."; + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage?.setItem("bros2-theme", theme); + document.body.classList.toggle("theme-light", theme === "light"); + }, [theme]); + + useEffect(() => { + if (!typeFilterMenu.visible) return; + const hide = (event: MouseEvent) => { + const target = event.target as Node; + if (typeFilterMenuRef.current?.contains(target)) return; + if (typeFilterRef.current?.contains(target)) return; + setTypeFilterMenu({ visible: false, x: 0, y: 0 }); + }; + const hideEsc = (e: KeyboardEvent) => e.key === "Escape" && setTypeFilterMenu({ visible: false, x: 0, y: 0 }); + document.addEventListener("mousedown", hide); + document.addEventListener("keydown", hideEsc); + return () => { + document.removeEventListener("mousedown", hide); + document.removeEventListener("keydown", hideEsc); + }; + }, [typeFilterMenu.visible]); + + useEffect(() => { + if (!locationFilterMenu.visible) return; + const hide = (event: MouseEvent) => { + const target = event.target as Node; + if (locationFilterMenuRef.current?.contains(target)) return; + if (locationFilterRef.current?.contains(target)) return; + setLocationFilterMenu({ visible: false, x: 0, y: 0 }); + }; + const hideEsc = (e: KeyboardEvent) => e.key === "Escape" && setLocationFilterMenu({ visible: false, x: 0, y: 0 }); + document.addEventListener("mousedown", hide); + document.addEventListener("keydown", hideEsc); + return () => { + document.removeEventListener("mousedown", hide); + document.removeEventListener("keydown", hideEsc); + }; + }, [locationFilterMenu.visible]); + useEffect(() => { if (!isAccountMenuOpen) return; @@ -283,7 +418,57 @@ const Dashboard: React.FC = () => { return () => document.removeEventListener("mousedown", handleClickOutside); }, [isAccountMenuOpen]); - const handleCreateWorkspace = useCallback(async () => { + const handleCreateFolder = useCallback(async () => { + if (!window.folder) { + setWorkspaceError("Folder bridge unavailable. Restart the app to reload preload scripts."); + return; + } + const name = createFolderName.trim(); + if (!name) { + setWorkspaceError("Folder name is required."); + return; + } + try { + if (createFolderMode === "rename" && renameFolderTarget) { + await window.folder.rename({ oldPath: renameFolderTarget, newName: name }); + } else { + const created = await window.folder.create(name, currentFolder || null); + setCurrentFolder(created.path ?? currentFolder); + } + const folderList = await window.folder.list(); + setFolders(folderList); + setCreateFolderName(""); + setIsCreateFolderOpen(false); + setIsCreateFolderClosing(false); + setCreateFolderMode("create"); + setRenameFolderTarget(null); + } catch (err) { + console.error("[dashboard] create folder failed", err); + setWorkspaceError("Unable to create folder. Check filesystem permissions."); + } + }, [createFolderMode, createFolderName, currentFolder, renameFolderTarget]); + + const closeCreateFolderModal = useCallback(() => { + if (isCreateFolderClosing) return; + setIsCreateFolderClosing(true); + createFolderCloseRef.current = setTimeout(() => { + setIsCreateFolderOpen(false); + setIsCreateFolderClosing(false); + setCreateFolderMode("create"); + setRenameFolderTarget(null); + }, 180); + }, [isCreateFolderClosing]); + + const closeStorageModal = useCallback(() => { + if (isStorageClosing) return; + setIsStorageClosing(true); + storageCloseRef.current = setTimeout(() => { + setIsStorageModalOpen(false); + setIsStorageClosing(false); + }, 180); + }, [isStorageClosing]); + + const handleCreateWorkspace = useCallback(async (folderPath?: string) => { if (!window.workspace) { setWorkspaceError( "Workspace bridge not ready yet. Try quitting and reopening the desktop app." @@ -294,6 +479,7 @@ const Dashboard: React.FC = () => { try { const created = await window.workspace.create({ name: `Workspace ${workspaces.length + 1}`, + meta: folderPath || currentFolder ? { folder: folderPath ?? currentFolder } : undefined, }); await refreshWorkspaces(); navigate(`/workspace/${created.id}`); @@ -303,7 +489,7 @@ const Dashboard: React.FC = () => { "Unable to create a workspace. Please confirm the app has access to your Documents folder or try again after relaunching." ); } - }, [navigate, refreshWorkspaces, workspaces.length]); + }, [currentFolder, navigate, refreshWorkspaces, workspaces.length]); const handleOpenWorkspace = useCallback( (workspaceId: string) => { @@ -317,16 +503,346 @@ const Dashboard: React.FC = () => { navigate("/"); }; + const refreshStorageEntries = useCallback(async () => { + try { + if (!window.workspace || typeof window.workspace.storageList !== "function") { + console.warn("[dashboard] storageList bridge is unavailable; reload app to refresh preload scripts."); + setStorageEntries([]); + return; + } + const entries = await window.workspace.storageList(); + setStorageEntries(entries); + } catch (err) { + console.error("[dashboard] storage list failed", err); + } + }, []); + + const closeContextMenu = () => + setContextMenu({ visible: false, x: 0, y: 0, workspaceId: null }); + + const handleTrashFolder = useCallback( + async (targetPath: string, folderPath: string) => { + try { + await window.folder?.trash(targetPath); + const folderList = await window.folder?.list(); + setFolders(folderList ?? []); + if (folderPath === currentFolder || currentFolder.startsWith(`${folderPath}/`)) { + setCurrentFolder(""); + } + await refreshWorkspaces(); + await refreshStorageEntries(); + } catch (err) { + console.error("[dashboard] trash folder failed", err); + setWorkspaceError("Unable to delete folder. Check permissions or try again."); + } finally { + setFolderContextMenu({ + visible: false, + x: 0, + y: 0, + name: null, + path: null, + fullPath: null, + }); + setPendingFolderDelete(null); + } + }, + [currentFolder, refreshStorageEntries, refreshWorkspaces] + ); + + useEffect(() => { + if (!contextMenu.visible) return; + const hideOnClick = (e: MouseEvent) => { + if (e.button !== 0) return; // only left click closes + const target = e.target as Node; + if (contextMenuRef.current && contextMenuRef.current.contains(target)) return; + closeContextMenu(); + }; + const hideEsc = (e: KeyboardEvent) => e.key === "Escape" && closeContextMenu(); + document.addEventListener("mousedown", hideOnClick); + document.addEventListener("keydown", hideEsc); + return () => { + document.removeEventListener("mousedown", hideOnClick); + document.removeEventListener("keydown", hideEsc); + }; + }, [contextMenu.visible]); + + useEffect(() => { + if (!folderContextMenu.visible) return; + const hideOnClick = (e: MouseEvent) => { + if (e.button !== 0) return; + const target = e.target as Node; + if (folderContextRef.current && folderContextRef.current.contains(target)) return; + setFolderContextMenu({ visible: false, x: 0, y: 0, name: null, path: null, fullPath: null }); + }; + const hideEsc = (e: KeyboardEvent) => + e.key === "Escape" && + setFolderContextMenu({ visible: false, x: 0, y: 0, name: null, path: null, fullPath: null }); + document.addEventListener("mousedown", hideOnClick); + document.addEventListener("keydown", hideEsc); + return () => { + document.removeEventListener("mousedown", hideOnClick); + document.removeEventListener("keydown", hideEsc); + }; + }, [folderContextMenu.visible]); + + useEffect(() => { + if (!newActionMenu.visible) return; + const hide = (event: MouseEvent) => { + const target = event.target as Node; + if (newActionMenuRef.current?.contains(target)) return; + if (newActionRef.current?.contains(target)) return; + setNewActionMenu({ visible: false, x: 0, y: 0 }); + }; + const hideEsc = (e: KeyboardEvent) => e.key === "Escape" && setNewActionMenu({ visible: false, x: 0, y: 0 }); + document.addEventListener("mousedown", hide); + document.addEventListener("keydown", hideEsc); + return () => { + document.removeEventListener("mousedown", hide); + document.removeEventListener("keydown", hideEsc); + }; + }, [newActionMenu.visible]); + + useEffect(() => { + if (activeTab === "trash" && locationFilter !== "trash") { + setLocationFilter("trash"); + } else if (activeTab !== "trash" && locationFilter !== "home") { + setLocationFilter("home"); + } + }, [activeTab, locationFilter]); + + useEffect(() => { + if (!folderActionMenu.visible) return; + const hide = (event: MouseEvent) => { + const target = event.target as Node; + if (folderActionMenuRef.current?.contains(target)) return; + if (folderActionRef.current?.contains(target)) return; + setFolderActionMenu({ visible: false, x: 0, y: 0 }); + }; + const hideEsc = (e: KeyboardEvent) => e.key === "Escape" && setFolderActionMenu({ visible: false, x: 0, y: 0 }); + document.addEventListener("mousedown", hide); + document.addEventListener("keydown", hideEsc); + return () => { + document.removeEventListener("mousedown", hide); + document.removeEventListener("keydown", hideEsc); + }; + }, [folderActionMenu.visible]); + + useEffect(() => { + if (isStorageModalOpen) { + void refreshStorageEntries(); + } + }, [isStorageModalOpen, refreshStorageEntries]); + + useEffect(() => { + return () => { + if (createFolderCloseRef.current) clearTimeout(createFolderCloseRef.current); + if (storageCloseRef.current) clearTimeout(storageCloseRef.current); + }; + }, []); + + const handleOpenContextMenu = useCallback( + (event: React.MouseEvent, workspaceId: string) => { + event.preventDefault(); + setContextMenu({ + visible: true, + x: event.clientX, + y: event.clientY, + workspaceId, + }); + }, + [] + ); + + const handleLoadWorkspaceDoc = useCallback(async (workspaceId: string) => { + const doc = await window.workspace.load(workspaceId); + return doc; + }, []); + + const handleEditWorkspace = useCallback( + async (workspaceId: string) => { + try { + const doc = await handleLoadWorkspaceDoc(workspaceId); + setEditingWorkspace(doc); + setEditName(doc.name); + setEditDescription(doc.meta?.description ?? ""); + setEditType((doc.meta as any)?.type ?? ""); + setIsEditModalOpen(true); + } catch (err) { + console.error("[dashboard] edit load failed", err); + setWorkspaceError("Unable to open workspace for editing."); + } finally { + closeContextMenu(); + } + }, + [handleLoadWorkspaceDoc] + ); + + const handleSaveEdit = useCallback(async () => { + if (!editingWorkspace) return; + try { + const payload: WorkspaceDocument = { + ...editingWorkspace, + name: editName.trim() || "Untitled Workspace", + meta: { + ...(editingWorkspace.meta ?? {}), + description: editDescription, + type: editType || undefined, + }, + }; + await window.workspace.save(editingWorkspace.id, payload); + await refreshWorkspaces(); + } catch (err) { + console.error("[dashboard] edit save failed", err); + setWorkspaceError("Unable to save changes. Check disk permissions."); + } finally { + setIsEditModalOpen(false); + setEditingWorkspace(null); + } + }, [editDescription, editName, editType, editingWorkspace, refreshWorkspaces]); + + const handleDuplicateWorkspace = useCallback( + async (workspaceId: string) => { + try { + const doc = await handleLoadWorkspaceDoc(workspaceId); + const dupName = `${doc.name} copy`; + await window.workspace.create({ + name: dupName, + template: doc, + meta: doc.meta, + }); + await refreshWorkspaces(); + await refreshStorageEntries(); + } catch (err) { + console.error("[dashboard] duplicate failed", err); + setWorkspaceError("Unable to duplicate workspace."); + } finally { + closeContextMenu(); + } + }, + [handleLoadWorkspaceDoc, refreshStorageEntries, refreshWorkspaces] + ); + + const handleTrashWorkspace = useCallback( + async (workspaceId: string) => { + try { + const doc = await handleLoadWorkspaceDoc(workspaceId); + const tags = new Set([...(doc.meta?.tags ?? [])]); + tags.add("trash"); + await window.workspace.save(workspaceId, { + ...doc, + meta: { ...(doc.meta ?? {}), tags: Array.from(tags) }, + }); + await refreshWorkspaces(); + await refreshStorageEntries(); + } catch (err) { + console.error("[dashboard] trash failed", err); + setWorkspaceError("Unable to move workspace to trash."); + } finally { + closeContextMenu(); + } + }, + [handleLoadWorkspaceDoc, refreshStorageEntries, refreshWorkspaces] + ); + + const handleOpenInFolder = useCallback( + async (workspaceId: string) => { + try { + if (!window.workspace || typeof window.workspace.load !== "function") return; + const doc = await window.workspace.load(workspaceId); + const storageItems = await window.workspace.storageList(); + const match = storageItems.find((item) => item.id === doc.id); + if (match) { + if (window.folder?.open) { + const dir = (() => { + const idx = Math.max(match.path.lastIndexOf("/"), match.path.lastIndexOf("\\")); + return idx > 0 ? match.path.slice(0, idx) : match.path; + })(); + await window.folder.open(dir); + } + } + } catch (err) { + console.error("[dashboard] open in folder failed", err); + } finally { + closeContextMenu(); + } + }, + [closeContextMenu] + ); + + const handleRestoreWorkspace = useCallback( + async (workspaceId: string) => { + try { + const doc = await handleLoadWorkspaceDoc(workspaceId); + const tags = new Set([...(doc.meta?.tags ?? [])]); + tags.delete("trash"); + await window.workspace.save(workspaceId, { + ...doc, + meta: { ...(doc.meta ?? {}), tags: Array.from(tags) }, + }); + await refreshWorkspaces(); + await refreshStorageEntries(); + } catch (err) { + console.error("[dashboard] restore failed", err); + setWorkspaceError("Unable to restore workspace."); + } finally { + closeContextMenu(); + } + }, + [handleLoadWorkspaceDoc, refreshStorageEntries, refreshWorkspaces] + ); + + const formatBytes = (bytes: number) => { + if (bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const digits = value < 10 && unitIndex > 0 ? 1 : 0; + return `${value.toFixed(digits)} ${units[unitIndex]}`; + }; + + const quotaLabel = `${formatBytes(storageUsedBytes)}`; + return ( -
-