Skip to content

feat(desktop): add Workbench/Review mode and workspace navigation sidebar#559

Closed
andreasasprou wants to merge 43 commits intosuperset-sh:mainfrom
andreasasprou:workspace-sidebar
Closed

feat(desktop): add Workbench/Review mode and workspace navigation sidebar#559
andreasasprou wants to merge 43 commits intosuperset-sh:mainfrom
andreasasprou:workspace-sidebar

Conversation

@andreasasprou
Copy link
Copy Markdown
Contributor

@andreasasprou andreasasprou commented Dec 31, 2025

Summary

This PR bundles two related features for the desktop app:

  1. Workbench/Review Mode - A workspace-level view mode toggle:

    • Workbench mode: Mosaic panes layout with terminals + file viewers side-by-side
    • Review mode: Dedicated Changes page for focused code review
  2. Configurable Workspace Navigation - A setting to choose between:

    • Top bar: Horizontal tabs (current behavior)
    • Sidebar: Vertical list grouped by project (Linear/GitHub Desktop style)

Also includes:

  • "Mark as Unread" / "Mark as Read" context menu for workspaces with auto-clear and undo
  • Configurable terminal file link behavior (open in file viewer vs external editor)
  • Inline file editing in FileViewerPane with ⌘+S save
  • Improved sidebar mode layout: "Open In" and "Workbench/Review" controls moved to content header

Part 1: Workbench/Review Mode

Why

Users need to view code, diffs, and docs while keeping terminals visible in the same window. The existing Changes page is great for focused review, but doesn't support in-flow work where you want terminals and file viewers side-by-side.

What / How

Introduces a workspace-level Workbench | Review view mode toggle:

  • Workbench mode: Mosaic panes layout with terminals + file viewers for in-flow work. Clicking a file in the sidebar opens a FileViewer pane next to your terminals.
  • Review mode: Dedicated Changes page for focused code review (existing behavior preserved).

Key implementation details:

  1. ViewModeToggle - Prominent segmented control in workspace header for switching modes
  2. FileViewerPane - New pane type with Raw/Rendered/Diff view modes, lock/unlock (prevents file replacement), and split support
  3. GroupStrip - Group switching strip above Mosaic content (moves group tabs out of sidebar)
  4. Unified sidebar - Full ChangesView in both modes; in Workbench, file clicks trigger onFileOpen callback to open FileViewer panes
  5. workspace-view-mode store - Per-workspace persistence via Zustand + localStorage
  6. readWorkingFile tRPC - Safe file reads with size cap (2MB) and binary detection
  7. MRU pane reuse - File clicks reuse most-recently-used unlocked FileViewer pane; locked panes are preserved
  8. ⌘+T behavior - In Review mode, switches to Workbench first, then creates terminal

Inline Editing Support

FileViewerPane supports full editing in both Raw and Diff modes:

  • Raw mode - Monaco Editor with syntax highlighting
  • Diff mode - Editing enabled on the modified side of the diff viewer
  • ⌘+S to save - Standard keyboard shortcut saves changes to disk
  • Dirty state tracking - Orange dot (●) indicator before filename when unsaved changes exist
  • Editable badge - Shows "⌘S" hint (changes to "Saving..." during save operation)
  • Auto-refresh - Queries invalidated after save to keep UI in sync

Security Hardening

File Viewer file operations include robust symlink escape protection:

  • Centralized read security: secureFs.readFile() and readFileBuffer() enforce symlink-escape checks internally
  • ENOENT handling: Writes through non-existent paths validate parent directory chain (detects dangling symlinks pointing outside worktree)
  • Distinct error codes: symlink-escape reason returned separately from outside-worktree for precise error handling
  • Fail-closed behavior: All validation errors default to blocking the operation

Configurable Terminal File Link Behavior

Added a global setting to control how Cmd+clicking file paths in the terminal behaves:

  • Setting location: Settings → Behavior → "Terminal file links"
  • Options:
    • "External editor" (default) - Opens file in configured external editor
    • "File viewer" - Opens file in the in-app FileViewerPane
  • Uses ref pattern to avoid terminal recreation when setting changes
  • Setting changes apply immediately without terminal restart
  • Line/column support - Terminal links with file:line:col format navigate to the correct position in File Viewer

Part 2: Workspace Navigation Sidebar

Architecture

  • Persisted setting: Navigation style stored in SQLite via settings.navigationStyle column
  • Shared hook pattern: useWorkspaceShortcuts extracts keyboard shortcuts (⌘1-9) and auto-create logic shared between both modes
  • Zustand state: Sidebar width, collapsed projects, and open state persisted with zustand/persist
  • Lazy data loading: GitHub PR status loaded on hover to avoid N+1 queries

How it works

  1. User selects "Top bar" or "Sidebar" in Settings → Behavior
  2. MainScreen queries the setting and conditionally renders:
    • Top bar mode: WorkspacesTabs in the header (current behavior)
    • Sidebar mode: ResizableWorkspaceSidebar on the left + simplified TopBar
  3. Both modes share the same keyboard shortcuts via useWorkspaceShortcuts hook

Sidebar Mode Layout Improvements

In sidebar navigation mode, the "Open In" button and "Workbench/Review" toggle have been moved from the TopBar down to the ContentHeader:

Before:

TopBar:        [◧] │ Project / workspace-name │ [Open ▾] [Workbench│Review] [👤]
ContentHeader: [☰] │ [Group1 ×] [Group2 ×] [+]

After:

TopBar:        [◧] │ Project / workspace-name │ [👤]
ContentHeader: [☰] │ [Group1 ×] [Group2 ×] [+] │ [Open ▾] [Workbench│Review]

This keeps the profile dropdown at the top while placing workspace controls closer to the content they affect. The controls are visible in both:

  • Workbench mode: With group tabs
  • Review mode: Without group tabs (just the controls)

Top-bar navigation mode is unchanged.

Key decisions

Decision Choice Rationale
Sidebar type New dedicated sidebar Avoids complexity of reusing ModeCarousel
State storage Zustand + localStorage Matches existing sidebar patterns
Collapsed projects string[] not Set<string> JSON serialization for persist middleware
Diff stats source getGitHubStatus on hover Avoids N+1 queries, lazy load pattern
TopBar in sidebar mode Show project/workspace name Maintains context without tabs
Controls placement (sidebar mode) ContentHeader Keeps controls near content, profile at top

New Components

WorkspaceSidebar

  • WorkspaceSidebar.tsx - Main sidebar component
  • WorkspaceSidebarHeader.tsx - "Workspaces" title header
  • WorkspaceSidebarFooter.tsx - "Add project" button
  • ResizableWorkspaceSidebar.tsx - Wrapper with resize handle

ProjectSection

  • ProjectSection.tsx - Collapsible project group with workspaces
  • ProjectHeader.tsx - Project name, color dot, collapse toggle

WorkspaceListItem

  • WorkspaceListItem.tsx - Individual workspace row with lazy-loaded PR data
  • WorkspaceStatusBadge.tsx - PR status badge (open/merged/closed/draft)
  • WorkspaceDiffStats.tsx - Additions/deletions display

Shared

  • useWorkspaceShortcuts.ts - Shared hook for ⌘1-9 shortcuts and auto-create logic
  • workspace-sidebar-state.ts - Zustand store for sidebar UI state

Part 3: Mark as Unread/Read for Workspaces

Right-click any workspace (in top bar tabs or sidebar) to toggle its "unread" state. This is a workspace-level feature, separate from pane-level agent notifications.

How it works

  1. "Mark as Unread" - Sets workspace.isUnread = true in SQLite database
  2. Red pulsing indicator - Shows on the workspace tab/list item when unread
  3. Auto-clear on switch - When you click to switch to an unread workspace:
    • The unread state is automatically cleared
    • An undo toast appears for 5 seconds: "Marked as read" with Undo button
    • Clicking Undo restores the unread state
  4. "Mark as Read" - Explicitly clear the unread state via context menu

Why workspace-level instead of pane-level?

Previously, "Mark as Unread" set needsAttention: true on all panes in the workspace, causing:

  • ❌ Every tab and pane showing attention indicators (visual clutter)
  • ❌ Semantic confusion (manual reminder ≠ agent output needing attention)
  • ❌ Tedious to clear (had to visit every pane)

The new approach:

  • ✅ Single indicator on the workspace only
  • ✅ Clean semantic separation: workspace.isUnread (manual reminder) vs pane.needsAttention (agent notifications)
  • ✅ Indicator shows if either workspace is manually unread OR any pane has agent attention

Why SQLite instead of Zustand/localStorage?

We store isUnread in SQLite rather than in-memory state for two key reasons:

  1. Persistence across app restarts - Users expect "mark as unread" to survive closing and reopening the app, similar to email clients and Slack. SQLite data persists to disk automatically.

  2. Workspace is a database entity - The workspaces table already exists in SQLite with other workspace metadata. Adding isUnread as a column keeps related data together and avoids split-brain between SQLite and localStorage.

Implementation details

  • New isUnread column in workspaces table (migration: 0007_add_workspace_is_unread.sql)
  • New workspaces.setUnread tRPC mutation
  • setActive mutation now returns wasUnread and auto-clears + invalidates queries
  • Context menu shows "Mark as Read" or "Mark as Unread" based on current state

Keyboard Shortcuts

Shortcut Action
⌘⇧B Toggle workspace sidebar (sidebar mode only)
⌘B Toggle files sidebar
⌘1-9 Switch to workspace N (works in both modes)
⌘← / ⌘→ Previous/next workspace (works in both modes)

Manual Test Checklist

