Skip to content

ADR 44: Explorer Workspace Decomposition and View Orchestration Boundaries

Status: Proposed
Date: 2026-06-12
Last Updated: 2026-06-12 (Proposed)

Terms (this ADR)

IDTermMeaning
RnFunctional requirementNumbered obligation the system must meet (R1, R2, …).
NRnNon-functional requirementQuality attribute: security, performance, operability (NR1, NR2, …).
CnConstraintNon-negotiable boundary; violating it invalidates the decision (C1, C2, …).
CCnCross-cutting challengeRisk or tension that spans components, with a documented mitigation (CC1, …).
OrchestratorExplorerWorkspace Mithril classRoute-scoped component that subscribes to resolver $ streams, composes child components, and delegates behavior to services and lib/ modules.
Coordinator moduleFunction or small module under src/lib/ or src/components/explorer-workspace/Stateless or view-local orchestration extracted from the workspace class; not a singleton service.
Derived view statee.g. columnEntriesTree columns merged with optimistic upload rows; recomputed when tree or upload batch state changes—not persisted in a service.

Canonical product vocabulary: Glossary.

Context

The Web File Explorer (ADR 15) centers on ExplorerWorkspace, a Mithril class component that coordinates route state, Miller/single-pane views, selection, uploads, context panels, and entitlement UX. ADR 20 already moved HTTP-backed resources into singleton services (driveQueryService, explorerTreeService, uploadBatchService, contextActionService) with resolver adapters and RxJS state$ streams.

ExplorerWorkspace.tsx has grown to roughly 750+ lines because it still owns:

  • Five observable subscriptions and derived column assembly (tree + optimistic uploads).
  • Route-synchronized navigation (segments, panel query params via m.route).
  • Ephemeral interaction state (selection, context menu coordinates).
  • Upload pickers, entitlement validation, and create drive/folder prompts.
  • Panel action dispatch and post-mutation refresh (cache invalidation + column re-resolve).

Contributors need a clear rule for what stays in the orchestrator, what extends the existing service layer, and what moves to pure lib/ coordinators—without introducing a second route store or violating ADR 33 package boundaries.

Requirements

Functional requirements (R1–R6)

IDRequirement
R1ExplorerWorkspace remains the single orchestrator for the explorer route: subscribe to explorerRoute$, explorerTree$, session$, accountLimits$, and upload batch state; compose ExplorerToolbar, Miller/single-pane views, context menu, and panel host.
R2URL remains the source of truth for explorer path segments and route-driven panel state (panel, targetId query params). No singleton service may duplicate navigable path state.
R3HTTP mutations and session-scoped cache invalidation (drive list refresh, Miller column cache clear, shared-with-me invalidate, column re-resolve after create/move/delete/upload) live in or behind explorerTreeService / driveQueryService, exposed via resolvers where route gates need them.
R4Upload execution stays in uploadBatchService; file/folder picker orchestration, pre-upload entitlement checks, and batch start may move to lib/explorer-upload.ts (or equivalent) without duplicating TUS logic.
R5Selection, context menu visibility, column width persistence, and hidden file-input DOM refs stay view-local (component instance or SelectionManager in lib/), not global services.
R6Refactors preserve existing Vitest coverage patterns: unit-test extracted modules with plain fixtures; keep at least one integration-style test on ExplorerWorkspace for subscription wiring where behavior is lifecycle-bound.

Non-functional requirements (NR1–NR3)

IDRequirement
NR1Incremental extraction: each PR moves one concern (e.g. path refresh, upload orchestration) without a big-bang rewrite.
NR2New session-scoped services follow ADR 20: state$, getSnapshot(), reset() wired through auth-service on logout.
NR3@substratum/ui-kit stays presentational; orchestration must not move into the shared kit (ADR 33).

Constraints (C1–C4)

IDConstraint
C1One SPA (apps/file-explorer); no runtime micro-frontends or cross-app imports of explorer orchestration (ADR 33).
C2Services must not call m.redraw() or own Mithril lifecycle; components subscribe and redraw.
C3Gateway HTTP paths use @substratum/api-client helpers inside services, not ad-hoc fetch in coordinator modules (ADR 20).
C4Content URLs for open-in-tab continue to use passport.asset_cid via resolveContentCid (ADR 28); moving openFileInNewTab to lib/ must preserve that rule.

Cross-cutting challenges (CC1–CC3)

IDChallengeMitigation
CC1Extracting subscriptions breaks redraw timing if modules call m.redraw() internally.Subscriptions stay in ExplorerWorkspace.oninit; extracted functions return results or mutate a passed view model; component calls m.redraw() once.
CC2refreshCurrentPath touches Miller cache, shared-with-me, and tree service—easy to scatter invalidation.Single explorerTreeService.refreshPath(segments, drives) (and resolver refreshExplorerPath) owns the sequence; all callers use it.
CC3Tests today cast private methods on ExplorerWorkspace (e.g. onNewDrive).Move test targets to extracted lib/explorer-create.ts (or service methods) with explicit public exports.

