From 77fb74c188809dca3d8bec979709d457739a412a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 20 Mar 2026 13:08:28 +0700 Subject: [PATCH] feat(app): redirect to next workspace after archiving --- docs/ELECTRON_MIGRATION.md | 192 ++++++++++++++++++ packages/app/playwright.webkit.tmp.config.ts | 23 +-- .../src/components/sidebar-workspace-list.tsx | 84 +++++++- .../workspace-archive-navigation.test.ts | 76 +++++++ .../src/utils/workspace-archive-navigation.ts | 67 ++++++ 5 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 docs/ELECTRON_MIGRATION.md create mode 100644 packages/app/src/utils/workspace-archive-navigation.test.ts create mode 100644 packages/app/src/utils/workspace-archive-navigation.ts diff --git a/docs/ELECTRON_MIGRATION.md b/docs/ELECTRON_MIGRATION.md new file mode 100644 index 000000000..5c212ee07 --- /dev/null +++ b/docs/ELECTRON_MIGRATION.md @@ -0,0 +1,192 @@ +# Electron Migration + +## Why switch + +Paseo Desktop is a webview that loads an Expo web build plus a Node daemon that manages AI coding agents. Tauri is designed for apps that need a thin native wrapper — it assumes your backend logic lives in Rust. Paseo's backend is entirely Node/TypeScript. The result is that we're reimplementing Electron's built-in Node bundling inside Tauri's resource system. + +### Pain points with Tauri today + +**Managed runtime bundling is the #1 source of complexity.** `build-managed-runtime.mjs` (590 lines) downloads a Node distribution, packs three workspace tarballs, installs them with the bundled npm, separately installs sherpa-onnx platform packages, prunes ONNX/node-pty/ripgrep binaries per platform, and writes a runtime manifest. This exists solely because Tauri doesn't ship Node. + +**Linux is fragile.** We wrote a custom AppImage Wayland patch (`maybePatchLinuxAddonRunpath` in sherpa-onnx-node-loader, plus AppImage patching scripts) because Tauri's Linux webview has Wayland issues. Electron handles Wayland/X11 transparently via Chromium's `--ozone-platform-hint=auto`. + +**Native notification callbacks required Objective-C.** We wrote `paseo_notifications.m` (UNUserNotificationCenter delegate bridge) and compiled it into the Tauri build just to get notification click callbacks on macOS. Linux and Windows notification callbacks are unimplemented. Electron's `Notification` API provides click/reply/action callbacks cross-platform with zero native code. + +**Windows is undertested.** The Rust code has `#[cfg(windows)]` blocks for named pipes, PowerShell `Expand-Archive`, `CREATE_NO_WINDOW` flags, NSIS installer hooks — all written speculatively with limited testing. Electron's cross-platform abstractions cover all of this. + +**IPC is boilerplate-heavy.** Every desktop command requires a Rust `#[tauri::command]` function, a `spawn_blocking` wrapper, serde types, registration in `generate_handler![]`, and a TypeScript `invokeDesktopCommand()` call. There are 22 commands today. In Electron, the main process is Node — these become direct function calls or simple `ipcMain.handle()` handlers in the same language as the rest of the codebase. + +**Tauri plugin gaps.** Features like tray menus with dynamic updates, global shortcuts, deep links, system idle detection, auto-launch on login, and rich clipboard access all require Rust plugins (often per-platform). Electron provides these as JS API calls. + +### What we gain + +- **Delete `build-managed-runtime.mjs`** — Node comes with Electron +- **Delete the Wayland/AppImage patching** — Chromium handles it +- **Delete `paseo_notifications.m`** — `Notification` API works cross-platform +- **Delete `runtime_manager.rs`** (1080 lines) — daemon becomes a `child_process.fork()` call +- **Delete all Rust code** — `lib.rs` (683 lines), `desktop_notifications.rs`, `build.rs`, Cargo.toml dependencies +- **Delete sherpa rpath patching** — `electron-rebuild` handles native addon ABI matching +- **One language** for the entire desktop app (TypeScript) +- **Windows support** works out of the box +- **Mature auto-update** via electron-updater (GitHub Releases, S3, etc.) +- **Mature code signing** for macOS and Windows without custom scripts + +### What we lose + +- **App size increases** from ~20 MB to ~150-200 MB (ships Chromium) +- **Memory footprint increases** (Chromium process model) +- **Tauri's Rust escape hatch** — though we only use Rust because Tauri requires it, not because we need it + +### ONNX / Sherpa native addons + +`sherpa-onnx-node` and `onnxruntime-node` ship prebuilt `.node` addons per platform. In Electron, `@electron/rebuild` rebuilds or verifies native addons against Electron's Node ABI. This is standard practice — packages like `better-sqlite3`, `sharp`, and `node-pty` all work this way. The manual sherpa platform package installation and rpath patching goes away entirely. + +## What can be collapsed + +### Build infrastructure (delete entirely) + +| File | Lines | Purpose | +|---|---|---| +| `packages/desktop/scripts/build-managed-runtime.mjs` | 590 | Bundle Node + workspaces into Tauri sidecar | +| `packages/desktop/scripts/validate-managed-runtime.mjs` | ~100 | Validate bundled runtime integrity | +| `packages/desktop/scripts/sign-managed-runtime-macos.mjs` | ~100 | Code-sign macOS sidecar bundles | +| `packages/desktop/scripts/managed-daemon-smoke.mjs` | ~50 | Smoke test for managed daemon | + +Electron replacement: None. Node is part of Electron. Workspace code loads directly via `require`/`import`. + +### Rust backend (delete entirely) + +| File | Lines | Purpose | +|---|---|---| +| `src-tauri/src/lib.rs` | 683 | App setup, IPC commands, menu, zoom | +| `src-tauri/src/runtime_manager.rs` | 1080 | Managed runtime/daemon lifecycle | +| `src-tauri/src/desktop_notifications.rs` | ~80 | Notification bridge | +| `src-tauri/src/main.rs` | ~10 | Entry point | +| `src-tauri/build.rs` | ~30 | Build script (compiles ObjC) | +| `src-tauri/Cargo.toml` | ~30 | Rust dependencies | +| `src-tauri/tauri.conf.json` | ~100 | Tauri configuration | + +Electron replacement: A single `main.ts` (~200-300 lines) covering window creation, menu, IPC handlers, and daemon lifecycle. + +### Native platform code (delete entirely) + +| File | Purpose | +|---|---| +| `src-tauri/macos/paseo_notifications.h` | ObjC notification bridge header | +| `src-tauri/macos/paseo_notifications.m` | ObjC UNUserNotificationCenter delegate | +| `src-tauri/Info.plist` | macOS bundle config | +| `src-tauri/Entitlements.plist` | macOS entitlements | +| `src-tauri/installer-hooks.nsh` | Windows NSIS post-install hooks | + +Electron replacement: `Notification` API (2-3 lines per notification). macOS entitlements handled by `electron-builder` config. + +### Daemon management (massive simplification) + +**Tauri today:** Bundled Node binary → spawn as child process → pipe stdout/stderr → poll `daemon status --json` in a loop → parse JSON → return via Tauri IPC. + +**Electron:** `child_process.fork('./packages/server/dist/scripts/daemon-runner.js')` — same Node runtime, same ABI, direct IPC via Node's built-in channel. + +### Local daemon transport (simplification) + +**Tauri today:** `runtime_manager.rs` implements a full WebSocket client in Rust (tokio-tungstenite) with session management, base64 binary encoding over Tauri events, and manual read/write task spawning. ~300 lines of Rust. + +**Electron:** The renderer can connect to Unix sockets / named pipes directly via the main process using Node's `ws` package (already a dependency). Or use `ipcMain` to proxy — still TypeScript, still ~50 lines. + +### Attachment file management (simplification) + +**Tauri today:** 6 Rust commands (`write_attachment_base64`, `copy_attachment_file`, `read_file_base64`, `delete_attachment_file`, `garbage_collect_attachment_files`) with path validation, base64 encoding/decoding, and directory traversal protection. ~200 lines of Rust. + +**Electron:** Same logic in TypeScript using `fs` and `app.getPath('userData')`. ~50 lines. + +### Window management (simplification) + +**Tauri today:** Custom `tauri-window.ts` with `plugin:window|start_dragging`, `plugin:window|toggle_maximize`, `plugin:window|is_fullscreen` invocations. + +**Electron:** `BrowserWindow` API — `win.maximize()`, `win.isFullScreen()`, etc. Frameless window dragging via CSS `-webkit-app-region: drag`. + +### App updates (simplification) + +**Tauri today:** Rust `check_app_update` / `install_app_update` commands using `tauri_plugin_updater`. Custom update check UI in the renderer. + +**Electron:** `electron-updater` with `autoUpdater.checkForUpdatesAndNotify()`. Built-in download progress, restart handling, differential updates. + +## Migration plan + +### Guiding principle + +Electron is mature. We are not doing anything new. Every feature we need has official documentation and established patterns. Never use workarounds — if something doesn't work, find the official way. + +### Phase 1: Scaffold in parallel + +Create `packages/desktop-electron` alongside `packages/desktop` (Tauri). Both coexist. + +- Initialize with `electron-forge` or `electron-builder` +- Configure `BrowserWindow` to load the Expo web build (same as Tauri's webview) +- Set up `electron-builder` for macOS, Linux, Windows packaging +- Get a window rendering the Expo web app with no desktop features + +### Phase 2: Introduce `isElectron` branching + +Add platform detection infrastructure in `packages/app`: + +- Add `isElectron()` / `isElectronMac()` alongside existing `isTauri()` / `isTauriMac()` +- Introduce a `isDesktop()` helper that returns `isTauri() || isElectron()` +- Create `packages/app/src/desktop/electron/invoke-desktop-command.ts` — Electron's equivalent of the Tauri invoke bridge, using `ipcRenderer.invoke()` +- Update `invokeDesktopCommand()` to route to the correct backend based on platform + +### Phase 3: Port features incrementally + +Work through each Tauri feature, adding the Electron variant. Never break Tauri while doing this. + +**Tier 1 — Core (must work for the app to be usable):** + +1. **Daemon lifecycle** — `child_process.fork()` the daemon runner from the main process. Expose `start/stop/restart/status` via `ipcMain.handle()`. +2. **Local daemon transport** — WebSocket client in the main process using `ws`, proxied to renderer via IPC. +3. **Window management** — Frameless window with overlay title bar, drag regions, zoom controls, fullscreen. +4. **Menu** — `Menu.buildFromTemplate()` with zoom controls and macOS app menu. + +**Tier 2 — Desktop features:** + +5. **Notifications** — `new Notification()` with click handlers. +6. **App updates** — `electron-updater` with GitHub Releases. +7. **File dialogs** — `dialog.showOpenDialog()` for directory picker. +8. **Confirm dialogs** — `dialog.showMessageBox()`. +9. **Open external URLs** — `shell.openExternal()`. +10. **Attachment file management** — `fs` operations in main process via IPC. + +**Tier 3 — Polish:** + +11. **CLI symlink instructions** — Generate platform-specific instructions in Node. +12. **Logging** — `electron-log` or simple file-based logging. +13. **Single instance** — `app.requestSingleInstanceLock()`. +14. **Console forwarding** — No bridge needed, main process is already Node. +15. **NSIS installer hooks** — `electron-builder` NSIS config for CLI symlink. + +### Phase 4: Feature parity validation + +- Test all features on macOS, Linux (X11 + Wayland), Windows +- Verify sherpa-onnx-node loads correctly via `@electron/rebuild` +- Verify daemon start/stop/restart lifecycle +- Verify auto-update flow +- Verify notification callbacks on all platforms +- Smoke test CLI passthrough mode + +### Phase 5: Cut over + +- Update CI to build Electron packages instead of Tauri +- Remove `packages/desktop` (Tauri) +- Remove all `isTauri()` branching, keep only `isElectron()` (rename to `isDesktop()`) +- Delete `src-tauri/` and all Rust code +- Delete `build-managed-runtime.mjs` and related scripts +- Delete sherpa rpath patching code + +## Estimated code impact + +| Category | Lines removed | Lines added | +|---|---|---| +| Rust backend | ~1,900 | 0 | +| ObjC native code | ~150 | 0 | +| Build scripts | ~850 | 0 | +| Tauri TypeScript bridge | ~300 | ~150 (Electron IPC) | +| Electron main process | 0 | ~300 | +| **Net** | **~3,200 removed** | **~450 added** | diff --git a/packages/app/playwright.webkit.tmp.config.ts b/packages/app/playwright.webkit.tmp.config.ts index e399bfb49..9cdc91600 100644 --- a/packages/app/playwright.webkit.tmp.config.ts +++ b/packages/app/playwright.webkit.tmp.config.ts @@ -1,12 +1,11 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test' const baseURL = - process.env.E2E_BASE_URL ?? - `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; + process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}` export default defineConfig({ - testDir: "./e2e", - globalSetup: "./e2e/global-setup.ts", + testDir: './e2e', + globalSetup: './e2e/global-setup.ts', timeout: 60_000, expect: { timeout: 10_000, @@ -14,17 +13,17 @@ export default defineConfig({ fullyParallel: false, workers: 1, retries: 0, - reporter: [["list"]], + reporter: [['list']], use: { baseURL, - trace: "retain-on-failure", - screenshot: "only-on-failure", - video: "retain-on-failure", + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', }, projects: [ { - name: "Desktop Safari", - use: { ...devices["Desktop Safari"] }, + name: 'Desktop Safari', + use: { ...devices['Desktop Safari'] }, }, ], -}); +}) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 09a50706a..5757d26ab 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -73,6 +73,7 @@ import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model' import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store' import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' import { createNameId } from 'mnemonic-id' +import { buildWorkspaceArchiveRedirectRoute } from '@/utils/workspace-archive-navigation' function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -85,6 +86,7 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey +const EMPTY_WORKSPACES = new Map() interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[] @@ -789,7 +791,11 @@ function WorkspaceRowWithMenu({ isCreating?: boolean }) { const toast = useToast() + const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) + const sessionWorkspaces = useSessionStore( + (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES + ) const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) const archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ @@ -800,6 +806,27 @@ function WorkspaceRowWithMenu({ ) const isWorktree = workspace.workspaceKind === 'worktree' const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace + const redirectAfterArchive = useCallback(() => { + if ( + activeWorkspaceSelection?.serverId !== workspace.serverId || + activeWorkspaceSelection.workspaceId !== workspace.workspaceId + ) { + return + } + + router.replace( + buildWorkspaceArchiveRedirectRoute({ + serverId: workspace.serverId, + archivedWorkspaceId: workspace.workspaceId, + workspaces: sessionWorkspaces.values(), + }) as any + ) + }, [ + activeWorkspaceSelection, + sessionWorkspaces, + workspace.serverId, + workspace.workspaceId, + ]) const handleArchiveWorktree = useCallback(() => { if (isArchiving) { @@ -823,12 +850,22 @@ function WorkspaceRowWithMenu({ serverId: workspace.serverId, cwd: workspace.workspaceId, worktreePath: workspace.workspaceId, + }).then(() => { + redirectAfterArchive() }).catch((error) => { const message = error instanceof Error ? error.message : 'Failed to archive worktree' toast.error(message) }) })() - }, [archiveWorktree, isArchiving, toast, workspace.name, workspace.serverId, workspace.workspaceId]) + }, [ + archiveWorktree, + isArchiving, + redirectAfterArchive, + toast, + workspace.name, + workspace.serverId, + workspace.workspaceId, + ]) const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { @@ -859,13 +896,21 @@ function WorkspaceRowWithMenu({ if (payload.error) { throw new Error(payload.error) } + redirectAfterArchive() } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') } finally { setIsArchivingWorkspace(false) } })() - }, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) + }, [ + isArchivingWorkspace, + redirectAfterArchive, + toast, + workspace.name, + workspace.serverId, + workspace.workspaceId, + ]) const handleCopyPath = useCallback(() => { void Clipboard.setStringAsync(workspace.workspaceId) @@ -927,7 +972,32 @@ function NonGitProjectRowWithMenuContent({ }) { const toast = useToast() const contextMenu = useContextMenu() + const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() + const sessionWorkspaces = useSessionStore( + (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES + ) const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) + const redirectAfterArchive = useCallback(() => { + if ( + activeWorkspaceSelection?.serverId !== workspace.serverId || + activeWorkspaceSelection.workspaceId !== workspace.workspaceId + ) { + return + } + + router.replace( + buildWorkspaceArchiveRedirectRoute({ + serverId: workspace.serverId, + archivedWorkspaceId: workspace.workspaceId, + workspaces: sessionWorkspaces.values(), + }) as any + ) + }, [ + activeWorkspaceSelection, + sessionWorkspaces, + workspace.serverId, + workspace.workspaceId, + ]) const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { @@ -958,13 +1028,21 @@ function NonGitProjectRowWithMenuContent({ if (payload.error) { throw new Error(payload.error) } + redirectAfterArchive() } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') } finally { setIsArchivingWorkspace(false) } })() - }, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) + }, [ + isArchivingWorkspace, + redirectAfterArchive, + toast, + workspace.name, + workspace.serverId, + workspace.workspaceId, + ]) return ( <> diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts new file mode 100644 index 000000000..fc1f52989 --- /dev/null +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkspaceArchiveRedirectRoute, + resolveWorkspaceArchiveRedirectWorkspaceId, +} from "@/utils/workspace-archive-navigation"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; + +function workspace( + input: Partial & Pick +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project", + projectRootPath: input.projectRootPath ?? "/repo", + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "worktree", + name: input.name ?? input.id, + status: input.status ?? "done", + activityAt: input.activityAt ?? null, + diffStat: input.diffStat ?? null, + }; +} + +describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { + it("redirects an archived worktree to the visible local checkout for the same project", () => { + const workspaces = [ + workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }), + workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }), + ]; + + expect( + resolveWorkspaceArchiveRedirectWorkspaceId({ + archivedWorkspaceId: "/repo/.paseo/worktrees/feature", + workspaces, + }) + ).toBe("/repo"); + }); + + it("falls back to the project root path when the root checkout is not in the visible workspace list", () => { + const workspaces = [ + workspace({ + id: "/repo/.paseo/worktrees/feature", + name: "feature", + projectRootPath: "/repo", + }), + ]; + + expect( + resolveWorkspaceArchiveRedirectWorkspaceId({ + archivedWorkspaceId: "/repo/.paseo/worktrees/feature", + workspaces, + }) + ).toBe("/repo"); + }); + + it("falls back to the host root route when no alternate workspace target exists", () => { + const workspaces = [ + workspace({ + id: "/notes", + projectId: "notes", + projectRootPath: "/notes", + projectKind: "non_git", + workspaceKind: "directory", + }), + ]; + + expect( + buildWorkspaceArchiveRedirectRoute({ + serverId: "server-1", + archivedWorkspaceId: "/notes", + workspaces, + }) + ).toBe("/h/server-1"); + }); +}); diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts new file mode 100644 index 000000000..1481f596b --- /dev/null +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -0,0 +1,67 @@ +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { + archivedWorkspaceId: string; + workspaces: Iterable; +}): string | null { + const archivedWorkspaceId = trimNonEmpty(input.archivedWorkspaceId); + if (!archivedWorkspaceId) { + return null; + } + + const workspaces = Array.from(input.workspaces); + const archivedWorkspace = + workspaces.find((workspace) => workspace.id === archivedWorkspaceId) ?? null; + if (!archivedWorkspace) { + return null; + } + + const sameProjectWorkspaces = workspaces.filter( + (workspace) => workspace.projectId === archivedWorkspace.projectId + ); + const rootCheckoutWorkspace = + sameProjectWorkspaces.find( + (workspace) => + workspace.workspaceKind === "local_checkout" && + workspace.id !== archivedWorkspace.id + ) ?? null; + if (rootCheckoutWorkspace) { + return rootCheckoutWorkspace.id; + } + + const fallbackProjectRootPath = trimNonEmpty(archivedWorkspace.projectRootPath); + if (fallbackProjectRootPath && fallbackProjectRootPath !== archivedWorkspace.id) { + return fallbackProjectRootPath; + } + + const siblingWorkspace = + sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null; + return siblingWorkspace?.id ?? null; +} + +export function buildWorkspaceArchiveRedirectRoute(input: { + serverId: string; + archivedWorkspaceId: string; + workspaces: Iterable; +}): string { + const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({ + archivedWorkspaceId: input.archivedWorkspaceId, + workspaces: input.workspaces, + }); + + if (!redirectWorkspaceId) { + return buildHostRootRoute(input.serverId); + } + + return buildHostWorkspaceRoute(input.serverId, redirectWorkspaceId); +}