Workbench/Review Mode

  • Toggle between Workbench/Review modes
  • File clicks open FileViewer panes in Workbench
  • ⌘+T switches to Workbench from Review mode
  • FileViewer Raw/Rendered/Diff modes work
  • Lock/unlock and split pane work
  • GroupStrip switches between groups
  • Edit files in Raw mode and save with ⌘+S
  • Edit files in Diff mode and save with ⌘+S
  • Dirty indicator appears when changes are unsaved

Terminal Link Behavior

  • Settings → Behavior shows "Terminal file links" dropdown
  • Default is "External editor" (existing behavior preserved)
  • With "External editor": Cmd+click file path → opens in external editor
  • With "File viewer": Cmd+click file path → opens FileViewerPane
  • With "File viewer": Cmd+click file:line:col → opens at correct line/column
  • Changing setting applies immediately (no terminal restart needed)
  • Setting persists after app restart
  • Falls back to external editor if workspace cwd not loaded yet

Security (Symlink Escape Protection)

  • File Viewer cannot read files via symlinks pointing outside worktree
  • File Viewer cannot write files via symlinks pointing outside worktree
  • Error message distinguishes symlink-escape from other validation errors
  • Terminal link to file outside workspace shows warning toast (file-viewer mode)

Navigation Style Setting

  • Setting persists across app restarts
  • Changing setting immediately switches layout
  • Default is "Top bar" for new installs

Sidebar Mode

  • Sidebar renders with correct width (default 280px)
  • Sidebar is resizable between 220-400px
  • Resize persists across restarts
  • ⌘⇧B toggles sidebar visibility
  • Projects are collapsible
  • Collapsed state persists
  • Active workspace has left border indicator
  • Hover shows keyboard shortcut (⌘1-9)
  • PR status/diff stats load on hover (worktree workspaces)
  • "Add project" button works

Sidebar Mode Layout

  • In sidebar mode, "Open In" and "Workbench/Review" controls appear in ContentHeader (not TopBar)
  • Profile dropdown remains in TopBar
  • Controls visible in Workbench mode (with group tabs)
  • Controls visible in Review mode (without group tabs)
  • Changes sidebar toggle (☰) works in both Workbench and Review modes
  • Top-bar navigation mode is unchanged (controls remain in TopBar)

Top Bar Mode

  • Existing tab behavior unchanged
  • No workspace sidebar visible
  • ⌘⇧B does nothing (correct)

Keyboard Shortcuts (Both Modes)

  • ⌘1-9 switches to correct workspace
  • ⌘← / ⌘→ navigates workspaces
  • Auto-create main workspace still works

Mark as Unread/Read Feature

  • Right-click workspace in top bar shows context menu
  • Right-click workspace in sidebar shows context menu
  • "Mark as Unread" shows red pulsing indicator on workspace only (not on every tab)
  • "Mark as Read" clears the indicator
  • Switching to unread workspace shows "Marked as read" toast with Undo button
  • Clicking Undo in toast restores the unread state
  • Unread state persists after app restart
  • Works for both worktree and branch workspaces
  • Works in both top bar and sidebar navigation modes
  • Pane-level agent attention indicators still work independently

Future Work

  • Keyboard shortcut for mode toggle - Add ⌘+\ or similar to toggle between Workbench/Review
  • Migrate to workspaceId-based APIs - Accept workspaceId instead of worktreePath in renderer calls

Files Changed

New Files (Workbench/Review)

  • GroupStrip/GroupStrip.tsx - Group switching strip component
  • FileViewerPane/FileViewerPane.tsx - File viewer with Raw/Rendered/Diff modes + inline editing
  • ViewModeToggle/ViewModeToggle.tsx - Segmented control for mode toggle
  • workspace-view-mode.ts - Per-workspace view mode store
  • 0004_add_terminal_link_behavior_setting.sql - Migration for terminal link behavior setting

