Skip to content

RFC — Filebrowser Quantum Dual-Pane Layout

Created: 2026-05-15 Status: Draft — soliciting review (Codex / second opinion) Decision: Pending. Related: ADR-021, RFC progress+tasks.

Goal

Add a dual-pane (two file listings side-by-side) layout to Filebrowser Quantum, matching the workflow of Total Commander / Midnight Commander / Cloud Commander. Source pane and destination pane visible simultaneously; copy/move/drag-drop between them without modal pickers; Tab toggles which pane has focus.

Why this is needed

  • Today every cross-folder transfer goes through the "Move to..." dialog, which is a recursive folder picker in a modal. For four-level-deep destinations on /srv/filedump/foo/bar/baz/qux/ you click nine times to confirm one move.
  • Cloud Commander (sidecar at cmd.eva-00.network) solves the dual-pane workflow but is a parallel app with parallel auth, parallel theming, and zero progress-bar integration with the byte-copy work landed in RFC progress+tasks. Two file managers for one homelab is a smell.
  • All progress-bar/tasks/SSE infrastructure already in Filebrowser Quantum would compose naturally with dual-pane: drag from left to right, see the copy in the Tasks page, get the badge update.

Non-goals

  • Bookmarkable dual-pane layouts. Sharing a URL that opens both panes at specific paths is out of scope. URL reflects the active pane only (same as Cloud Commander). Rationale: avoids redesigning the URL-as-state pattern; dual-pane is a workflow tool, not a sharing surface.
  • Per-pane settings (sort order, view mode, dark mode). Settings stay global. Cloud Commander persists view mode per pane; we defer this — single global sort is enough for v1.
  • Mobile dual-pane. No realistic way to fit two listings on a 360 px wide screen. Mobile falls back to single-pane regardless of the user's preference.
  • Three or more panes. Two only. Anything more is feature creep.
  • Mass-action diff/sync between panes. No "synchronize folders" or "show only differences" mode in v1. That's a Total Commander power-user feature and worth a separate RFC.

Design

Architectural pattern: "active pane"

