ADR 44: Explorer Workspace Decomposition and View Orchestration Boundaries
Status: Proposed
Date: 2026-06-12
Last Updated: 2026-06-12 (Proposed)
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered obligation the system must meet (R1, R2, …). |
| NRn | Non-functional requirement | Quality attribute: security, performance, operability (NR1, NR2, …). |
| Cn | Constraint | Non-negotiable boundary; violating it invalidates the decision (C1, C2, …). |
| CCn | Cross-cutting challenge | Risk or tension that spans components, with a documented mitigation (CC1, …). |
| Orchestrator | ExplorerWorkspace Mithril class | Route-scoped component that subscribes to resolver $ streams, composes child components, and delegates behavior to services and lib/ modules. |
| Coordinator module | Function 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 state | e.g. columnEntries | Tree 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 viam.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)
| ID | Requirement |
|---|---|
| R1 | ExplorerWorkspace 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. |
| R2 | URL 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. |
| R3 | HTTP 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. |
| R4 | Upload 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. |
| R5 | Selection, context menu visibility, column width persistence, and hidden file-input DOM refs stay view-local (component instance or SelectionManager in lib/), not global services. |
| R6 | Refactors 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)
| ID | Requirement |
|---|---|
| NR1 | Incremental extraction: each PR moves one concern (e.g. path refresh, upload orchestration) without a big-bang rewrite. |
| NR2 | New 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)
| ID | Constraint |
|---|---|
| C1 | One SPA (apps/file-explorer); no runtime micro-frontends or cross-app imports of explorer orchestration (ADR 33). |
| C2 | Services must not call m.redraw() or own Mithril lifecycle; components subscribe and redraw. |
| C3 | Gateway HTTP paths use @substratum/api-client helpers inside services, not ad-hoc fetch in coordinator modules (ADR 20). |
| C4 | Content 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)
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | Extracting 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. |
| CC2 | refreshCurrentPath 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. |
| CC3 | Tests 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
Keep a thin
ExplorerWorkspaceMithril class (~120–200 lines target after refactor) responsible for:oninit/onremovesubscription wiring to resolver$attrs.- Owning
SelectionManager, context menu{ x, y, entry }, and column width array (via existinglayout-persistence). view()composition: toolbar, entitlement banner, content shell, hidden upload inputs (optional subcomponent), panel host, context menu layer.- Calling
m.route.setfor navigation and panel query changes (or thinlib/explorer-navigation.tswrappers that accept route helpers for tests).
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, invalidatessharedWithMeService, and awaitsresolveMillerColumns. - Export
refreshExplorerPathfromsrc/resolvers/explorer-tree.tsfor components and panel actions. driveQueryService.createDriveremains the HTTP entry for new drives; prompts stay UI-adjacent.
- Add
Introduce coordinator modules under
apps/file-explorer/src/components/explorer-workspace/(orsrc/lib/where logic is reusable and DOM-free):Module Responsibility 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 (usesexplorer-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
SelectionManagerinlib/.Do not add a singleton
ExplorerWorkspaceServicewithBehaviorSubjectfor segments, selection, and column widths. That duplicatesm.routeand complicates logout/sync (CC1).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.Recommended extraction order (incremental PRs):
explorerTreeService.refreshPath+ resolver shim.lib/explorer-upload.ts(orcomponents/explorer-workspace/explorer-upload.ts).explorer-create.ts+ migrateExplorerWorkspace.prompt.spec.ts.explorer-interaction.ts+ navigation helpers.- Subscription binder module last (highest coupling).
Update
apps/file-explorer/AGENTS.mdwhen the folder layout lands so agents and contributors follow the same boundaries.
Rejected alternatives
| Alternative | Why rejected |
|---|---|
Monolithic ExplorerViewStateService with state$ for path, selection, and layout | Second source of truth vs URL; requires logout reset and sync with every m.route change (violates R2). |
| Many micro-Mithril subcomponents for each handler cluster | Does not reduce coordination complexity; inflates prop drilling. |
Move orchestration into @substratum/ui-kit | Violates 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 PR | Fails 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/orexplorer-workspace/; lifecycle → component. - Smaller, reviewable
ExplorerWorkspace.tsxwithout changing user-visible architecture. refreshPathbecomes 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
| Scenario | Expected |
|---|---|
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 toolbar | uploadBatchService.startBatch invoked; optimistic rows appear via existing injectOptimisticUploadColumns; entitlement denial uses existing toast path. |
| New folder prompt failure | explorer-create.ts surfaces gateway message via toastService; no duplicate refresh logic in component. |
| Logout | No new service holds explorer path state; existing auth-service reset chain unchanged (ADR 20). |
| Open file in new tab | resolveContentCid used for content URL (C4). |
Related
- Glossary
- ADR 15: Web File Explorer
- ADR 20: File Explorer Service Layer
- ADR 33: Frontend Modular Monolith
- ADR 28: Receipt Sync and Access Removal
apps/file-explorer/AGENTS.md— runtime layout and testing pyramid