New Files (Workspace Sidebar)

  • apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts
  • apps/desktop/src/renderer/stores/workspace-sidebar-state.ts
  • apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx
  • apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/* (10 files)
  • packages/local-db/drizzle/0005_add_navigation_style.sql

New Files (Mark as Unread)

  • packages/local-db/drizzle/0007_add_workspace_is_unread.sql - Migration for isUnread column

Modified Files

  • file-contents.ts - Added readWorkingFile procedure
  • ContentView/index.tsx - Routes to TabsContent or ChangesContent based on mode; passes WorkspaceControls in sidebar mode
  • ContentHeader/ContentHeader.tsx - Added trailingAction prop for right-side controls
  • ChangesView.tsx - Added onFileOpen callback prop
  • Sidebar/index.tsx - Passes onFileOpen in Workbench mode
  • WorkspaceActionBar.tsx - Added ViewModeToggle to header
  • WorkspaceView/index.tsx - View mode reactivity and ⌘+T behavior
  • tabs/store.ts - Removed old markWorkspaceAsUnread (now uses tRPC)
  • tabs/types.ts - Removed markWorkspaceAsUnread from interface
  • shared/tabs-types.ts - Added file-viewer pane type
  • Terminal/helpers.ts - Refactored to accept onFileLinkClick callback
  • Terminal/Terminal.tsx - Wired up terminal link behavior setting
  • BehaviorSettings.tsx - Added terminal link behavior and navigation style dropdowns
  • settings/index.ts (tRPC) - Added getters/setters for new settings
  • schema.ts / zod.ts (local-db) - Added isUnread column to workspaces
  • workspaces.ts (tRPC) - Added setUnread mutation, updated setActive to auto-clear and return wasUnread
  • useSetActiveWorkspace.ts - Added undo toast for auto-cleared unread state
  • WorkspaceItemContextMenu.tsx - Now uses tRPC mutation, shows Read/Unread toggle
  • WorkspaceItem.tsx - Uses workspace-level isUnread for indicator
  • WorkspaceGroup.tsx - Passes isUnread to WorkspaceItem
  • WorkspaceListItem.tsx - Uses workspace-level isUnread for indicator
  • ProjectSection.tsx - Passes isUnread to WorkspaceListItem
  • TopBar/index.tsx - Conditional rendering based on navigation style; WorkspaceControls only in top-bar mode
  • TopBar/WorkspaceTabs/index.tsx - Refactored to use shared hook
  • MainScreen/index.tsx - Wire up sidebar and navigation style
  • shared/constants.ts - Added DEFAULT_NAVIGATION_STYLE
  • shared/hotkeys.ts - Added TOGGLE_WORKSPACE_SIDEBAR, renamed TOGGLE_SIDEBAR
  • secure-fs.ts - Added symlink escape protection for reads and ENOENT handling

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Dec 31, 2025

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

This pull request introduces left-sidebar workspace navigation as an alternative to the top-bar mode, adds a file-viewer pane component for editing and viewing files within tabs, implements per-workspace view-mode switching (workbench vs. review), and adds a comprehensive security layer for git operations and filesystem access with path validation.

Changes

Cohort / File(s) Summary
ExecPlan Template & Guide
.agents/commands/create-plan-file.md
New markdown template documenting the ExecPlan format, lifecycle, discovery/clarification/drafting/validation process, formatting rules, naming conventions with timestamps, and evidence capture for self-contained executable specifications.
ExecPlan Instance
apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md
Comprehensive implementation plan for the left-sidebar workspace navigation feature, detailing component structure, state management, TRPC routes, data flows, UI/UX elements, performance considerations, and idempotent migrations.
Security Layer: Path Validation & Secure Filesystem
apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts
apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts
apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
apps/desktop/src/lib/trpc/routers/changes/security/index.ts
New security module introducing PathValidationError, worktree registration checks, relative-path validation with traversal guards, path resolution utilities, and a secureFs wrapper providing read/write/delete/stat operations with implicit path validation and symlink checks. Adds semantic git command helpers (gitSwitchBranch, gitCheckoutFile, gitStageFile, etc.) with input validation.
TRPC Router Security Integration
apps/desktop/src/lib/trpc/routers/changes/branches.ts
apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
apps/desktop/src/lib/trpc/routers/changes/staging.ts
apps/desktop/src/lib/trpc/routers/changes/status.ts
apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
Integrates security helpers (assertRegisteredWorktree, secureFs, git command wrappers) into existing routers. Adds readWorkingFile procedure with size caps and binary detection; refactors file I/O and git operations to use security wrappers; removes inline saveFile mutation from git-operations.
Settings Router: Navigation & Terminal Link Behavior
apps/desktop/src/lib/trpc/routers/settings/index.ts
Adds getNavigationStyle/setNavigationStyle and getTerminalLinkBehavior/setTerminalLinkBehavior procedures for managing new settings via upsert logic on the settings table.
UI State Router
apps/desktop/src/lib/trpc/routers/ui-state/index.ts
Extends paneSchema to include new "file-viewer" pane type with optional cwd, cwdConfirmed, and fileViewer (FileViewerState) fields.
Workspace Sidebar Components
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts
New sidebar UI showing grouped projects with collapsible sections, workspace listings per project, "Add project" footer, and header. Includes mouse-driven resizing with width clamping and state persistence. Exposes ResizableWorkspaceSidebar and WorkspaceSidebar for public usage.
Project Section & List Item Components
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts
Renders collapsible project headers with color indicators and workspace count; displays animated workspace list with dynamic shortcut indices. Uses store for collapse state.
Workspace List Item & Status Components
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts
Renders individual workspace items with active indicators, branch names, optional PR status badges, diff stats (additions/deletions), and keyboard shortcut hints. Lazy-loads GitHub PR status on hover.
File Viewer Pane & Tab Support
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx
New file-viewer pane for editing, diffing, and rendering files in tabs. Supports multiple view modes (raw Monaco editor, rendered markdown, diff view), split panes, draft preservation, and save operations. Integrates into TabView with worktreePath propagation.
Terminal File-Link Behavior Integration
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
Updates createTerminalInstance signature to accept CreateTerminalOptions object (cwd, initialTheme, onFileLinkClick). Adds file-link behavior handling: routes to file-viewer pane creation or external editor based on settings. FilePathLinkProvider now accepts optional onFileLinkClick callback.
Tab Group Strip
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx
New GroupStrip UI showing tabs filtered by active workspace with attention indicators. Renders above TabView in a split layout. Provides tab/group management (add, select, close).
View Mode Toggle & Integration
apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/ViewModeToggle.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/ViewModeToggle/index.ts
apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx
New toggle button for per-workspace view-mode switching (Workbench ↔ Review). Integrated into WorkspaceActionBar with centered layout. Wires view-mode changes to store and conditionally renders ChangesContent or TabsContent based on mode.
File Open Callback & Sidebar Integration
apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx
Updates ChangesView with optional onFileOpen callback for opening files in file-viewer panes. Wires data-driven view-mode selection per workspace via TRPC and store. Adds file-pane creation flow for workbench mode.
Workspace Shortcuts Hook
apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts
New hook fetching grouped workspaces and active workspace, tracking auto-creation state per project, registering Meta+1-9 workspace switches and PREV/NEXT workspace hotkeys, and exposing flattened workspace list and setActiveWorkspace method.
Top Bar & Navigation Style
apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx
apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx
TopBar now accepts navigationStyle prop and conditionally renders WorkspaceSidebarControl (when sidebar mode) or WorkspacesTabs (top-bar mode). Shows active workspace info in sidebar mode. Refactors WorkspaceTabs to use shared useWorkspaceShortcuts hook, removing duplicate keyboard/auto-creation logic.
Main Screen & Layout
apps/desktop/src/renderer/screens/main/index.tsx
Fetches navigation style setting via TRPC, computes effectiveNavigationStyle with fallback, passes to TopBar, renders ResizableWorkspaceSidebar conditionally, and wires TOGGLE_WORKSPACE_SIDEBAR hotkey to sidebar store.
Sidebar & Store State
apps/desktop/src/renderer/stores/workspace-sidebar-state.ts
apps/desktop/src/renderer/stores/workspace-view-mode.ts
apps/desktop/src/renderer/stores/index.ts
New Zustand stores: useWorkspaceSidebarStore (isOpen, width, collapsedProjectIds with persistence) and useWorkspaceViewModeStore (per-workspace mode tracking). Exported via stores index barrel.
Tabs Store Extensions
apps/desktop/src/renderer/stores/tabs/store.ts
apps/desktop/src/renderer/stores/tabs/types.ts
apps/desktop/src/renderer/stores/tabs/utils.ts
Adds addFileViewerPane method to create/reuse file-viewer panes; createFileViewerPane utility with auto-mode detection (diff/rendered/raw based on extension). Updates split operations to clone file-viewer panes (locked) instead of always creating terminals. New types: AddFileViewerPaneOptions, CreateFileViewerPaneOptions.
Behavior Settings UI
apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx
Integrates new navigation style and terminal-link-behavior settings with TRPC queries/mutations, optimistic updates, loading states, and select UI components in the settings panel.
Shared Constants & Types
apps/desktop/src/shared/constants.ts
apps/desktop/src/shared/hotkeys.ts
apps/desktop/src/shared/tabs-types.ts
Adds DEFAULT_NAVIGATION_STYLE and DEFAULT_TERMINAL_LINK_BEHAVIOR constants. Updates HOTKEYS with TOGGLE_WORKSPACE_SIDEBAR entry and updated TOGGLE_SIDEBAR label/category. Extends PaneType to "file-viewer" and introduces FileViewerMode, DiffLayout, FileViewerState types.
Database Schema & Migrations
packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql
packages/local-db/drizzle/0005_add_navigation_style.sql
packages/local-db/drizzle/meta/0004_snapshot.json
packages/local-db/drizzle/meta/0005_snapshot.json
packages/local-db/drizzle/meta/_journal.json
packages/local-db/src/schema/schema.ts
packages/local-db/src/schema/zod.ts
Adds terminal\_link\_behavior and navigation\_style columns to settings table via SQL migrations. Updates Drizzle snapshots and journal. Adds NavigationStyle and TerminalLinkBehavior types, TERMINAL\_LINK\_BEHAVIORS constant.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Settings as BehaviorSettings<br/>(React Component)
    participant TRPC as TRPC Router<br/>(settings)
    participant DB as LocalDB<br/>(SQLite)
    participant MainScreen as MainScreen<br/>(React Component)
    participant TopBar as TopBar<br/>(React Component)
    participant Sidebar as WorkspaceSidebar<br/>(React Component)

    User->>Settings: Change navigation style<br/>(select Top bar / Sidebar)
    Settings->>TRPC: setNavigationStyle(style)
    Note over TRPC: Apply optimistic<br/>update
    TRPC->>DB: Upsert settings.navigation_style
    DB-->>TRPC: OK
    TRPC-->>Settings: Success
    Settings->>MainScreen: Re-query via useQuery
    MainScreen->>MainScreen: Compute effectiveNavigationStyle
    rect rgba(34, 197, 94, 0.1)
        Note over MainScreen,Sidebar: If sidebar mode enabled
        MainScreen->>TopBar: Pass navigationStyle prop
        MainScreen->>Sidebar: Render ResizableWorkspaceSidebar
    end
    rect rgba(239, 68, 68, 0.1)
        Note over MainScreen,TopBar: If top-bar mode enabled
        MainScreen->>TopBar: Pass navigationStyle prop
        TopBar->>TopBar: Render WorkspacesTabs instead
    end
    TopBar-->>User: UI updates (show sidebar control or tabs)
    Sidebar-->>User: Sidebar visibility toggles
Loading
sequenceDiagram
    actor User
    participant Terminal as Terminal<br/>(React Component)
    participant Store as TabsStore<br/>(Zustand)
    participant TRPC as TRPC Router<br/>(settings)
    participant FilePane as FileViewerPane<br/>(React Component)
    participant Editor as Monaco Editor<br/>(or Markdown)

    User->>Terminal: Click file path link<br/>in terminal output
    Terminal->>TRPC: Query getTerminalLinkBehavior
    TRPC-->>Terminal: "file-viewer" | "external-editor"
    rect rgba(34, 197, 94, 0.1)
        Note over Terminal,FilePane: If file-viewer behavior
        Terminal->>Store: addFileViewerPane(workspaceId,<br/>{filePath, diffCategory, ...})
        Store->>Store: Create/reuse file-viewer pane
        Store-->>Terminal: Return paneId
        Terminal->>FilePane: Mount with filePath
        FilePane->>Editor: Fetch content via readWorkingFile
        Editor-->>FilePane: Render markdown/raw/diff
        FilePane-->>User: Display file in editor pane
    end
    rect rgba(239, 68, 68, 0.1)
        Note over Terminal: Else external-editor behavior
        Terminal->>TRPC: external.openFileInEditor.mutate
        TRPC-->>User: Open in external editor
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • CharlieHelps

🐰 Hoppy skips through the sidebar, keyboard shortcuts ablaze,
File viewers dance with diffs, in tabs they find their place,
A toggle here, a pane there, security paths so bright,
The rabbit's workspace dream comes true—what a delightful sight! 🚀✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly summarizes the main feature: adding a configurable workspace navigation style with Workbench/Review modes and a new sidebar component.
Description check ✅ Passed The pull request description is comprehensive and well-structured, covering the three main features (Workbench/Review Mode, Workspace Navigation Sidebar, Mark as Unread/Read), implementation details, security considerations, keyboard shortcuts, and a detailed manual test checklist.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@andreasasprou andreasasprou marked this pull request as draft December 31, 2025 07:47
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (33)
.agents/commands/create-plan-file.md (7)

29-42: Add language identifier to fenced code block.

The example code block lacks a language identifier, which improves readability and syntax highlighting in documentation renderers.

🔎 Proposed fix
- Example questions:
- ```
+ Example questions:
+ ```markdown
  I reviewed the existing auth implementation in apps/web/src/app/auth/.
  
  Where should the new OAuth provider live?

56-64: Reduce sentence-start repetition for readability.

Three successive sentences begin with "Every ExecPlan must", which creates a rhythmic monotony. Varying the sentence structure would improve flow and readability.

🔎 Proposed revision

Instead of:

* Every ExecPlan must be fully self-contained. Self-contained means…
* Every ExecPlan is a living document. Contributors are required…
* Every ExecPlan must enable a complete novice to implement…

Consider:

* Every ExecPlan must be fully self-contained. Self-contained means…
* As a living document, ExecPlans require contributors to revise…
* A complete novice should be able to implement the feature end-to-end…

76-81: Vary sentence structure to improve readability.

Three successive sentences begin with "Do not", which creates repetitive emphasis. Reordering or restructuring a few clauses would maintain the directive tone while improving prose flow.

🔎 Suggested revision

Instead of:

Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader.

Consider:

Avoid common failure modes: do not rely on undefined jargon; avoid describing "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful; and take responsibility for key decisions rather than outsourcing them to the reader.

88-88: Format bare URL in backticks for consistency.

The URL http://localhost:3000/health appears unformatted in prose. Wrapping it in backticks improves readability and distinguishes it as a code reference.

- Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to http://localhost:3000/health returns HTTP 200 with body OK")
+ Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to `http://localhost:3000/health` returns HTTP 200 with body OK")

133-133: Simplify "overall goal" for conciseness.

The phrase "overall goal" is somewhat redundant. Simply "goal" conveys the same meaning more directly.

- Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
+ Each milestone must be independently verifiable and incrementally implement the goal of the execution plan.

72-72: Standardize emphasis style to underscores.

Per the codebase conventions, emphasis should use underscores (_text_) rather than asterisks (*text*) for consistency.

- When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
+ When writing an ExecPlan to a Markdown (.md) file where the content of the file _is only_ the single ExecPlan, you should omit the triple backticks.

244-259: Add language identifier to directory structure code block.

The directory structure code block lacks a language identifier. Adding one improves formatting and readability.

- ```
+ ```plaintext
  apps/<app-name>/.agents/plans/       # App-specific plans
    <active-plans>.md
    done/
    abandoned/
packages/local-db/drizzle/0005_add_navigation_style.sql (1)

1-1: Consider adding a DEFAULT value to the migration.

The migration adds navigation_style as a nullable text column without a SQL-level default. While the application layer handles defaults via the TRPC router, adding a DEFAULT constraint in the migration would ensure data consistency and simplify null handling.

🔎 Proposed fix with DEFAULT constraint
-ALTER TABLE `settings` ADD `navigation_style` text;
+ALTER TABLE `settings` ADD `navigation_style` text DEFAULT 'top-bar';

This aligns with the DEFAULT_NAVIGATION_STYLE constant mentioned in the PR context and ensures existing rows have a consistent value.

apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx (1)

22-25: Align color naming with existing component.

The merged state uses purple-500 here, but the similar PRStatusBadge component (referenced in relevant snippets) uses violet-500 for the merged state. For visual consistency across the UI, consider aligning with the existing component.

🔎 Proposed fix for color consistency
 	merged: {
-		icon: <LuGitMerge className={cn(iconClass, "text-purple-500")} />,
-		bgColor: "bg-purple-500/10",
+		icon: <LuGitMerge className={cn(iconClass, "text-violet-500")} />,
+		bgColor: "bg-violet-500/15",
 	},

This matches the color and opacity used in PRStatusBadge (apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx).

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts (1)

148-170: Consider consistent error handling for custom link click handler.

The fallback path (lines 155-168) includes comprehensive error logging with path details. However, when onFileLinkClick is provided (lines 151-152), any errors will bubble up without logging. Consider whether callers consistently handle errors, or if defensive logging here would be beneficial.

🔎 Optional: Add error boundary
 if (onFileLinkClick) {
-	onFileLinkClick(path, line, column);
+	try {
+		onFileLinkClick(path, line, column);
+	} catch (error) {
+		console.error(
+			"[Terminal] Failed to handle file link click:",
+			path,
+			error,
+		);
+	}
 } else {
packages/local-db/src/schema/schema.ts (1)

115-118: Consider relocating NavigationStyle to zod.ts for consistency.

While the current inline definition works, TerminalLinkBehavior is defined in zod.ts following the same const-array-to-type pattern. Moving NavigationStyle there would maintain consistency and make both enums discoverable in one place.

Suggested refactor

In packages/local-db/src/schema/zod.ts:

+/**
+ * Navigation style options
+ */
+export const NAVIGATION_STYLES = ["top-bar", "sidebar"] as const;
+
+export type NavigationStyle = (typeof NAVIGATION_STYLES)[number];