Decision

  1. Keep a thin ExplorerWorkspace Mithril class (~120–200 lines target after refactor) responsible for:

    • oninit / onremove subscription wiring to resolver $ attrs.
    • Owning SelectionManager, context menu { x, y, entry }, and column width array (via existing layout-persistence).
    • view() composition: toolbar, entitlement banner, content shell, hidden upload inputs (optional subcomponent), panel host, context menu layer.
    • Calling m.route.set for navigation and panel query changes (or thin lib/explorer-navigation.ts wrappers that accept route helpers for tests).
  2. Extend the existing service layer (ADR 20) for path refresh orchestration, not a new ExplorerViewStateService:

    • Add IExplorerTreeService.refreshPath(segments, drives) (name may vary) that: clears Miller column cache, rebinds cache loader, invalidates sharedWithMeService, and awaits resolveMillerColumns.
    • Export refreshExplorerPath from src/resolvers/explorer-tree.ts for components and panel actions.
    • driveQueryService.createDrive remains the HTTP entry for new drives; prompts stay UI-adjacent.
  3. Introduce coordinator modules under apps/file-explorer/src/components/explorer-workspace/ (or src/lib/ where logic is reusable and DOM-free):

    ModuleResponsibility
    explorer-upload.tsValidate against limits, resolve active folder, start upload batch, pause/resume upload rows (delegates to uploadBatchService).
    explorer-create.tsNew drive/folder dialog copy + calls to driveQueryService / createExplorerDirectory + refreshExplorerPath.
    explorer-interaction.tsOpen file in tab, context action dispatch (immediate vs panel), row click handlers (pure where possible).
    explorer-navigation.tsSegment/path helpers, canNavigateUp, navigate-up (uses explorer-routes).
    ExplorerUploadInputs.tsx(Optional) Hidden <input type="file"> elements and refs.

    Pattern: functions take a view model (segments, drives, column entries, selection) plus deps (redraw callback, route setter)—same precedent as SelectionManager in lib/.

  4. Do not add a singleton ExplorerWorkspaceService with BehaviorSubject for segments, selection, and column widths. That duplicates m.route and complicates logout/sync (CC1).

  5. Child components stay presentational (ExplorerToolbar, ExplorerMillerColumns, ExplorerSinglePane, ExplorerPanelHost). Do not split the workspace into many tiny Mithril components solely to reduce line count if callback surface area unchanged.

  6. Recommended extraction order (incremental PRs):

    1. explorerTreeService.refreshPath + resolver shim.
    2. lib/explorer-upload.ts (or components/explorer-workspace/explorer-upload.ts).
    3. explorer-create.ts + migrate ExplorerWorkspace.prompt.spec.ts.
    4. explorer-interaction.ts + navigation helpers.
    5. Subscription binder module last (highest coupling).
  7. Update apps/file-explorer/AGENTS.md when the folder layout lands so agents and contributors follow the same boundaries.

Rejected alternatives

AlternativeWhy rejected
Monolithic ExplorerViewStateService with state$ for path, selection, and layoutSecond source of truth vs URL; requires logout reset and sync with every m.route change (violates R2).
Many micro-Mithril subcomponents for each handler clusterDoes not reduce coordination complexity; inflates prop drilling.
Move orchestration into @substratum/ui-kitViolates presentational-only kit boundary (ADR 33, NR3).
Replace service layer with a generic client-side store (e.g. Redux)Conflicts with accepted ADR 20 RxJS + resolver pattern; unnecessary for single-route orchestrator scope.
Big-bang rewrite of ExplorerWorkspace in one PRFails incremental review and NR1; high regression risk for E2E explorer flows.

Consequences

Positive

  • Clear placement rules for new explorer features: HTTP → service; pure orchestration → lib/ or explorer-workspace/; lifecycle → component.
  • Smaller, reviewable ExplorerWorkspace.tsx without changing user-visible architecture.
  • refreshPath becomes reusable from panel actions, create flows, and upload-complete refresh with one invalidation story.
  • Unit tests target extracted modules instead of private class method casts.

Negative

  • Temporary duplication during migration (old private methods + new modules) until extraction PRs land.
  • Contributors must learn view model + deps pattern alongside existing service/resolver docs.
  • Feature folder components/explorer-workspace/ adds directory depth; imports must stay app-local.

Neutral

  • No change to routing table, OpenAPI contracts, or gateway APIs.
  • E2E explorer specs remain the regression layer; Vitest gains module-level tests.

Verification

ScenarioExpected
After refreshExplorerPath, Miller cache and shared-with-me are invalidated; tree columns match current URL segments.Passes unit test on explorerTreeService; workspace integration test mocks resolver.
File upload from toolbaruploadBatchService.startBatch invoked; optimistic rows appear via existing injectOptimisticUploadColumns; entitlement denial uses existing toast path.
New folder prompt failureexplorer-create.ts surfaces gateway message via toastService; no duplicate refresh logic in component.
LogoutNo new service holds explorer path state; existing auth-service reset chain unchanged (ADR 20).
Open file in new tabresolveContentCid used for content URL (C4).