Both panes hold full state (listing, selection, clipboard). Exactly one pane is the active pane at any moment. The active pane is where:

  • The URL points (/files/source/path → active pane's path)
  • Keyboard events route (Delete, F2, Ctrl+C)
  • Toolbar / sidebar operations act
  • Tab key toggles which pane is active

This pattern is universal across desktop dual-pane file managers (Total Commander, Double Commander, Midnight Commander, Far Manager) and the only widely-used web one (Cloud Commander). Reasons it's the right choice — not just the cheap choice — are in the "Alternatives considered" section below.

State shape

Today (singleton):

state.req         // current directory listing { source, path, items[], numDirs, numFiles }
state.selected    // number[] indices into state.req.items
state.clipboard   // { key: 'copy' | 'cut', items: [...] }

Proposed:

state.panes = {
  left:  { req, selected, clipboard, scrollTop },
  right: { req, selected, clipboard, scrollTop },
}
state.activePane = 'left' | 'right'
state.dualPaneEnabled = false   // user toggle; persisted in profile

Backwards-compatible reads via getters:

getters.activePane()        // → state.panes[state.activePane]
getters.req()               // → activePane.req         (existing call sites work)
getters.selected()          // → activePane.selected     (existing call sites work)

Existing single-pane code paths read through getters; new dual-pane code paths read state.panes[paneId] directly. No existing component breaks if dualPaneEnabled === false.

Component changes

Layout.vue
└── router-view
    └── Files.vue                       ← gains paneContainer wrapper
        ├── ListingView.vue (paneId="left")    ← gains paneId prop
        └── ListingView.vue (paneId="right")   ← only rendered when dualPaneEnabled

ListingView.vue accepts an optional paneId prop: - If set, reads/writes state.panes[paneId]. - If unset (single-pane mode), reads/writes the legacy global state.req/state.selected for backwards compatibility.

The same component renders both panes. Vue's reactivity propagates state changes per pane via the prop.

Layout (CSS)

.pane-container { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
.pane-container.single { grid-template-columns: 1fr; }
.pane { border-left: 1px solid var(--border); }
.pane.active { box-shadow: inset 0 0 0 2px var(--accent); }
@media (max-width: 768px) { .pane-container { grid-template-columns: 1fr; } /* force single */ }

Resizable divider deferred to phase 4; v1 ships fixed 50/50 split.

Cross-pane operations

Operation UX
Copy from active to other pane New toolbar button "Copy to other pane →" (or ← if right is active). Wires through the existing PATCH /api/resources copy flow with the inactive pane's path as destination.
Move from active to other pane Same with "Move to other pane".
Drag-drop Drag an item from one pane's listing onto the other pane's listing. Drop-target highlights. Default action = move (Cmd/Ctrl modifier = copy).
Cross-pane clipboard state.panes.left.clipboard is independent from state.panes.right.clipboard. Paste reads from the active pane's clipboard — already works.

Routing

URL stays singular and reflects only activePane.req.path:

/files/unohana/music                ← single-pane (today) OR active pane = left
/files/filedump/dest                ← active pane (left or right) navigated here

When the user switches active pane via Tab, the URL updates to the newly-active pane's path. The inactive pane's state lives in state.panes and localStorage (persisted across reloads) but never in the URL.

Why this works: Cloud Commander does exactly this; nobody complains. The URL answers "where am I focused right now," not "what's my entire workspace layout."

Persistence

state.dualPaneEnabled, state.panes.left.req.source/path, state.panes.right.req.source/path persist to localStorage under one key (filebrowser.panes). On reload:

  1. If dualPaneEnabled === false, ignore persisted pane state; load single-pane from URL.
  2. If dualPaneEnabled === true, restore both panes' paths from localStorage. Active pane = URL.

No backend changes for v1. (Pane state could later move to the user profile via PATCH /api/users/{id} so it follows the user across devices, but not now.)

Keyboard

Key Behavior
Tab Toggle active pane (when dual-pane on; existing focus behavior preserved when off)
F5 Copy active selection to other pane
F6 Move active selection to other pane
Enter / Delete / F2 / Ctrl+C / Ctrl+V Apply to active pane's selection (existing behavior — only the lookup changes from state.selected to getters.selected())

Implementation plan (phased)

Each phase is committable and shippable. The fork can pause at any phase.

Phase 1 — State refactor (invisible, reversible)

  • Introduce state.panes, state.activePane, state.dualPaneEnabled (default false).
  • Add getters.activePane, getters.req, getters.selected.
  • Refactor mutations.setReq(), mutations.toggleSelected(), mutations.setClipboard() to write through the active pane.
  • Backwards-compat: state.req becomes a computed getter aliased to state.panes[activePane].req. Existing call sites unchanged.
  • ListingView.vue accepts optional paneId prop; uses it if present, falls back to global otherwise.
  • All existing tests must pass with zero changes. This is the acceptance criterion for Phase 1.

Files: store/state.js, store/mutations.js, store/getters.js, views/ListingView.vue (or wherever the listing is).

Effort: 12–16 hours.

Phase 2 — Render the second pane

  • Files.vue reads state.dualPaneEnabled and renders one or two <ListingView> instances.
  • Each instance gets its own paneId prop.
  • Pane container uses CSS grid; mobile breakpoint forces single.
  • Tab key toggles state.activePane. Click in a pane sets that pane active.
  • Sidebar toggle: "Dual-pane mode" — wires to state.dualPaneEnabled.

Effort: 8–12 hours.

Phase 3 — Cross-pane operations

  • Toolbar buttons: "Copy / Move to other pane" (visible only when dual-pane is on).
  • Drag-drop handler on each pane: detect cross-pane drag, default to move, Cmd/Ctrl modifies to copy.
  • Hooks into existing PATCH /api/resources copy/move flow; emits to the existing job registry (so the progress badge and Tasks page work for cross-pane transfers automatically).

Effort: 6–10 hours.

Phase 4 — Polish

  • Resizable divider (drag-to-resize, persists width to localStorage).
  • Per-pane breadcrumbs at top of each pane.
  • Visual indicator on active pane (inset border or top-bar tint).
  • localStorage persistence of dual-pane layout across reloads.

Effort: 8–12 hours.

Total: 34–50 hours (≈ 1 week focused).

Alternatives considered

Alternative A — URL encodes both pane paths

/files-split/source1/path1/source2/path2

Both panes are URL-canonical; the layout is bookmarkable.

Rejected because: - Doubles route complexity. Every route now has single-pane and dual-pane variants. - URL gets long (~200+ chars for deep paths). Browsers truncate the address bar; users complain. - Forces a decision on every action: which pane's URL changes? Both? URL becomes a poor model for "what changed." - No major dual-pane file manager does this. That's strong evidence the URL-as-dual-state pattern is dominated by active-pane. - Bookmarkable dual-pane is a low-value feature (rarely-used; users want bookmarks of single locations).

Alternative B — Two independent app instances (iframe / popout window)

Render two Filebrowser frontends side-by-side, each unaware of the other.

Rejected because: - Two stores, two SSE connections, two auth sessions per user. Wasteful. - Cross-pane operations (drag-drop, "copy to other pane") require postMessage choreography. Fragile. - Breaks Vue's reactivity composition — operations have to be RPC across iframes. - Doesn't actually feel dual-pane; it feels like two tabs.

Alternative C — Modal-based destination picker (the status quo, improved)

Keep single-pane but make "Move to..." faster: remember recent destinations, quick-pick by alias.

Rejected because: - Doesn't solve the visual-comparison workflow (seeing source and destination at once). - Doesn't improve drag-drop ergonomics. - Won't displace Cloud Commander — users still reach for it for browsing-while-deciding.

Alternative D — Stick with Cloud Commander as the dual-pane tool

Don't add dual-pane to Filebrowser. Direct users to cmd.eva-00.network for cross-folder operations.

Rejected because: - Two file managers for one set of mounts is operationally smelly. - Cloud Commander has no progress bars (this is the original pain point that drove RFC-progress-tasks). Re-introducing the "stuck at 1%" UX for cross-folder copies undoes that win. - Cloud Commander has weaker auth integration (oauth2-proxy stitched in front; no OIDC-native user mapping; no per-user file ownership rules). - Cloud Commander's UI is dated; Filebrowser's matches the rest of the homelab.

Reason "active pane" wins: It's universal across desktop dual-pane file managers because the alternatives are worse. URL-as-dual-state fights URL-as-state. Two-app instances fight component composition. Modal-pickers don't deliver the workflow. Active-pane is the canonical answer, not a shortcut.

Validation plan

  • Phase 1 acceptance: All existing single-pane tests pass; no UI change visible. Manual: navigate folders, copy, move, delete, search — everything works.
  • Phase 2 acceptance: Toggle dual-pane on in sidebar; second pane renders; Tab switches active; URL updates only when active pane navigates.
  • Phase 3 acceptance: Cross-pane copy/move uses the job registry; progress bar appears in Tasks page; badge increments.
  • Phase 4 acceptance: Resize the divider; reload; layout restored.
  • Regression risk: Single-pane users (mobile, anyone with toggle off) see zero behavior change. Verified by leaving dualPaneEnabled = false as default and running existing e2e suite.

Open questions

  1. Active-pane indicator style. Inset border, top-bar tint, or both? Cloud Commander tints the path bar. Total Commander draws a filled selection bar. Defer to design feel; start with inset 2 px accent border.
  2. Tab vs Ctrl+Tab. Tab is intuitive but conflicts with focus traversal inside form fields. Cloud Commander uses bare Tab globally; we may need Ctrl+Tab outside listing-focused contexts. Decide during Phase 2.
  3. Drag-drop default: copy or move? Cloud Commander defaults to move (Cmd = copy). Filebrowser today defaults to copy in drag-drop. Pick one and document; mid-stream flip causes muscle-memory bugs.
  4. Persist active pane? On reload, is the active pane the URL-restored one (left by default if both exist), or remembered from last session? Lean toward "active = whatever the URL targets after reload," to keep URL semantics intact.
  • ADR-021: Filebrowser Quantum local fork
  • RFC progress + tasks — dual-pane composes with the job registry
  • Cloud Commander source: https://github.com/coderaiser/cloudcmd (reference implementation)
  • Upstream issue (none yet — would file under gtsteffaniak/filebrowser after fork validates the design)