Then in this file:

 import type {
   ExternalApp,
   GitHubStatus,
   GitStatus,
+  NavigationStyle,
   TerminalLinkBehavior,
   TerminalPreset,
   WorkspaceType,
 } from "./zod";

-/**
- * Navigation style for workspace display
- */
-export type NavigationStyle = "top-bar" | "sidebar";
apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx (1)

22-33: Consider useMemo for handleFileOpen callback.

While the current conditional definition works, wrapping it in useMemo would make the dependency chain explicit and prevent unnecessary rerenders of ChangesView when unrelated state changes.

Suggested refactor
-	const handleFileOpen =
-		viewMode === "workbench" && workspaceId
-			? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => {
-					addFileViewerPane(workspaceId, {
-						filePath: file.path,
-						diffCategory: category,
-						commitHash,
-						oldPath: file.oldPath,
-					});
-				}
-			: undefined;
+	const handleFileOpen = useMemo(
+		() =>
+			viewMode === "workbench" && workspaceId
+				? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => {
+						addFileViewerPane(workspaceId, {
+							filePath: file.path,
+							diffCategory: category,
+							commitHash,
+							oldPath: file.oldPath,
+						});
+					}
+				: undefined,
+		[viewMode, workspaceId, addFileViewerPane],
+	);
apps/desktop/src/lib/trpc/routers/changes/status.ts (1)

147-176: Secure file reading with appropriate safeguards.

The implementation correctly:

  • Uses secureFs for path validation and symlink safety
  • Caps file size at 1 MiB to prevent OOM on large files
  • Silently skips files that fail validation or reading

Consider adding debug-level logging in the catch block to aid troubleshooting without affecting production behavior.

🔎 Optional: Add debug logging for skipped files
 		} catch {
-			// Skip files that fail validation or reading
+			// Skip files that fail validation or reading (debug: consider logging for troubleshooting)
 		}

If you have a logger available, you could optionally log at debug level:

} catch (err) {
    // Log at debug level for troubleshooting, silently skip in production
    logger?.debug?.(`Skipped untracked file ${file.path}:`, err);
}
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx (1)

6-15: Consider using useMemo for shortcut index calculation.

The projectShortcutIndices computation runs on every render. While the current implementation is correct, memoizing the calculation based on groups would be more efficient if the component re-renders frequently.

🔎 Optional: Memoize shortcut indices
+import { useMemo } from "react";
 import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts";
 // ...

 export function WorkspaceSidebar() {
 	const { groups, activeWorkspaceId } = useWorkspaceShortcuts();

-	// Calculate shortcut base indices for each project group
-	let shortcutIndex = 0;
-	const projectShortcutIndices = groups.map((group) => {
-		const baseIndex = shortcutIndex;
-		shortcutIndex += group.workspaces.length;
-		return baseIndex;
-	});
+	// Calculate shortcut base indices for each project group
+	const projectShortcutIndices = useMemo(() => {
+		let index = 0;
+		return groups.map((group) => {
+			const baseIndex = index;
+			index += group.workspaces.length;
+			return baseIndex;
+		});
+	}, [groups]);
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx (1)

77-91: Consider adding keyboard support for the resize handle.

The resize handle has appropriate ARIA attributes (role="separator", aria-orientation, aria-valuenow, etc.) and tabIndex={0}, but lacks onKeyDown handler for keyboard-based resizing. Users who focus the handle via keyboard cannot adjust the width.

🔎 Suggested keyboard handler
+	const handleKeyDown = useCallback(
+		(e: React.KeyboardEvent) => {
+			const step = e.shiftKey ? 50 : 10;
+			if (e.key === "ArrowLeft") {
+				e.preventDefault();
+				setWidth(Math.max(MIN_WORKSPACE_SIDEBAR_WIDTH, width - step));
+			} else if (e.key === "ArrowRight") {
+				e.preventDefault();
+				setWidth(Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, width + step));
+			}
+		},
+		[width, setWidth],
+	);

 			<div
 				role="separator"
 				aria-orientation="vertical"
 				aria-valuenow={width}
 				aria-valuemin={MIN_WORKSPACE_SIDEBAR_WIDTH}
 				aria-valuemax={MAX_WORKSPACE_SIDEBAR_WIDTH}
 				tabIndex={0}
 				onMouseDown={handleMouseDown}
