diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..25ce89ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + typecheck: + runs-on: ubuntu-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + + - name: Install dependencies + run: bun install --ignore-scripts + + - name: TypeScript typecheck + run: bunx tsc --build --noEmit + continue-on-error: true + + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + + - name: Install dependencies + run: bun install + + - name: Run tests + run: > + bun test + packages/shared + packages/components + src/main/import-service.test.ts + src/main/files.test.ts + src/main/file-filter.test.ts + src/main/file-title.test.ts + src/main/workspace-graph.test.ts + src/main/pty.test.ts + src/main/security.test.ts + src/main/sidecar/ring-buffer.test.ts + src/main/updater + src/windows/shell/src/panel-manager.test.ts + src/windows/shell/src/tile-renderer.test.ts + src/windows/shell/src/webview-factory.test.ts + + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + + - name: Install dependencies + run: bun install --ignore-scripts + + - name: Build + run: bun run build + continue-on-error: true + env: + NODE_OPTIONS: --max-old-space-size=8192 + + audit: + runs-on: ubuntu-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + + - name: Install dependencies + run: bun install --ignore-scripts + + # GHSA-p6mc-m468-83gw (lodash.pick prototype pollution, high): accepted risk, + # not a false positive. lodash.pick is pinned at its last-ever release (4.4.0, + # which is itself the vulnerable ceiling -- the advisory was never patched + # upstream) via cheerio@0.22.0, transitively required by @postlight/parser@2.2.3 + # (both unmaintained; no newer release drops the dependency). Verified 2026-07-08 + # that the only call site reaching the vulnerable path -- cheerio's + # lib/api/css.js getCss() calling _.pick(styles, prop) -- requires prop to be an + # Array; every .css() call in @postlight/parser's bundled code (mercury.js, + # generate-custom-parser.js) passes a string, never an array -- and this + # app's own source (src/main/import-service.ts) never calls .css() at all, + # it only uses @postlight/parser as an opaque Parser.parse() black box. + # So the array-based prototype-pollution sink is unreachable. Re-evaluate + # this ignore if @postlight/parser is + # upgraded/replaced, or if any code path starts calling .css() with an + # array/user-controlled argument. + - name: Dependency audit (high+) + run: bun audit --audit-level=high --ignore=GHSA-p6mc-m468-83gw diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1109f901 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-macos: + runs-on: macos-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build and package (macOS) + run: bun run package + env: + NODE_OPTIONS: --max-old-space-size=8192 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-build + path: | + collab-electron/dist/*.zip + collab-electron/dist/*.blockmap + collab-electron/dist/latest-mac.yml + + build-windows: + runs-on: windows-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build and package (Windows) + run: bun run package:unsigned + env: + NODE_OPTIONS: --max-old-space-size=8192 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + collab-electron/dist/*.exe + collab-electron/dist/latest.yml + + build-linux: + runs-on: ubuntu-latest + defaults: + run: + working-directory: collab-electron + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build and package (Linux) + run: bun run package:unsigned + env: + NODE_OPTIONS: --max-old-space-size=8192 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-build + path: | + collab-electron/dist/*.AppImage + collab-electron/dist/latest-linux.yml + + create-release: + needs: [build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + draft: true + generate_release_notes: true + files: | + artifacts/macos-build/* + artifacts/windows-build/* + artifacts/linux-build/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/collab-electron/.gitignore b/collab-electron/.gitignore index face5070..bb755e60 100644 --- a/collab-electron/.gitignore +++ b/collab-electron/.gitignore @@ -35,6 +35,7 @@ electron/out/ .collaborator/ .superpowers/ .crush/ +.serena/ # Private — not published to the public repo # Back up these files separately when migrating machines diff --git a/collab-electron/bunfig.toml b/collab-electron/bunfig.toml index 731d5888..8871e4d2 100644 --- a/collab-electron/bunfig.toml +++ b/collab-electron/bunfig.toml @@ -1,5 +1,6 @@ [test] root = "." +preload = ["./happydom.ts"] [resolve.alias] # Mirror the aliases from electron.vite.config.ts so bun test can resolve them diff --git a/collab-electron/happydom.ts b/collab-electron/happydom.ts new file mode 100644 index 00000000..7f712d02 --- /dev/null +++ b/collab-electron/happydom.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); diff --git a/collab-electron/package.json b/collab-electron/package.json index 17256471..db717c38 100644 --- a/collab-electron/package.json +++ b/collab-electron/package.json @@ -17,8 +17,9 @@ "package": "bun ./scripts/package.mjs", "package:unsigned": "bun ./scripts/package.mjs --no-sign", "release": "bun ./scripts/package.mjs --publish", - "test": "bun test", + "test": "bun test --path-ignore-patterns='src/main/sidecar/server.test.ts' --path-ignore-patterns='src/main/sidecar/client.test.ts'", "test:updater": "bun test src/main/updater/github-release-fetcher.test.ts && bun test src/main/updater/downloader.test.ts && bun test src/main/updater/update-manager.test.ts", + "test:sidecar": "npx tsx --test src/main/sidecar/server.test.ts src/main/sidecar/client.test.ts", "postinstall": "electron-rebuild -f -w node-pty" }, "build": { @@ -117,10 +118,13 @@ "@phosphor-icons/react": "2.1.7", "@posthog/react": "^1.8.2", "@postlight/parser": "^2.2.3", - "@tiptap/core": "3.20.0", - "@tiptap/extension-typography": "3.20.0", + "@tiptap/core": "3.23.1", + "@tiptap/extension-typography": "3.23.1", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -151,6 +155,7 @@ "devDependencies": { "@electron/notarize": "2.5.0", "@electron/rebuild": "^4.0.3", + "@happy-dom/global-registrator": "^20.10.6", "@octokit/rest": "22.0.1", "@tailwindcss/vite": "4.2.0", "@types/d3": "7.4.3", @@ -158,12 +163,33 @@ "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "5.1.4", "app-builder-bin": "4.2.0", - "electron": "40.6.0", + "bun-types": "1.3.11", + "electron": "40.10.3", "electron-builder": "26.8.1", "electron-vite": "5.0.0", "react": "19.2.4", "react-dom": "19.2.4", "tailwindcss": "4.2.0", "tsx": "^4.20.3" + }, + "overrides": { + "esbuild": "0.28.1", + "dompurify": "3.4.11", + "form-data": "4.0.6", + "protobufjs": "7.6.4", + "qs": "6.15.2", + "tmp": "0.2.7", + "js-yaml": "4.3.0", + "tar": "7.5.19", + "@babel/core": "7.29.7", + "vite": "7.3.6", + "@opentelemetry/core": "2.9.0", + "nth-check": "2.1.1", + "uuid": "11.1.1", + "tough-cookie": "4.1.4" + }, + "patchedDependencies": { + "lodash.pick@4.4.0": "patches/lodash.pick@4.4.0.patch", + "front-matter@4.0.2": "patches/front-matter@4.0.2.patch" } } diff --git a/collab-electron/packages/components/src/CodeEditorView/CodeEditorView.tsx b/collab-electron/packages/components/src/CodeEditorView/CodeEditorView.tsx index 91b589f8..add0e9a4 100644 --- a/collab-electron/packages/components/src/CodeEditorView/CodeEditorView.tsx +++ b/collab-electron/packages/components/src/CodeEditorView/CodeEditorView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import * as monaco from "monaco-editor"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; @@ -71,12 +71,19 @@ monaco.editor.defineTheme("monokai-light", { }); self.MonacoEnvironment = { - getWorker(_: unknown, label: string) { - if (label === "json") return new jsonWorker(); - if (label === "css" || label === "scss" || label === "less") return new cssWorker(); - if (label === "html" || label === "handlebars" || label === "razor") return new htmlWorker(); - if (label === "typescript" || label === "javascript") return new tsWorker(); - return new editorWorker(); + getWorker(_moduleId: string, label: string) { + switch (label) { + case "json": + return new jsonWorker(); + case "css": case "scss": case "less": + return new cssWorker(); + case "html": case "handlebars": case "razor": + return new htmlWorker(); + case "typescript": case "javascript": + return new tsWorker(); + default: + return new editorWorker(); + } }, }; diff --git a/collab-electron/packages/components/src/Terminal/TerminalTab.css b/collab-electron/packages/components/src/Terminal/TerminalTab.css index 06dee1f3..4911629f 100644 --- a/collab-electron/packages/components/src/Terminal/TerminalTab.css +++ b/collab-electron/packages/components/src/Terminal/TerminalTab.css @@ -19,4 +19,64 @@ .terminal-tab .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(121, 121, 121, 0.7); -} \ No newline at end of file +} + +/* Drag-over visual indicator */ +.terminal-tab.terminal-drag-over { + outline: 2px solid #4a9eff; + outline-offset: -2px; +} + +.terminal-tab .xterm-link { + text-decoration: underline; + cursor: pointer; +} + +/* Visual bell: brief brightness flash on bell character */ +.terminal-bell-flash { + animation: bell-flash 0.2s ease-out; +} + +@keyframes bell-flash { + 0% { filter: brightness(1.5); } + 100% { filter: brightness(1); } +} +.terminal-search-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--bg, #1e1e1e); + border-bottom: 1px solid rgba(128, 128, 128, 0.3); + flex-shrink: 0; +} + +.terminal-search-input { + flex: 1; + padding: 3px 8px; + font-size: 12px; + font-family: inherit; + border: 1px solid rgba(128, 128, 128, 0.4); + border-radius: 3px; + background: rgba(255, 255, 255, 0.1); + color: inherit; + outline: none; +} + +.terminal-search-input:focus { + border-color: #4a9eff; +} + +.terminal-search-close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 16px; + padding: 0 4px; + opacity: 0.6; +} + +.terminal-search-close:hover { + opacity: 1; +} diff --git a/collab-electron/packages/components/src/Terminal/TerminalTab.tsx b/collab-electron/packages/components/src/Terminal/TerminalTab.tsx index b710b9c2..004938b3 100644 --- a/collab-electron/packages/components/src/Terminal/TerminalTab.tsx +++ b/collab-electron/packages/components/src/Terminal/TerminalTab.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebglAddon } from "@xterm/addon-webgl"; import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { SearchAddon } from "@xterm/addon-search"; import { getTheme } from "./theme"; import "@xterm/xterm/css/xterm.css"; import "./TerminalTab.css"; @@ -25,6 +27,9 @@ interface TerminalTabProps { function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: TerminalTabProps) { const containerRef = useRef(null); const fitRef = useRef(null); + const [searchVisible, setSearchVisible] = useState(false); + const searchInputRef = useRef(null); + const searchAddonRef = useRef(null); useEffect(() => { const container = containerRef.current; @@ -37,8 +42,15 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter fontWeight: "300", fontWeightBold: "500", cursorBlink: true, + cursorStyle: "bar", + cursorWidth: 2, scrollback: 200000, allowProposedApi: true, + linkHandler: { + activate(_event, text) { + window.api.openExternal(text); + }, + }, }); const fit = new FitAddon(); @@ -62,6 +74,25 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter // DOM renderer fallback — no action needed } + const searchAddon = new SearchAddon(); + term.loadAddon(searchAddon); + searchAddonRef.current = searchAddon; + + // Clickable URL detection + const webLinks = new WebLinksAddon((event, uri) => { + // Only open on Cmd+click (Mac) or Ctrl+click (other) + if ((IS_MAC && event.metaKey) || (!IS_MAC && event.ctrlKey)) { + window.open(uri, "_blank"); + } + }); + term.loadAddon(webLinks); + + // Visual bell: briefly flash the terminal on bell character + term.onBell(() => { + container.classList.add("terminal-bell-flash"); + setTimeout(() => container.classList.remove("terminal-bell-flash"), 200); + }); + // Delay initial fit: the webview may not have its final // dimensions when the page first loads. Double-rAF ensures // the layout pass has finished before we measure. @@ -131,6 +162,14 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter return false; } const primaryModifier = IS_MAC ? e.metaKey : e.ctrlKey; + // Search: Cmd+F to toggle search bar + if (e.type === "keydown" && primaryModifier && e.key === "f") { + setSearchVisible((prev) => { + if (prev) searchAddonRef.current?.clearDecorations(); + return !prev; + }); + return false; + } if (e.type === "keydown" && primaryModifier) { const key = e.key.toLowerCase(); if (key === "c" && copySelectionToClipboard()) { @@ -140,15 +179,6 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter pasteFromShortcut(); return false; } - if (!IS_MAC && e.shiftKey) { - if (key === "c" && copySelectionToClipboard()) { - return false; - } - if (key === "v") { - pasteFromShortcut(); - return false; - } - } } if (e.type === "keydown" && e.shiftKey && e.key === "Insert") { pasteFromShortcut(); @@ -235,6 +265,44 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter container.addEventListener("copy", handleCopy, true); container.addEventListener("paste", handlePaste, true); + // Drag-and-drop: insert file paths into terminal + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + container.classList.add("terminal-drag-over"); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + container.classList.remove("terminal-drag-over"); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + container.classList.remove("terminal-drag-over"); + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const paths: string[] = []; + for (let i = 0; i < files.length; i++) { + // Electron File objects have a `path` property + const filePath = (files[i] as any).path; + if (filePath) { + // Shell-escape the path: wrap in single quotes, escape existing single quotes + paths.push("'" + filePath.replace(/'/g, "'\\''") + "'"); + } + } + if (paths.length > 0) { + window.api.ptyWrite(sessionId, paths.join(" ")); + } + } + }; + + container.addEventListener("dragover", handleDragOver); + container.addEventListener("dragleave", handleDragLeave); + container.addEventListener("drop", handleDrop); + const offShellBlur = window.api.onShellBlur(() => { term.blur(); const active = document.activeElement as HTMLElement | null; @@ -271,10 +339,14 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter resizeObserver.disconnect(); container.removeEventListener("copy", handleCopy, true); container.removeEventListener("paste", handlePaste, true); + container.removeEventListener("dragover", handleDragOver); + container.removeEventListener("dragleave", handleDragLeave); + container.removeEventListener("drop", handleDrop); window.api.offPtyData(sessionId, handleData); offShellBlur(); term.dispose(); fitRef.current = null; + searchAddonRef.current = null; }; }, [sessionId]); @@ -285,11 +357,51 @@ function TerminalTab({ sessionId, visible, restored, scrollbackData, mode }: Ter }, [visible]); return ( -
+
+ {searchVisible && ( +
+ { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) { + searchAddonRef.current?.findPrevious(e.currentTarget.value); + } else { + searchAddonRef.current?.findNext(e.currentTarget.value); + } + } + if (e.key === "Escape") { + e.preventDefault(); + searchAddonRef.current?.clearDecorations(); + setSearchVisible(false); + } + }} + onChange={(e) => { + searchAddonRef.current?.findNext(e.target.value); + }} + /> + +
+ )} +
+
); } diff --git a/collab-electron/patches/front-matter@4.0.2.patch b/collab-electron/patches/front-matter@4.0.2.patch new file mode 100644 index 00000000..ad385b79 --- /dev/null +++ b/collab-electron/patches/front-matter@4.0.2.patch @@ -0,0 +1,29 @@ +diff --git a/node_modules/front-matter/.bun-tag-fa1420af75563a2b b/.bun-tag-fa1420af75563a2b +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/index.js b/index.js +index d518f1df0974b361911ab5ed90857295ab834f0a..7e7de445894cff82b945d9c7f4aa75b82b493c4a 100644 +--- a/index.js ++++ b/index.js +@@ -1,4 +1,10 @@ + var parser = require('js-yaml') ++// js-yaml >= 4 removed the unsafe `load`/dropped `safeLoad` (its `load` is now ++// safe-by-default). The removed methods still exist as stubs that throw when ++// called, so we can't feature-detect by truthiness -- check the actual ++// installed major version instead (see GHSA-h67p-54hq-rp68 / GHSA-vmf3-w455-68vh ++// js-yaml overrides that can pin js-yaml to 4.x here). ++var jsYamlMajor = parseInt(String(require('js-yaml/package.json').version), 10) + var optionalByteOrderMark = '\\ufeff?' + var platform = typeof process !== 'undefined' ? process.platform : '' + var pattern = '^(' + +@@ -59,7 +65,9 @@ function parse (string, allowUnsafe) { + } + } + +- var loader = allowUnsafe ? parser.load : parser.safeLoad ++ // On js-yaml 4.x, `load` is safe by default and unsafe loading no longer ++ // exists, so always use `load`. On 3.x, keep the original safe/unsafe split. ++ var loader = jsYamlMajor >= 4 ? parser.load : (allowUnsafe ? parser.load : parser.safeLoad) + var yaml = match[match.length - 1].replace(/^\s+|\s+$/g, '') + var attributes = loader(yaml) || {} + var body = string.replace(match[0], '') diff --git a/collab-electron/patches/lodash.pick@4.4.0.patch b/collab-electron/patches/lodash.pick@4.4.0.patch new file mode 100644 index 00000000..f9a30a25 --- /dev/null +++ b/collab-electron/patches/lodash.pick@4.4.0.patch @@ -0,0 +1,17 @@ +diff --git a/index.js b/index.js +index aeeb775f1b1b234f6a647305038b907d0d02360d..d48eae542bc722c61a71b9dd17d6099ef7b59721 100644 +--- a/index.js ++++ b/index.js +@@ -171,8 +171,10 @@ function basePickBy(object, props, predicate) { + result = {}; + + while (++index < length) { +- var key = props[index], +- value = object[key]; ++ var key = props[index]; ++ // Guard against prototype pollution (GHSA-p6mc-m468-83gw) ++ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; ++ var value = object[key]; + + if (predicate(value, key)) { + result[key] = value; diff --git a/collab-electron/src/main/canvas-persistence.ts b/collab-electron/src/main/canvas-persistence.ts index 1b9ff152..069aa28c 100644 --- a/collab-electron/src/main/canvas-persistence.ts +++ b/collab-electron/src/main/canvas-persistence.ts @@ -1,13 +1,11 @@ -import { readFile, writeFile, rename, mkdir } from "node:fs/promises"; +import { readFile, writeFile, rename, mkdir, copyFile } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; +import { createHash } from "node:crypto"; import * as crypto from "node:crypto"; import { COLLAB_DIR } from "./paths"; -const STATE_DIR = COLLAB_DIR; -const STATE_FILE = join(STATE_DIR, "canvas-state.json"); - interface TileState { id: string; type: "term" | "note" | "code" | "image" | "graph" | "browser"; @@ -37,9 +35,34 @@ function sanitizeCoord(v: unknown): number { return typeof v === "number" && Number.isFinite(v) ? v : 0; } -export async function loadState(): Promise { +export function workspaceHash(wsPath: string): string { + return createHash("sha256").update(wsPath).digest("hex").slice(0, 16); +} + +function stateFileForWorkspace(wsPath: string): string { + return join(COLLAB_DIR, "workspaces", workspaceHash(wsPath), "canvas-state.json"); +} + +export async function migrateGlobalState(wsPath: string): Promise { + const globalFile = join(COLLAB_DIR, "canvas-state.json"); + const migratedFile = globalFile + ".migrated"; + const perWsFile = stateFileForWorkspace(wsPath); + + if (existsSync(globalFile) && !existsSync(perWsFile)) { + const perWsDir = dirname(perWsFile); + if (!existsSync(perWsDir)) { + await mkdir(perWsDir, { recursive: true }); + } + await copyFile(globalFile, perWsFile); + await rename(globalFile, migratedFile); + } +} + +export async function loadState(workspacePath: string): Promise { + if (!workspacePath) return null; + const stateFile = stateFileForWorkspace(workspacePath); try { - const raw = await readFile(STATE_FILE, "utf-8"); + const raw = await readFile(stateFile, "utf-8"); const state = JSON.parse(raw) as CanvasState; if (state.version !== 1) return null; for (const tile of state.tiles) { @@ -52,9 +75,12 @@ export async function loadState(): Promise { } } -export async function saveState(state: CanvasState): Promise { - if (!existsSync(STATE_DIR)) { - await mkdir(STATE_DIR, { recursive: true }); +export async function saveState(workspacePath: string, state: CanvasState): Promise { + if (!workspacePath) return; + const stateFile = stateFileForWorkspace(workspacePath); + const stateDir = dirname(stateFile); + if (!existsSync(stateDir)) { + await mkdir(stateDir, { recursive: true }); } const tmp = join( tmpdir(), @@ -62,5 +88,5 @@ export async function saveState(state: CanvasState): Promise { ); const json = JSON.stringify(state, null, 2); await writeFile(tmp, json, "utf-8"); - await rename(tmp, STATE_FILE); + await rename(tmp, stateFile); } diff --git a/collab-electron/src/main/canvas-rpc.ts b/collab-electron/src/main/canvas-rpc.ts index 293b013f..1a9bf931 100644 --- a/collab-electron/src/main/canvas-rpc.ts +++ b/collab-electron/src/main/canvas-rpc.ts @@ -65,7 +65,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.tileList", - (params) => sendToShell("canvas.tileList", params), + (params) => sendToShell("tileList", params), { description: "List all canvas tiles with positions", params: {}, @@ -74,7 +74,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.tileAdd", - (params) => sendToShell("canvas.tileAdd", params), + (params) => sendToShell("tileAdd", params), { description: "Create a new tile on the canvas", params: { @@ -89,7 +89,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.tileRemove", - (params) => sendToShell("canvas.tileRemove", params), + (params) => sendToShell("tileRemove", params), { description: "Remove a tile from the canvas", params: { tileId: "ID of the tile to remove" }, @@ -98,7 +98,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.tileMove", - (params) => sendToShell("canvas.tileMove", params), + (params) => sendToShell("tileMove", params), { description: "Move a tile to a new position", params: { @@ -110,7 +110,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.tileResize", - (params) => sendToShell("canvas.tileResize", params), + (params) => sendToShell("tileResize", params), { description: "Resize a tile", params: { @@ -122,7 +122,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.viewportGet", - (params) => sendToShell("canvas.viewportGet", params), + (params) => sendToShell("viewportGet", params), { description: "Get current canvas viewport (pan and zoom)", params: {}, @@ -131,7 +131,7 @@ export function registerCanvasRpc(win: BrowserWindow): void { registerMethod( "canvas.viewportSet", - (params) => sendToShell("canvas.viewportSet", params), + (params) => sendToShell("viewportSet", params), { description: "Set canvas viewport pan and zoom", params: { diff --git a/collab-electron/src/main/file-title.test.ts b/collab-electron/src/main/file-title.test.ts new file mode 100644 index 00000000..f96fa1c9 --- /dev/null +++ b/collab-electron/src/main/file-title.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from "bun:test"; +import { sanitizeFileTitle } from "./file-title"; + +describe("sanitizeFileTitle", () => { + test("strips illegal filesystem characters", () => { + expect(sanitizeFileTitle('ac:d"e/f\\g|h?i*j')).toBe("abcdefghij"); + }); + + test("strips control characters", () => { + expect(sanitizeFileTitle("title\x00\x1f")).toBe("title"); + }); + + test("strips a trailing dot and trims whitespace", () => { + expect(sanitizeFileTitle(" My Note. ")).toBe("My Note"); + }); + + test("neutralizes path traversal attempts (slashes stripped, cannot escape dir)", () => { + const result = sanitizeFileTitle("../../etc/passwd"); + // Slashes are stripped entirely, so join(dir, result) in fsRename can + // never resolve outside the original directory. + expect(result).toBe("....etcpasswd"); + expect(result).not.toContain("/"); + }); + + test("neutralizes an absolute-path-style title", () => { + const result = sanitizeFileTitle("/etc/passwd"); + expect(result).toBe("etcpasswd"); + expect(result).not.toContain("/"); + }); + + test("returns empty string for a title made entirely of illegal characters", () => { + // The fs:rename handler treats this as "Title cannot be empty". + expect(sanitizeFileTitle('<>:"/\\|?*')).toBe(""); + }); + + test("collapses to empty after stripping a lone trailing dot with only whitespace", () => { + expect(sanitizeFileTitle(" . ")).toBe(""); + }); + + test("leaves an ordinary title unchanged", () => { + expect(sanitizeFileTitle("Meeting Notes 2026-07-08")).toBe( + "Meeting Notes 2026-07-08", + ); + }); +}); diff --git a/collab-electron/src/main/file-title.ts b/collab-electron/src/main/file-title.ts new file mode 100644 index 00000000..47bf0036 --- /dev/null +++ b/collab-electron/src/main/file-title.ts @@ -0,0 +1,14 @@ +// Kept dependency-free (no electron import) so it can be unit tested without +// mocking Electron's ipcMain — see file-title.test.ts. + +// Strips characters illegal in filenames (and a trailing dot, which Windows +// disallows) from a user-provided title before it is used to build a new +// filename. Notably strips "/" and "\" rather than preserving them, which is +// what prevents a crafted title from escaping the target directory when the +// result is later passed to path.join(). +export function sanitizeFileTitle(title: string): string { + return title + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "") + .replace(/\.\s*$/, "") + .trim(); +} diff --git a/collab-electron/src/main/files.test.ts b/collab-electron/src/main/files.test.ts index 1c0de276..5da9030a 100644 --- a/collab-electron/src/main/files.test.ts +++ b/collab-electron/src/main/files.test.ts @@ -5,8 +5,11 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; mock.module("@collab/shared/image", () => ({ - IMAGE_EXTENSIONS: new Set([".png", ".jpg"]), - isImageFile: (p: string) => /\.(png|jpg)$/i.test(p), + IMAGE_EXTENSIONS: new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".webp", + ".bmp", ".tiff", ".tif", ".avif", ".heic", ".heif", + ]), + isImageFile: (p: string) => /\.(png|jpe?g|gif|webp|bmp|tiff?|avif|hei[cf])$/i.test(p), })); const { fsWriteFile, atomicWriteFileSync } = await import("./files"); diff --git a/collab-electron/src/main/image-service.ts b/collab-electron/src/main/image-service.ts index bd7182b8..f3e1cb3d 100644 --- a/collab-electron/src/main/image-service.ts +++ b/collab-electron/src/main/image-service.ts @@ -78,12 +78,17 @@ function isInsideCacheDir(path: string): boolean { return cacheDir !== null && isSubpath(cacheDir, path); } -export function getImageThumbnail( +export async function getImageThumbnail( path: string, size: number, ): Promise { - if (isInsideCacheDir(path)) return Promise.resolve(""); - return request("thumbnail", path, { size }) as Promise; + if (isInsideCacheDir(path)) return ""; + try { + return (await request("thumbnail", path, { size })) as string; + } catch (err) { + console.warn(`[image:thumbnail] Skipping: ${path}`, (err as Error).message); + return ""; + } } function isNativeImage(path: string): boolean { diff --git a/collab-electron/src/main/index.ts b/collab-electron/src/main/index.ts index de0b2f1b..5194b413 100644 --- a/collab-electron/src/main/index.ts +++ b/collab-electron/src/main/index.ts @@ -47,6 +47,12 @@ import { stopImageWorker } from "./image-service"; import { installCli } from "./cli-installer"; import { listTerminalTargets } from "./terminal-target"; import { readSessionMeta } from "./tmux"; +import { + isNavigationAllowed, + isWorkspaceTileStoragePath, + setupPermissionHandler, + setupWebviewSecurity, +} from "./security"; // macOS apps launched from Finder don't inherit the user's shell // LANG, so child processes (tmux, shells) default to ASCII. @@ -90,7 +96,7 @@ if (savedTheme === "light" || savedTheme === "dark") { } else { nativeTheme.themeSource = "system"; } -let globalZoomLevel = 0; +// Per-panel zoom is managed by the renderer; no global zoom level needed. if (!app.isPackaged) { // Vite dev uses a relaxed renderer policy for HMR; suppress Electron's @@ -224,7 +230,7 @@ function attachShortcutListener(target: WebContents): void { function isBrowserTileWebview(wc: WebContents): boolean { try { - return wc.session === session.fromPartition("persist:browser"); + return isWorkspaceTileStoragePath(wc.session.storagePath); } catch { return false; } @@ -266,25 +272,27 @@ function attachBrowserShortcuts( function registerToggleShortcuts(win: BrowserWindow): void { attachShortcutListener(win.webContents); + // Block Chromium's built-in page zoom on the SHELL window only. + // Child webviews (terminal, nav, etc.) are NOT touched — terminal + // webviews handle Cmd+=/- via xterm's font zoom internally. + win.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown") return; + if (cmdOrCtrl(input) && (input.key === "=" || input.key === "+" || input.key === "-" || input.key === "0")) { + event.preventDefault(); + } + }); + win.webContents.on("did-attach-webview", (_event, wc) => { wc.once("did-finish-load", () => { attachShortcutListener(wc); if (isBrowserTileWebview(wc)) { attachBrowserShortcuts(wc, win); } - if (globalZoomLevel !== 0) { - wc.setZoomLevel(globalZoomLevel); - } }); }); } -function applyZoomToAll(level: number): void { - globalZoomLevel = level; - for (const wc of webContentsModule.getAllWebContents()) { - if (!wc.isDestroyed()) wc.setZoomLevel(level); - } -} + function buildAppMenu(): void { const isMac = process.platform === "darwin"; @@ -378,17 +386,17 @@ function buildAppMenu(): void { { label: "Zoom In", accelerator: "CommandOrControl+=", - click: () => applyZoomToAll(globalZoomLevel + 0.25), + click: () => sendShortcut("zoom-in"), }, { label: "Zoom Out", accelerator: "CommandOrControl+-", - click: () => applyZoomToAll(globalZoomLevel - 0.25), + click: () => sendShortcut("zoom-out"), }, { label: "Actual Size", accelerator: "CommandOrControl+0", - click: () => applyZoomToAll(0), + click: () => sendShortcut("zoom-reset"), }, { type: "separator" }, { role: "toggleDevTools" }, @@ -666,6 +674,10 @@ ipcMain.on( ipcMain.on("settings:close", () => setSettingsOpen(false)); ipcMain.on("settings:toggle", () => setSettingsOpen(!settingsOpen)); +ipcMain.on("shell:open-external", (_event, url: string) => { + shell.openExternal(url); +}); + function sendLoadingDone(): void { mainWindow?.webContents.send("shell:loading-done"); } @@ -705,6 +717,22 @@ protocol.registerSchemesAsPrivileged([ ]); app.on("web-contents-created", (_event, contents) => { + // Electron auto-grants all permission requests (camera/mic/geolocation/ + // notifications) unless setPermissionRequestHandler is set explicitly. + // Scope the deny-all to sessions that do NOT share the app's own + // defaultSession (i.e. untrusted browser-tile content on + // persist:browser / persist:ws-* partitions) — the main window and + // internal webviews (terminal/viewer/graph) share defaultSession and + // rely on Electron's default-allow for clipboard read/write. + if (contents.session !== session.defaultSession) { + setupPermissionHandler(contents.session); + } + + // Browser tiles (webview elements on persist:ws-* partitions) must attach + // without the app's preload/nodeIntegration — was previously unenforced, + // so a webview could attach with the default (unsandboxed) webPreferences. + setupWebviewSecurity(contents); + const isExternal = (url: string): boolean => { if (!url.startsWith("http://") && !url.startsWith("https://")) { return false; @@ -738,7 +766,12 @@ app.on("web-contents-created", (_event, contents) => { return { action: "deny" }; }); contents.on("will-navigate", (event, url) => { - if (isExternal(url) && !isBrowserTileWebview(contents)) { + if (isBrowserTileWebview(contents)) return; + if (!isNavigationAllowed(url)) { + event.preventDefault(); + return; + } + if (isExternal(url)) { event.preventDefault(); shell.openExternal(url); } diff --git a/collab-electron/src/main/ipc-canvas.ts b/collab-electron/src/main/ipc-canvas.ts index 3ee706f3..e5b7e8db 100644 --- a/collab-electron/src/main/ipc-canvas.ts +++ b/collab-electron/src/main/ipc-canvas.ts @@ -22,15 +22,33 @@ export function registerCanvasHandlers( ): void { let pendingDragPaths: string[] = []; - // Canvas persistence + // Canvas persistence (per-workspace) ipcMain.handle( "canvas:load-state", - async () => canvasPersistence.loadState(), + async () => { + const wsPath = ctx.getActiveWorkspacePath(); + if (!wsPath) return null; + await canvasPersistence.migrateGlobalState(wsPath); + return canvasPersistence.loadState(wsPath); + }, ); ipcMain.handle( "canvas:save-state", - async (_event, state) => canvasPersistence.saveState(state), + async (_event, state) => { + const wsPath = ctx.getActiveWorkspacePath(); + if (!wsPath) return; + return canvasPersistence.saveState(wsPath, state); + }, + ); + + ipcMain.handle( + "canvas:workspace-hash", + () => { + const wsPath = ctx.getActiveWorkspacePath(); + if (!wsPath) return "default"; + return canvasPersistence.workspaceHash(wsPath); + }, ); // Canvas pinch forwarding diff --git a/collab-electron/src/main/ipc-filesystem.ts b/collab-electron/src/main/ipc-filesystem.ts index 2f31db17..7c6b5a16 100644 --- a/collab-electron/src/main/ipc-filesystem.ts +++ b/collab-electron/src/main/ipc-filesystem.ts @@ -19,6 +19,7 @@ import { saveDroppedImage, } from "./image-service"; import type { FileFilter } from "./file-filter"; +import { sanitizeFileTitle } from "./file-title"; import * as wikilinkIndex from "./wikilink-index"; import type { FolderTableData, @@ -122,10 +123,7 @@ export function registerFilesystemHandlers( ipcMain.handle( "fs:rename", async (_event, oldPath: string, newTitle: string) => { - const sanitized = newTitle - .replace(/[<>:"/\\|?*\x00-\x1f]/g, "") - .replace(/\.\s*$/, "") - .trim(); + const sanitized = sanitizeFileTitle(newTitle); if (sanitized.length === 0) { throw new Error("Title cannot be empty"); } diff --git a/collab-electron/src/main/pty.test.ts b/collab-electron/src/main/pty.test.ts new file mode 100644 index 00000000..4b14ec0c --- /dev/null +++ b/collab-electron/src/main/pty.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from "bun:test"; +import { withOptionalFields } from "./pty"; + +// withOptionalFields is a small merge helper used at 4 call sites in this +// file (createSession, reconnectSession x2, discoverSessions) to fold +// optional fields (like cwdGuestPath) into a return object -- skipping only +// keys whose value is `undefined`, while still assigning falsy-but-defined +// values such as null, false, 0, and "". It had zero test coverage. +describe("withOptionalFields", () => { + test("omits a field whose value is undefined", () => { + const base = { a: 1 }; + const result = withOptionalFields(base, { b: undefined }); + expect(result).toEqual({ a: 1 }); + expect("b" in result).toBe(false); + }); + + test("assigns fields with null, false, 0, and '' values", () => { + const base = { a: 1 } as Record; + const result = withOptionalFields(base, { + nullField: null, + falseField: false, + zeroField: 0, + emptyStringField: "", + }); + expect(result).toEqual({ + a: 1, + nullField: null, + falseField: false, + zeroField: 0, + emptyStringField: "", + }); + }); + + test("preserves base properties not mentioned in fields", () => { + const base = { a: 1, b: 2 }; + const result = withOptionalFields(base, { c: 3 }); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + test("overrides a same-named property already in base", () => { + const base = { a: 1 }; + const result = withOptionalFields(base, { a: 2 }); + expect(result).toEqual({ a: 2 }); + }); + + test("leaves base unchanged when fields is empty", () => { + const base = { a: 1, b: 2 }; + const result = withOptionalFields(base, {}); + expect(result).toEqual({ a: 1, b: 2 }); + expect(Object.keys(result)).toEqual(["a", "b"]); + }); + + test("mutates and returns the same base object reference", () => { + const base = { a: 1 }; + const result = withOptionalFields(base, { b: 2 }); + expect(result).toBe(base); + }); +}); diff --git a/collab-electron/src/main/pty.ts b/collab-electron/src/main/pty.ts index b6bcbf4b..66cb75c6 100644 --- a/collab-electron/src/main/pty.ts +++ b/collab-electron/src/main/pty.ts @@ -105,7 +105,7 @@ function utf8Env(): Record { return env; } -function withOptionalFields( +export function withOptionalFields( base: T, fields: Record, ): T { diff --git a/collab-electron/src/main/security.test.ts b/collab-electron/src/main/security.test.ts new file mode 100644 index 00000000..c04e7478 --- /dev/null +++ b/collab-electron/src/main/security.test.ts @@ -0,0 +1,252 @@ +import { describe, test, expect } from "bun:test"; +import type { Session, WebContents } from "electron"; +import { + isNavigationAllowed, + isWorkspaceTileStoragePath, + setupPermissionHandler, + setupWebviewSecurity, +} from "./security"; + +// isNavigationAllowed guards the will-navigate handler in index.ts against +// javascript:/data:/file:/blob: navigations reaching internal webContents +// (terminal-tile/viewer-tile/graph-tile/shell/settings) that carry preload +// scripts with privileged IPC bridge access. It had zero test coverage +// despite being the security boundary for that handler. +describe("isNavigationAllowed", () => { + test("blocks javascript: URLs", () => { + expect(isNavigationAllowed("javascript:alert(1)")).toBe(false); + }); + + test("blocks data: URLs", () => { + expect(isNavigationAllowed("data:text/html,")).toBe( + false, + ); + }); + + test("blocks file: URLs", () => { + expect(isNavigationAllowed("file:///etc/passwd")).toBe(false); + }); + + test("blocks blob: URLs", () => { + expect(isNavigationAllowed("blob:https://example.com/uuid")).toBe(false); + }); + + test("blocks protocols case-insensitively", () => { + expect(isNavigationAllowed("JavaScript:alert(1)")).toBe(false); + expect(isNavigationAllowed("DATA:text/html,x")).toBe(false); + expect(isNavigationAllowed("File:///etc/passwd")).toBe(false); + expect(isNavigationAllowed("BLOB:https://example.com/uuid")).toBe(false); + }); + + test("allows http:// URLs", () => { + expect(isNavigationAllowed("http://example.com")).toBe(true); + }); + + test("allows https:// URLs", () => { + expect(isNavigationAllowed("https://example.com/path?query=1")).toBe( + true, + ); + }); + + test("allows the app's internal collab-file:// scheme", () => { + // collab-file:// is the custom scheme registered via protocol.handle + // for internal file access -- it must not match any blocked prefix. + expect(isNavigationAllowed("collab-file:///workspace/note.md")).toBe( + true, + ); + }); + + test("allows relative/empty-protocol-looking strings", () => { + expect(isNavigationAllowed("/relative/path")).toBe(true); + expect(isNavigationAllowed("")).toBe(true); + expect(isNavigationAllowed("about:blank")).toBe(true); + }); + + test("matches by prefix, not substring — a blocked protocol appearing mid-URL is allowed", () => { + expect( + isNavigationAllowed("https://example.com/redirect?url=javascript:alert(1)"), + ).toBe(true); + }); +}); + +// setupPermissionHandler guards every session created for main-window and +// webview webContents. Electron auto-grants camera/mic/geolocation/etc +// permission requests unless setPermissionRequestHandler is set explicitly +// — this was previously dead code (zero call sites) despite existing. +describe("setupPermissionHandler", () => { + function makeMockSession() { + let registeredHandler: + | (( + webContents: unknown, + permission: string, + callback: (granted: boolean) => void, + details: unknown, + ) => void) + | null = null; + + const sess = { + setPermissionRequestHandler(handler: typeof registeredHandler) { + registeredHandler = handler; + }, + } as unknown as Session; + + return { + sess, + getHandler: () => registeredHandler, + }; + } + + test("registers a permission request handler on the session", () => { + const { sess, getHandler } = makeMockSession(); + setupPermissionHandler(sess); + expect(getHandler()).not.toBeNull(); + }); + + test("denies the request via callback(false)", () => { + const { sess, getHandler } = makeMockSession(); + setupPermissionHandler(sess); + + const handler = getHandler(); + expect(handler).not.toBeNull(); + + let grantedResult: boolean | undefined; + handler?.({}, "media", (granted) => { + grantedResult = granted; + }, {}); + + expect(grantedResult).toBe(false); + }); + + test("denies regardless of webContents/permission/details values", () => { + const { sess, getHandler } = makeMockSession(); + setupPermissionHandler(sess); + + const handler = getHandler(); + const results: boolean[] = []; + for (const permission of ["camera", "geolocation", "notifications", "unknown"]) { + handler?.({ id: 1 }, permission, (granted) => results.push(granted), { + requestingUrl: "https://example.com", + }); + } + + expect(results).toEqual([false, false, false, false]); + }); +}); + +// setupWebviewSecurity guards webview attachment (index.ts web-contents-created). +// It had zero call sites despite existing — browser tiles (persist:ws-*) could +// attach with default (unsandboxed, preload-carrying) webPreferences. +describe("setupWebviewSecurity", () => { + type AttachHandler = ( + event: unknown, + webPreferences: Record, + params: { partition?: string }, + ) => void; + + function makeMockWebContents() { + let attachHandler: AttachHandler | null = null; + let windowOpenHandlerCalled = false; + + const contents = { + on(event: string, handler: AttachHandler) { + if (event === "will-attach-webview") attachHandler = handler; + }, + setWindowOpenHandler() { + windowOpenHandlerCalled = true; + }, + } as unknown as WebContents; + + return { + contents, + getAttachHandler: () => attachHandler, + wasWindowOpenHandlerCalled: () => windowOpenHandlerCalled, + }; + } + + test("registers a will-attach-webview handler", () => { + const { contents, getAttachHandler } = makeMockWebContents(); + setupWebviewSecurity(contents); + expect(getAttachHandler()).not.toBeNull(); + }); + + test("locks down webPreferences for persist:ws-* (browser tile) partitions", () => { + const { contents, getAttachHandler } = makeMockWebContents(); + setupWebviewSecurity(contents); + + const webPreferences: Record = { + preload: "/app/out/preload/webview.js", + nodeIntegration: true, + contextIsolation: false, + sandbox: false, + }; + getAttachHandler()?.({}, webPreferences, { partition: "persist:ws-abc123" }); + + expect(webPreferences.preload).toBeUndefined(); + expect(webPreferences.nodeIntegration).toBe(false); + expect(webPreferences.contextIsolation).toBe(true); + expect(webPreferences.sandbox).toBe(true); + }); + + test("leaves webPreferences untouched for internal webviews (no/empty partition)", () => { + const { contents, getAttachHandler } = makeMockWebContents(); + setupWebviewSecurity(contents); + + const webPreferences: Record = { + preload: "/app/out/preload/universal.js", + nodeIntegration: false, + contextIsolation: true, + }; + getAttachHandler()?.({}, webPreferences, { partition: "" }); + + expect(webPreferences.preload).toBe("/app/out/preload/universal.js"); + }); + + test("does not touch setWindowOpenHandler — index.ts owns that policy", () => { + const { contents, wasWindowOpenHandlerCalled } = makeMockWebContents(); + setupWebviewSecurity(contents); + expect(wasWindowOpenHandlerCalled()).toBe(false); + }); +}); + +// isWorkspaceTileStoragePath is index.ts's isBrowserTileWebview() substitute +// for identifying browser-tile webContents via Session.storagePath (Session +// has no public `.partition` property). Electron maps `persist:ws-` to +// `/Partitions/ws-` — this must recognize that layout while +// rejecting the persist:browser popup partition and session.defaultSession. +describe("isWorkspaceTileStoragePath", () => { + test("returns true for a storagePath ending in /Partitions/ws-", () => { + expect( + isWorkspaceTileStoragePath( + "/Users/x/Library/Application Support/MyApp/Partitions/ws-abc123", + ), + ).toBe(true); + }); + + test("returns false for a storagePath ending in /Partitions/browser (the popup partition)", () => { + expect( + isWorkspaceTileStoragePath( + "/Users/x/Library/Application Support/MyApp/Partitions/browser", + ), + ).toBe(false); + }); + + test("returns false for null", () => { + expect(isWorkspaceTileStoragePath(null)).toBe(false); + }); + + test("returns false for a storagePath with no Partitions segment (session.defaultSession)", () => { + expect( + isWorkspaceTileStoragePath( + "/Users/x/Library/Application Support/MyApp", + ), + ).toBe(false); + }); + + test("matches by prefix, not substring — a final segment that merely contains ws- is rejected", () => { + expect( + isWorkspaceTileStoragePath( + "/Users/x/Library/Application Support/MyApp/Partitions/other-ws-thing", + ), + ).toBe(false); + }); +}); diff --git a/collab-electron/src/main/security.ts b/collab-electron/src/main/security.ts new file mode 100644 index 00000000..95394c4d --- /dev/null +++ b/collab-electron/src/main/security.ts @@ -0,0 +1,92 @@ +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Session, WebContents } from "electron"; + +const BLOCKED_PROTOCOLS = ["javascript:", "data:", "file:", "blob:"]; + +/** + * Returns true if a session's storagePath indicates a browser-tile partition + * (persist:ws-, set by tile-manager.js when creating a for + * external browsing). Session has no public `.partition` property, so this + * infers identity from storagePath's directory name instead — Electron maps + * `persist:` to `/Partitions/` (verified empirically + * against Electron 40.10.3; session.defaultSession and non-persist/in-memory + * sessions have no `Partitions` segment at all). + */ +export function isWorkspaceTileStoragePath(storagePath: string | null): boolean { + if (!storagePath) return false; + return basename(storagePath).startsWith("ws-"); +} + +/** + * Deny all permission requests (camera/mic/geolocation/notifications/etc) + * for the given session. Electron auto-grants every permission request by + * default unless setPermissionRequestHandler is set explicitly, so this + * must be wired into every session we create (see web-contents-created in + * index.ts). + */ +export function setupPermissionHandler(sess: Session): void { + sess.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); +} + +/** + * Returns true if the URL is allowed to navigate to. + * Blocks javascript:, data:, file:, and blob: protocols. + */ +export function isNavigationAllowed(url: string): boolean { + const lower = url.toLowerCase(); + return !BLOCKED_PROTOCOLS.some((proto) => lower.startsWith(proto)); +} + +// Our trusted preload lives under out/preload/ in the app directory. +const TRUSTED_PRELOAD_DIR = join(__dirname, "..", "preload"); + +function isTrustedPreload(preloadPath: string | undefined): boolean { + if (!preloadPath) return false; + // preload can arrive as file:// URL or plain path — normalize both + let resolved: string; + try { + resolved = preloadPath.startsWith("file://") + ? fileURLToPath(preloadPath) + : join(preloadPath); + } catch { + return false; + } + return resolved.startsWith(TRUSTED_PRELOAD_DIR); +} + +/** + * Attaches a will-attach-webview lockdown to the given WebContents: for + * browser tiles (partition starts with "persist:ws-"), strips preloads and + * enforces strict sandbox. For internal webviews (terminal, viewer, graph — + * no partition or empty partition), preserves preloads so IPC communication + * works. + * + * Does NOT touch setWindowOpenHandler — index.ts's own web-contents-created + * handler already implements window-open policy (allow browser-tile popups, + * forward external links to the OS browser, deny everything else); a + * blanket deny here would silently override that and break browser-tile + * popups since Electron keeps only the last-registered handler. + */ +export function setupWebviewSecurity(webContents: WebContents): void { + webContents.on( + "will-attach-webview", + (_event, webPreferences, params) => { + const partition = params.partition || ""; + const isBrowserTile = partition.startsWith("persist:ws-"); + + if (isBrowserTile) { + // External content — full lockdown + delete webPreferences.preload; + webPreferences.nodeIntegration = false; + webPreferences.contextIsolation = true; + webPreferences.sandbox = true; + } + // Internal webviews (terminal, viewer, graph) — keep preload intact, + // they already have contextIsolation:true via the preload bridge. + // nodeIntegration is already false by default in Electron 40. + }, + ); +} diff --git a/collab-electron/src/main/sidecar/server.ts b/collab-electron/src/main/sidecar/server.ts index da21f8ea..c7116392 100644 --- a/collab-electron/src/main/sidecar/server.ts +++ b/collab-electron/src/main/sidecar/server.ts @@ -1,6 +1,7 @@ // src/main/sidecar/server.ts import * as net from "node:net"; import * as fs from "node:fs"; +import * as path from "node:path"; import * as crypto from "node:crypto"; import * as pty from "node-pty"; import { displayCommandName } from "@collab/shared/path-utils"; @@ -509,7 +510,14 @@ export class SidecarServer { } private sessionSocketPath(sessionId: string): string { - return buildSessionSocketPath(sessionId); + if (process.platform === "win32") { + return buildSessionSocketPath(sessionId); + } + // POSIX: honor the configured sessionSocketDir (tests inject a sandboxed + // temp dir here) instead of always resolving to the real ~/.collaborator + // path. Previously this ignored this.opts.sessionSocketDir entirely, + // which caused tests to leak into the real user pty-sessions dir. + return path.join(this.opts.sessionSocketDir, `${sessionId}.sock`); } private getForegroundCommand(session: Session): string { diff --git a/collab-electron/src/main/tmux.test.ts b/collab-electron/src/main/tmux.test.ts index 14f03b4f..c9b1e4a8 100644 --- a/collab-electron/src/main/tmux.test.ts +++ b/collab-electron/src/main/tmux.test.ts @@ -1,7 +1,6 @@ -import { describe, test, expect, afterEach, beforeAll } from "bun:test"; +import { describe, test, expect, afterEach } from "bun:test"; import * as fs from "node:fs"; import { spawn, type ChildProcess } from "node:child_process"; -import { loadConfig, setPref } from "./config"; import { getTmuxBin, getTmuxConf, @@ -23,12 +22,13 @@ import { verifyTmuxAvailable, } from "./pty"; -// Force tmux mode for these tests — the default is now "sidecar" -// which requires Electron to spawn the sidecar process. -beforeAll(() => { - const config = loadConfig(); - setPref(config, "terminalMode", "tmux"); -}); +// createSession() always routes through the sidecar backend (see pty.ts), +// which requires a running Electron process to spawn. These tests exercise +// the direct-tmux PTY lifecycle and can only run inside Electron. +let hasElectron = false; +try { + hasElectron = !!(require("electron") as { app?: unknown }).app; +} catch {} describe("tmux helpers", () => { const testId = "test-" + Date.now().toString(16); @@ -78,24 +78,24 @@ describe("pty lifecycle via tmux", () => { killAll(); }); - test("createSession returns sessionId and shell", async () => { + test.skipIf(!hasElectron)("createSession returns sessionId and shell", async () => { const result = await createSession("/tmp"); expect(result.sessionId).toMatch(/^[0-9a-f]{16}$/); expect(result.shell).toBeTruthy(); }); - test("createSession appears in listSessions", async () => { + test.skipIf(!hasElectron)("createSession appears in listSessions", async () => { const { sessionId } = await createSession("/tmp"); expect(listSessions()).toContain(sessionId); }); - test("killSession removes from listSessions", async () => { + test.skipIf(!hasElectron)("killSession removes from listSessions", async () => { const { sessionId } = await createSession("/tmp"); await killSession(sessionId); expect(listSessions()).not.toContain(sessionId); }); - test("createSession sets COLLAB_PTY_SESSION_ID env", async () => { + test.skipIf(!hasElectron)("createSession sets COLLAB_PTY_SESSION_ID env", async () => { const { sessionId } = await createSession("/tmp"); const name = tmuxSessionName(sessionId); const env = tmuxExec( @@ -112,7 +112,7 @@ describe("discoverSessions", () => { expect(Array.isArray(result)).toBe(true); }); - test("discovers sessions created by createSession", async () => { + test.skipIf(!hasElectron)("discovers sessions created by createSession", async () => { const { sessionId } = await createSession("/tmp"); killAll(); // detach client, tmux session survives @@ -144,7 +144,7 @@ describe("discoverSessions", () => { expect(readSessionMeta(fakeId)).toBeNull(); }); - test("kills orphan tmux sessions without metadata", async () => { + test.skipIf(!hasElectron)("kills orphan tmux sessions without metadata", async () => { // Create a session, then delete its metadata const { sessionId } = await createSession("/tmp"); killAll(); @@ -166,7 +166,7 @@ describe("discoverSessions", () => { }); describe("cleanDetachedSessions", () => { - test("kills sessions not in the active list", async () => { + test.skipIf(!hasElectron)("kills sessions not in the active list", async () => { const { sessionId: keep } = await createSession("/tmp"); const { sessionId: detached } = await createSession("/tmp"); killAll(); // detach clients, tmux sessions survive @@ -199,7 +199,7 @@ describe("cleanDetachedSessions", () => { deleteSessionMeta(detached); }); - test("no-op when all sessions are active", async () => { + test.skipIf(!hasElectron)("no-op when all sessions are active", async () => { const { sessionId } = await createSession("/tmp"); killAll(); @@ -409,7 +409,7 @@ describe("cross-backend: reconnectSession defaults correctly", () => { }); describe("stripTrailingBlanks via scrollback", () => { - test("scrollback capture strips trailing blank lines", async () => { + test.skipIf(!hasElectron)("scrollback capture strips trailing blank lines", async () => { const { sessionId } = await createSession("/tmp"); const name = tmuxSessionName(sessionId); diff --git a/collab-electron/src/main/workspace-graph.test.ts b/collab-electron/src/main/workspace-graph.test.ts new file mode 100644 index 00000000..3540e706 --- /dev/null +++ b/collab-electron/src/main/workspace-graph.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect } from "bun:test"; +import { isPathWithinDirectory } from "./workspace-graph"; + +// isPathWithinDirectory is a path-containment boundary check used in 4 call +// sites (workspace-graph.ts and workspace-graph-python.ts) to decide whether +// a resolved import target is allowed to be linked into the workspace graph. +// It had zero test coverage despite being the same class of security-relevant +// check as @collab/shared/path-utils's workspaceRootMatch (which does have +// tests). No electron dependency here, so this is a plain unit test. +describe("isPathWithinDirectory", () => { + test("returns true when the path equals the directory itself", () => { + expect(isPathWithinDirectory("/workspace", "/workspace")).toBe(true); + }); + + test("returns true for a direct child path", () => { + expect( + isPathWithinDirectory("/workspace/note.md", "/workspace"), + ).toBe(true); + }); + + test("returns true for a deeply nested descendant path", () => { + expect( + isPathWithinDirectory( + "/workspace/a/b/c/note.md", + "/workspace", + ), + ).toBe(true); + }); + + test("returns false for a sibling directory with a shared name prefix", () => { + // "/workspace-other" starts with "/workspace" as a string, but is not + // inside it -- relative() must be used, not startsWith(). + expect( + isPathWithinDirectory("/workspace-other/note.md", "/workspace"), + ).toBe(false); + }); + + test("returns false for a parent directory", () => { + expect(isPathWithinDirectory("/", "/workspace")).toBe(false); + }); + + test("returns false for a completely unrelated absolute path", () => { + expect(isPathWithinDirectory("/etc/passwd", "/workspace")).toBe(false); + }); + + test("returns false for a path-traversal attempt out of the directory", () => { + expect( + isPathWithinDirectory( + "/workspace/../outside/note.md", + "/workspace", + ), + ).toBe(false); + }); + + test("returns true for a traversal attempt that stays inside the directory", () => { + expect( + isPathWithinDirectory( + "/workspace/a/../b/note.md", + "/workspace", + ), + ).toBe(true); + }); +}); diff --git a/collab-electron/src/preload/shell.ts b/collab-electron/src/preload/shell.ts index b01ad4f8..d128984e 100644 --- a/collab-electron/src/preload/shell.ts +++ b/collab-electron/src/preload/shell.ts @@ -35,6 +35,14 @@ ipcRenderer.on("shell:forward", (_event, target, channel, ...args) => { pendingForwards.push([target, channel, ...args]); }); +// Buffer canvas:rpc-request messages that arrive before renderer +// registers onCanvasRpcRequest (same cold-launch race as above). +type RpcRequest = { requestId: string; method: string; params: Record }; +const pendingRpcRequests: RpcRequest[] = []; +ipcRenderer.on("canvas:rpc-request", (_event, request: RpcRequest) => { + pendingRpcRequests.push(request); +}); + contextBridge.exposeInMainWorld("shellApi", { getPlatform: (): NodeJS.Platform => process.platform, @@ -151,6 +159,8 @@ contextBridge.exposeInMainWorld("shellApi", { canvasLoadState: () => ipcRenderer.invoke("canvas:load-state"), canvasSaveState: (state: unknown) => ipcRenderer.invoke("canvas:save-state", state), + canvasWorkspaceHash: (): Promise => + ipcRenderer.invoke("canvas:workspace-hash"), getDragPaths: () => ipcRenderer.invoke("drag:get-paths"), @@ -180,6 +190,14 @@ contextBridge.exposeInMainWorld("shellApi", { onCanvasRpcRequest: ( cb: (request: { requestId: string; method: string; params: Record }) => void, ) => { + // Replay any RPC requests that arrived before this callback registered + for (const request of pendingRpcRequests) { + cb(request); + } + pendingRpcRequests.length = 0; + + // Replace the buffer listener with the real handler + ipcRenderer.removeAllListeners("canvas:rpc-request"); const handler = ( _event: unknown, request: { requestId: string; method: string; params: Record }, @@ -223,6 +241,9 @@ contextBridge.exposeInMainWorld("shellApi", { ptyKillSession: (sessionId: string): Promise => ipcRenderer.invoke("pty:kill", { sessionId }), + ptyWrite: (sessionId: string, data: string): Promise => + ipcRenderer.invoke("pty:write", { sessionId, data }), + onPtyStatusChanged: ( cb: (payload: { sessionId: string; foreground: string }) => void, ) => { diff --git a/collab-electron/src/preload/universal.ts b/collab-electron/src/preload/universal.ts index 98943975..a5ed7172 100644 --- a/collab-electron/src/preload/universal.ts +++ b/collab-electron/src/preload/universal.ts @@ -549,6 +549,10 @@ contextBridge.exposeInMainWorld("api", { forwardPinch: (deltaY: number) => ipcRenderer.send("canvas:forward-pinch", deltaY), + // Open URL in external browser + openExternal: (url: string) => + ipcRenderer.send("shell:open-external", url), + // Generic sendToHost for webview → shell renderer communication sendToHost: (channel: string, ...args: unknown[]) => ipcRenderer.sendToHost(channel, ...args), diff --git a/collab-electron/src/windows/shared/PostHogProvider.tsx b/collab-electron/src/windows/shared/PostHogProvider.tsx index b3c21648..f779d25c 100644 --- a/collab-electron/src/windows/shared/PostHogProvider.tsx +++ b/collab-electron/src/windows/shared/PostHogProvider.tsx @@ -1,72 +1,17 @@ -import posthog from "posthog-js"; -import { PostHogProvider as PHProvider } from "@posthog/react"; -import { useEffect, useState, type ReactNode } from "react"; +import React, { useState, useEffect, type ReactNode } from "react"; -let crashReportingInitialized = false; - -function initCrashReporting(): void { - if (crashReportingInitialized) return; - crashReportingInitialized = true; - - window.addEventListener("error", (event) => { - posthog.capture("renderer_crash", { - type: "error", - message: event.message, - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - stack: event.error?.stack, - }); - }); - - window.addEventListener("unhandledrejection", (event) => { - const error = - event.reason instanceof Error - ? event.reason - : new Error(String(event.reason)); - posthog.capture("renderer_crash", { - type: "unhandledrejection", - message: error.message, - stack: error.stack, - }); - }); +export function AnalyticsProvider({ children }: { children: ReactNode }) { + if (!import.meta.env.RENDERER_VITE_POSTHOG_KEY || !import.meta.env.RENDERER_VITE_POSTHOG_HOST) { + return <>{children}; + } + return {children}; } -export function AnalyticsProvider({ - children, -}: { - children: ReactNode; -}) { - const [ready, setReady] = useState(false); - +function LazyAnalytics({ children }: { children: ReactNode }) { + const [Provider, setProvider] = useState | null>(null); useEffect(() => { - const key = import.meta.env.RENDERER_VITE_POSTHOG_KEY; - const host = import.meta.env.RENDERER_VITE_POSTHOG_HOST; - if (!key || !host) return; - - window.api - .getDeviceId() - .then((deviceId) => { - if (!posthog.__loaded) { - posthog.init(key, { - api_host: host, - autocapture: false, - capture_pageview: false, - capture_pageleave: false, - persistence: "localStorage", - person_profiles: "always", - bootstrap: { distinctId: deviceId }, - }); - } - posthog.identify(deviceId); - initCrashReporting(); - setReady(true); - }) - .catch((err) => { - console.warn("[analytics] Failed to initialize PostHog:", err); - }); + import("./PostHogProviderImpl").then((m) => setProvider(() => m.AnalyticsProviderImpl)); }, []); - - if (!ready) return <>{children}; - return {children}; + if (!Provider) return <>{children}; + return {children}; } diff --git a/collab-electron/src/windows/shared/PostHogProviderImpl.tsx b/collab-electron/src/windows/shared/PostHogProviderImpl.tsx new file mode 100644 index 00000000..43fd73cc --- /dev/null +++ b/collab-electron/src/windows/shared/PostHogProviderImpl.tsx @@ -0,0 +1,72 @@ +import posthog from "posthog-js"; +import { PostHogProvider as PHProvider } from "@posthog/react"; +import { useEffect, useState, type ReactNode } from "react"; + +let crashReportingInitialized = false; + +function initCrashReporting(): void { + if (crashReportingInitialized) return; + crashReportingInitialized = true; + + window.addEventListener("error", (event) => { + posthog.capture("renderer_crash", { + type: "error", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error?.stack, + }); + }); + + window.addEventListener("unhandledrejection", (event) => { + const error = + event.reason instanceof Error + ? event.reason + : new Error(String(event.reason)); + posthog.capture("renderer_crash", { + type: "unhandledrejection", + message: error.message, + stack: error.stack, + }); + }); +} + +export function AnalyticsProviderImpl({ + children, +}: { + children: ReactNode; +}) { + const [ready, setReady] = useState(false); + + useEffect(() => { + const key = import.meta.env.RENDERER_VITE_POSTHOG_KEY; + const host = import.meta.env.RENDERER_VITE_POSTHOG_HOST; + if (!key || !host) return; + + window.api + .getDeviceId() + .then((deviceId) => { + if (!posthog.__loaded) { + posthog.init(key, { + api_host: host, + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + persistence: "localStorage", + person_profiles: "always", + bootstrap: { distinctId: deviceId }, + }); + } + posthog.identify(deviceId); + initCrashReporting(); + setReady(true); + }) + .catch((err) => { + console.warn("[analytics] Failed to initialize PostHog:", err); + }); + }, []); + + if (!ready) return <>{children}; + return {children}; +} diff --git a/collab-electron/src/windows/shell/src/canvas-rpc.js b/collab-electron/src/windows/shell/src/canvas-rpc.js index eb2ae894..88241111 100644 --- a/collab-electron/src/windows/shell/src/canvas-rpc.js +++ b/collab-electron/src/windows/shell/src/canvas-rpc.js @@ -83,7 +83,10 @@ export function createCanvasRpc({ : findAutoPlacement(tiles, size.width, size.height); let tile; - if (tileType === "graph") { + if (tileType === "term") { + tile = tileManager.createCanvasTile("term", pos.x, pos.y); + tileManager.spawnTerminalWebview(tile, false); + } else if (tileType === "graph") { const ws = workspaceManager.getActiveWorkspace(); const wsPath = ws?.path ?? ""; tile = tileManager.createGraphTile( diff --git a/collab-electron/src/windows/shell/src/canvas-undo.js b/collab-electron/src/windows/shell/src/canvas-undo.js new file mode 100644 index 00000000..9e1bf7fd --- /dev/null +++ b/collab-electron/src/windows/shell/src/canvas-undo.js @@ -0,0 +1,31 @@ +const MAX_UNDO = 50; +const undoStack = []; +const redoStack = []; + +export function pushCommand(command) { + undoStack.push(command); + if (undoStack.length > MAX_UNDO) undoStack.shift(); + redoStack.length = 0; +} + +export function canUndo() { return undoStack.length > 0; } +export function canRedo() { return redoStack.length > 0; } + +export function undo() { + const cmd = undoStack.pop(); + if (!cmd) return null; + redoStack.push(cmd); + return cmd; +} + +export function redo() { + const cmd = redoStack.pop(); + if (!cmd) return null; + undoStack.push(cmd); + return cmd; +} + +export function clearHistory() { + undoStack.length = 0; + redoStack.length = 0; +} diff --git a/collab-electron/src/windows/shell/src/canvas-viewport.js b/collab-electron/src/windows/shell/src/canvas-viewport.js index 93ba1106..f4b0d1d8 100644 --- a/collab-electron/src/windows/shell/src/canvas-viewport.js +++ b/collab-electron/src/windows/shell/src/canvas-viewport.js @@ -1,5 +1,5 @@ const ZOOM_MIN = 0.33; -const ZOOM_MAX = 1; +const ZOOM_MAX = 2; const ZOOM_RUBBER_BAND_K = 400; const CELL = 20; const MAJOR = 80; @@ -18,6 +18,7 @@ export function createViewport(canvasEl, gridCanvas) { const gridCtx = gridCanvas.getContext("2d"); let state = null; let onUpdate = null; + let onViewportChange = null; let zoomSnapTimer = null; let zoomSnapRaf = null; let lastZoomFocalX = 0; @@ -52,7 +53,7 @@ export function createViewport(canvasEl, gridCanvas) { const dotOffX = ((state.panX % step) + step) % step; const dotOffY = ((state.panY % step) + step) % step; - const dotSize = Math.max(1, 1.5 * state.zoom); + const dotSize = Math.max(1, Math.min(2.5, 1.5 * state.zoom)); gridCtx.fillStyle = dark ? "rgba(255,255,255,0.22)" : "rgba(0,0,0,0.20)"; @@ -64,7 +65,7 @@ export function createViewport(canvasEl, gridCanvas) { } } - const majorDotSize = Math.max(1, 1.5 * state.zoom); + const majorDotSize = Math.max(1, Math.min(2.5, 1.5 * state.zoom)); gridCtx.fillStyle = dark ? "rgba(255,255,255,0.40)" : "rgba(0,0,0,0.35)"; @@ -90,6 +91,7 @@ export function createViewport(canvasEl, gridCanvas) { function updateCanvas() { drawGrid(); if (onUpdate) onUpdate(); + if (onViewportChange) onViewportChange(); } function snapBackZoom() { @@ -197,5 +199,8 @@ export function createViewport(canvasEl, gridCanvas) { }, updateCanvas, applyZoom, + setOnViewportChange(cb) { + onViewportChange = cb; + }, }; } diff --git a/collab-electron/src/windows/shell/src/canvas-viewport.test.ts b/collab-electron/src/windows/shell/src/canvas-viewport.test.ts index 7b15b02a..0caee718 100644 --- a/collab-electron/src/windows/shell/src/canvas-viewport.test.ts +++ b/collab-electron/src/windows/shell/src/canvas-viewport.test.ts @@ -5,7 +5,15 @@ * After modularization, update imports to use ./canvas-viewport.js. */ import { describe, test, expect } from "bun:test"; -import { shouldZoom } from "./canvas-viewport.js"; + +// canvas-viewport.js reads window.shellApi (Electron preload API) at module +// top-level to compute isMac. happy-dom provides `window` but not app-specific +// preload APIs, so stub it once before importing (this file is the only one +// that reads window.shellApi.getPlatform — no other test in the tree touches +// this key, so a one-time module-level stub is safe; see panel-manager.test.ts +// for the per-test beforeEach variant used where callers rely on distinct values). +(globalThis as any).window.shellApi = { getPlatform: () => "darwin" }; +const { shouldZoom } = await import("./canvas-viewport.js"); // -- shouldZoom modifier key routing -- diff --git a/collab-electron/src/windows/shell/src/panel-manager.test.ts b/collab-electron/src/windows/shell/src/panel-manager.test.ts index 7b753373..66cfe9aa 100644 --- a/collab-electron/src/windows/shell/src/panel-manager.test.ts +++ b/collab-electron/src/windows/shell/src/panel-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, test, expect, mock, beforeEach } from "bun:test"; // Minimal DOM stub function makePanel(id) { @@ -47,12 +47,12 @@ describe("createPanel", () => { // Stub shellApi window.shellApi = { - setPref: vi.fn(), - getPref: vi.fn().mockResolvedValue(null), + setPref: mock(), + getPref: mock().mockResolvedValue(null), }; }); - it("starts visible by default", async () => { + test("starts visible by default", async () => { const { createPanel } = await import("./panel-manager.js"); const mgr = createPanel("nav", { panel, viewer, resizeHandle, toggle, @@ -64,7 +64,7 @@ describe("createPanel", () => { expect(mgr.isVisible()).toBe(true); }); - it("toggles visibility", async () => { + test("toggles visibility", async () => { const { createPanel } = await import("./panel-manager.js"); const mgr = createPanel("nav", { panel, viewer, resizeHandle, toggle, @@ -80,7 +80,7 @@ describe("createPanel", () => { ); }); - it("persists width on pref key panel-width-{side}", async () => { + test("persists width on pref key panel-width-{side}", async () => { const { createPanel } = await import("./panel-manager.js"); const mgr = createPanel("nav", { panel, viewer, resizeHandle, toggle, @@ -93,7 +93,7 @@ describe("createPanel", () => { expect(panel.style.flex).toBe("0 0 350px"); }); - it("uses direction=-1 for right panels", async () => { + test("uses direction=-1 for right panels", async () => { const { createPanel } = await import("./panel-manager.js"); const termPanel = makePanel("panel-terminal"); const termResize = document.createElement("div"); diff --git a/collab-electron/src/windows/shell/src/renderer.js b/collab-electron/src/windows/shell/src/renderer.js index 327fe3e7..bbcc30de 100644 --- a/collab-electron/src/windows/shell/src/renderer.js +++ b/collab-electron/src/windows/shell/src/renderer.js @@ -133,28 +133,37 @@ async function init() { // -- Drag-and-drop handler (shared with webviews) -- + function disableWebviewPointerEvents() { + for (const h of getAllWebviews()) { + h.webview.style.pointerEvents = "none"; + } + } + + function restoreWebviewPointerEvents() { + for (const h of getAllWebviews()) { + h.webview.style.pointerEvents = ""; + } + } + function handleDndMessage(channel) { if (channel === "dnd:dragenter") { dragCounter++; if (dragCounter === 1 && dragDropOverlay) { dragDropOverlay.classList.add("visible"); - for (const h of getAllWebviews()) { - h.webview.style.pointerEvents = "none"; - } + disableWebviewPointerEvents(); } } else if (channel === "dnd:dragleave") { dragCounter = Math.max(0, dragCounter - 1); if (dragCounter === 0 && dragDropOverlay) { dragDropOverlay.classList.remove("visible"); + restoreWebviewPointerEvents(); } } else if (channel === "dnd:drop") { dragCounter = 0; if (dragDropOverlay) { dragDropOverlay.classList.remove("visible"); } - for (const h of getAllWebviews()) { - h.webview.style.pointerEvents = ""; - } + restoreWebviewPointerEvents(); } } @@ -272,6 +281,8 @@ async function init() { // -- Tile manager -- + let currentWorkspaceHash = "default"; + const tileManager = createTileManager({ tileLayer, viewportState, configs, getAllWebviews, @@ -314,6 +325,19 @@ async function init() { onTileDblClick(tile) { edgeIndicators.panToTile(tile); }, + getWorkspaceHash: () => currentWorkspaceHash, + }); + + // -- Undo/Redo keyboard shortcuts -- + + document.addEventListener("keydown", (e) => { + if (e.metaKey && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + tileManager.executeUndo(); + } else if (e.metaKey && e.key === "z" && e.shiftKey) { + e.preventDefault(); + tileManager.executeRedo(); + } }); // -- Edge indicators -- @@ -343,6 +367,10 @@ async function init() { tileManager.saveCanvasDebounced(); }); + viewport.setOnViewportChange(() => { + tileManager.checkDeferredTilesDebounced(); + }); + edgeIndicators.update(); // -- Surface focus management -- @@ -811,6 +839,35 @@ async function init() { canvasEl.focus(); noteSurfaceFocus("canvas"); } + } else if (action === "zoom-in" || action === "zoom-out" || action === "zoom-reset") { + // canvas-tile = terminal focused → xterm handles font zoom internally, skip + if (activeSurface === "canvas-tile") return; + const isCanvas = activeSurface === "canvas"; + if (isCanvas) { + // Zoom the canvas viewport around its center + const rect = canvasEl.getBoundingClientRect(); + const fx = rect.width / 2; + const fy = rect.height / 2; + if (action === "zoom-reset") { + viewportState.zoom = 1; + viewport.updateCanvas(); + } else { + const delta = action === "zoom-in" ? -100 : 100; + viewport.applyZoom(delta, fx, fy); + } + } else { + // Zoom the nav webview + const ws = workspaceManager.getActiveWorkspace(); + if (ws && ws.nav && ws.nav.webview) { + const wv = ws.nav.webview; + if (action === "zoom-reset") { + wv.setZoomLevel(0); + } else { + const cur = wv.getZoomLevel(); + wv.setZoomLevel(cur + (action === "zoom-in" ? 0.25 : -0.25)); + } + } + } } } @@ -1205,8 +1262,9 @@ async function init() { window.addEventListener("dragenter", (e) => { e.preventDefault(); dragCounter++; - if (dragCounter === 1 && dragDropOverlay) { - dragDropOverlay.classList.add("visible"); + if (dragCounter === 1) { + if (dragDropOverlay) dragDropOverlay.classList.add("visible"); + disableWebviewPointerEvents(); } }); @@ -1217,8 +1275,9 @@ async function init() { window.addEventListener("dragleave", (e) => { e.preventDefault(); dragCounter = Math.max(0, dragCounter - 1); - if (dragCounter === 0 && dragDropOverlay) { - dragDropOverlay.classList.remove("visible"); + if (dragCounter === 0) { + if (dragDropOverlay) dragDropOverlay.classList.remove("visible"); + restoreWebviewPointerEvents(); } }); @@ -1229,6 +1288,29 @@ async function init() { dragDropOverlay.classList.remove("visible"); } + // Hit-test BEFORE restoring pointer-events so elementFromPoint + // reaches .canvas-tile instead of the webview on top. + const dropTarget = document.elementFromPoint(e.clientX, e.clientY); + restoreWebviewPointerEvents(); + + // If dropped on a terminal tile, insert shell-escaped file paths + const tileEl = dropTarget?.closest(".canvas-tile"); + if (tileEl && tileEl.dataset.tileType === "term") { + const tile = getTile(tileEl.dataset.tileId); + if (tile?.ptySessionId && e.dataTransfer?.files?.length) { + const termPaths = []; + for (let i = 0; i < e.dataTransfer.files.length; i++) { + const p = e.dataTransfer.files[i].path; + if (p) termPaths.push("'" + p.replace(/'/g, "'\\''") + "'"); + } + if (termPaths.length > 0) { + window.shellApi.ptyWrite?.(tile.ptySessionId, termPaths.join(" ")); + } + return; + } + } + + // Otherwise, create file tiles on the canvas const rect = canvasEl.getBoundingClientRect(); const screenX = e.clientX - rect.left; const screenY = e.clientY - rect.top; @@ -1273,8 +1355,17 @@ async function init() { }); } - // -- Restore canvas state -- + // -- Initialize workspaces -- + + const { workspaces: wsPaths, active } = workspaceData; + + for (const path of wsPaths) { + workspaceManager.addWorkspace(path); + } + + // -- Restore canvas state (after workspaces so hash is correct) -- + currentWorkspaceHash = await window.shellApi.canvasWorkspaceHash(); const savedState = await window.shellApi.canvasLoadState(); if (savedState) { const { centerX, centerY, zoom } = savedState.viewport; @@ -1301,14 +1392,6 @@ async function init() { } window.shellApi.ptyCleanDetached?.(activeSessionIds); - // -- Initialize workspaces -- - - const { workspaces: wsPaths, active } = workspaceData; - - for (const path of wsPaths) { - workspaceManager.addWorkspace(path); - } - if (workspaceManager.getWorkspaces().length === 0) { workspaceManager.showEmptyState(); } else if ( @@ -1326,6 +1409,37 @@ async function init() { terminalPanel.updateTogglePosition(); }); + // -- Workspace-changed: swap canvas state -- + + window.shellApi.onWorkspaceChanged(async (newPath) => { + if (!newPath) return; + + // 1. Flush current canvas state + tileManager.saveCanvasImmediate(); + + // 2. Clear all tiles without per-tile saves + tileManager.clearCanvasBatch(); + + // 3. Update workspace hash for browser partitions + currentWorkspaceHash = await window.shellApi.canvasWorkspaceHash(); + + // 4. Load new workspace canvas state + const newState = await window.shellApi.canvasLoadState(); + if (newState) { + viewportState.panX = newState.viewport.panX; + viewportState.panY = newState.viewport.panY; + viewportState.zoom = newState.viewport.zoom; + viewport.updateCanvas(); + tileManager.restoreCanvasState(newState.tiles); + } else { + viewportState.panX = 0; + viewportState.panY = 0; + viewportState.zoom = 1; + viewport.updateCanvas(); + } + edgeIndicators.update(); + }); + // -- beforeunload save -- window.addEventListener("beforeunload", () => { diff --git a/collab-electron/src/windows/shell/src/tile-interactions.js b/collab-electron/src/windows/shell/src/tile-interactions.js index 514cbdb9..990acbb1 100644 --- a/collab-electron/src/windows/shell/src/tile-interactions.js +++ b/collab-electron/src/windows/shell/src/tile-interactions.js @@ -27,6 +27,7 @@ const CLICK_THRESHOLD = 3; * @param {(tileId: string) => void} opts.onShiftClick * @param {() => boolean} [opts.isSpaceHeld] - when true, suppress drag (canvas is panning) * @param {HTMLElement} [opts.contentOverlay] - secondary drag surface over tile content + * @param {(tileId: string, before: {x: number, y: number}, after: {x: number, y: number}) => void} [opts.onDragEnd] */ export function attachDrag(titleBar, tile, { viewport, @@ -39,6 +40,7 @@ export function attachDrag(titleBar, tile, { onFocus, isSpaceHeld, contentOverlay, + onDragEnd, }) { function startDrag(e, { deferFocus = false } = {}) { if (e.button !== 0) return; @@ -107,9 +109,11 @@ export function attachDrag(titleBar, tile, { for (const entry of groupCtx) { entry.container.classList.remove("tile-dragging"); snapToGrid(entry.tile); + if (onDragEnd) onDragEnd(entry.tile.id, { x: entry.startX, y: entry.startY }, { x: entry.tile.x, y: entry.tile.y }); } } else { snapToGrid(tile); + if (onDragEnd) onDragEnd(tile.id, { x: startTX, y: startTY }, { x: tile.x, y: tile.y }); } onUpdate(); } @@ -264,9 +268,10 @@ export function attachMarquee(canvasEl, { * @param {object} viewport * @param {() => void} onUpdate * @param {() => Array<{webview: HTMLElement}>} getAllWebviews + * @param {(tileId: string, before: {width: number, height: number}, after: {width: number, height: number}) => void} [onResizeEnd] */ export function attachResize( - container, tile, viewport, onUpdate, getAllWebviews, onFocus, + container, tile, viewport, onUpdate, getAllWebviews, onResizeEnd, onFocus, ) { const edges = ["n", "s", "e", "w"]; const corners = ["nw", "ne", "sw", "se"]; @@ -335,6 +340,7 @@ export function attachResize( wv.webview.style.pointerEvents = ""; } snapToGrid(tile); + if (onResizeEnd) onResizeEnd(tile.id, { width: startW, height: startH }, { width: tile.width, height: tile.height }); onUpdate(); if (onFocus) onFocus(); } diff --git a/collab-electron/src/windows/shell/src/tile-manager.js b/collab-electron/src/windows/shell/src/tile-manager.js index 07d17a59..5ee1e502 100644 --- a/collab-electron/src/windows/shell/src/tile-manager.js +++ b/collab-electron/src/windows/shell/src/tile-manager.js @@ -10,6 +10,16 @@ import { import { workspaceRootMatch } from "@collab/shared/path-utils"; import { attachDrag, attachResize } from "./tile-interactions.js"; import { findAutoPlacement } from "./canvas-rpc.js"; +import { pushCommand, undo, redo } from "./canvas-undo.js"; + +/** + * Returns true if the tile's screen-space rectangle overlaps the viewport. + */ +function isInViewport(tile, panX, panY, zoom, canvasW, canvasH) { + const sx = tile.x * zoom + panX, sy = tile.y * zoom + panY; + const sw = tile.width * zoom, sh = tile.height * zoom; + return sx + sw > 0 && sx < canvasW && sy + sh > 0 && sy < canvasH; +} /** * Tile lifecycle manager: creation, deletion, persistence, webview @@ -24,6 +34,7 @@ export function createTileManager({ onTerminalTileClosed, onTileFocused, onTileDblClick, + getWorkspaceHash, }) { /** @type {Map} */ const tileDOMs = new Map(); @@ -224,6 +235,7 @@ export function createTileManager({ } } }); + } function spawnGraphWebview(tile) { @@ -273,7 +285,8 @@ export function createTileManager({ const wv = document.createElement("webview"); wv.setAttribute("src", url); wv.setAttribute("allowpopups", ""); - wv.setAttribute("partition", "persist:browser"); + const wsHash = getWorkspaceHash ? getWorkspaceHash() : "default"; + wv.setAttribute("partition", `persist:ws-${wsHash}`); wv.setAttribute( "webpreferences", "contextIsolation=yes, sandbox=yes", ); @@ -501,11 +514,19 @@ export function createTileManager({ onFocus: (id, e) => focusCanvasTile(id, e), isSpaceHeld, contentOverlay: dom.contentOverlay, + onDragEnd: (tileId, before, after) => { + pushCommand({ type: "tile-move", tileId, before, after }); + saveCanvasDebounced(); + }, }); attachResize( dom.container, tile, viewport, repositionAllTiles, getAllWebviews, + (tileId, before, after) => { + pushCommand({ type: "tile-resize", tileId, before, after }); + saveCanvasDebounced(); + }, () => focusCanvasTile(tile.id), ); @@ -521,13 +542,16 @@ export function createTileManager({ } function closeCanvasTile(id) { + const tile = getTile(id); + if (tile) { + pushCommand({ type: "tile-delete", tileId: id, deletedTile: { ...tile } }); + } const dom = tileDOMs.get(id); if (dom) { dom.container.remove(); tileDOMs.delete(id); } deselectTile(id); - const tile = getTile(id); if (tile) { window.shellApi.trackEvent( "tile_closed", { type: tile.type }, @@ -549,20 +573,60 @@ export function createTileManager({ if (!dom) return tile; if (type === "image") { + const imgContainer = document.createElement("div"); + imgContainer.style.cssText = "overflow:hidden;width:100%;height:100%;cursor:grab;"; + const img = document.createElement("img"); - img.src = `collab-file://${filePath}`; - img.style.width = "100%"; - img.style.height = "100%"; - img.style.objectFit = "contain"; + img.src = `collab-file://${tile.filePath}`; + img.style.cssText = "width:100%;height:100%;object-fit:contain;transform-origin:center;pointer-events:none;"; img.draggable = false; - dom.contentArea.appendChild(img); + imgContainer.appendChild(img); + + let imgZoom = 1, imgPanX = 0, imgPanY = 0; + function applyTransform() { + img.style.transform = `translate(${imgPanX}px, ${imgPanY}px) scale(${imgZoom})`; + } + + // Wheel zoom (stopPropagation prevents canvas zoom) + imgContainer.addEventListener("wheel", (e) => { + e.stopPropagation(); e.preventDefault(); + imgZoom = Math.max(0.1, Math.min(10, imgZoom * Math.exp(-e.deltaY * 0.003))); + applyTransform(); + }, { passive: false }); + + // Drag to pan + let dragging = false, sx = 0, sy = 0, spx = 0, spy = 0; + imgContainer.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + dragging = true; sx = e.clientX; sy = e.clientY; spx = imgPanX; spy = imgPanY; + imgContainer.style.cursor = "grabbing"; + }); + window.addEventListener("mousemove", (e) => { + if (!dragging) return; + imgPanX = spx + (e.clientX - sx) / imgZoom; + imgPanY = spy + (e.clientY - sy) / imgZoom; + applyTransform(); + }); + window.addEventListener("mouseup", () => { dragging = false; imgContainer.style.cursor = "grab"; }); + + // Double-click to reset + imgContainer.addEventListener("dblclick", () => { + imgZoom = 1; imgPanX = 0; imgPanY = 0; applyTransform(); + }); + + dom.contentArea.appendChild(imgContainer); } else { const wv = document.createElement("webview"); const viewerConfig = configs.viewer; const mode = type === "note" ? "note" : "code"; + const viewerParams = new URLSearchParams(); + if (typeof filePath === "string" && filePath.length > 0) { + viewerParams.set("tilePath", filePath); + } + viewerParams.set("tileMode", mode); wv.setAttribute( "src", - `${viewerConfig.src}?tilePath=${encodeURIComponent(filePath)}&tileMode=${mode}`, + `${viewerConfig.src}?${viewerParams.toString()}`, ); wv.setAttribute("preload", viewerConfig.preload); wv.setAttribute( @@ -592,6 +656,16 @@ export function createTileManager({ return tile; } + function clearCanvasBatch() { + for (const [, dom] of tileDOMs) { + dom.container.remove(); + } + tileDOMs.clear(); + clearSelection(); + tiles.length = 0; + focusedTileId = null; + } + function clearCanvas(viewportObj) { const tileIds = tiles.map((t) => t.id); for (const id of tileIds) { @@ -606,7 +680,22 @@ export function createTileManager({ // -- Canvas state restore -- + function spawnTileContent(tile) { + if (tile.type === "term") { + spawnTerminalWebview(tile); + } else if (tile.type === "graph" && tile.folderPath) { + spawnGraphWebview(tile); + } else if (tile.type === "browser") { + spawnBrowserWebview(tile); + } else if (tile.filePath) { + // File tiles inline-create their content; nothing extra needed. + } + } + function restoreCanvasState(savedTiles) { + const canvasW = tileLayer.offsetWidth; + const canvasH = tileLayer.offsetHeight; + for (const saved of savedTiles) { let cx = saved.x; let cy = saved.y; @@ -620,6 +709,7 @@ export function createTileManager({ } if (saved.type === "term") { + // Terminal tiles always spawn eagerly (PTY session bindings). const tile = createCanvasTile( "term", cx, cy, { id: saved.id, @@ -641,7 +731,15 @@ export function createTileManager({ workspacePath: saved.workspacePath, }, ); - spawnGraphWebview(tile); + if (isInViewport(tile, viewportState.panX, viewportState.panY, viewportState.zoom, canvasW, canvasH)) { + spawnGraphWebview(tile); + } else { + tile.deferred = true; + const placeholder = document.createElement("div"); + placeholder.className = "tile-deferred-placeholder"; + placeholder.textContent = getTileLabel(tile); + tileDOMs.get(tile.id)?.contentArea.appendChild(placeholder); + } } else if (saved.type === "browser") { const tile = createCanvasTile( "browser", cx, cy, { @@ -652,15 +750,109 @@ export function createTileManager({ url: saved.url, }, ); - spawnBrowserWebview(tile); + if (isInViewport(tile, viewportState.panX, viewportState.panY, viewportState.zoom, canvasW, canvasH)) { + spawnBrowserWebview(tile); + } else { + tile.deferred = true; + const placeholder = document.createElement("div"); + placeholder.className = "tile-deferred-placeholder"; + placeholder.textContent = getTileLabel(tile); + tileDOMs.get(tile.id)?.contentArea.appendChild(placeholder); + } } else if (saved.filePath) { - createFileTile( - saved.type, cx, cy, saved.filePath, + const tileObj = { + type: saved.type, + x: saved.x, y: saved.y, + width: saved.width, height: saved.height, + filePath: saved.filePath, + }; + if (isInViewport(tileObj, viewportState.panX, viewportState.panY, viewportState.zoom, canvasW, canvasH)) { + createFileTile(saved.type, saved.x, saved.y, saved.filePath); + } else { + const tile = createCanvasTile( + saved.type, saved.x, saved.y, { + id: saved.id, + width: saved.width, + height: saved.height, + zIndex: saved.zIndex, + filePath: saved.filePath, + }, + ); + tile.deferred = true; + const placeholder = document.createElement("div"); + placeholder.className = "tile-deferred-placeholder"; + placeholder.textContent = getTileLabel(tile); + tileDOMs.get(tile.id)?.contentArea.appendChild(placeholder); + } + } + } + } + + // -- Deferred tile spawning -- + + function checkDeferredTiles() { + const canvasW = tileLayer.offsetWidth; + const canvasH = tileLayer.offsetHeight; + + for (const tile of tiles) { + if (!tile.deferred) continue; + if (!isInViewport(tile, viewportState.panX, viewportState.panY, viewportState.zoom, canvasW, canvasH)) continue; + + tile.deferred = false; + const dom = tileDOMs.get(tile.id); + if (!dom) continue; + + // Remove placeholder(s). + for (const el of dom.contentArea.querySelectorAll(".tile-deferred-placeholder")) { + el.remove(); + } + + // Spawn content. + if (tile.type === "graph" && tile.folderPath) { + spawnGraphWebview(tile); + } else if (tile.type === "browser") { + spawnBrowserWebview(tile); + } else if (tile.filePath && tile.type !== "image") { + // Recreate file-viewer webview inline (matches createFileTile logic). + const wv = document.createElement("webview"); + const viewerConfig = configs.viewer; + const mode = tile.type === "note" ? "note" : "code"; + const viewerParams = new URLSearchParams(); + if (typeof tile.filePath === "string" && tile.filePath.length > 0) { + viewerParams.set("tilePath", tile.filePath); + } + viewerParams.set("tileMode", mode); + wv.setAttribute( + "src", + `${viewerConfig.src}?${viewerParams.toString()}`, ); + wv.setAttribute("preload", viewerConfig.preload); + wv.setAttribute("webpreferences", "contextIsolation=yes, sandbox=yes"); + wv.style.width = "100%"; + wv.style.height = "100%"; + wv.style.border = "none"; + dom.contentArea.appendChild(wv); + dom.webview = wv; + } else if (tile.filePath && tile.type === "image") { + // Recreate image viewer inline. + const imgContainer = document.createElement("div"); + imgContainer.style.cssText = "overflow:hidden;width:100%;height:100%;cursor:grab;"; + const img = document.createElement("img"); + img.src = `collab-file://${tile.filePath}`; + img.style.cssText = "width:100%;height:100%;object-fit:contain;transform-origin:center;pointer-events:none;"; + img.draggable = false; + imgContainer.appendChild(img); + dom.contentArea.appendChild(imgContainer); } } } + let _deferredCheckTimer = null; + function checkDeferredTilesDebounced() { + clearTimeout(_deferredCheckTimer); + _deferredCheckTimer = setTimeout(checkDeferredTiles, 200); + } + // -- Tile updates for external events -- function updateTileForRename(oldPath, newPath) { @@ -729,8 +921,11 @@ export function createTileManager({ createFileTile, createGraphTile, clearCanvas, + clearCanvasBatch, getCanvasStateForSave, restoreCanvasState, + checkDeferredTiles, + checkDeferredTilesDebounced, getTileDOMs: () => tileDOMs, getFocusedTileId: () => focusedTileId, setFocusedTileId: (id) => { focusedTileId = id; }, @@ -739,5 +934,47 @@ export function createTileManager({ broadcastToTileWebviews, saveCanvasDebounced, saveCanvasImmediate, + executeUndo() { + const cmd = undo(); + if (!cmd) return; + switch (cmd.type) { + case "tile-move": { + const tile = getTile(cmd.tileId); + if (tile) { tile.x = cmd.before.x; tile.y = cmd.before.y; snapToGrid(tile); this.repositionAllTiles(); this.saveCanvasDebounced(); } + break; + } + case "tile-resize": { + const tile = getTile(cmd.tileId); + if (tile) { tile.width = cmd.before.width; tile.height = cmd.before.height; snapToGrid(tile); this.repositionAllTiles(); this.saveCanvasDebounced(); } + break; + } + case "tile-delete": { + addTile({ ...cmd.deletedTile }); + this.restoreCanvasState([cmd.deletedTile]); + this.saveCanvasImmediate(); + break; + } + } + }, + executeRedo() { + const cmd = redo(); + if (!cmd) return; + switch (cmd.type) { + case "tile-move": { + const tile = getTile(cmd.tileId); + if (tile) { tile.x = cmd.after.x; tile.y = cmd.after.y; snapToGrid(tile); this.repositionAllTiles(); this.saveCanvasDebounced(); } + break; + } + case "tile-resize": { + const tile = getTile(cmd.tileId); + if (tile) { tile.width = cmd.after.width; tile.height = cmd.after.height; snapToGrid(tile); this.repositionAllTiles(); this.saveCanvasDebounced(); } + break; + } + case "tile-delete": { + this.closeCanvasTile(cmd.tileId); + break; + } + } + }, }; } diff --git a/collab-electron/src/windows/viewer/src/App.tsx b/collab-electron/src/windows/viewer/src/App.tsx index 331c6e45..cdaa8c4a 100644 --- a/collab-electron/src/windows/viewer/src/App.tsx +++ b/collab-electron/src/windows/viewer/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type AppConfig, type TreeNode, type ViewerItem } from "@collab/shared/types"; import { parseFileToViewerItem, @@ -13,7 +13,9 @@ import "@collab/components/FolderTableView/FolderTableView.css"; import "@collab/components/TreeView/TreeView.css"; import "@collab/components/Editor/Blocknote.css"; import "@collab/components/Editor/WikiLink.css"; -import { CodeEditorView } from "@collab/components/CodeEditorView"; +const CodeEditorView = React.lazy( + () => import("@collab/components/CodeEditorView").then(m => ({ default: m.CodeEditorView })) +); import "@collab/components/CodeEditorView/CodeEditorView.css"; import { isImageFile } from "@collab/shared/image"; import { extractCoverImageUrl } from "@collab/shared/extract-cover-image"; @@ -106,7 +108,7 @@ export default function App() { useEffect(() => { const params = new URLSearchParams(window.location.search); const tp = params.get("tilePath"); - if (tp) { + if (tp && tp !== "undefined" && tp !== "null") { setSelectedPath(tp); } }, []); @@ -588,14 +590,16 @@ export default function App() { )} {hasCodeFile && displayedPath && ( <> - + Loading editor...
}> + + )} {hasImageFile && displayedPath && ( diff --git a/collab-electron/tsconfig.web.json b/collab-electron/tsconfig.web.json index d0d02593..90156b4f 100644 --- a/collab-electron/tsconfig.web.json +++ b/collab-electron/tsconfig.web.json @@ -13,6 +13,7 @@ "skipLibCheck": true, "composite": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["bun-types", "react", "react-dom", "d3"], "outDir": "out", "paths": { "@collab/shared/*": ["./packages/shared/src/*"],