diff --git a/openspec/changes/progress-display-redesign/.openspec.yaml b/openspec/changes/archive/2026-03-28-progress-display-redesign/.openspec.yaml similarity index 100% rename from openspec/changes/progress-display-redesign/.openspec.yaml rename to openspec/changes/archive/2026-03-28-progress-display-redesign/.openspec.yaml diff --git a/openspec/changes/progress-display-redesign/design.md b/openspec/changes/archive/2026-03-28-progress-display-redesign/design.md similarity index 100% rename from openspec/changes/progress-display-redesign/design.md rename to openspec/changes/archive/2026-03-28-progress-display-redesign/design.md diff --git a/openspec/changes/progress-display-redesign/proposal.md b/openspec/changes/archive/2026-03-28-progress-display-redesign/proposal.md similarity index 100% rename from openspec/changes/progress-display-redesign/proposal.md rename to openspec/changes/archive/2026-03-28-progress-display-redesign/proposal.md diff --git a/openspec/changes/progress-display-redesign/specs/archive-pipeline/spec.md b/openspec/changes/archive/2026-03-28-progress-display-redesign/specs/archive-pipeline/spec.md similarity index 100% rename from openspec/changes/progress-display-redesign/specs/archive-pipeline/spec.md rename to openspec/changes/archive/2026-03-28-progress-display-redesign/specs/archive-pipeline/spec.md diff --git a/openspec/changes/progress-display-redesign/specs/cli/spec.md b/openspec/changes/archive/2026-03-28-progress-display-redesign/specs/cli/spec.md similarity index 100% rename from openspec/changes/progress-display-redesign/specs/cli/spec.md rename to openspec/changes/archive/2026-03-28-progress-display-redesign/specs/cli/spec.md diff --git a/openspec/changes/progress-display-redesign/specs/progress-display/spec.md b/openspec/changes/archive/2026-03-28-progress-display-redesign/specs/progress-display/spec.md similarity index 100% rename from openspec/changes/progress-display-redesign/specs/progress-display/spec.md rename to openspec/changes/archive/2026-03-28-progress-display-redesign/specs/progress-display/spec.md diff --git a/openspec/changes/progress-display-redesign/tasks.md b/openspec/changes/archive/2026-03-28-progress-display-redesign/tasks.md similarity index 100% rename from openspec/changes/progress-display-redesign/tasks.md rename to openspec/changes/archive/2026-03-28-progress-display-redesign/tasks.md diff --git a/openspec/specs/archive-pipeline/spec.md b/openspec/specs/archive-pipeline/spec.md index 4cec2f3b..4c10a4aa 100644 --- a/openspec/specs/archive-pipeline/spec.md +++ b/openspec/specs/archive-pipeline/spec.md @@ -9,6 +9,8 @@ Defines the archive pipeline for Arius: file enumeration, hashing, deduplication ### Requirement: File enumeration The system SHALL recursively enumerate all files in the local root directory, producing FilePair units for archiving using a single-pass streaming approach. Files with the `.pointer.arius` suffix SHALL always be treated as pointer files. All other files SHALL be treated as binary files. If a file cannot be read (e.g., system-protected), the system SHALL log a warning and continue with the remaining files. Enumeration SHALL be depth-first to provide directory affinity for the tar builder. Enumeration SHALL yield FilePair objects immediately as files are discovered without materializing the full file list into memory. When encountering a binary file, the system SHALL check `File.Exists(binaryPath + ".pointer.arius")` to pair it. When encountering a pointer file, the system SHALL check `File.Exists(pointerPath[..^".pointer.arius".Length])` -- if the binary exists, skip (already emitted with the binary); if not, yield as pointer-only. No dictionaries or state tracking SHALL be used. +During enumeration, the system SHALL publish a `FileScannedEvent(string RelativePath, long FileSize)` for each file discovered. The `RelativePath` and `FileSize` SHALL be taken from the `FilePair` at the enumeration site. After enumeration completes, the system SHALL publish a `ScanCompleteEvent(long TotalFiles, long TotalBytes)` with the final counts. + #### Scenario: Binary file with matching pointer - **WHEN** a binary file `photos/vacation.jpg` exists alongside `photos/vacation.jpg.pointer.arius` - **THEN** the system SHALL produce a FilePair with both binary and pointer present, discovered via `File.Exists` check on the binary @@ -37,6 +39,14 @@ The system SHALL recursively enumerate all files in the local root directory, pr - **WHEN** enumerating a directory with 1 million files - **THEN** the pipeline SHALL begin processing the first FilePair before enumeration completes, with no `.ToList()` or equivalent materialization +#### Scenario: Per-file scanning event published +- **WHEN** a FilePair is discovered during enumeration +- **THEN** the system SHALL publish `FileScannedEvent` with the file's `RelativePath` and `FileSize` before writing the FilePair to the channel + +#### Scenario: Scan complete event published +- **WHEN** all files have been enumerated and the channel is about to be completed +- **THEN** the system SHALL publish `ScanCompleteEvent` with the total file count and total bytes + ### Requirement: Streaming hash computation The system SHALL compute content hashes by streaming file data through the hash function without loading the entire file into memory. The hash function SHALL be SHA256(data) in plaintext mode or SHA256(passphrase + data) in encrypted mode (literal byte concatenation). Pointer file hashes SHALL NEVER be trusted as a cache — every binary file SHALL be re-hashed on every archive run. During hashing, the system SHALL publish `FileHashingEvent` with the file's relative path and file size (in bytes) to enable per-file progress display. When `ArchiveOptions.CreateHashProgress` is provided, the file stream SHALL be wrapped in `ProgressStream` before being passed to `ComputeHashAsync`. @@ -112,6 +122,8 @@ The system SHALL upload large files individually as chunks using streaming uploa ### Requirement: Tar builder The system SHALL bundle small files into tar archives using a single tar builder. Files inside the tar SHALL be named by their content-hash (not original path). The tar builder SHALL seal and hand off the tar to the upload channel when the accumulated uncompressed size reaches `--tar-target-size` (default 64 MB). After sealing, the builder SHALL immediately start a new tar. The tar builder SHALL stream to a temp file on disk (not memory). Depth-first enumeration provides natural directory affinity. The tar hash SHALL be computed using `_encryption.ComputeHashAsync(fs)` (passphrase-seeded when a passphrase is provided) for consistency with content hash computation. +The tar builder SHALL publish `TarBundleStartedEvent()` when initializing a new tar (before writing the first entry). + #### Scenario: Tar sealing at target size - **WHEN** accumulated small files in the current tar reach 64 MB uncompressed - **THEN** the system SHALL seal the tar, compute its tar-hash via `_encryption.ComputeHashAsync`, and hand it off for upload @@ -132,6 +144,18 @@ The system SHALL bundle small files into tar archives using a single tar builder - **WHEN** a tar is sealed with a passphrase configured - **THEN** the tar-hash SHALL be `SHA256(passphrase + tarBytes)` via `_encryption.ComputeHashAsync` +#### Scenario: TarBundleStartedEvent published on new tar +- **WHEN** the tar builder initializes a new tar archive (before writing the first entry) +- **THEN** the system SHALL publish `TarBundleStartedEvent()` with no parameters + +#### Scenario: TarBundleStartedEvent on first tar +- **WHEN** the first small file arrives at the tar builder +- **THEN** `TarBundleStartedEvent()` SHALL be published before the first `TarEntryAddedEvent` + +#### Scenario: TarBundleStartedEvent on subsequent tars +- **WHEN** a tar is sealed and the next small file arrives +- **THEN** a new `TarBundleStartedEvent()` SHALL be published before the new tar's first `TarEntryAddedEvent` + ### Requirement: TarEntryAddedEvent A notification record `TarEntryAddedEvent(string ContentHash, int CurrentEntryCount, long CurrentTarSize)` SHALL be published after each file is written to the tar archive (after `tarWriter.WriteEntryAsync`). A corresponding `ILogger` debug-level log line SHALL be emitted for consistency with existing event logging patterns. @@ -139,26 +163,61 @@ A notification record `TarEntryAddedEvent(string ContentHash, int CurrentEntryCo - **WHEN** a small file is added to the current tar bundle - **THEN** the pipeline SHALL publish `TarEntryAddedEvent` with the file's content hash, the updated entry count, and the updated cumulative uncompressed size -### Requirement: TarBundleSealingEvent with content hashes -The `TarBundleSealingEvent` record SHALL be enriched to include the list of content hashes in the sealed tar bundle: +### Requirement: FileScannedEvent per-file notification +The `FileScannedEvent` record SHALL be defined as `FileScannedEvent(string RelativePath, long FileSize) : INotification`. This replaces the previous `FileScannedEvent(long TotalFiles)` batch event. The event SHALL be published once per file discovered during enumeration, providing the file's relative path and size in bytes. + +#### Scenario: Per-file event during enumeration +- **WHEN** a file `photos/vacation.jpg` (1.2 MB) is discovered during enumeration +- **THEN** the system SHALL publish `FileScannedEvent("photos/vacation.jpg", 1200000)` + +#### Scenario: Events published before channel write +- **WHEN** a FilePair is discovered +- **THEN** `FileScannedEvent` SHALL be published before the FilePair is written to `filePairChannel` -`TarBundleSealingEvent(int EntryCount, long UncompressedSize, string TarHash, IReadOnlyList ContentHashes)` +### Requirement: ScanCompleteEvent notification +A new notification record `ScanCompleteEvent(long TotalFiles, long TotalBytes) : INotification` SHALL be published once when file enumeration completes. It SHALL carry the final total file count and total byte count. A corresponding `ILogger` debug-level log line SHALL be emitted. -The `TarHash` parameter is the tar bundle's content hash (the bundle-level digest). The `ContentHashes` parameter SHALL contain the content hash of every file entry in the tar, in the order they were added — projected from the existing `tarEntries` list in `ArchivePipelineHandler`. +#### Scenario: Scan complete after enumeration +- **WHEN** enumeration finishes having discovered 1523 files totaling 5 GB +- **THEN** the system SHALL publish `ScanCompleteEvent(1523, 5000000000)` -#### Scenario: Tar sealed with 5 files -- **WHEN** a tar bundle containing 5 files is sealed -- **THEN** the pipeline SHALL publish `TarBundleSealingEvent(5, totalSize, ["hash1", "hash2", "hash3", "hash4", "hash5"])` where each hash corresponds to a file in the tar +#### Scenario: ScanCompleteEvent published before channel completion +- **WHEN** all files have been enumerated +- **THEN** `ScanCompleteEvent` SHALL be published before `filePairChannel.Writer.Complete()` is called -#### Scenario: CLI uses content hashes for state transition -- **WHEN** the CLI receives `TarBundleSealingEvent` with `ContentHashes` -- **THEN** it SHALL use the `ContentHash → RelativePath` reverse map to find each file's `TrackedFile` entry and transition its state from `QueuedInTar` to `UploadingTar` +### Requirement: TarBundleStartedEvent notification +A new notification record `TarBundleStartedEvent() : INotification` SHALL be published by the tar builder when it initializes a new tar archive. The event SHALL have no parameters — bundle numbering is a CLI display concern, not a Core concern. A corresponding `ILogger` debug-level log line SHALL be emitted. + +#### Scenario: Event published before first entry +- **WHEN** the tar builder creates a new tar archive +- **THEN** `TarBundleStartedEvent()` SHALL be published before the first `TarEntryAddedEvent` for that tar + +#### Scenario: Event published on each new tar +- **WHEN** a tar is sealed at 64 MB and a new tar begins +- **THEN** a new `TarBundleStartedEvent()` SHALL be published for the new tar + +### Requirement: TAR upload ProgressStream wiring +The tar upload stage SHALL wrap the sealed tar's `FileStream` in a `ProgressStream` when `CreateUploadProgress` is provided. The `ProgressStream` SHALL report cumulative bytes read to the `IProgress` returned by `CreateUploadProgress(tarHash, uncompressedSize)`. This enables byte-level upload progress for TAR bundles in the display. + +#### Scenario: TAR upload with progress +- **WHEN** a sealed tar with hash `"tarhash1"` and uncompressed size 52 MB is uploaded and `CreateUploadProgress` is not null +- **THEN** the pipeline SHALL call `CreateUploadProgress("tarhash1", 52MB)` and wrap the tar `FileStream` in `ProgressStream` using the returned `IProgress` + +#### Scenario: TAR upload without progress callback +- **WHEN** a sealed tar is uploaded and `CreateUploadProgress` is null +- **THEN** the pipeline SHALL upload using a no-op `IProgress` (no ProgressStream overhead) + +#### Scenario: Progress bytes match source stream +- **WHEN** the tar upload streams through `ProgressStream -> GZipStream -> EncryptingStream -> OpenWriteAsync` +- **THEN** the `IProgress` SHALL receive cumulative bytes of the uncompressed tar data read from the source `FileStream` ### Requirement: Progress callbacks on ArchiveOptions `ArchiveOptions` SHALL expose two optional callback properties for injecting byte-level progress reporting: - `Func>? CreateHashProgress` — called by the pipeline when a file begins hashing. Parameters: relative path, file size in bytes. Returns an `IProgress` that receives cumulative bytes hashed. Default: `null` (no-op). - `Func>? CreateUploadProgress` — called by the pipeline when a chunk begins uploading. Parameters: content hash, uncompressed size in bytes. Returns an `IProgress` that receives cumulative bytes read from the source stream. Default: `null` (no-op). +- `Action>? OnHashQueueReady` — called by the pipeline when the hash input channel is created. The pipeline passes a `Func` that returns `filePairChannel.Reader.Count`. Default: `null`. +- `Action>? OnUploadQueueReady` — called by the pipeline when the upload channels are created. The pipeline passes a `Func` that returns `largeChannel.Reader.Count + sealedTarChannel.Reader.Count`. Default: `null`. This follows the same pattern as `RestoreOptions.ConfirmRehydration` — Core exposes observable hooks, the UI injects callbacks. Core SHALL NOT take any dependency on Spectre.Console or any display library. @@ -178,6 +237,14 @@ This follows the same pattern as `RestoreOptions.ConfirmRehydration` — Core ex - **WHEN** `CreateUploadProgress` is null - **THEN** the pipeline SHALL use a no-op `IProgress` (current behavior) +#### Scenario: Pipeline registers hash queue depth +- **WHEN** the pipeline creates `filePairChannel` and `OnHashQueueReady` is not null +- **THEN** the pipeline SHALL call `OnHashQueueReady(() => filePairChannel.Reader.Count)` + +#### Scenario: Pipeline registers upload queue depth +- **WHEN** the pipeline creates `largeChannel` and `sealedTarChannel` and `OnUploadQueueReady` is not null +- **THEN** the pipeline SHALL call `OnUploadQueueReady(() => largeChannel.Reader.Count + sealedTarChannel.Reader.Count)` + ### Requirement: ProgressStream wiring for hash path The archive pipeline SHALL wrap the file `FileStream` in a `ProgressStream` during hash computation when `CreateHashProgress` is provided. The `ProgressStream` SHALL report cumulative source bytes read to the `IProgress` returned by the factory. The hash computation (`ComputeHashAsync`) SHALL read from the `ProgressStream` instead of the raw `FileStream`. diff --git a/openspec/specs/cli/spec.md b/openspec/specs/cli/spec.md index b7ea0b90..76111594 100644 --- a/openspec/specs/cli/spec.md +++ b/openspec/specs/cli/spec.md @@ -126,13 +126,13 @@ The display SHALL NOT use Spectre.Console `Progress`, `ProgressTask`, or `Progre - **THEN** the CLI SHALL fall back to running the pipeline with no visual progress display ### Requirement: Archive display layout -The `BuildArchiveDisplay` function SHALL return a `Rows(...)` renderable with two sections: +The `BuildArchiveDisplay` function SHALL return a `Rows(...)` renderable with three sections: **Stage headers** (persistent summary lines at top): ``` - ● Scanning 1523 files - ○ Hashing 640/1523 - ○ Uploading 3/11 chunks + ● Scanning 1.523 files + ○ Hashing 720 / 1.523 files (312 unique) [12 pending] + ○ Uploading 3 unique chunks [2 pending] ``` Symbols: @@ -140,42 +140,70 @@ Symbols: - `[yellow]○[/]` (U+25CB) — stage in progress - `[dim]○[/]` or `[grey] [/]` (two spaces) — stage not yet started -- Scanning: dim placeholder until `TotalFiles` is known, then `[green]●[/]` with count -- Hashing: `[yellow]○[/]` with `FilesHashed / TotalFiles`, or `[green]●[/]` when `FilesHashed == TotalFiles` -- Uploading: `[yellow]○[/]` with `ChunksUploaded / TotalChunks` (or `ChunksUploaded chunks...` when `TotalChunks` unknown), or `[green]●[/]` when complete +- Scanning: `[yellow]○[/]` with `FilesScanned` ticking up during enumeration. `[green]●[/]` with final `TotalFiles` count when `ScanComplete` is true. +- Hashing: `[yellow]○[/]` with `FilesHashed / TotalFiles` (or `FilesHashed files...` when `TotalFiles` unknown). Shows `(N unique)` suffix with `FilesUnique` count. Shows `[N pending]` dimmed suffix when `HashQueueDepth` returns > 0. `[green]●[/]` when `FilesHashed == TotalFiles`. +- Uploading: `[yellow]○[/]` with `ChunksUploaded unique chunks` (or `ChunksUploaded / TotalChunks` when `TotalChunks` known). Shows `[N pending]` dimmed suffix when `UploadQueueDepth` returns > 0. Only shown when there is upload activity. `[green]●[/]` when complete. -**Per-file lines** (below stage headers, appear/disappear based on TrackedFile state): +**Per-file lines** (only `TrackedFile` entries where `State is Hashing or Uploading`): ``` - ...s/march/video.mp4 ████████░░░░ Hashing 62% 3.1 GB / 5.0 GB - ...ments/data.db ██████░░░░░░ Hashing 28% 560.0 MB / 2.0 GB - notes.txt Queued in TAR 1.0 KB - config.yml Queued in TAR 512 B - readme.md ████░░░░░░░░ Uploading TAR 33% 850 B + ...rview-v2 - WouterNotes.pptx ██████░░░░░░ Hashing 50% 6,67 / 13,34 MB + ...FY14 - EMS Plan.pptx ████████████ Uploading 100% 6,39 / 6,39 MB ``` - File name column: `TruncateAndLeftJustify(file.RelativePath, 30)` then `Markup.Escape()` -- Progress bar column: 12-char Markup bar for Hashing/Uploading states; blank (`"".PadRight(12)`) for QueuedInTar/UploadingTar +- Progress bar column: 12-char Markup bar for Hashing/Uploading states - State label column: fixed-width state name -- Percentage column: present for Hashing/Uploading states only -- Size column: `BytesProcessed.Bytes().Humanize() + " / " + TotalBytes.Bytes().Humanize()` for Hashing/Uploading; `TotalBytes.Bytes().Humanize()` for QueuedInTar/UploadingTar +- Percentage column: present for Hashing/Uploading states +- Size column: `BytesProcessed.Bytes().Humanize() + " / " + TotalBytes.Bytes().Humanize()` -Files in `Done` state are excluded from the output entirely. +Files in `Hashed` or `Done` state SHALL NOT appear in the per-file area. -#### Scenario: Full archive display -- **WHEN** 640 of 1523 files are hashed, 4 files are hashing with byte-level progress, 2 files queued in tar, 3 files in uploading tar, 3 of 11 chunks uploaded -- **THEN** the display SHALL show stage headers with correct counts and per-file lines for all active TrackedFile entries +**TAR bundle lines** (all `TrackedTar` entries from `ProgressState.TrackedTars`): +``` + TAR #1 (23 files, 5,1 MB) ███░░░░░░░░░ Accumulating 5,1 / 64 MB + TAR #2 (64 files, 47,8 MB) ████████████ Sealing 47,8 / 64 MB + TAR #3 (64 files, 52,1 MB) ██████████░░ Uploading 83% 43,2 / 52,1 MB +``` + +- Name column: `TAR #N (M files, X MB)` where N is `BundleNumber`, M is `FileCount`, X is `AccumulatedBytes` humanized +- Progress bar column: 12-char Markup bar + - `Accumulating`: fill = `AccumulatedBytes / TargetSize` + - `Sealing`: bar frozen at last accumulation ratio + - `Uploading`: fill = `BytesUploaded / TotalBytes` +- State label column: `Accumulating`, `Sealing`, or `Uploading` +- Size column: progress bytes / target or total bytes + +#### Scenario: Full archive display with TAR bundles +- **WHEN** scanning is complete with 1523 files, 720 hashed (312 unique), 2 files actively hashing, 1 file uploading, TAR #1 accumulating, TAR #2 uploading +- **THEN** the display SHALL show stage headers with correct counts/dedup/queue depths, per-file lines for the 2 hashing and 1 uploading file, and TAR lines for both bundles + +#### Scenario: Scanning counter ticks up live +- **WHEN** enumeration is in progress and 500 of (unknown total) files have been scanned +- **THEN** the scanning header SHALL show `[yellow]○[/] Scanning 500 files...` (ticking up with each `FileScannedEvent`) + +#### Scenario: Queue depth shown when non-zero +- **WHEN** `HashQueueDepth` returns 12 and `UploadQueueDepth` returns 2 +- **THEN** the hashing header SHALL include `[dim][12 pending][/]` and the uploading header SHALL include `[dim][2 pending][/]` + +#### Scenario: Queue depth hidden when zero +- **WHEN** `HashQueueDepth` returns 0 +- **THEN** the hashing header SHALL NOT show any `[N pending]` suffix + +#### Scenario: Dedup count shown on hashing header +- **WHEN** `FilesUnique` is 312 and `FilesHashed` is 720 +- **THEN** the hashing header SHALL show `720 / 1.523 files (312 unique)` -#### Scenario: File completes and disappears -- **WHEN** a large file finishes uploading (`ChunkUploadedEvent`) -- **THEN** the `TrackedFile` entry SHALL be removed and the file's line SHALL not appear in the next display tick +#### Scenario: File completes hashing and disappears +- **WHEN** a file transitions from `Hashing` to `Hashed` +- **THEN** the file's per-file line SHALL NOT appear in the next display tick -#### Scenario: Tar batch completes and all files disappear -- **WHEN** a tar bundle finishes uploading (`TarBundleUploadedEvent`) -- **THEN** all `TrackedFile` entries in that tar SHALL be removed and their lines SHALL not appear in the next display tick +#### Scenario: TAR bundle removed after upload +- **WHEN** `TarBundleUploadedEvent` fires for TAR #1 +- **THEN** TAR #1's line SHALL NOT appear in the next display tick #### Scenario: Empty display between phases -- **WHEN** all files have been processed (all `TrackedFile` entries removed) -- **THEN** only stage headers SHALL be shown (all with `●`) +- **WHEN** all `TrackedFile` entries are in `Hashed`/`Done` state and no `TrackedTar` entries exist +- **THEN** only stage headers SHALL be shown ### Requirement: TruncateAndLeftJustify helper The CLI SHALL expose an `internal static string TruncateAndLeftJustify(string input, int width)` helper with the following rules: @@ -204,15 +232,37 @@ Per-file progress bars SHALL be rendered as Markup strings with a configurable w - **THEN** the progress bar SHALL render as approximately 7-8 filled characters and 4-5 empty characters (at width 12) ### Requirement: Archive progress callback wiring -The CLI SHALL inject `IProgress` callbacks into Core via `ArchiveOptions.CreateHashProgress` and `ArchiveOptions.CreateUploadProgress`. These factory callbacks SHALL look up the corresponding `TrackedFile` entry in `ProgressState` and return an `IProgress` that updates `TrackedFile.BytesProcessed` via `Interlocked.Exchange`. +The CLI SHALL inject `IProgress` callbacks into Core via `ArchiveOptions.CreateHashProgress` and `ArchiveOptions.CreateUploadProgress`. The CLI SHALL also wire `ArchiveOptions.OnHashQueueReady` and `ArchiveOptions.OnUploadQueueReady` to store the queue depth getters in `ProgressState`. + +The `CreateHashProgress` factory SHALL look up the corresponding `TrackedFile` entry in `ProgressState` and return an `IProgress` that updates `TrackedFile.BytesProcessed` via `Interlocked.Exchange`. + +The `CreateUploadProgress` factory SHALL perform a dual lookup: +1. First check `TrackedFiles` via the `ContentHash → RelativePath` reverse map (for large file uploads) +2. Then check `TrackedTars` by matching `TarHash` (for TAR bundle uploads) +Only one lookup SHALL match for any given content hash (TAR hashes and content hashes are hashes of different content, so collisions are impossible). + +For large files, the returned `IProgress` SHALL update `TrackedFile.BytesProcessed`. For TAR bundles, it SHALL update `TrackedTar.BytesUploaded`. #### Scenario: Hash progress callback - **WHEN** Core calls `CreateHashProgress("video.mp4", 5GB)` - **THEN** the factory SHALL look up the `TrackedFile` for `"video.mp4"` and return an `IProgress` that sets its `BytesProcessed` -#### Scenario: Upload progress callback -- **WHEN** Core calls `CreateUploadProgress("abc123", 5GB)` -- **THEN** the factory SHALL use the `ContentHash → RelativePath` reverse map to find the `TrackedFile` and return an `IProgress` that sets its `BytesProcessed` +#### Scenario: Upload progress callback for large file +- **WHEN** Core calls `CreateUploadProgress("abc123", 5GB)` and `"abc123"` is found in `ContentHashToPath` +- **THEN** the factory SHALL find the `TrackedFile` and return an `IProgress` that sets its `BytesProcessed` + +#### Scenario: Upload progress callback for TAR bundle +- **WHEN** Core calls `CreateUploadProgress("tarhash1", 52MB)` and `"tarhash1"` matches a `TrackedTar.TarHash` +- **THEN** the factory SHALL find the `TrackedTar` and return an `IProgress` that sets its `BytesUploaded` + +#### Scenario: Upload progress callback with no match +- **WHEN** Core calls `CreateUploadProgress` with a hash that matches neither a `TrackedFile` nor a `TrackedTar` +- **THEN** the factory SHALL return a no-op `IProgress` + +#### Scenario: Queue depth callbacks wired +- **WHEN** the CLI creates `ArchiveOptions` +- **THEN** `OnHashQueueReady` SHALL be set to store the getter in `ProgressState.HashQueueDepth` +- **AND** `OnUploadQueueReady` SHALL be set to store the getter in `ProgressState.UploadQueueDepth` ### Requirement: Responsive poll loop The archive display poll loop SHALL use `await Task.WhenAny(pipelineTask, Task.Delay(100, ct))` instead of unconditional `await Task.Delay(100)` to respond immediately when the pipeline completes while still throttling the refresh rate during active operation. @@ -276,7 +326,7 @@ The restore flow SHALL have distinct phases: - **THEN** the display SHALL show `[green]●[/] Restoring 1000/1000 files` header with byte totals, and NO tail lines ### Requirement: Streaming progress events from Core -Arius.Core SHALL emit progress events via Mediator notifications. Event types SHALL include: FileScanned, FileHashing (with byte progress), FileHashed (with dedup result), ChunkUploading (with byte progress), ChunkUploaded, TarBundleSealing, TarBundleUploaded, SnapshotCreated, and equivalent restore events. The CLI SHALL subscribe to these events to drive the display. +Arius.Core SHALL emit progress events via Mediator notifications. Event types SHALL include: FileScanned (per-file, with RelativePath and FileSize), ScanComplete (with TotalFiles and TotalBytes), FileHashing (with byte progress), FileHashed (with dedup result), TarBundleStarted (parameterless), TarEntryAdded, TarBundleSealing, ChunkUploading (with byte progress), ChunkUploaded, TarBundleUploaded, SnapshotCreated, and equivalent restore events. The CLI SHALL subscribe to these events to drive the display. #### Scenario: Progress event emission - **WHEN** a file is hashed during archive @@ -285,3 +335,11 @@ Arius.Core SHALL emit progress events via Mediator notifications. Event types SH #### Scenario: CLI subscription - **WHEN** Core emits a ChunkUploaded event - **THEN** the CLI SHALL update the upload progress counter in the Spectre.Console display + +#### Scenario: Per-file scanning events +- **WHEN** files are being enumerated +- **THEN** Core SHALL emit `FileScannedEvent` per file (not a single batch event at the end) + +#### Scenario: TAR lifecycle events +- **WHEN** a TAR bundle is being built +- **THEN** Core SHALL emit `TarBundleStartedEvent` at creation, `TarEntryAddedEvent` per file, `TarBundleSealingEvent` at seal, and `TarBundleUploadedEvent` after upload diff --git a/openspec/specs/progress-display/spec.md b/openspec/specs/progress-display/spec.md index e07b6c59..dcaa8c4c 100644 --- a/openspec/specs/progress-display/spec.md +++ b/openspec/specs/progress-display/spec.md @@ -11,7 +11,7 @@ The CLI assembly SHALL have `Mediator.SourceGenerator` and `Mediator.Abstraction #### Scenario: Handler invoked on publish - **WHEN** Core publishes `FileScannedEvent` via `_mediator.Publish()` -- **THEN** the `FileScannedHandler` in `Arius.Cli` SHALL be invoked and update `ProgressState` +- **THEN** the `FileScannedHandler` in `Arius.Cli` SHALL be invoked and update `ProgressState` (incrementing `FilesScanned` and `BytesScanned`) #### Scenario: Production DI wiring - **WHEN** `BuildProductionServices` creates the service provider @@ -25,17 +25,23 @@ The `ProgressState` class SHALL track each file through a unified lifecycle usin - `State` (FileState enum, volatile) — current lifecycle state - `TotalBytes` (long) — file size, set at creation - `BytesProcessed` (long, Interlocked-updated) — for hashing/uploading progress -- `TarId` (string?, volatile) — set when assigned to a tar bundle -The `FileState` enum SHALL have values: `Hashing`, `QueuedInTar`, `UploadingTar`, `Uploading`, `Done`. +The `FileState` enum SHALL have values: `Hashing`, `Hashed`, `Uploading`, `Done`. + +- `Hashing` — file is being hashed, visible in per-file display with byte-level progress +- `Hashed` — hashing complete, invisible in per-file display; entry remains for `ContentHashToPath` lookup +- `Uploading` — large file upload in progress, visible in per-file display with byte-level progress +- `Done` — processing complete, entry about to be removed + +The `TarId` field SHALL be removed from `TrackedFile`. TAR bundle tracking is handled by the separate `TrackedTar` entity. #### Scenario: Large file lifecycle - **WHEN** `FileHashingEvent("video.mp4", 5GB)` is published - **THEN** a `TrackedFile` entry SHALL be added with `State = Hashing` - **WHEN** `FileHashedEvent("video.mp4", "abc123")` is published -- **THEN** `ContentHash` SHALL be set to `"abc123"` +- **THEN** `ContentHash` SHALL be set to `"abc123"` and `State` SHALL transition to `Hashed` - **WHEN** `ChunkUploadingEvent("abc123", 5GB)` is published -- **THEN** `State` SHALL transition to `Uploading` +- **THEN** `State` SHALL transition to `Uploading` and `BytesProcessed` SHALL be reset to 0 - **WHEN** `ChunkUploadedEvent("abc123", 4GB)` is published - **THEN** the entry SHALL be removed from the dictionary @@ -43,13 +49,14 @@ The `FileState` enum SHALL have values: `Hashing`, `QueuedInTar`, `UploadingTar` - **WHEN** `FileHashingEvent("notes.txt", 1KB)` is published - **THEN** a `TrackedFile` entry SHALL be added with `State = Hashing` - **WHEN** `FileHashedEvent("notes.txt", "def456")` is published -- **THEN** `ContentHash` SHALL be set to `"def456"` +- **THEN** `ContentHash` SHALL be set to `"def456"` and `State` SHALL transition to `Hashed` (invisible in display) - **WHEN** `TarEntryAddedEvent("def456", 3, 15KB)` is published -- **THEN** `State` SHALL transition to `QueuedInTar` -- **WHEN** `TarBundleSealingEvent(5, 100KB, ["def456", ...])` is published -- **THEN** `State` SHALL transition to `UploadingTar` and `TarId` SHALL be set -- **WHEN** `TarBundleUploadedEvent(tarHash, 80KB, 5)` is published -- **THEN** all entries with matching `TarId` SHALL be removed from the dictionary +- **THEN** the `TrackedFile` entry SHALL be removed from the dictionary (small file subsumed into TAR bundle) + +#### Scenario: Hashed state is invisible in display +- **WHEN** a `TrackedFile` has `State = Hashed` +- **THEN** it SHALL NOT appear in the per-file display area +- **AND** it SHALL remain in the `TrackedFiles` dictionary for `ContentHashToPath` lookup #### Scenario: Byte-level progress update during hashing - **WHEN** the `IProgress` callback for a hashing file reports 2.5 GB read @@ -64,55 +71,144 @@ The `FileState` enum SHALL have values: `Hashing`, `QueuedInTar`, `UploadingTar` - **THEN** no data races SHALL occur and aggregate counters SHALL remain correct ### Requirement: ContentHash to RelativePath reverse lookup -`ProgressState` SHALL maintain a `ConcurrentDictionary` mapping `ContentHash → RelativePath`. This mapping SHALL be populated when `FileHashedEvent` fires (which provides both `RelativePath` and `ContentHash`). Handlers for events keyed by content hash (e.g., `TarEntryAddedEvent`, `ChunkUploadingEvent`, `ChunkUploadedEvent`) SHALL use this mapping to locate the corresponding `TrackedFile` entry. +`ProgressState` SHALL maintain a `ConcurrentDictionary>` mapping `ContentHash` to one or more `RelativePath` values. This mapping SHALL be populated when `FileHashedEvent` fires (which provides both `RelativePath` and `ContentHash`). Multiple files with identical content SHALL all be recorded under the same content hash key. Handlers for events keyed by content hash (e.g., `TarEntryAddedEvent`, `ChunkUploadingEvent`, `ChunkUploadedEvent`) SHALL use this mapping to locate the corresponding `TrackedFile` entries. #### Scenario: Reverse lookup for tar entry - **WHEN** `TarEntryAddedEvent("def456", 3, 15KB)` is published -- **THEN** the handler SHALL look up `"def456"` in the reverse map to find the `RelativePath`, then update the `TrackedFile` entry's state +- **THEN** the handler SHALL look up `"def456"` in the reverse map to find all `RelativePath` values, then remove each matching `TrackedFile` entry #### Scenario: Reverse lookup populated before downstream events - **WHEN** `FileHashedEvent("notes.txt", "def456")` is published -- **THEN** the reverse map SHALL contain `"def456" → "notes.txt"` BEFORE any `TarEntryAddedEvent` or `ChunkUploadingEvent` for `"def456"` can arrive (guaranteed by pipeline ordering) +- **THEN** the reverse map SHALL contain `"def456" → ["notes.txt"]` BEFORE any `TarEntryAddedEvent` or `ChunkUploadingEvent` for `"def456"` can arrive (guaranteed by pipeline ordering) + +#### Scenario: Multiple files with same content hash +- **WHEN** `FileHashedEvent("a.txt", "abc")` and `FileHashedEvent("b.txt", "abc")` are published +- **THEN** the reverse map SHALL contain `"abc" → ["a.txt", "b.txt"]` ### Requirement: Stage aggregate counters `ProgressState` SHALL maintain aggregate counters for stage header display: -- `TotalFiles` (long?, set by `FileScannedEvent`) +- `FilesScanned` (long, Interlocked-incremented by `FileScannedEvent`) — count of files discovered during enumeration +- `BytesScanned` (long, Interlocked-incremented by `FileScannedEvent`) — total bytes of files discovered +- `ScanComplete` (bool, set by `ScanCompleteEvent`) — true when enumeration finishes +- `TotalFiles` (long?, set by `ScanCompleteEvent`) — final file count +- `TotalBytes` (long?, set by `ScanCompleteEvent`) — final total bytes - `FilesHashed` (long, incremented by `FileHashedEvent`) +- `FilesUnique` (long, Interlocked-incremented) — count of files that passed dedup and need uploading - `ChunksUploaded` (long, incremented by `ChunkUploadedEvent`) - `TotalChunks` (long?, set when dedup completes) - `BytesUploaded` (long, incremented by `ChunkUploadedEvent`) - `TarsUploaded` (long, incremented by `TarBundleUploadedEvent`) - `SnapshotComplete` (bool, set by `SnapshotCreatedEvent`) +- `HashQueueDepth` (`Func?`) — getter for hash channel pending count, set via `OnHashQueueReady` callback +- `UploadQueueDepth` (`Func?`) — getter for upload channel pending count, set via `OnUploadQueueReady` callback + +#### Scenario: Per-file scanning events tick up counter +- **WHEN** `FileScannedEvent("photos/img.jpg", 1200000)` is published +- **THEN** `ProgressState.FilesScanned` SHALL be incremented by 1 and `ProgressState.BytesScanned` SHALL be incremented by 1,200,000 -These are used by the stage header lines in the display, independent of the per-file `TrackedFile` state. +#### Scenario: ScanCompleteEvent sets totals +- **WHEN** `ScanCompleteEvent(TotalFiles: 1523, TotalBytes: 5000000000)` is published +- **THEN** `ProgressState.TotalFiles` SHALL be set to 1523, `ProgressState.TotalBytes` SHALL be set to 5,000,000,000, and `ProgressState.ScanComplete` SHALL be set to true -#### Scenario: FileScannedEvent sets total -- **WHEN** `FileScannedEvent(TotalFiles: 1523)` is published -- **THEN** `ProgressState.TotalFiles` SHALL be set to 1523 +#### Scenario: FilesUnique incremented for large file upload +- **WHEN** `ChunkUploadingEvent("abc123", 5GB)` is published for a large file (found in `TrackedFiles`) +- **THEN** `ProgressState.FilesUnique` SHALL be incremented by 1 + +#### Scenario: FilesUnique incremented for small file in tar +- **WHEN** `TarEntryAddedEvent("def456", 3, 15KB)` is published +- **THEN** `ProgressState.FilesUnique` SHALL be incremented by 1 + +#### Scenario: Queue depth getter stored +- **WHEN** the pipeline calls `OnHashQueueReady` with a `Func` getter +- **THEN** `ProgressState.HashQueueDepth` SHALL be set to that getter and callable during display rendering ### Requirement: Archive notification handlers -Handlers SHALL be thin (state transition / counter increment only, no business logic). Each handler SHALL update the `TrackedFile` state machine and/or aggregate counters on `ProgressState`: - -| Event | TrackedFile action | Aggregate action | -|-------|-------------------|------------------| -| `FileScannedEvent` | (none) | Set `TotalFiles` | -| `FileHashingEvent` | Add entry, `State = Hashing` | (none) | -| `FileHashedEvent` | Set `ContentHash`, populate reverse map | Increment `FilesHashed` | -| `TarEntryAddedEvent` | `State → QueuedInTar` | (none) | -| `TarBundleSealingEvent` | All matching hashes: `State → UploadingTar`, set `TarId` | (none) | -| `ChunkUploadingEvent` | If not in tar path: `State → Uploading` | (none) | -| `ChunkUploadedEvent` | Remove entry (large file) | Increment `ChunksUploaded`, add `BytesUploaded` | -| `TarBundleUploadedEvent` | Remove all entries with matching `TarId` | Increment `TarsUploaded`, increment `ChunksUploaded` | -| `SnapshotCreatedEvent` | (none) | Set `SnapshotComplete` | - -#### Scenario: TarBundleSealingEvent transitions multiple files -- **WHEN** `TarBundleSealingEvent(5, 100KB, ["hash1", "hash2", "hash3", "hash4", "hash5"])` is published -- **THEN** the handler SHALL look up all 5 content hashes in the reverse map, find their `TrackedFile` entries, and set `State = UploadingTar` and `TarId` on each - -#### Scenario: TarBundleUploadedEvent removes multiple files -- **WHEN** `TarBundleUploadedEvent("tarHash", 80KB, 5)` is published -- **THEN** all `TrackedFile` entries with `TarId == "tarHash"` SHALL be removed from the dictionary +Handlers SHALL be thin (state transition / counter increment only, no business logic). Each handler SHALL update the `TrackedFile` / `TrackedTar` state machine and/or aggregate counters on `ProgressState`: + +| Event | TrackedFile action | TrackedTar action | Aggregate action | +|-------|-------------------|-------------------|------------------| +| `FileScannedEvent` | (none) | (none) | Increment `FilesScanned`, add `FileSize` to `BytesScanned` | +| `ScanCompleteEvent` | (none) | (none) | Set `TotalFiles`, `TotalBytes`, `ScanComplete` | +| `FileHashingEvent` | Add entry, `State = Hashing` | (none) | (none) | +| `FileHashedEvent` | Set `ContentHash`, `State = Hashed`, populate reverse map | (none) | Increment `FilesHashed` | +| `TarBundleStartedEvent` | (none) | Create new `TrackedTar`, `State = Accumulating` | (none) | +| `TarEntryAddedEvent` | Remove entry (small file done) | Update `FileCount` + `AccumulatedBytes` on current tar | Increment `FilesUnique` | +| `TarBundleSealingEvent` | (none) | `State → Sealing`, set `TarHash` + `TotalBytes` | (none) | +| `ChunkUploadingEvent` | If large file: `State → Uploading`, reset `BytesProcessed` | If tar: `State → Uploading` | Increment `FilesUnique` (large file only) | +| `ChunkUploadedEvent` | Remove entry (large file) | (none) | Increment `ChunksUploaded`, add `BytesUploaded` | +| `TarBundleUploadedEvent` | (none) | Remove `TrackedTar` entry | Increment `TarsUploaded`, increment `ChunksUploaded` | +| `SnapshotCreatedEvent` | (none) | (none) | Set `SnapshotComplete` | + +The `ChunkUploadingHandler` SHALL perform a dual lookup: first check `TrackedFiles` (via `ContentHashToPath` reverse map) for large files, then check `TrackedTars` (via `TarHash` match) for TAR bundles. Only one lookup SHALL match for any given content hash. + +#### Scenario: TarBundleStartedEvent creates tracked tar +- **WHEN** `TarBundleStartedEvent()` is published +- **THEN** the handler SHALL create a new `TrackedTar` with a sequential `BundleNumber`, `State = Accumulating`, and `TargetSize` set to `TarTargetSize` (64 MB default) + +#### Scenario: TarEntryAddedEvent updates tar and removes file +- **WHEN** `TarEntryAddedEvent("def456", 3, 15KB)` is published +- **THEN** the handler SHALL update the current `TrackedTar`'s `FileCount` and `AccumulatedBytes`, remove the `TrackedFile` entry for `"def456"` via reverse lookup, and increment `FilesUnique` + +#### Scenario: ChunkUploadingEvent routes to large file +- **WHEN** `ChunkUploadingEvent("abc123", 5GB)` is published and `"abc123"` is found in `ContentHashToPath` +- **THEN** the handler SHALL transition the matching `TrackedFile` to `State = Uploading`, reset `BytesProcessed` to 0, and increment `FilesUnique` + +#### Scenario: ChunkUploadingEvent routes to tar bundle +- **WHEN** `ChunkUploadingEvent("tarhash1", 50MB)` is published and `"tarhash1"` matches a `TrackedTar.TarHash` +- **THEN** the handler SHALL transition the matching `TrackedTar` to `State = Uploading` + +#### Scenario: TarBundleUploadedEvent removes tar +- **WHEN** `TarBundleUploadedEvent("tarhash1", 40MB, 64)` is published +- **THEN** the handler SHALL remove the `TrackedTar` with matching `TarHash` + +### Requirement: TrackedTar display entity +`ProgressState` SHALL maintain a `ConcurrentDictionary` keyed by `BundleNumber` for tracking TAR bundle lifecycle in the display. `TrackedTar` SHALL contain: + +- `BundleNumber` (int) — sequential display-only identifier, assigned by CLI handler +- `State` (TarState enum) — current lifecycle state +- `FileCount` (int) — number of files added so far +- `AccumulatedBytes` (long) — cumulative uncompressed bytes of files added +- `TargetSize` (long) — `TarTargetSize` value (default 64 MB), for accumulation progress bar +- `TotalBytes` (long) — final uncompressed size, set at sealing +- `BytesUploaded` (long, Interlocked-updated) — cumulative bytes uploaded, for upload progress bar +- `TarHash` (string?) — set at sealing, used for upload progress lookup + +The `TarState` enum SHALL have values: `Accumulating`, `Sealing`, `Uploading`. + +- `Accumulating` — tar is accepting file entries; display shows accumulation progress bar (`AccumulatedBytes / TargetSize`) +- `Sealing` — tar is being sealed and hashed; display shows bar frozen at last accumulation value +- `Uploading` — tar is being uploaded; display shows upload progress bar (`BytesUploaded / TotalBytes`) + +Bundle numbering SHALL be a CLI-only concern. Core events SHALL NOT carry bundle numbers. + +#### Scenario: TrackedTar lifecycle +- **WHEN** `TarBundleStartedEvent()` is published +- **THEN** a `TrackedTar` SHALL be created with `BundleNumber` = next sequential number, `State = Accumulating`, `TargetSize = TarTargetSize` +- **WHEN** `TarEntryAddedEvent` events update the tar +- **THEN** `FileCount` and `AccumulatedBytes` SHALL be updated on the current (most recent) `TrackedTar` +- **WHEN** `TarBundleSealingEvent(5, 100KB, "tarhash", [...])` is published +- **THEN** the current `TrackedTar`'s `State` SHALL transition to `Sealing`, `TarHash` SHALL be set, and `TotalBytes` SHALL be set +- **WHEN** `ChunkUploadingEvent("tarhash", ...)` is published matching the `TarHash` +- **THEN** `State` SHALL transition to `Uploading` +- **WHEN** `TarBundleUploadedEvent("tarhash", ...)` is published +- **THEN** the `TrackedTar` SHALL be removed + +#### Scenario: Accumulation progress bar target +- **WHEN** a `TrackedTar` is in `Accumulating` state with `AccumulatedBytes = 32MB` and `TargetSize = 64MB` +- **THEN** the accumulation progress bar SHALL show 50% fill + +#### Scenario: Upload byte-level progress via ProgressStream +- **WHEN** `CreateUploadProgress` is called with a content hash matching a `TrackedTar.TarHash` +- **THEN** the returned `IProgress` SHALL update `TrackedTar.BytesUploaded` via `Interlocked.Exchange` + +#### Scenario: Final tar partially filled +- **WHEN** the last TAR seals with `AccumulatedBytes` well below `TargetSize` (e.g., 3 MB of 64 MB) +- **THEN** the accumulation bar SHALL have shown low fill, and the tar SHALL proceed normally through `Sealing` and `Uploading` + +#### Scenario: Concurrent tars in different states +- **WHEN** TAR #1 is `Uploading`, TAR #2 is `Sealing`, and TAR #3 is `Accumulating` +- **THEN** all three SHALL appear in the display simultaneously with their respective progress indicators ### Requirement: Restore notification handlers The system SHALL implement `INotificationHandler` for all 4 restore notification events. Each handler SHALL update the corresponding fields on `ProgressState`: diff --git a/src/Arius.Cli/CliBuilder.cs b/src/Arius.Cli/CliBuilder.cs index 149a6e37..236ff40b 100644 --- a/src/Arius.Cli/CliBuilder.cs +++ b/src/Arius.Cli/CliBuilder.cs @@ -16,6 +16,7 @@ using Spectre.Console; using Spectre.Console.Rendering; using System.CommandLine; +using System.Globalization; namespace Arius.Cli; @@ -914,98 +915,136 @@ internal static IRenderable BuildArchiveDisplay(ProgressState state) } } - // ── 6.4 Per-file lines (only Hashing or Uploading state) ───────────── + // ── 6.4 + 6.5 Per-file and TAR bundle rows in a borderless table ──────── var activeFiles = state.TrackedFiles.Values .Where(f => f.State is FileState.Hashing or FileState.Uploading) .ToList(); + var trackedTars = state.TrackedTars.Values.OrderBy(t => t.BundleNumber).ToList(); - if (activeFiles.Count > 0) + if (activeFiles.Count > 0 || trackedTars.Count > 0) { lines.Add(new Markup("")); // blank separator + + // Collect row data first so we can compute max widths for padding. + // Each row: (nameStr, barMarkup, stateLabel, pctStr, curStr, totStr, unitStr) + var rowData = new List<(string name, string bar, string stateLabel, string pct, string cur, string tot, string unit)>(); + foreach (var file in activeFiles) { - var displayName = Markup.Escape(TruncateAndLeftJustify(file.RelativePath, 30)); - var stateLabel = file.State == FileState.Hashing ? "Hashing " : "Uploading "; - var pct = file.TotalBytes > 0 - ? (double)file.BytesProcessed / file.TotalBytes - : 0.0; - var bar = RenderProgressBar(pct, 12); - var pctStr = $"{pct * 100:F0}%".PadLeft(4); - var sizeStr = $"{file.BytesProcessed.Bytes().LargestWholeNumberValue:0.##} / {file.TotalBytes.Bytes().Humanize()}"; - lines.Add(new Markup( - $" [dim]{displayName}[/] {bar} [dim]{stateLabel} {pctStr} {Markup.Escape(sizeStr)}[/]")); + var pct = file.TotalBytes > 0 ? (double)file.BytesProcessed / file.TotalBytes : 0.0; + var (cur, tot, unit) = SplitSizePair(file.BytesProcessed, file.TotalBytes); + rowData.Add(( + TruncateAndLeftJustify(file.RelativePath, 30), + RenderProgressBar(pct, 12), + file.State == FileState.Hashing ? "Hashing" : "Uploading", + Math.Min(pct * 100, 100).ToString("F0", CultureInfo.InvariantCulture) + "%", + cur, tot, unit)); } - } - // ── 6.5 TAR bundle lines ────────────────────────────────────────────── - var trackedTars = state.TrackedTars.Values.OrderBy(t => t.BundleNumber).ToList(); - if (trackedTars.Count > 0) - { - lines.Add(new Markup("")); // blank separator foreach (var tar in trackedTars) { var label = $"TAR #{tar.BundleNumber} ({tar.FileCount} files, {tar.AccumulatedBytes.Bytes().Humanize()})"; - var displayLabel = Markup.Escape(TruncateAndLeftJustify(label, 30)); - - string bar; - string stateLabel; - string sizeStr; + string stateText, bar, pctText, cur, tot, unit; switch (tar.State) { case TarState.Accumulating: { - var pct = tar.TargetSize > 0 - ? (double)tar.AccumulatedBytes / tar.TargetSize - : 0.0; - bar = RenderProgressBar(pct, 12); - stateLabel = "Accumulating"; - sizeStr = $"{tar.AccumulatedBytes.Bytes().Humanize()} / {tar.TargetSize.Bytes().Humanize()}"; + var pct = tar.TargetSize > 0 ? (double)tar.AccumulatedBytes / tar.TargetSize : 0.0; + bar = RenderProgressBar(pct, 12); + stateText = "Accumulating"; + pctText = Math.Min(pct * 100, 100).ToString("F0", CultureInfo.InvariantCulture) + "%"; + (cur, tot, unit) = SplitSizePair(tar.AccumulatedBytes, tar.TargetSize); break; } case TarState.Sealing: { - var pct = tar.TargetSize > 0 - ? (double)tar.AccumulatedBytes / tar.TargetSize - : 1.0; - bar = RenderProgressBar(pct, 12); - stateLabel = "Sealing "; - sizeStr = $"{tar.AccumulatedBytes.Bytes().Humanize()} / {tar.TargetSize.Bytes().Humanize()}"; + var pct = tar.TargetSize > 0 ? (double)tar.AccumulatedBytes / tar.TargetSize : 1.0; + bar = RenderProgressBar(pct, 12); + stateText = "Sealing"; + pctText = Math.Min(pct * 100, 100).ToString("F0", CultureInfo.InvariantCulture) + "%"; + (cur, tot, unit) = SplitSizePair(tar.AccumulatedBytes, tar.TargetSize); break; } default: // Uploading { var totalBytes = tar.TotalBytes > 0 ? tar.TotalBytes : tar.AccumulatedBytes; - var pct = totalBytes > 0 - ? (double)tar.BytesUploaded / totalBytes - : 0.0; - var pctStr = $"{pct * 100:F0}%".PadLeft(4); - bar = RenderProgressBar(pct, 12); - stateLabel = $"Uploading {pctStr}"; - sizeStr = $"{tar.BytesUploaded.Bytes().Humanize()} / {totalBytes.Bytes().Humanize()}"; + var pct = totalBytes > 0 ? (double)tar.BytesUploaded / totalBytes : 0.0; + bar = RenderProgressBar(pct, 12); + stateText = "Uploading"; + pctText = Math.Min(pct * 100, 100).ToString("F0", CultureInfo.InvariantCulture) + "%"; + (cur, tot, unit) = SplitSizePair(tar.BytesUploaded, totalBytes); break; } } - lines.Add(new Markup( - $" [dim]{displayLabel}[/] {bar} [dim]{stateLabel} {Markup.Escape(sizeStr)}[/]")); + rowData.Add((TruncateAndLeftJustify(label, 30), bar, stateText, pctText, cur, tot, unit)); } + + // Compute max widths across all rows for the columns that need alignment. + var maxPct = rowData.Max(r => r.pct.Length); + var maxCur = rowData.Max(r => r.cur.Length); + var maxTot = rowData.Max(r => r.tot.Length); + var maxState = rowData.Max(r => r.stateLabel.Length); + + // Borderless table with 5 columns: name | bar | state | % | "cur / tot unit" + var table = new Table() + .NoBorder() + .HideHeaders() + .AddColumn(new TableColumn("").NoWrap().LeftAligned()) // name + .AddColumn(new TableColumn("").NoWrap().LeftAligned()) // bar + .AddColumn(new TableColumn("").NoWrap().LeftAligned()) // state label (padded) + .AddColumn(new TableColumn("").NoWrap().RightAligned()) // % (padded) + .AddColumn(new TableColumn("").NoWrap().LeftAligned()); // "cur / tot unit" (pre-padded) + + foreach (var (name, bar, stateLabel, pct, cur, tot, unit) in rowData) + { + var paddedState = stateLabel.PadRight(maxState); + var paddedPct = pct.PadLeft(maxPct); + var paddedCur = cur.PadLeft(maxCur); + var paddedTot = tot.PadLeft(maxTot); + var sizeStr = $"{paddedCur} / {paddedTot} {unit}"; + + table.AddRow( + new Markup("[dim]" + Markup.Escape(name) + "[/]"), + new Markup(bar), + new Markup("[dim]" + paddedState + "[/]"), + new Markup("[dim]" + paddedPct + "[/]"), + new Markup("[dim]" + sizeStr + "[/]")); + } + + lines.Add(table); } return new Rows(lines); } + /// + /// Splits a (current, total) byte pair into aligned string values sharing the unit of . + /// Returns (currentValueStr, totalValueStr, unitSymbol) — both values formatted to up to 2 decimal places. + /// + internal static (string current, string total, string unit) SplitSizePair(long current, long total) + { + var totalInfo = total.Bytes(); + var unit = totalInfo.LargestWholeNumberSymbol; + var divisor = total > 0 ? (double)total / totalInfo.LargestWholeNumberValue : 1.0; + var currentVal = divisor > 0 ? current / divisor : 0.0; + var totalVal = totalInfo.LargestWholeNumberValue; + return (currentVal.ToString("0.00", CultureInfo.InvariantCulture), + totalVal .ToString("0.00", CultureInfo.InvariantCulture), + unit); + } + /// /// Renders a progress bar as a Markup string with the given fill ratio and character width. /// Filled characters use [green]█[/] and empty characters use [dim]░[/]. /// /// Fill ratio in [0.0, 1.0]. - /// - /// Render a horizontal progress bar as a markup string using filled and empty block characters. - /// /// Total bar width in characters. /// - /// A markup string of length `width` composed of green filled block characters for the completed fraction and dim empty block characters for the remainder; `fraction` values outside 0.0–1.0 are clamped to that range. + /// A markup string of length composed of green filled block characters + /// for the completed fraction and dim empty block characters for the remainder; + /// values outside 0.0–1.0 are clamped to that range. /// internal static string RenderProgressBar(double fraction, int width) { diff --git a/src/Arius.Core/Encryption/PassphraseEncryptionService.cs b/src/Arius.Core/Encryption/PassphraseEncryptionService.cs index b3aae269..1cc6fbf2 100644 --- a/src/Arius.Core/Encryption/PassphraseEncryptionService.cs +++ b/src/Arius.Core/Encryption/PassphraseEncryptionService.cs @@ -75,14 +75,15 @@ public async Task ComputeHashAsync(Stream data, CancellationToken cancel private static (byte[] key, byte[] iv) DeriveKeyIv(byte[] passphraseBytes, byte[] salt) { // openssl EVP_BytesToKey equivalent via PBKDF2-SHA256 - using var pbkdf2 = new Rfc2898DeriveBytes( + var derived = Rfc2898DeriveBytes.Pbkdf2( passphraseBytes, salt, Pbkdf2Iter, - HashAlgorithmName.SHA256); + HashAlgorithmName.SHA256, + KeySize + IvSize); - var key = pbkdf2.GetBytes(KeySize); - var iv = pbkdf2.GetBytes(IvSize); + var key = derived[..KeySize]; + var iv = derived[KeySize..]; return (key, iv); }