+				onKeyDown={handleKeyDown}
 				className={cn(
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx (1)

30-39: Consider awaiting toast.promise for consistent error handling.

The toast.promise call is not awaited, so handleOpenNewProject returns immediately after starting workspace creation. While toast.promise handles displaying errors, this means:

  1. The button re-enables before workspace creation completes (only openNew.isPending is checked on line 55)
  2. If createBranchWorkspace fails, the outer catch block won't handle it
🔎 Proposed fix
 			// Create a main workspace on the current branch for the new project
-			toast.promise(
+			await toast.promise(
 				createBranchWorkspace.mutateAsync({ projectId: result.project.id }),
 				{
 					loading: "Opening project...",
 					success: "Project opened",
 					error: (err) =>
 						err instanceof Error ? err.message : "Failed to open project",
 				},
 			);
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx (1)

6-14: Consider importing the Workspace type from a shared location.

The local Workspace interface duplicates fields that likely exist in a shared schema type. This could lead to drift if the workspace schema evolves.

If a shared workspace type exists (e.g., from the database schema or TRPC router), consider importing it instead:

-interface Workspace {
-	id: string;
-	projectId: string;
-	worktreePath: string;
-	type: "worktree" | "branch";
-	branch: string;
-	name: string;
-	tabOrder: number;
-}
+import type { Workspace } from "renderer/types/workspaces"; // or appropriate path
apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts (1)

32-63: Potential stale reference in effect dependency.

The createBranchWorkspace mutation object is included in the dependency array but will be a new reference on each render (from the hook return). This causes the effect to re-run on every render, though the isCreating guard prevents actual re-execution.

Consider using a ref for the mutation or extracting the stable mutate function:

🔎 Suggested improvement
+	const createBranchWorkspaceMutate = createBranchWorkspace.mutate;
+
 	// Auto-create main workspace for new projects (one-time per project)
 	useEffect(() => {
 		if (isCreating) return;

 		for (const group of groups) {
 			// ...
-			createBranchWorkspace.mutate(
+			createBranchWorkspaceMutate(
 				{ projectId },
 				{
 					onSettled: () => {
 						setIsCreating(false);
 					},
 				},
 			);
 			break;
 		}
-	}, [groups, isCreating, createBranchWorkspace]);
+	}, [groups, isCreating, createBranchWorkspaceMutate]);
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx (1)

34-37: Consider using cn utility for className consistency.

The codebase appears to use the cn utility (imported in other files like WorkspaceListItem.tsx) for conditional class merging. Using template literals with ternary operators here works but is inconsistent with the rest of the codebase.

🔎 Suggested change
+import { cn } from "@superset/ui/utils";
+
 					<button
 						type="button"
 						onClick={onSelect}
-						className={`
-							px-3 py-1 text-xs rounded-md transition-colors flex items-center gap-1.5 max-w-[120px]
-							${isActive ? "bg-accent text-accent-foreground" : "hover:bg-muted text-muted-foreground hover:text-foreground"}
-						`}
+						className={cn(
+							"px-3 py-1 text-xs rounded-md transition-colors flex items-center gap-1.5 max-w-[120px]",
+							isActive
+								? "bg-accent text-accent-foreground"
+								: "hover:bg-muted text-muted-foreground hover:text-foreground",
+						)}
 					>
apps/desktop/src/renderer/stores/tabs/utils.ts (1)

117-123: Case-sensitive file extension check may miss uppercase variants.

The .endsWith() checks for markdown extensions are case-sensitive. Files like README.MD or notes.Markdown won't be detected as renderable markdown.

🔎 Proposed fix
+	const lowerPath = options.filePath.toLowerCase();
 	if (options.diffCategory) {
 		defaultViewMode = "diff";
 	} else if (
-		options.filePath.endsWith(".md") ||
-		options.filePath.endsWith(".markdown") ||
-		options.filePath.endsWith(".mdx")
+		lowerPath.endsWith(".md") ||
+		lowerPath.endsWith(".markdown") ||
+		lowerPath.endsWith(".mdx")
 	) {
 		defaultViewMode = "rendered";
 	}
apps/desktop/src/renderer/stores/tabs/store.ts (2)

359-374: Edge case: Creating a file-viewer when no active tab creates a terminal pane first, then overwrites it.

When !activeTab, the code calls addTab() which creates a terminal pane, then immediately replaces it with a file-viewer pane. This works but leaves orphaned terminal state (the terminal pane's isNew: true flag, name: "Terminal") partially in the overwritten object. Consider creating the tab with a file-viewer pane directly.

🔎 Proposed fix - create file-viewer tab directly
 				// If no active tab, create a new one (this shouldn't normally happen)
 				if (!activeTab) {
-					const { tabId, paneId } = get().addTab(workspaceId);
-					// Update the pane to be a file-viewer (must use set() to get fresh state after addTab)
-					const fileViewerPane = createFileViewerPane(tabId, options);
-					set((s) => ({
-						panes: {
-							...s.panes,
-							[paneId]: {
-								...fileViewerPane,
-								id: paneId, // Keep the original ID
-							},
-						},
-					}));
-					return paneId;
+					const state = get();
+					const tabId = generateId("tab");
+					const fileViewerPane = createFileViewerPane(tabId, options);
+					const workspaceTabs = state.tabs.filter(
+						(t) => t.workspaceId === workspaceId,
+					);
+					const tab: Tab = {
+						id: tabId,
+						name: fileViewerPane.name,
+						workspaceId,
+						layout: fileViewerPane.id,
+						createdAt: Date.now(),
+					};
+					set({
+						tabs: [...state.tabs, tab],
+						panes: { ...state.panes, [fileViewerPane.id]: fileViewerPane },
+						activeTabIds: { ...state.activeTabIds, [workspaceId]: tabId },
+						focusedPaneIds: { ...state.focusedPaneIds, [tabId]: fileViewerPane.id },
+					});
+					return fileViewerPane.id;
 				}

Note: You'd need to import generateId and the Tab type if not already available.


393-403: Duplicated view mode determination logic.

The view mode logic here duplicates createFileViewerPane in utils.ts (lines 114-123). Consider extracting a shared helper to avoid drift.

🔎 Proposed fix - extract helper in utils.ts

In utils.ts, add:

export const getDefaultFileViewerMode = (
	filePath: string,
	diffCategory?: ChangeCategory,
): FileViewerMode => {
	if (diffCategory) return "diff";
	const lowerPath = filePath.toLowerCase();
	if (
		lowerPath.endsWith(".md") ||
		lowerPath.endsWith(".markdown") ||
		lowerPath.endsWith(".mdx")
	) {
		return "rendered";
	}
	return "raw";
};

Then in store.ts:

-						// Determine default view mode
-						let viewMode: "raw" | "rendered" | "diff" = "raw";
-						if (options.diffCategory) {
-							viewMode = "diff";
-						} else if (
-							options.filePath.endsWith(".md") ||
-							options.filePath.endsWith(".markdown") ||
-							options.filePath.endsWith(".mdx")
-						) {
-							viewMode = "rendered";
-						}
+						const viewMode = getDefaultFileViewerMode(
+							options.filePath,
+							options.diffCategory,
+						);
apps/desktop/src/lib/trpc/routers/settings/index.ts (1)

217-218: Consider importing navigation style enum values from a shared source.

The navigation style values are hardcoded as z.enum(["top-bar", "sidebar"]) while TERMINAL_LINK_BEHAVIORS is imported. For consistency and to avoid drift, consider importing from @superset/local-db or a shared constants file.

Based on the relevant snippet from packages/local-db/src/schema/schema.ts, NavigationStyle is defined as a type but there's no exported array constant for Zod validation.

🔎 Proposed fix - add and use a shared constant

In packages/local-db/src/schema/zod.ts (alongside TERMINAL_LINK_BEHAVIORS):

export const NAVIGATION_STYLES = ["top-bar", "sidebar"] as const;

Then in the router:

-			.input(z.object({ style: z.enum(["top-bar", "sidebar"]) }))
+			.input(z.object({ style: z.enum(NAVIGATION_STYLES) }))
apps/desktop/src/renderer/stores/workspace-sidebar-state.ts (2)

54-72: Potential UX issue: setWidth with small positive values snaps to minimum width.

If a user drags the sidebar to a very small width (e.g., 50px) intending to close it, the width > 0 check will clamp it to MIN_WORKSPACE_SIDEBAR_WIDTH (220px) and keep it open. Consider adding a threshold below which the sidebar closes.

🔎 Proposed fix - add close threshold
+const CLOSE_THRESHOLD = 100; // Close if dragged below this width

 setWidth: (width) => {
+	// Close the sidebar if dragged below threshold
+	if (width > 0 && width < CLOSE_THRESHOLD) {
+		set({
+			width: 0,
+			isOpen: false,
+		});
+		return;
+	}
+
 	const clampedWidth = Math.max(
 		MIN_WORKSPACE_SIDEBAR_WIDTH,
 		Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, width),
 	);

 	if (width > 0) {
 		set({
 			width: clampedWidth,
 			lastOpenWidth: clampedWidth,
 			isOpen: true,
 		});
 	} else {
 		set({
 			width: 0,
 			isOpen: false,
 		});
 	}
 },

86-88: isProjectCollapsed as a store method is unconventional for Zustand.

Zustand stores typically expose selectors externally rather than as store methods. While this works, it's a bit unusual. The method itself is correct.

This is fine as-is, but an alternative pattern would be to use a selector:

// External usage
const isCollapsed = useWorkspaceSidebarStore(
  (state) => state.collapsedProjectIds.includes(projectId)
);
apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx (1)

14-17: activeWorkspace query runs even in top-bar mode where it's not displayed.

The trpc.workspaces.getActive.useQuery() runs regardless of navigation style, but the result is only rendered in sidebar mode. This is minor since the query is likely cached and used elsewhere, but could be skipped for top-bar mode.

🔎 Proposed fix - conditionally enable query
-	const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
+	const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(
+		undefined,
+		{ enabled: navigationStyle === "sidebar" },
+	);
apps/desktop/src/renderer/stores/workspace-view-mode.ts (1)

50-52: Consider adding a version to the persist config for future migrations.

The workspace-sidebar-state.ts store uses version: 1, but this store omits it. Adding a version now makes future schema migrations easier.

🔎 Proposed fix
 		{
 			name: "workspace-view-mode-store",
+			version: 1,
 		},
apps/desktop/src/lib/trpc/routers/changes/file-contents.ts (1)

177-200: safeGitShow silently returns empty string on errors, which may mask legitimate issues.

While this is intentional for "file doesn't exist" cases, it also swallows unexpected errors (permission issues, git corruption, etc.). Consider logging unexpected errors for debugging.

🔎 Proposed fix - log unexpected errors
 async function safeGitShow(
 	git: ReturnType<typeof simpleGit>,
 	spec: string,
 ): Promise<string> {
 	try {
 		// Preflight: check blob size before loading into memory
 		try {
 			const sizeOutput = await git.raw(["cat-file", "-s", spec]);
 			const blobSize = Number.parseInt(sizeOutput.trim(), 10);
 			if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) {
 				return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`;
 			}
 		} catch {
 			// cat-file failed (blob doesn't exist) - let git.show handle the error
 		}

 		const content = await git.show([spec]);
 		return content;
-	} catch {
+	} catch (error) {
+		// Expected: file doesn't exist in the specified ref
+		// Log unexpected errors for debugging
+		const message = error instanceof Error ? error.message : String(error);
+		if (!message.includes("does not exist") && !message.includes("bad revision")) {
+			console.warn(`[safeGitShow] Unexpected error for spec "${spec}":`, message);
+		}
 		return "";
 	}
 }
apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts (1)

100-109: Consider distinguishing validation errors from non-existence.

Returning false for both validation failures and non-existent files could mask malicious path attempts. Consider returning a discriminated result or logging validation failures for security monitoring, while keeping the current behavior for callers that only need a boolean.

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx (1)

169-187: Consider extracting state update to a store action.

The direct state mutation via useTabsStore.setState() inside the mutation callback works but bypasses the store's action pattern used elsewhere. Consider adding an updatePaneFileViewer action to the store for consistency.

apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx (2)

13-14: Import NavigationStyle from @superset/local-db instead of redefining.

This type is already exported from packages/local-db/src/schema/schema.ts. Import it alongside TerminalLinkBehavior to maintain a single source of truth.

🔎 Proposed fix
-import type { TerminalLinkBehavior } from "@superset/local-db";
+import type { NavigationStyle, TerminalLinkBehavior } from "@superset/local-db";
 import { Label } from "@superset/ui/label";
 ...
-type NavigationStyle = "top-bar" | "sidebar";
-
 export function BehaviorSettings() {

117-119: Use shared constants for default values.

Import and use DEFAULT_NAVIGATION_STYLE, DEFAULT_CONFIRM_ON_QUIT, and DEFAULT_TERMINAL_LINK_BEHAVIOR from apps/desktop/src/shared/constants.ts instead of hardcoding the fallback values at lines 118, 144, and 163 for consistency with server-side defaults.

apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx (1)

79-103: Consider user-facing error feedback for file open failures.

The current implementation logs errors to console, but users won't see why a file failed to open. Consider showing a toast notification on failure for better UX.

Comment on lines +73 to +80
## Progress

- [ ] Initial plan created and awaiting approval
- [ ] (Pending) Milestone 1: Add navigation style setting
- [ ] (Pending) Milestone 2: Create WorkspaceSidebar component
- [ ] (Pending) Milestone 3: Create sidebar-mode TopBar variant
- [ ] (Pending) Milestone 4: Wire up setting to conditionally render layouts
- [ ] (Pending) Milestone 5: Polish and validation
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update progress checkboxes to reflect implementation status.

The document states it's a "living document" where Progress should be kept up to date, but all milestones are still marked as pending despite implementation code being in this PR.

🤖 Prompt for AI Agents
In apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md
around lines 73-80, the Progress checkboxes are stale; update them to reflect
the actual implementation in this PR by marking the appropriate items as
completed (e.g., check "Initial plan created", and whichever Milestone steps are
implemented — change “[ ]” to “[x]”), add a one-line status note with the PR
number or date and who completed the work, and keep any remaining pending items
unchanged; ensure the file still reads as a living document by adding a short
instruction to update Progress on future changes.

Comment on lines +1 to +10
import { LuLayers } from "react-icons/lu";

export function WorkspaceSidebarHeader() {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<LuLayers className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Workspaces</span>
</div>
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Restructure to follow component folder convention.

Based on coding guidelines, components should follow the pattern ComponentName/ComponentName.tsx with an index.ts barrel export. This file should be restructured as:

  • WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx
  • WorkspaceSidebar/WorkspaceSidebarHeader/index.ts (re-exporting the component)

Based on learnings, this organizational pattern improves consistency and maintainability across the codebase.

🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx
lines 1-10: the component file must be moved to the folder-per-component
convention. Create directory
apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/,
move the current WorkspaceSidebarHeader.tsx into that directory (keeping the
same component name and contents), and add an index.ts that re-exports the
component (export { WorkspaceSidebarHeader } from "./WorkspaceSidebarHeader";).
Update any imports in the codebase that referenced the old file path to import
from the new barrel export path.

Comment on lines +218 to +241
// Editor mount handler - set up Cmd+S keybinding
const handleEditorMount: OnMount = useCallback(
(editor) => {
editorRef.current = editor;
// Store original content for dirty tracking (only if not restoring draft)
// If we have draft content, originalContentRef is already set to the file content
if (!draftContentRef.current) {
originalContentRef.current = editor.getValue();
}
// P1: Update dirty state based on restored draft content
setIsDirty(editor.getValue() !== originalContentRef.current);

// Register save action with Cmd+S / Ctrl+S
editor.addAction({
id: "save-file",
label: "Save File",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {
handleSaveRaw();
},
});
},
[handleSaveRaw],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stale closure in Monaco save action registration.

The handleSaveRaw callback is captured at mount time in the Monaco action. If handleSaveRaw changes (e.g., when filePath or worktreePath changes), the registered action will still call the old closure. Use a ref pattern to ensure the action always calls the current handler.

🔎 Proposed fix using ref pattern
+	// Ref to ensure Monaco action always calls current save handler
+	const handleSaveRawRef = useRef(handleSaveRaw);
+	handleSaveRawRef.current = handleSaveRaw;
+
 	// Editor mount handler - set up Cmd+S keybinding
-	const handleEditorMount: OnMount = useCallback(
-		(editor) => {
+	const handleEditorMount: OnMount = useCallback((editor) => {
 			editorRef.current = editor;
 			// Store original content for dirty tracking (only if not restoring draft)
 			// If we have draft content, originalContentRef is already set to the file content
 			if (!draftContentRef.current) {
 				originalContentRef.current = editor.getValue();
 			}
 			// P1: Update dirty state based on restored draft content
 			setIsDirty(editor.getValue() !== originalContentRef.current);
 
 			// Register save action with Cmd+S / Ctrl+S
 			editor.addAction({
 				id: "save-file",
 				label: "Save File",
 				keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
 				run: () => {
-					handleSaveRaw();
+					handleSaveRawRef.current();
 				},
 			});
-		},
-		[handleSaveRaw],
-	);
+	}, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Editor mount handler - set up Cmd+S keybinding
const handleEditorMount: OnMount = useCallback(
(editor) => {
editorRef.current = editor;
// Store original content for dirty tracking (only if not restoring draft)
// If we have draft content, originalContentRef is already set to the file content
if (!draftContentRef.current) {
originalContentRef.current = editor.getValue();
}
// P1: Update dirty state based on restored draft content
setIsDirty(editor.getValue() !== originalContentRef.current);
// Register save action with Cmd+S / Ctrl+S
editor.addAction({
id: "save-file",
label: "Save File",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {
handleSaveRaw();
},
});
},
[handleSaveRaw],
);
// Ref to ensure Monaco action always calls current save handler
const handleSaveRawRef = useRef(handleSaveRaw);
handleSaveRawRef.current = handleSaveRaw;
// Editor mount handler - set up Cmd+S keybinding
const handleEditorMount: OnMount = useCallback((editor) => {
editorRef.current = editor;
// Store original content for dirty tracking (only if not restoring draft)
// If we have draft content, originalContentRef is already set to the file content
if (!draftContentRef.current) {
originalContentRef.current = editor.getValue();
}
// P1: Update dirty state based on restored draft content
setIsDirty(editor.getValue() !== originalContentRef.current);
// Register save action with Cmd+S / Ctrl+S
editor.addAction({
id: "save-file",
label: "Save File",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {
handleSaveRawRef.current();
},
});
}, []);

Comment on lines +291 to +297
// P1-1: Update originalContentRef when raw content loads (dirty tracking fix)
// biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when content loads
useEffect(() => {
if (rawFileData?.ok === true && !isDirty) {
originalContentRef.current = rawFileData.content;
}
}, [rawFileData]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential stale closure issue with isDirty reference.

The effect depends on rawFileData but also checks isDirty which isn't in the dependency array. If isDirty changes between renders, this check may use a stale value. Consider including isDirty in dependencies or using a ref.

🔎 Suggested fix
-	// biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when content loads
 	useEffect(() => {
 		if (rawFileData?.ok === true && !isDirty) {
 			originalContentRef.current = rawFileData.content;
 		}
-	}, [rawFileData]);
+	}, [rawFileData, isDirty]);
🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx
around lines 291-297, the useEffect reads isDirty but doesn’t include it in the
dependency array which can cause a stale-closure bug; update the effect so it
either includes isDirty in the dependency array (e.g., [rawFileData, isDirty])
or read a stable ref (e.g., isDirtyRef.current) inside the effect, and remove or
adjust the biome-ignore comment accordingly so linting accurately reflects the
intended dependencies.

Comment on lines +40 to +43
// Get worktree path for file viewer panes
const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery();
const worktreePath = activeWorkspace?.worktreePath ?? "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find the FileViewerPane component
fd -t f "FileViewerPane" apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/

Repository: superset-sh/superset

Length of output: 193


🏁 Script executed:

# Read the FileViewerPane component to understand worktreePath handling
wc -l apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx

Repository: superset-sh/superset

Length of output: 197


🏁 Script executed:

# Search for worktreePath usage in FileViewerPane
rg -n "worktreePath" apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx

Repository: superset-sh/superset

Length of output: 622


🏁 Script executed:

# Read the FileViewerPane component to understand worktreePath handling and error states
sed -n '70,300p' apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx

Repository: superset-sh/superset

Length of output: 6943


🏁 Script executed:

# Continue reading FileViewerPane to see what gets rendered when worktreePath is empty
sed -n '300,400p' apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx

Repository: superset-sh/superset

Length of output: 2720


🏁 Script executed:

# Continue reading to see the complete render logic and error handling
sed -n '400,500p' apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx

Repository: superset-sh/superset

Length of output: 2724


🏁 Script executed:

# Read the final render/return section to see the complete UI structure
sed -n '500,624p' apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx

Repository: superset-sh/superset

Length of output: 3678


🏁 Script executed:

# Check the TabView component where worktreePath is derived and passed to FileViewerPane
sed -n '1,150p' apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx

Repository: superset-sh/superset

Length of output: 4373


Add a visual indicator when workspace is not available to file-viewer panes.

When activeWorkspace is undefined, worktreePath defaults to an empty string. FileViewerPane correctly disables queries and handlers when this occurs (using !!worktreePath guards), but the UI shows generic messages like "No diff available" or "File not found" instead of clearly indicating the workspace is unavailable. Users cannot distinguish between missing workspace vs. actual file issues.

Consider rendering an explicit message when worktreePath is empty: "No workspace loaded. Switch to a workspace to enable file editing."

🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx
around lines 40-43, the code defaults worktreePath to an empty string which
causes file-viewer panes to show generic messages; update the TabView to detect
when worktreePath is falsy and render an explicit, visible message like "No
workspace loaded. Switch to a workspace to enable file editing." (e.g., a small
banner or centered notice above/inside the pane) instead of rendering the
FileViewerPane or relying on its generic messages; ensure this check happens
before passing worktreePath to child panes so they remain disabled and the
message is keyboard-accessible and styled consistently with app UI.

@andreasasprou
Copy link
Copy Markdown
Contributor Author

PR Review Fixes Applied

Addressed all review feedback in commit a149549:

P0 ✅ Branch workspace security validation

Extended assertRegisteredWorktree to check both worktrees.path AND projects.mainRepoPath, fixing Changes functionality for branch workspaces.

P1 ✅ Terminal file-viewer absolute paths

Terminal now normalizes absolute paths to worktree-relative before opening file viewer pane.

P2 ✅ Misleading security comments

Fixed comments that incorrectly claimed symlink checks exist.


Follow-up: Migrate to workspaceId-based APIs

Should we stop accepting worktreePath from the renderer and migrate changes/file APIs to workspaceId inputs?

Yes, but as a separate PR. Here's why:

Current (after this fix) Proposed
Renderer resolves path, passes worktreePath Renderer passes workspaceId
Server validates path against worktrees + projects Server resolves path from workspace record
Two validation paths Single source of truth

Benefits of migration:

  1. Eliminates path spoofing/casing edge cases
  2. Naturally supports branch workspaces without special-casing
  3. Cleaner API semantics

Migration approach:

  1. Add workspaceId as optional input to changes routers
  2. Create resolveWorkspacePathById() helper using existing getWorkspacePath()
  3. When workspaceId provided, resolve server-side; fallback to legacy worktreePath
  4. Update renderer to pass workspaceId
  5. Deprecate worktreePath input

Effort: ~2-3 hours. Should be a clean follow-up PR after this ships.

andreasasprou and others added 26 commits January 2, 2026 08:07
Introduces a workspace-level view mode toggle allowing users to switch between:
- **Workbench mode**: Mosaic panes layout with terminals + file viewers for in-flow work
- **Review mode**: Dedicated Changes page for focused code review

Key changes:
- Add ViewModeToggle component in workspace header (prominent segmented control)
- Add FileViewerPane with Raw/Rendered/Diff modes, lock/unlock, and split support
- Add GroupStrip for group switching above Mosaic content
- Unify sidebar to use full ChangesView in both modes (with onFileOpen callback)
- Add workspace-view-mode store with per-workspace persistence
- Add readWorkingFile tRPC procedure for safe file reads (size/binary checks)
- Wire file clicks to open/reuse FileViewer panes (MRU unlocked policy)
- Cmd+T in Review mode switches to Workbench first, then creates terminal
- Add !!worktreePath checks to FileViewerPane query enabled conditions
- Add !!worktreePath checks to save handler guards (handleSaveRaw, handleSaveDiff)
- Fix stale state reference after addTab() in addFileViewerPane action

Addresses CodeRabbit review feedback.
… and UX improvements

- Add validatePathForWrite() to prevent path traversal attacks in saveFile
- Add aria-pressed attribute to ViewModeToggle buttons for accessibility
- Increase close button touch target in GroupStrip for better UX
- Add .mdx file support for rendered view mode
P0 Security:
- Add path validation to getUnstagedVersions before readFile (prevents traversal)
- Key Editor/DiffViewer by filePath to force remount (fixes stale Cmd+S closure)

P1 Fixes:
- Update originalContentRef when raw content loads (dirty tracking fix)
- Add .mdx to isMarkdown check for toolbar consistency
- Gate diff editable to staged/unstaged only (against-main/committed now read-only)

P2 Performance:
- Add safeGitShow helper with 2MB size limit on all git show calls
- Add size check before reading working tree files in getUnstagedVersions
- P0-1: Add file-viewer type and fileViewer object to paneSchema for tab persistence
- P0-2: Fix symlink escape vulnerability in validatePathForWrite by checking
  if target is symlink and resolving parent directory paths
- P1-1: Pass defaultBranch to FileViewerPane for against-main diffs
- P1-2: Switch staged diff to unstaged after save (matches Review mode behavior)
- P2: Use Buffer.byteLength instead of string.length in safeGitShow for
  accurate UTF-8 byte counting
P0 (critical):
- Remove duplicate saveFile from git-operations.ts that was overwriting
  the hardened version in file-contents.ts (security vulnerability)

P1 (must fix):
- Use basename()/dirname() from node:path instead of split('/') for
  cross-platform Windows compatibility in validatePathForWrite
- Add preflight size check with git cat-file -s in safeGitShow to
  prevent memory spikes from large blobs before materializing content

P2 (UX):
- Fix split pane to clone file-viewer state instead of creating terminal
  when splitting a file-viewer pane (locked by default)

Question fix:
- Show GroupStrip with add button even when tabs.length === 0 so users
  have visible UI to create new terminal (not just hotkey)
…ocalDb

P0 (CRITICAL SECURITY):
- Add validateWorktreePathInDb() that verifies worktreePath exists in
  localDb.worktrees before any filesystem operations
- Without this, a compromised renderer could read/write arbitrary files
  by passing worktreePath='/' and filePath='.ssh/id_rsa'
- Applied to getFileContents, saveFile, and readWorkingFile procedures

P1 (correctness):
- Replace startsWith('..') checks with segment-aware containsPathTraversal()
  and isPathOutsideBase() helpers that use path.sep
- Fixes false positives on valid paths like '..foo/bar' (directories
  starting with '..')
- Cross-platform compatible (handles both / and \ separators)

P2 (performance):
- Guard killTerminalForPane() calls on pane.type === 'terminal'
- Prevents unnecessary IPC and warning logs when closing file-viewer panes
…ve editor drafts

P0 (security):
- Extract assertWorktreePathInDb to shared security.ts
- Returns worktree record to avoid duplicate queries
- Apply validation to ALL routes: git-operations, status, staging, branches

P1 (data loss):
- Preserve unsaved editor content across view mode switches
- Store draft in ref before switching away from raw mode
- Restore draft when returning to raw mode
- Clear draft on save and file change
- Update dirty state on editor mount with draft content
…otection

P0 Security (BLOCK):
- Add validatePathInWorktree() check before rm() in deleteUntracked
- Prevents path traversal (../) and symlink escape attacks
- Consolidated path validation utilities in security.ts

P1 Data Loss:
- Track save source (Raw vs Diff) with savingFromRawRef
- Only clear draft when saving from Raw mode
- Disable Diff editing when Raw draft exists (forces user to save/discard)

P2:
- Use ['--', filePath] in git.add() to handle paths starting with -
…th validation

- Reduce security.ts from 213 to 65 lines
- Remove async symlink/realpath checking (users own their repos)
- Remove segment-aware path traversal (..foo edge case not worth complexity)
- Keep worktreePath DB validation (the real security boundary)
- Keep simple .. and absolute path checks (sufficient for 99.99% of cases)
P0 (blocking):
- Add path validation (rejects .. and absolute) to applyUntrackedLineCount
- Add 1MB size cap to prevent OOM on large untracked files during polling

P2:
- Add type guard in updateTabLayout - only call killTerminalForPane for terminal panes
- Use ['--', branch] in switchBranch to ensure branch treated as refname
…lidation

- Add path-validation.ts with industry-standard containment check (path.relative)
- Add secure-fs.ts with self-validating FS wrappers (symlink escape protection)
- Add git-commands.ts with semantic helpers (gitSwitchBranch vs gitCheckoutFile)
- Fix switchBranch: use 'git switch' instead of 'git checkout --' (was file checkout)
- Fix path traversal check: segment-aware (allows ..foo, rejects ..)
- Fix symlink bypass: check parent dirs for new files, use stat not lstat
- Fix worktree root deletion: explicit allowRoot check
- All FS operations now go through secureFs with validation built-in
Add a setting to control whether Cmd+clicking file paths in the terminal
opens them in an external editor (default) or in the in-app FileViewerPane.

- Add terminalLinkBehavior setting to local-db schema with migration
- Add getTerminalLinkBehavior/setTerminalLinkBehavior tRPC procedures
- Refactor createTerminalInstance to accept onFileLinkClick callback
- Wire up setting in Terminal.tsx with ref pattern (avoids terminal recreation)
- Add Select UI in BehaviorSettings for choosing link behavior
Remove async symlink escape detection from path validation since the
threat model doesn't justify it: a compromised renderer already has
terminal access for arbitrary command execution.

Changes:
- Replace resolveSecurePath (async) with resolvePathInWorktree (sync)
- Remove assertNoSymlinkEscape and checkSymlinks option
- Add validateRelativePath for simple path safety checks
- Update secure-fs.ts to use new sync functions
- Update threat model documentation in path-validation.ts
…s sidebar)

Add a setting to let users choose between displaying workspaces as
horizontal tabs in the TopBar (current behavior) or in a dedicated left
sidebar (new feature, similar to Linear/GitHub Desktop).

Key changes:
- Add navigationStyle column to settings table (migration 0005)
- Add navigation style dropdown in Behavior Settings
- Create WorkspaceSidebar component with collapsible project sections
- Create shared useWorkspaceShortcuts hook (⌘1-9 shortcuts, auto-create)
- Update TopBar to conditionally render based on navigation style
- Add ⌘⇧B hotkey to toggle workspace sidebar

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ition

P0: Add CreateWorkspaceButton to TopBar when in sidebar mode
- Button was previously only rendered via WorkspaceTabs
- Now renders in top-right section when navigationStyle is sidebar

P1: Fix race condition in createBranchWorkspace
- Add unique partial index on (projectId) WHERE type='branch'
- Use INSERT ON CONFLICT DO NOTHING to handle concurrent calls
- If conflict, fetch the existing workspace instead of failing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
WorkspaceListItem now has full feature parity:
- Close/delete button with confirmation dialog
- Context menu (Rename, Open in Finder)
- Hover card with PR details, checks, reviews
- Needs attention indicator (red pulse)
- Inline rename (double-click)
- Drag & drop reordering
- BranchSwitcher for branch workspaces

Other changes:
- Remove CreateWorkspaceButton from TopBar in sidebar mode
- Add per-project "Add workspace" dropdown (New Workspace, Quick Create)
- Add preSelectedProjectId to modal store for pre-selecting project
- Simplify GroupStrip to use consistent browser-tab style

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extract useWorkspaceDeleteHandler hook for shared delete logic
- Use existing extractPaneIdsFromLayout from tabs/utils instead of inline collectPaneIds
- Add named constants for magic numbers (staleTime, delays, shortcut index)
- Reduces code duplication between WorkspaceItem and WorkspaceListItem

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ar mode

Move SidebarControl to shared location (screens/main/components/) and
conditionally render based on navigation style:
- top-bar mode: toggle in TopBar (unchanged)
- sidebar mode: toggle in WorkspaceActionBar (left side, before branch info)

This improves UX by placing the sidebar toggle closer to the content
it controls when in sidebar navigation mode.
Default to Mac layout (80px padding) while platform query is loading,
since undefined === 'darwin' evaluates to false, causing 16px padding
to be used initially on Mac before the query resolves.
…minal links

P0: Fix branch workspace support in assertRegisteredWorktree
- Extended validation to check both worktrees.path AND projects.mainRepoPath
- Branch workspaces use mainRepoPath which wasn't being validated

P1: Fix terminal file-viewer links for absolute paths
- Normalize absolute paths to worktree-relative before opening file viewer
- File viewer expects relative paths but terminal links can be absolute

P2: Fix misleading security comments
- Removed claims about symlink checks that aren't implemented
- Comments now accurately describe worktree registration + path traversal validation
- Add markWorkspaceAsUnread action to tabs store that sets needsAttention=true
  for all panes in a workspace
- Add context menu item with LuEyeOff icon to:
  - WorkspaceItemContextMenu (top bar tabs)
  - WorkspaceListItem (sidebar) for both branch and worktree workspaces
- Leverages existing needsAttention indicator system (red pulsing dot)
- Logs when no panes exist in workspace (empty workspace edge case)
Previously, clicking a workspace only set it as active but didn't clear
the needsAttention state on panes. This meant 'Mark as Unread' worked
but clicking the workspace didn't mark it as read.

Added clearWorkspaceAttention action that clears needsAttention for all
panes in a workspace, called when clicking workspace in both top bar
and sidebar.
P0: Make unique branch workspace migration resilient to existing duplicates
- Dedupe existing duplicate branch workspaces before creating unique index
- Update settings.last_active_workspace_id if it points to deleted workspace

P1: Fix isResizing persistence and store selector usage
- Exclude ephemeral isResizing from Zustand persistence via partialize
- Use selector pattern in MainScreen to avoid unnecessary re-renders

P1: Fix delete handler to show dialog when canDeleteData is undefined
- Safe default: show confirmation when query fails

P2: Fix path boundary check in terminal file links
- Use path.startsWith(cwd + '/') to avoid false prefix matches

P2: Add missing drizzle migration snapshot
- Created 0006_snapshot.json for consistency
P0: Add symlink escape protection for File Viewer writes
- Block writes if file's realpath escapes worktree boundary
- Add isSymlinkEscaping() method for UI to detect and warn users
- Update threat model docs to reflect new symlink protection

P1: Fix race condition in createBranchWorkspace ordering
- Insert workspace first, then shift others only on success
- Losers of the race no longer corrupt tabOrder
- Exclude newly inserted workspace from shift operation

P2: Fix migration to keep most recently used duplicate
- Select survivor by last_opened_at DESC (not arbitrary MIN(id))
- Use id ASC as tiebreaker when last_opened_at is equal
@andreasasprou andreasasprou changed the title feat(desktop): add configurable workspace navigation style (top-bar vs sidebar) feat(desktop): add Workbench/Review mode and workspace navigation sidebar Jan 2, 2026
Consolidate two navigation bars into a single unified top bar:

- Create WorkspaceControls component group in TopBar/
  - BranchIndicator: shows current branch (keyboard-focusable)
  - OpenInMenuButton: compact Open button with dropdown
  - ViewModeToggleCompact: Workbench/Review toggle (24px height)

- Remove WorkspaceActionBar from WorkspaceView entirely
- Delete unused WorkspaceActionBar component folder

Improvements based on Oracle review:
- Optimize Zustand selector to minimize rerenders
- Add toast error handling for open/copy mutations
- Add path to Open button tooltip for discoverability
- Add focus-visible rings for keyboard accessibility
- Use text-xs for consistent typography

Also includes minor lint auto-fixes (import sorting, template literals)
- Simplify useWorkspaceDeleteHandler to always show dialog instead of
  auto-deleting clean workspaces
- Update tooltip from 'Delete workspace' to 'Close or delete' for clarity
- Add isUnread state for workspace unread tracking
- Add workspace setUnread mutation and schema migration
- Fix hotkey() typo to defineHotkey() in hotkeys.ts
- Add getHotkey() helper that returns string (not null) for useHotkeys compatibility
- Replace all HOTKEYS.*.keys usages with getHotkey() calls
- Fix 'against-main' to 'against-base' in ChangeCategory references
- Add proper type annotation in WorkspaceSidebarControl.tsx
Replace intense bg-tertiary-active background with subtle border-b-2
border-border for active tab state - cleaner visual that's less heavy
Change from bottom border to top/left/right borders for a traditional
tab appearance that connects to content below
…n mode

Show SidebarControl in GroupStrip header and EmptyTabView when using
sidebar navigation style, allowing users to toggle the changes sidebar
…egression

The sidebar toggle was missing when switching to Review mode because it was
rendered inside TabsContent, which doesn't render in review mode.

Changes:
- Create ContentHeader component at ContentView level with leadingAction slot
- ContentView now owns SidebarControl placement for both workbench and review modes
- Remove SidebarControl from TabsContent and EmptyTabView (now handled by parent)
- Remove unused BranchIndicator component and HOTKEYS import (cleanup)
CMD+T creates a new Group (tab container), not a terminal pane.
This aligns the hotkey naming with the existing 'New Group' button tooltip.
andreasasprou added a commit to andreasasprou/superset that referenced this pull request Jan 2, 2026
In sidebar navigation mode, move the 'Open In' button and 'Workbench/Review'
toggle from TopBar down to ContentHeader alongside the group tabs. This keeps
the profile dropdown in TopBar while placing workspace controls closer to
the content they affect.

Changes:
- ContentHeader: Add trailingAction prop for right-side controls
- ContentView: Pass WorkspaceControls as trailingAction when isSidebarMode
- TopBar: Only render WorkspaceControls when not in sidebar mode

The controls are visible in both Workbench (with group tabs) and Review
(without group tabs) modes. Top-bar navigation mode is unchanged.
…andling

Security fixes (code review feedback):
- P0-1: Fix ENOENT handling in secure-fs to detect dangling symlinks and
  validate parent directory chains for write operations
- P0-2: Add centralized symlink-escape enforcement to secureFs.readFile
  and readFileBuffer - reads now blocked if symlink escapes worktree
- Add 'symlink-escape' error reason to distinguish from other validation errors
- Use path.relative() for safer boundary checks instead of string prefix matching
- P1: Handle undefined workspaceCwd gracefully in Terminal file-viewer mode
  by falling back to external editor when workspace is still initializing
- P2: Update FileViewerState schema comment to clarify intentional omission
  of transient fields (initialLine/initialColumn)
- P2: Document unique branch-workspace index limitation in schema.ts

Feature completion:
- Terminal file links now pass line/column to File Viewer for initial scroll
- FileViewerPane applies initial line/column navigation in raw mode
- Add initialLine/initialColumn to FileViewerState and store types
- P0: Add dangling symlink validation to prevent writes outside worktree
- P2: Fix isPathWithinWorktree to not incorrectly reject '..config' dirs
- P1: Add symlink-escape error message in FileViewerPane
- P1: Fix line/column navigation when clicking same file with new coords
P2 from review: The dangling symlink target check in assertParentInWorktree
was still using startsWith('..') which incorrectly rejects '..config' dirs.
Now uses the same pattern as isPathWithinWorktree.
P0: secureFs.delete symlink escape
- Check if target is symlink via lstat before deletion
- Symlinks: delete the link itself (safe - lives in worktree)
- Files/dirs: validate realpath is within worktree before rm
- Prevents attack: docs -> /victim, delete docs/file deletes /victim/file

P1: Block external images in markdown renderer
- Add SafeImage component that blocks http:// and https:// URLs
- Shows 'External image blocked' placeholder for external images
- Allows relative paths and data: URLs (safe for repo content)
- Prevents tracking pixels and privacy leaks from untrusted repos

Design decisions (reviewer questions):
- Markdown remote images: Blocked by default. No opt-in toggle yet.
- Deleting symlinks: Allow deleting the symlink file itself (it lives
  in worktree). Never follow the symlink during deletion.
P0: Previous fix only blocked http/https but missed:
- file:// URLs (arbitrary local file access)
- Absolute paths /... or \... (become file:// in Electron)
- Relative paths with .. (escape repo boundary)
- UNC paths //server/share (Windows NTLM credential leak)

Now uses strict allowlist: ONLY data: URLs are allowed.
All other sources show 'Image blocked' placeholder.

Future: Could add opt-in secure loader for repo-relative images
via secureFs validation + blob: URL serving.
@andreasasprou
Copy link
Copy Markdown
Contributor Author

Recreating PR from main repo now that I have direct access

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant