From 072dc188f3cd8b4f2e59520d8ad2723a2285e161 Mon Sep 17 00:00:00 2001 From: nhathout Date: Mon, 24 Nov 2025 09:38:00 -0800 Subject: [PATCH 1/4] Added rosbridge to connect ROS2 backend with the actual node creation --- Projects/hello_ros/docker-compose.yml | 10 ++- README.md | 73 ++++++++++++++++++- apps/desktop-app/README.md | 11 +++ apps/desktop-app/package.json | 1 + apps/desktop-app/src/preload.ts | 35 +-------- .../src/renderer/runtime/nodes/Forwarder.ts | 32 ++++++++ .../renderer/runtime/nodes/RosbridgeBridge.ts | 50 +++++++++++++ .../src/renderer/runtime/registry.ts | 12 ++- .../runner/images/ros2-humble/Dockerfile | 17 +++++ .../runner/images/ros2-humble/README.md | 1 + packages/services/runner/src/compose.ts | 16 +++- packages/services/runner/src/index.ts | 9 ++- packages/services/runner/src/types.ts | 7 ++ 13 files changed, 227 insertions(+), 47 deletions(-) create mode 100644 apps/desktop-app/README.md create mode 100644 apps/desktop-app/src/renderer/runtime/nodes/Forwarder.ts create mode 100644 apps/desktop-app/src/renderer/runtime/nodes/RosbridgeBridge.ts create mode 100644 packages/services/runner/images/ros2-humble/Dockerfile create mode 100644 packages/services/runner/images/ros2-humble/README.md 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..594fea9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,45 @@ Verify Docker access before continuing: docker ps ``` +## Quick Dev Loop (tl;dr) + +For day-to-day hacking from the repo root: + +1. Select Node 20.19.x + + ```bash + source ~/.nvm/nvm.sh + nvm use 20.19.0 + ``` + +2. Refresh deps + clean old builds when needed: + + ```bash + pnpm install -r + pnpm -r clean && pnpm -r build # optional but ensures fresh dist/ files + ``` + +3. Build the ROS image once per machine (safe to rerun): + + ```bash + pnpm --filter ./apps/desktop-app ros:build-image + ``` + +4. Launch the desktop dev stack (Electron main + Vite renderer): + + ```bash + pnpm --filter ./apps/desktop-app dev + ``` + +5. 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. + ## First-Time Setup ```bash @@ -48,7 +87,7 @@ 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 @@ -64,8 +103,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 +122,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 @@ -153,6 +198,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`): @@ -186,6 +246,11 @@ If `window.runtime` is missing, run `pnpm --filter ./apps/desktop-app build:main 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/`. - If `pnpm dev` fails because Electron is missing, re-run `node node_modules/electron/install.js`. 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..6e9fc01 100644 --- a/apps/desktop-app/package.json +++ b/apps/desktop-app/package.json @@ -14,6 +14,7 @@ "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" }, diff --git a/apps/desktop-app/src/preload.ts b/apps/desktop-app/src/preload.ts index 454691a..b1280e4 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"; diff --git a/apps/desktop-app/src/renderer/runtime/nodes/Forwarder.ts b/apps/desktop-app/src/renderer/runtime/nodes/Forwarder.ts new file mode 100644 index 0000000..bc00dce --- /dev/null +++ b/apps/desktop-app/src/renderer/runtime/nodes/Forwarder.ts @@ -0,0 +1,32 @@ +import type { NodeContext, NodeInstance } from "@bros2/runtime"; + +export class Forwarder implements NodeInstance { + id: string; + private ctx: NodeContext; + private from: string; + private to: string; + private send: (topic: string, msg: any) => void; + private handler?: (evt: any) => void; + + constructor( + ctx: NodeContext, + cfg: { from: string; to: string; send: (topic: string, msg: any) => void } + ) { + this.id = ctx.id; + this.ctx = ctx; + this.from = cfg.from; + this.to = cfg.to; + this.send = cfg.send; + } + + start() { + this.handler = (evt: any) => this.send(this.to, evt.data ?? evt); + this.ctx.bus.on(this.from, this.handler); + this.ctx.log(`forwarding "${this.from}" -> ROS "${this.to}"`); + } + + stop() { + if (this.handler) this.ctx.bus.off(this.from, this.handler); + this.handler = undefined; + } +} diff --git a/apps/desktop-app/src/renderer/runtime/nodes/RosbridgeBridge.ts b/apps/desktop-app/src/renderer/runtime/nodes/RosbridgeBridge.ts new file mode 100644 index 0000000..e14e39b --- /dev/null +++ b/apps/desktop-app/src/renderer/runtime/nodes/RosbridgeBridge.ts @@ -0,0 +1,50 @@ +import type { NodeContext, NodeInstance } from "@bros2/runtime"; + +export class RosbridgeBridge implements NodeInstance { + id: string; + private ctx: NodeContext; + private url: string; + private ws?: WebSocket; + + constructor(ctx: NodeContext, cfg: { url?: string } = {}) { + this.id = ctx.id; + this.ctx = ctx; + this.url = cfg.url ?? "ws://localhost:9090"; + (globalThis as any).__rosbridge__ = this; + } + + start() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + this.ws = new WebSocket(this.url); + this.ws.onopen = () => this.ctx.log(`rosbridge connected: ${this.url}`); + this.ws.onclose = () => this.ctx.log("rosbridge closed"); + this.ws.onmessage = (m) => { + try { + const msg = JSON.parse(m.data as string); + if (msg.op === "publish" && msg.topic && msg.msg) { + this.ctx.publish(msg.topic, msg.msg); + } + } catch {} + }; + } + + publishRos(topic: string, msg: any) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ op: "publish", topic, msg })); + } + } + + subscribeRos(topic: string) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ op: "subscribe", topic })); + } + } + + stop() { + this.ws?.close(); + this.ws = undefined; + if ((globalThis as any).__rosbridge__ === this) { + delete (globalThis as any).__rosbridge__; + } + } +} diff --git a/apps/desktop-app/src/renderer/runtime/registry.ts b/apps/desktop-app/src/renderer/runtime/registry.ts index 98ea4a0..322a7b6 100644 --- a/apps/desktop-app/src/renderer/runtime/registry.ts +++ b/apps/desktop-app/src/renderer/runtime/registry.ts @@ -5,12 +5,20 @@ import { Runtime } from "@bros2/runtime"; import type { NodeContext, NodeInstance } from "@bros2/runtime"; import { ArrowKeyPub } from "./nodes/ArrowKeyPub"; import { ConsoleSub } from "./nodes/ConsoleSub"; +import { Forwarder } from "./nodes/Forwarder"; +import { RosbridgeBridge } from "./nodes/RosbridgeBridge"; type Factory = (ctx: NodeContext, config?: any) => NodeInstance; -export const registry: Record = { +const baseRegistry: Record = { ArrowKeyPub: (ctx, config) => new ArrowKeyPub(ctx, config), - ConsoleSub: (ctx, config) => new ConsoleSub(ctx, config), + ConsoleSub: (ctx, config) => new ConsoleSub(ctx, config) +}; + +export const registry: Record = { + ...baseRegistry, + RosbridgeBridge: (ctx, config) => new RosbridgeBridge(ctx, config), + Forwarder: (ctx, config) => new Forwarder(ctx, config) }; export const runtime = new Runtime(registry); diff --git a/packages/services/runner/images/ros2-humble/Dockerfile b/packages/services/runner/images/ros2-humble/Dockerfile new file mode 100644 index 0000000..2496dbf --- /dev/null +++ b/packages/services/runner/images/ros2-humble/Dockerfile @@ -0,0 +1,17 @@ +FROM ros:humble +SHELL ["/bin/bash","-lc"] + +RUN apt-get update && apt-get install -y \ + python3-colcon-common-extensions python3-rosdep \ + ros-humble-rosbridge-server \ + ros-humble-turtlesim \ + && rm -rf /var/lib/apt/lists/* + +# dev user (optional) +RUN useradd -ms /bin/bash dev && adduser dev sudo && echo "dev ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers +USER dev +WORKDIR /home/dev + +RUN sudo rosdep init || true && rosdep update || true +RUN mkdir -p /home/dev/ws/src +ENV RMW_IMPLEMENTATION=rmw_fastrtps_cpp diff --git a/packages/services/runner/images/ros2-humble/README.md b/packages/services/runner/images/ros2-humble/README.md new file mode 100644 index 0000000..87dc29e --- /dev/null +++ b/packages/services/runner/images/ros2-humble/README.md @@ -0,0 +1 @@ +docker build -t bros2/ros2-humble:latest . \ No newline at end of file diff --git a/packages/services/runner/src/compose.ts b/packages/services/runner/src/compose.ts index 04ba767..37a29e1 100644 --- a/packages/services/runner/src/compose.ts +++ b/packages/services/runner/src/compose.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { ComposeOptions } from "./types.js"; +import { ComposeOptions, PortMapping } from "./types.js"; function toPosixPath(input: string): string { return input.split(path.sep).join("/"); @@ -11,13 +11,24 @@ function quote(value: string): string { return `"${escaped}"`; } +function formatPort({ host, container, protocol }: PortMapping): string { + const hostStr = typeof host === "number" ? host.toString(10) : host; + const containerStr = typeof container === "number" ? container.toString(10) : container; + const proto = protocol ? `/${protocol}` : ""; + return quote(`${hostStr}:${containerStr}${proto}`); +} + export async function writeComposeFile(options: ComposeOptions): Promise { - const { containerName, workspaceHostPath, image } = options; + const { containerName, workspaceHostPath, image, ports } = options; const composeDir = path.dirname(workspaceHostPath); await fs.mkdir(composeDir, { recursive: true }); const composeFilePath = path.join(composeDir, "docker-compose.yml"); const volumePath = quote(`${toPosixPath(workspaceHostPath)}:/workspace`); + const portSection = ports?.length + ? [" ports:", ...ports.map((port) => ` - ${formatPort(port)}`)] + : []; + const content = [ "services:", ` ${containerName}:`, @@ -28,6 +39,7 @@ export async function writeComposeFile(options: ComposeOptions): Promise " tty: true", " volumes:", ` - ${volumePath}`, + ...portSection, "" ].join("\n"); diff --git a/packages/services/runner/src/index.ts b/packages/services/runner/src/index.ts index e14487b..8be39ea 100644 --- a/packages/services/runner/src/index.ts +++ b/packages/services/runner/src/index.ts @@ -6,7 +6,9 @@ import { composeDown, composeUp, ensureImage, execInContainer } from "./docker.j import { writeComposeFile } from "./compose.js"; import type { ExecResult, LogFn, RunnerOptions } from "./types.js"; -const DEFAULT_IMAGE = "ros:humble"; +const DEFAULT_IMAGE = "bros2/ros2-humble:latest"; +const ROSBRIDGE_PORT = 9090; +const CONTAINER_PREFIX = "bros2_"; const PROJECT_ROOT = path.join(os.homedir(), "BROS2", "Projects"); function sanitizeProjectId(input: string): string { @@ -41,7 +43,7 @@ export class Runner { this.projectId = sanitizeProjectId(options.projectName); this.workspaceHostPath = path.resolve(options.workspaceHostPath); this.image = options.image ?? DEFAULT_IMAGE; - this.containerName = `bros_${this.projectId}`; + this.containerName = `${CONTAINER_PREFIX}${this.projectId}`; this.composeFilePath = path.join(path.dirname(this.workspaceHostPath), "docker-compose.yml"); } @@ -61,7 +63,8 @@ export class Runner { await writeComposeFile({ containerName: this.containerName, workspaceHostPath: this.workspaceHostPath, - image: this.image + image: this.image, + ports: [{ host: ROSBRIDGE_PORT, container: ROSBRIDGE_PORT }] }); await ensureImage(this.image, imageLog); diff --git a/packages/services/runner/src/types.ts b/packages/services/runner/src/types.ts index 3276dda..5911c73 100644 --- a/packages/services/runner/src/types.ts +++ b/packages/services/runner/src/types.ts @@ -12,8 +12,15 @@ export interface ExecResult { stderr: string; } +export interface PortMapping { + host: number | string; + container: number | string; + protocol?: "tcp" | "udp"; +} + export interface ComposeOptions { containerName: string; workspaceHostPath: string; image: string; + ports?: PortMapping[]; } From e6d079ee11a244f6fca06b2676615b30fce7c3c4 Mon Sep 17 00:00:00 2001 From: nhathout Date: Mon, 24 Nov 2025 10:00:40 -0800 Subject: [PATCH 2/4] UPDATE readme and gitignore --- .gitignore | 3 ++- README.md | 42 +++++++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 20 deletions(-) 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/README.md b/README.md index 594fea9..df48e62 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

