Arius.Explorer host¶
Code:
src/Arius.Explorer/(Program.cs,App.xaml.cs,Infrastructure/RepositorySession.cs,ChooseRepository/,RepositoryExplorer/,Settings/,Shared/{Services,Converters,Extensions}) · Decisions: ADR-0010 · ADR-0013 · Terms: snapshot · content hash · chunk index · storage tier hint · pointer file
Purpose¶
Arius.Explorer is the Windows-only WPF/MVVM desktop host for browsing and restoring an Arius repository. It is a thin GUI shell over Arius.Core: it never touches Azure or the chunk/filetree mechanics itself, it drives Core entirely through IMediator and renders the per-entry repository/disk/hydration state as a file-explorer tree + list. User-facing usage lives in [guide/explorer.md]; this doc is the maintainer view of how the shell is wired.
Status: thin coverage / GAP. This host has the least design intent of the three. It is a deliberately conventional CommunityToolkit.Mvvm app; the only non-obvious machinery is the per-repository DI provider (
RepositorySession) and the way it reuses the same Core feature handlers the CLI and Web hosts use.
How it works¶
Process startup and DI¶
Program.Main is [STAThread]. It builds a generic Host (CreateHostBuilder), starts it, hands the resulting IServiceProvider to the static App.ServiceProvider, then runs the WPF App. Serilog logs to %LocalAppData%/Arius/logs/arius-explorer-*.log.
The host container holds only shell services — it is not a connected repository:
IApplicationSettings/IRecentRepositoryManager— persisted recent-repository list (see Settings).IDialogService— opens the modalChooseRepositoryWindow.IRepositorySession(singleton) — the per-repository Core provider (below).IBlobServiceFactory→AzureBlobServiceFactory— used byRepositorySessionto open containers and by the root placeholder Core graph to answerContainerNamesQuerybefore any repository is connected.- The windows + ViewModels (
AddTransient). services.AddMediator()+RepositorySession.AddRootCorePlaceholders(services)— registers a root Core graph wired to aNullBlobContainerService. This exists so theChooseRepositoryflow has a workingIMediator(forContainerNamesQuery, which goes throughIBlobServiceFactory, not the container) before a repository is chosen.
App.OnStartup resolves and shows RepositoryExplorerWindow, after upgrading ClickOnce-era user settings if ApplicationSettings.Default.UpgradeRequired. It also installs global handlers for DispatcherUnhandledException, AppDomain.UnhandledException, and TaskScheduler.UnobservedTaskException that log and show a message box rather than crash — important because most Core work runs as fire-and-forget async void/Task off UI events.
The per-repository session — RepositorySession¶
This is the load-bearing piece. A connected repository needs its own Core service graph (AddArius(blobContainer, passphrase, account, container) builds one container-scoped instance of the shared-service stack). RepositorySession builds and owns that graph:
sequenceDiagram
participant VM as RepositoryExplorerViewModel
participant RS as RepositorySession
participant BF as IBlobServiceFactory
participant SP as child ServiceProvider
VM->>RS: ConnectAsync(RepositoryOptions)
RS->>RS: DisposeCurrentProvider() (tear down previous graph)
RS->>BF: CreateAsync(account, key) → OpenContainerServiceAsync(container, ReadOnly)
RS->>SP: new ServiceCollection → AddMediator() + AddArius(container, passphrase, …) → Build
RS->>SP: GetRequiredService<IMediator>()
RS-->>VM: Mediator + Repository now set
VM->>RS: Mediator.CreateStream(ListQuery / ChunkHydrationStatusQuery)
VM->>RS: Mediator.Send(RestoreCommand)
Key points:
- OpenContainerServiceAsync(..., PreflightMode.ReadOnly, ...) — Explorer never archives; it opens the container read-only.
- Logging is shared by handing the root ILoggerFactory into the child collection, so per-repository handlers log into the same Serilog file.
- ConnectAsync disposes the previous provider first, so switching repositories tears down the old Core graph (and its caches) deterministically. Mediator/Repository are null between connects.
Choosing a repository — ChooseRepositoryViewModel¶
The modal dialog collects local path + account/key + container + passphrase. It uses the root IMediator to live-discover containers:
OnAccountNameChanged/OnAccountKeyChangedpush into a RxSubject,.Throttle(500ms)debounce,.Switch()to cancel a prior probe; the resultingOnStorageAccountCredentialsChangedrunsmediator.CreateStream<string>(new ContainerNamesQuery(AccountName, AccountKey))and fills the container dropdown. UI mutation is marshalled back via the capturedSynchronizationContext.CanOpenRepositorygates the OK command: all fields non-empty andIsValidAzureContainerName(ContainerName).- On OK it builds a
RepositoryOptions(credentials DPAPI-protected, below) and returns it viaDialogResult = true.
ContainerNamesQuery is how Explorer detects "which containers on this account are usable" — it is the only repository-external Core query and is served by the root graph (no container open yet).
Browsing — RepositoryExplorerViewModel¶
On construction it auto-opens the most-recent repository (IRecentRepositoryManager.GetMostRecent) and fires LoadRepositoryAsync. The window's Loaded event triggers ViewLoadedAsync, which opens the ChooseRepository dialog if nothing is selected.
The view is a TreeView bound to RootNode (folders) + a ListView bound to SelectedTreeNode.Items (files). The data flow per folder:
flowchart TD
sel[TreeNode selected/expanded] --> onsel[OnNodeSelected]
onsel --> lnc[LoadNodeContentAsync]
lnc -->|ListQuery prefix=node, Recursive=false, LocalPath| stream[Mediator.CreateStream]
stream -->|RepositoryDirectoryEntry| folders[node.Folders += TreeNodeViewModel]
stream -->|RepositoryFileEntry| items[node.Items += FileItemViewModel]
items --> lhs[LoadHydrationStatusesAsync — fire-and-forget]
lhs -->|ChunkHydrationStatusQuery| refine[item.HydrationStatus refined live]
- One level at a time. Each
ListQueryisRecursive = false, scoped to the node'sPrefix, withLocalPath = Repository.LocalDirectoryPathso the listing overlays disk state on repository state (see list-query). Lazy expansion: a freshTreeNodeViewModelshows a "Loading..." placeholder child until selected. - Streaming binding. Results are bound to
ObservableCollections as they arrive —RepositoryDirectoryEntry→ childTreeNodeViewModel,RepositoryFileEntry→FileItemViewModel.SelectedItemsText(count + humanized size) is re-raised as items land. - Cancellation.
nodeLoadCancellation/hydrationLoadCancellationare swapped per load; selecting a new node cancels the in-flight stream (CancelNodeLoad/CancelHydrationLoad). Thefinallyblocks only clear state if they still own the current CTS (ReferenceEquals), so a superseded load can't clobber the new one's flags.
Two-pass hydration state. FileItemViewModel first derives an initial ChunkHydrationStatus cheaply from the RepositoryEntryState flags the ListQuery already carries (RepositoryArchived → NeedsRehydration, RepositoryHydrated → Available, RepositoryRehydrating → RehydrationPending, else Unknown). Then LoadHydrationStatusesAsync runs ChunkHydrationStatusQuery to refine it with live truth from IChunkStorageService.GetHydrationStatusAsync — the storage tier hint in the index is only a hint; the query is authoritative. The status drives the StateCircle colors via OnHydrationStatusChanged.
Restoring — RestoreAsync¶
For the checked SelectedFiles: EnsureHydrationStatusesAsync resolves any still-Unknown statuses, then a confirmation message box spells out the rehydration cost (how many items will start hydration / are already pending / will download now, with humanized byte totals — the "this may incur significant cost" warning). On confirm it issues one RestoreCommand per file through Mediator.Send (Overwrite = true, NoPointers = false), then reloads the current node. Cost surfacing and idempotent rehydration are Core's contract (see restore-command); Explorer only renders and confirms.
Settings and credentials¶
ApplicationSettings : ApplicationSettingsBase— Windows user-scoped settings holdingRecentRepositories(serialized as XML) andRecentLimit(default 10).RecentRepositoryManager.TouchOrAddkeeps the list most-recent-first, trimmed to the limit, identifying a repo by(LocalDirectoryPath, ContainerName, AccountName)— it refreshes the protected secrets andLastOpenedof an existing entry but never rewrites those key fields.RepositoryOptionsstoresAccountKeyProtected/PassphraseProtected;DataProtectionExtensions.Protect/Unprotectwrap DPAPI (ProtectedData,DataProtectionScope.CurrentUser). PlaintextAccountKey/Passphraseare[JsonIgnore, XmlIgnore]computed properties, so the persisted settings file never contains cleartext secrets. DPAPI failures fall back to returning the input unchanged (handles non-Windows / pre-encryption legacy values).
Key invariants¶
- One Core graph per connected repository, owned by the session.
RepositorySession.ConnectAsyncmust dispose the previous childServiceProviderbefore building the next, and must null outMediator/Repositorywhile disconnected. Two live graphs for the same container would split the cache/validation state of the singleton-service stack (overview §4). - Explorer opens containers
ReadOnly. It is a browse/restore host; it never archives. Do not introduce write-tier preflight here. - Persisted credentials are always DPAPI-protected. Anything written to
ApplicationSettingsgoes through.Protect(); the cleartext properties are serialization-excluded. A refactor must not persistAccountKey/Passphrasedirectly. - Per-load CTS ownership is reference-checked. The
finallyblocks inLoadNodeContentAsync/LoadHydrationStatusesAsynconly reset shared state when they still own the active CTS — required so a rapidly re-selected node doesn't have its state cleared by a stale, cancelled load. - Hydration status is refined, never trusted from flags alone. The flag-derived initial status is a placeholder; the
ChunkHydrationStatusQueryresult wins. Restore confirmation mustEnsureHydrationStatusesAsyncfirst so the cost estimate reflects live tier truth, not the storage tier hint.
Why this shape¶
- Drives Core only through
IMediator, never the handlers/services directly. Same coupling as every host — requests in, the sameListQuery/ChunkHydrationStatusQuery/RestoreCommandslices the CLI and Web use (ADR-0010, ADR-0013). The GUI carries zero repository mechanics, so it stays a thin shell. RepositorySessioninstead of a global Core container. Core is configured per container (AddAriusbinds oneIBlobContainerService+ passphrase). A desktop app switches repositories at runtime, so the host needs a swappable, disposable child provider rather than a process-lifetime singleton graph — hence the root-placeholder graph for pre-connect queries plus a rebuilt child graph perConnect.- Conventional CommunityToolkit.Mvvm.
[ObservableProperty]/[RelayCommand], DI-injected ViewModels, converters for presentation (BytesToReadableSizeConverter, theStateCirclebrush bindings). This is intentional boilerplate, documented in-repo bysrc/Arius.Explorer/CLAUDE.md; there is no hidden design here.
Open seams / future¶
- Windows-only. WPF + DPAPI +
ApplicationSettingsBase(the ClickOnce settings model) pin this host to Windows. A cross-platform desktop port would replace all three; theIMediator-driven ViewModels would largely survive. - Event consumption is minimal. Unlike the CLI (progress bars) and Web (SignalR relay), Explorer does not subscribe to Core's
INotificationprogress events — restore is a per-fileSendwith a coarseIsLoadingflag and no live progress. Wiring anINotificationHandler(or polling the stream) is the obvious next step; the seam is the shared event contract described in events-and-progress. - Restore is sequential, per file.
RestoreAsyncloopsSendoneRestoreCommandper selected file; batching the selection into a single command (so tar chunks shared across files download once) is a known efficiency gap. ArchiveStatisticsis a stub. The status bar shows "Loading..." / "" with a// STATISTICS TODOmarker — repository totals are not yet surfaced.- No archive workflow. Explorer is browse + restore only; archiving stays on the CLI.