BROS2 logo

@@ -23,9 +23,7 @@ Verify Docker access before continuing: docker ps ``` -## Quick Dev Loop (tl;dr) - -For day-to-day hacking from the repo root: +## Quick Dev Loop 1. Select Node 20.19.x @@ -34,33 +32,41 @@ For day-to-day hacking from the repo root: nvm use 20.19.0 ``` -2. Refresh deps + clean old builds when needed: +2. Refresh deps (clean if things feel stale): ```bash pnpm install -r - pnpm -r clean && pnpm -r build # optional but ensures fresh dist/ files + 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. Launch the desktop dev stack (Electron main + Vite renderer): +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 ``` -5. In Electron DevTools, bring up ROS 2 + rosbridge when you need it: +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. +Skip to the sections below for the full workflow details and tests. ## First-Time Setup @@ -93,7 +99,7 @@ It launches the packaged app once everything compiles. If the script adds an `nv 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 @@ -161,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 @@ -184,15 +190,15 @@ The preload now exposes `window.runtime` alongside the runner and IR bridges. Wi ```js typeof window.runtime; // "object" -const pubId = window.runtime.create("ArrowKeyPub", { topic: "keys/arrows" }); -const subId = window.runtime.create("ConsoleSub", { topic: "keys/arrows" }); -window.runtime.start(pubId); -window.runtime.start(subId); +const pub = window.runtime.create("ArrowKeyPub", { topic: "keys/arrows" }); +const sub = window.runtime.create("ConsoleSub", { topic: "keys/arrows" }); +window.runtime.start(pub.id); +window.runtime.start(sub.id); // Press arrow keys while the Electron window is focused: // [publish] keys/arrows <- { key: "left", ts: ... } // [node:ConsoleSub_1] received from ArrowKeyPub_1: {"key":"left","ts":...} -window.runtime.stop(subId); -window.runtime.stop(pubId); +window.runtime.stop(sub.id); +window.runtime.stop(pub.id); window.runtime.list(); // ["ArrowKeyPub_1", "ConsoleSub_1"] ``` @@ -244,8 +250,6 @@ 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. From 964722516f291bb74cadd2bf1631c8fad43625f8 Mon Sep 17 00:00:00 2001 From: nhathout Date: Mon, 24 Nov 2025 10:20:50 -0800 Subject: [PATCH 3/4] updated readme and bridges to get rid of preload script not being run --- README.md | 12 +++---- apps/desktop-app/src/remote/ir-bridge.cts | 33 ++++++++++--------- apps/desktop-app/src/remote/runner-bridge.cts | 11 ++++++- .../desktop-app/src/remote/runtime-bridge.cts | 14 ++++++-- 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index df48e62..d39a7af 100644 --- a/README.md +++ b/README.md @@ -190,15 +190,15 @@ The preload now exposes `window.runtime` alongside the runner and IR bridges. Wi ```js typeof window.runtime; // "object" -const pub = window.runtime.create("ArrowKeyPub", { topic: "keys/arrows" }); -const sub = window.runtime.create("ConsoleSub", { topic: "keys/arrows" }); -window.runtime.start(pub.id); -window.runtime.start(sub.id); +const pubId = window.runtime.create("ArrowKeyPub", { topic: "keys/arrows" }); +const subId = window.runtime.create("ConsoleSub", { topic: "keys/arrows" }); +window.runtime.start(pubId); +window.runtime.start(subId); // Press arrow keys while the Electron window is focused: // [publish] keys/arrows <- { key: "left", ts: ... } // [node:ConsoleSub_1] received from ArrowKeyPub_1: {"key":"left","ts":...} -window.runtime.stop(sub.id); -window.runtime.stop(pub.id); +window.runtime.stop(subId); +window.runtime.stop(pubId); window.runtime.list(); // ["ArrowKeyPub_1", "ConsoleSub_1"] ``` 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.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.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 +}); From 03d6a49a06da95c896bdb661afa4e228118d3d5a Mon Sep 17 00:00:00 2001 From: Trieu Tran Date: Sat, 29 Nov 2025 13:01:35 -0500 Subject: [PATCH 4/4] Hella changes to UI --- apps/desktop-app/package.json | 13 + .../src/assets/BROS2-logo-long.png | Bin 0 -> 53009 bytes apps/desktop-app/src/assets/BROS2-logo.PNG | Bin 0 -> 67412 bytes apps/desktop-app/src/main.ts | 491 +++++- apps/desktop-app/src/preload.js | 32 +- apps/desktop-app/src/preload.ts | 19 + apps/desktop-app/src/remote/ir-bridge.cjs | 18 +- apps/desktop-app/src/remote/runner-bridge.cjs | 14 +- .../desktop-app/src/remote/runtime-bridge.cjs | 14 +- .../src/renderer/pages/Dashboard.tsx | 1371 +++++++++++++++-- .../src/renderer/pages/Workspace.tsx | 1181 +++++++------- .../renderer/runtime/nodes/RosbridgeBridge.ts | 89 +- .../src/renderer/runtime/registry.ts | 188 ++- .../src/renderer/styles/Dashboard.css | 946 +++++++++++- .../src/renderer/styles/Workspace.css | 457 ++++-- apps/desktop-app/src/shared/workspace.ts | 3 + apps/desktop-app/src/types/global.d.ts | 28 +- apps/desktop-app/tsconfig.renderer.json | 3 +- assets/logos/BROS2.iconset/Contents.json | 15 + assets/logos/BROS2.iconset/icon_128x128.png | Bin 0 -> 4916 bytes .../logos/BROS2.iconset/icon_128x128@2x.png | Bin 0 -> 10814 bytes assets/logos/BROS2.iconset/icon_16x16.png | Bin 0 -> 739 bytes assets/logos/BROS2.iconset/icon_16x16@2x.png | Bin 0 -> 1368 bytes assets/logos/BROS2.iconset/icon_256x256.png | Bin 0 -> 10814 bytes .../logos/BROS2.iconset/icon_256x256@2x.png | Bin 0 -> 27221 bytes assets/logos/BROS2.iconset/icon_32x32.png | Bin 0 -> 1368 bytes assets/logos/BROS2.iconset/icon_32x32@2x.png | Bin 0 -> 2511 bytes assets/logos/BROS2.iconset/icon_512x512.png | Bin 0 -> 27221 bytes .../logos/BROS2.iconset/icon_512x512@2x.png | Bin 0 -> 78657 bytes assets/logos/bros-logo-icon.ico | Bin 0 -> 106491 bytes pnpm-lock.yaml | 278 +++- 31 files changed, 4307 insertions(+), 853 deletions(-) create mode 100644 apps/desktop-app/src/assets/BROS2-logo-long.png create mode 100644 apps/desktop-app/src/assets/BROS2-logo.PNG create mode 100644 assets/logos/BROS2.iconset/Contents.json create mode 100644 assets/logos/BROS2.iconset/icon_128x128.png create mode 100644 assets/logos/BROS2.iconset/icon_128x128@2x.png create mode 100644 assets/logos/BROS2.iconset/icon_16x16.png create mode 100644 assets/logos/BROS2.iconset/icon_16x16@2x.png create mode 100644 assets/logos/BROS2.iconset/icon_256x256.png create mode 100644 assets/logos/BROS2.iconset/icon_256x256@2x.png create mode 100644 assets/logos/BROS2.iconset/icon_32x32.png create mode 100644 assets/logos/BROS2.iconset/icon_32x32@2x.png create mode 100644 assets/logos/BROS2.iconset/icon_512x512.png create mode 100644 assets/logos/BROS2.iconset/icon_512x512@2x.png create mode 100644 assets/logos/bros-logo-icon.ico diff --git a/apps/desktop-app/package.json b/apps/desktop-app/package.json index 6e9fc01..681b030 100644 --- a/apps/desktop-app/package.json +++ b/apps/desktop-app/package.json @@ -21,6 +21,7 @@ "build": { "appId": "com.bros2.desktop", "productName": "BROS2 Desktop", + "icon": "../../assets/logos/bros-logo-icon.ico", "directories": { "output": "release" }, @@ -30,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": [ @@ -61,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", @@ -72,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 0000000000000000000000000000000000000000..c02ee7c2172db198d7581eeff2405538b9e22848 GIT binary patch literal 53009 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5dd586516d76d091f44c2d3fa148f8acade7cc36 GIT binary patch literal 67412 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 { + 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 b1280e4..837f65f 100644 --- a/apps/desktop-app/src/preload.ts +++ b/apps/desktop-app/src/preload.ts @@ -42,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/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/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/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 ( -
-