diff --git a/.claude/agent-memory/atomic-executor/MEMORY.md b/.claude/agent-memory/atomic-executor/MEMORY.md index db440fe8..88143534 100644 --- a/.claude/agent-memory/atomic-executor/MEMORY.md +++ b/.claude/agent-memory/atomic-executor/MEMORY.md @@ -1,5 +1,7 @@ # Atomic Executor Memory Index +- [#349 breadcrumb WebView2 gotchas](project_349_breadcrumb_webview2_gotchas.md) — retyped Designer field breaks reflection-injected tests (inject a router instead); aggregate async d__ classes for >=90% proofs; QuickFiler.Test is Newtonsoft-free + - [VS18 build/test toolchain paths](project_vs18_build_toolchain_paths.md) — build with VS **18** full-framework msbuild.exe (not Core .dotnet-sdk, which dies on binary resx MSB3822); nuget.exe restore; MSYS_NO_PATHCONV; csharpier v1 subcommands; dotnet-coverage needs `--` separator - [C# canonical coverage artifact conversion](project_csharp_canonical_coverage_artifact_conversion.md) — hook reads artifacts/csharp/coverage.xml as JaCoCo; convert feature Cobertura (dedup lines, per-package counters); first-party aggregate under-counts <85% because uninstrumented assemblies show 0% — defer repo-wide to PR CI per policy-audit §5.4, don't cherry-pick diff --git a/.claude/agent-memory/atomic-executor/project_349_breadcrumb_webview2_gotchas.md b/.claude/agent-memory/atomic-executor/project_349_breadcrumb_webview2_gotchas.md new file mode 100644 index 00000000..9acf42a7 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_349_breadcrumb_webview2_gotchas.md @@ -0,0 +1,15 @@ +--- +name: 349-breadcrumb-webview2-gotchas +description: "#349 EfcViewer breadcrumb swap: retyped Designer field breaks reflection test injection; async-state-machine classes hide coverage gaps; QuickFiler.Test has no Newtonsoft" +metadata: + type: project +--- + +Three gotchas from executing #349 (EfcViewer TreeListView -> WebView2 breadcrumb), directly relevant to sibling epic child 9103 (QuickFiler breadcrumb, same epic): + +1. Retyping the Designer `FolderListBox` field (TreeListView -> WebView2) compile-broke `EfcHomeControllerExecuteMovesTests`, which injected the removed private `_selectedNode` field via reflection. Fix: drive a REAL `BreadcrumbBridgeRouter` over `Mock` + `Mock` to a selected state (`BindRowsAsync` + `SelectFirstRow`) and inject `_router` instead. +2. Cobertura per-class rates hide async gaps: `BreadcrumbBridgeRouter` showed 91.6% at class level while its compiler-generated `d__` machine sat at 29%. Per-module >= 90% verification MUST aggregate the parent type plus its `<...>d__`/`<>c__DisplayClass` nested classes (line-level dedup). Scratchpad script pattern: fold `name -replace '\.<[^>]*>.*$'`. +3. QuickFiler.Test has NO Newtonsoft.Json reference (and must not gain one — Newtonsoft-consuming code lives only in UtilitiesCS). Router tests must assert on raw JSON payload strings (JSON-escape HTML fragments: `\` -> `\\`, `"` -> `\"`), not JObject. + +**Why:** 9103 consumes the same 9101 provider surface (`FolderBreadcrumbSegment` with `Key`/`FolderPath`/`HasChildren`, key-based `GetAncestorChainAsync`/`GetImmediateSubfoldersAsync`, string-path bridge `ResolveLeafKeyAsync`) and will hit the same three traps. +**How to apply:** when a plan retargets a QuickFiler viewer's folder list to WebView2, pre-check reflection-based tests for removed private fields, aggregate nested-type coverage before claiming >= 90%, and keep bridge-JSON assertions Newtonsoft-free in QuickFiler.Test. diff --git a/.claude/agent-memory/atomic-planner/MEMORY.md b/.claude/agent-memory/atomic-planner/MEMORY.md index 0c248e8f..134392b7 100644 --- a/.claude/agent-memory/atomic-planner/MEMORY.md +++ b/.claude/agent-memory/atomic-planner/MEMORY.md @@ -13,3 +13,4 @@ - [#307 F2 ScoCollection deletion gate](project_307_f2_scocollection_deletion_gate.md) — full first-party ScoCollection/ScoStack reference set incl. tests beyond spec §7; ISubjectMapSco/IScoCollection F5 boundary; FS/Prompt seams live in ScoCollection.cs - [#328 store-exclusion seams](project_328_store_exclusion_seams.md) — StoresWrapper(469)/TreeOfToDoItems(481) near 500-limit, ToDoEvents(594) pre-existing over-limit; new test .cs need csproj wiring; four inclusion surfaces lockstep; adopted persisted StoreWrapper.StoreId - [C# coverage gate expects JaCoCo](project_csharp_coverage_gate_jacoco_format.md) — validate-feature-review-coverage.ps1 reads artifacts/csharp/coverage.xml as JaCoCo, not Cobertura; plan a conversion scoped to first-party +- [#351 QuickFiler breadcrumb plan seams](project_351_quickfiler_breadcrumb_plan_seams.md) — JSON code in UtilitiesCS only (QuickFiler lacks Newtonsoft); P2-T1 blocked-if-9101-absent; evidence/repro/ rejected; coordinator pattern diff --git a/.claude/agent-memory/atomic-planner/project_351_quickfiler_breadcrumb_plan_seams.md b/.claude/agent-memory/atomic-planner/project_351_quickfiler_breadcrumb_plan_seams.md new file mode 100644 index 00000000..4210ea5a --- /dev/null +++ b/.claude/agent-memory/atomic-planner/project_351_quickfiler_breadcrumb_plan_seams.md @@ -0,0 +1,17 @@ +--- +name: project-351-quickfiler-breadcrumb-plan-seams +description: "#351 plan (plan.2026-07-16T21-53.md) load-bearing decisions: JSON code must live in UtilitiesCS (QuickFiler has no Newtonsoft ref), P2-T1 blocked-if-9101-absent gate, evidence/repro/ rejected, coordinator pattern" +metadata: + type: project +--- + +Plan `docs/features/active/2026-07-16-quickfiler-breadcrumb-webview2-351/plan.2026-07-16T21-53.md` (8 phases, 52 tasks) encodes these non-obvious decisions; keep them stable across preflight revision loops: + +- **JSON placement:** `Newtonsoft.Json` is referenced only by `UtilitiesCS.csproj` (13.0.4), NOT by `QuickFiler.csproj`. All bridge message serialization (`BreadcrumbBridgeMessages.cs`, router) lives in `UtilitiesCS/OutlookObjects/Folder/`; the QuickFiler-side `BreadcrumbBridgeCoordinator` handles raw JSON strings only (no-new-packages guardrail G2). +- **9101 gate:** P0-T8 records `9101-CONTRACT: PRESENT|ABSENT`; P2-T1 reconciles (`DIRECT-CONSUME` vs `ADAPTER-REQUIRED`) and halts BLOCKED if absent — the plan never re-implements the live Outlook query. P2-T2/T3 carry explicitly authorized skip branches tied to the P2-T1 decision (compatible with the no-SKIPPED rule). +- **Evidence override:** spec.md FR-5/AC-6 names `evidence/repro/` — rejected; repro goes to `evidence/regression-testing/`, post-fix to `evidence/qa-gates/`, with an `EVIDENCE_LOCATION_OVERRIDE_REJECTED:` line in the plan header. P1-T1 allows a fail-before exception dossier when live-host capture is impossible. +- **Testable layering:** host-neutral core in `UtilitiesCS/OutlookObjects/Folder/` (StateModel, RenderProjection, BridgeMessages, BridgeRouter, SelectionMap) mirroring `FolderTreeStateModel`; QuickFiler seams `IWebViewMessenger` + exempt `WebView2Messenger` + non-exempt `BreadcrumbBridgeCoordinator` (tested with Moq'd messenger). Legacy arrow fall-throughs preserved via `UnhandledArrow` event rerouted in `KeyboardHandler.cs:543-583` (reroute, not removal). + +**Why:** these came from repo verification (csproj greps) and spec/guardrail reconciliation during planning; a revision loop that moves JSON code into QuickFiler or drops the P2-T1 gate would break G2/G6. + +**How to apply:** when revising this plan after `PREFLIGHT: REVISIONS REQUIRED`, update the same file in place and re-verify these four decision points survive the delta. Related: [[evidence-path-normalization]], [[plan-validator-task-id-sequential-constraint]], [[project_legacy_csproj_explicit_compile_include]]. diff --git a/.claude/agent-memory/orchestrator/MEMORY.md b/.claude/agent-memory/orchestrator/MEMORY.md index 566599ed..8a4d6d12 100644 --- a/.claude/agent-memory/orchestrator/MEMORY.md +++ b/.claude/agent-memory/orchestrator/MEMORY.md @@ -11,6 +11,7 @@ - [Remediation-plan em-dash required](remediation-plan-em-dash-required.md) — the plan validator rejects `### Phase N (continued) — `; only canonical `### Phase N — <Title>` passes - [new_active_feature_folder date prefix](new-active-feature-folder-date-prefix.md) — standalone feature folders get the YYYY-MM-DD- prefix automatically; epic-child folders don't (git mv those) - [orchestrator-state validator divergence](orchestrator-state-validator-divergence.md) — MCP orchestrator-state check is stricter than the real SubagentStop hook; conform to the canonical schema's remediation_loop shape +- [orchestrator-state flat keys + step-status enum](orchestrator-state-flat-keys-and-enum.md) — validator needs FLAT top-level variable keys (not nested under "variables") and enum step statuses (in_progress not in-progress); fable_policy "available" => C3->opus - [C# analyzer packages.config quirks](csharp-analyzer-packages-config-quirks.md) — non-SDK analyzer wiring needs manual roslyn subfolder selection; SecurityCodeScan.VS2019 breaks Roslyn 5.6 via CS8032 (can't be silenced by editorconfig) - [Whole-repo CI gate is not out-of-scope](whole-repo-ci-gate-not-out-of-scope.md) — a pre-existing repo-wide csharpier/lint failure blocks the PR's required check (AC6); fix it, don't defer it - [Honor user's per-cycle folder layout](feedback_verify_flat_artifact_layout_after_executor.md) — #181 user committed a per-cycle folder layout (<ts>-remediation/, <ts>-audit/); follow it. Only revert UNDIRECTED agent relocations, not the user's own reorg @@ -45,4 +46,5 @@ - [Verify subagent capability claims](feedback_verify_subagent_capability_claims.md) — never relay "agent type not registered" without checking .claude/agents frontmatter; demand verbatim error; blocked state, not silent fallback - [Epic-child plan Phase 0 paths are stale](feedback_plan_phase0_paths_are_stale_in_epic_children.md) — epic-child plans cite the planning worktree's absolute paths for P0 policy reads; redirect the executor to the CURRENT worktree's files in the delegation prompt - [Unplanned epic-child worktree mechanics](unplanned-epic-child-worktree-mechanics.md) — cross-worktree delegation works via absolute paths; atomic-executor runs C# tools via pwsh with explicit paths (vstest/csharpier not on PATH); collect_pr_context + PR/merge hooks resolve against session root +- [Parallel epic children name collisions](parallel-epic-children-name-collisions.md) — siblings coin identical type names in shared namespaces; CS0101/CS0104 surface only at rebase; rename YOUR types, rerun toolchain, no re-review - [Swordfish epic F5 ScoDictionary blocker (RESOLVED)](project_swordfish_epic_f5_blocked_on_old_scodictionary.md) — F5 (#308) once WI-0-halted on the OLD ScoDictionary Swordfish base (ScoDictionaryNew was a decoy); #315/PR #316 deleted it, F5 then completed. Lesson: grep the OLD class base + using, not just the *New replacement diff --git a/.claude/agent-memory/orchestrator/agent-worktree-hooks-resolve-to-agent-cwd.md b/.claude/agent-memory/orchestrator/agent-worktree-hooks-resolve-to-agent-cwd.md index 3471c882..d4d707f1 100644 --- a/.claude/agent-memory/orchestrator/agent-worktree-hooks-resolve-to-agent-cwd.md +++ b/.claude/agent-memory/orchestrator/agent-worktree-hooks-resolve-to-agent-cwd.md @@ -11,6 +11,6 @@ Evidence (epic child #324, folder-probability-plumbing, 2026-07-16): `gh pr crea **Why:** an Agent-tool isolated worktree makes the agent's own cwd == the worktree, and hooks inherit that cwd. This differs from [[child-orchestrator-pr-hook-reads-session-root]], which applied when the session cwd was a *separately-created named* worktree distinct from the feature worktree (session cwd != feature worktree). Distinguish the two topologies before deciding whether to stage artifacts in the session root. -**How to apply:** in an Agent-tool isolated worktree, author the PR body/receipt and keep the child checkpoint in the agent worktree's `artifacts/` and do NOT copy them to the session root — copying `orchestrator-state.json` to the shared session root can clobber a concurrent sibling child's merge-gate checkpoint. Run `collect_pr_context` with `workspace_root` = the agent worktree. If a future run shows the hook reading the session root (e.g. `PR_CONTEXT_MISSING`/`ORCHESTRATOR_STATE_PREFLIGHT_FAILED` despite valid agent-worktree artifacts), only then stage into the session root. +**How to apply:** in an Agent-tool isolated worktree, author the PR body/receipt and keep the child checkpoint in the agent worktree's `artifacts/` and do NOT copy them to the session root — copying `orchestrator-state.json` to the shared session root can clobber a concurrent sibling child's merge-gate checkpoint. Run `collect_pr_context` with `workspace_root` = the agent worktree, but note (re-verified 2026-07-18, child #349/PR #355): even with that arg it WRITES `pr_context.*` to the SESSION ROOT while its JSON response claims agent-worktree paths; the head/base/merge-base refs are still resolved correctly by branch name. Copy `pr_context.summary.txt`/`.appendix.txt` into the agent worktree's `artifacts/` before authoring the receipt (whose `context_summary_path` points at the worktree copy). Other MCP tools (`resolve_execute_hard_lock_prompt`, `validate_orchestration_artifacts`) DO honor `workspace_root`; hard-lock target paths must be absolute or resolution fails. If a future run shows the hook reading the session root (e.g. `PR_CONTEXT_MISSING`/`ORCHESTRATOR_STATE_PREFLIGHT_FAILED` despite valid agent-worktree artifacts), only then stage into the session root. Also confirmed this run: on an epic integration-based branch the Python validator (`scripts/dev_tools/*.py`) is absent, so `Test-PythonOrchestratorValidatorAvailable` returns false and the portable `OrchestratorStateCompletion.psm1` gate is authoritative at SubagentStop (base presence + model-routing existence only). The bundled MCP `require_complete` check is much stricter (full large-route pr_gate/ci_gate + promotion/research/planning receipts) and will FAIL for a prepared-epic child that resumes at execution — that divergence is expected and is NOT the real Stop gate. See [[orchestrator-state-validator-divergence]]. diff --git a/.claude/agent-memory/orchestrator/orchestrator-state-flat-keys-and-enum.md b/.claude/agent-memory/orchestrator/orchestrator-state-flat-keys-and-enum.md new file mode 100644 index 00000000..3cff3d9c --- /dev/null +++ b/.claude/agent-memory/orchestrator/orchestrator-state-flat-keys-and-enum.md @@ -0,0 +1,31 @@ +--- +name: orchestrator-state-flat-keys-and-enum +description: The orchestrator-state MCP validator requires flat top-level variable keys and a specific step-status enum; nesting under "variables" or using "in-progress" fails +metadata: + type: feedback +--- + +The `validate_orchestration_artifacts` (artifact_type: orchestrator-state) MCP validator requires the +lifecycle variables as FLAT top-level keys, not nested under a `variables` object. Required top-level +keys: `objective`, `change_budget_estimate`, `path_selected`, `promotion-type`, `short-name`, +`relativeFile`, `long-name`, `issue-num`, `feature-folder`, `work-mode`, `plan-path`, +`completed_steps`, `next_step`, `last_updated`, `step5_status`..`step10_status`, +`delegation_receipts`, `blocked_reason`. + +Valid `stepN_status` enum: `not-applicable`, `pending`, `delegated`, `verified`, `blocked`, +`not_started`, `in_progress`, `completed`. Note `in_progress` uses an UNDERSCORE; `in-progress` +(hyphen) is rejected. Valid `blocked_reason`: `none`, `spawn_agent_unavailable`, +`delegation_launch_failed`, `delegate_no_receipt`, `delegate_contract_incomplete`, +`validator_failed`, `user_requested_stop` (use `"none"`, not null, to be safe). + +**Why:** On the folder-hierarchy-live-provider (#350) epic-child preparation run I first nested the +variables under `variables` and used `step6_status: "in-progress"`; the validator failed with +"Checkpoint missing required key: promotion-type ..." and "invalid step6_status". Authoritative +source is `extensions/drm-copilot/src/lib/validate/orchestrator-state-core.ts` +(`REQUIRED_STATE_KEYS`, `VALID_STEP_STATUS`, `VALID_BLOCKED_REASONS`) in the drm-copilot repo. + +**How to apply:** In preparation-mode epic-child runs (and any orchestrator run), the checkpoint also +needs `complexity_assessments[]` + `model_routing_receipts[]` populated for every delegated agent to +pass `require_model_routing=true`. In this repo `config/orchestration-routing.json` has +`fable_policy: "available"`, so C3 -> opus for all agents (base `complexity_to_model` table, no clamp, +no overlay). See [[orchestrator-state-validator-divergence]] and [[pr-author-hook-blocks-gh-in-this-repo]]. diff --git a/.claude/agent-memory/orchestrator/parallel-epic-children-name-collisions.md b/.claude/agent-memory/orchestrator/parallel-epic-children-name-collisions.md new file mode 100644 index 00000000..6c7d2ab7 --- /dev/null +++ b/.claude/agent-memory/orchestrator/parallel-epic-children-name-collisions.md @@ -0,0 +1,17 @@ +--- +name: parallel-epic-children-name-collisions +description: Parallel epic children can invent identical C# type names in the same shared namespace; collisions surface only at rebase — resolve by renaming YOUR types, rerun full toolchain, no re-review needed per epic directive +metadata: + type: project +--- + +Verified 2026-07-18 (epic folder-tree-breadcrumb-redesign, children #349/#351). Two siblings executing concurrently against the same integration base both created breadcrumb types in `UtilitiesCS.OutlookObjects.Folder`. Textual merge conflicts were confined to `.csproj` `<Compile Include>` blocks (union-resolve, keep both), but two SEMANTIC collisions appeared only at the post-sibling-merge rebase build: CS0101 (`BreadcrumbRow` defined by both) and CS0104 (`BreadcrumbBridgeRouter` ambiguous between `QuickFiler.Controllers` and `UtilitiesCS.OutlookObjects.Folder` in sibling test files importing both namespaces). + +**Why:** epic planning did not assign type-name budgets/prefixes per child; both features independently coined natural names for the same domain. git cannot see same-name-new-file collisions across different paths. + +**How to apply:** +- After a sibling merges first, rebase and expect the analyzer build to fail even when only csproj conflicts appeared; grep both branches' new type names in shared namespaces proactively (`git diff --name-only base..integration` vs your new files). +- Resolve by renaming YOUR OWN types (e.g. `BreadcrumbRow`->`BreadcrumbStateRow`, `BreadcrumbBridgeRouter`->`FolderBreadcrumbBridgeRouter` with git mv + csproj update), never editing sibling-owned merged files. +- Identifier-rename-only + full toolchain green in one pass counts as rebase conflict resolution under the epic directive ("rebase onto the updated integration tip and rerun the full toolchain before merging") — no feature-review re-run required; document it in the PR body and checkpoint. +- csharpier may need a re-format after renames (longer identifiers re-wrap lines). +- Also observed: `collect_pr_context` wrote pr_context.* INTO the agent worktree when `workspace_root` was the Agent-tool isolated worktree (cwd==worktree topology), consistent with [[agent-worktree-hooks-resolve-to-agent-cwd]] and contrasting [[collect-pr-context-lands-in-main-checkout]] (named-worktree topology). diff --git a/.claude/agent-memory/task-researcher/MEMORY.md b/.claude/agent-memory/task-researcher/MEMORY.md index 9bca3d62..95735f13 100644 --- a/.claude/agent-memory/task-researcher/MEMORY.md +++ b/.claude/agent-memory/task-researcher/MEMORY.md @@ -18,4 +18,6 @@ - [legacy-scodictionary-removal-315](project_legacy_scodictionary_removal_315.md) — #315: DELETE SCODictionary_Tests(+Additional), RETARGET 3 SmartSerializable* test files, IScoDictionary interface safe to keep for F5 (2026-07-11) - [lock-recursion-coverage-317](project_lock_recursion_coverage_317.md) — #317: deleted LockRecursionTests.cs is a restoration (F2-authored clean-base coverage F5 knowingly removed per WI-4), not new authoring; no git/shell tool this session (2026-07-11) - [qfc-folder-tree-percentage-325](project_qfc_folder_tree_percentage_325.md) — #325 (epic child 9003): only 1 live viewer (ItemViewer) despite 9 dead CboFolders variants; owner-draw ComboBox + host-neutral TreeNode<T> seams; consumes 9001 probability contract (2026-07-15) +- [qfc-breadcrumb-webview2-351](project_qfc_breadcrumb_webview2_351.md) — #351 (epic 9103): 9101 provider ABSENT (code to assumed contract); JS<->.NET bridge greenfield (no WebMessageReceived anywhere); Theme.Rendering TODO = top percentage hypothesis (2026-07-16) - [dependabot-net481-340](project_dependabot_net481_340.md) — #340: no packages.config package currently dropped net481; transitive-bump restraint is already Dependabot's NuGet default (cite security-updates docs, not a new config primitive); use semver-major ignore not fabricated version ceilings (2026-07-16) +- [folder-hierarchy-provider-350](project_folder_hierarchy_provider_350.md) — #350/epic 9101: reuse existing snapshot infra (IOutlookFolderTreeService.GetChildren + ParentKey walk), add IFolderHierarchyProvider facade + pure GetAncestorChain, no new COM seam; defer deleting BuildFromRows/Build to 9102/9103 (2026-07-16) diff --git a/.claude/agent-memory/task-researcher/project_folder_hierarchy_provider_350.md b/.claude/agent-memory/task-researcher/project_folder_hierarchy_provider_350.md new file mode 100644 index 00000000..ea0fe929 --- /dev/null +++ b/.claude/agent-memory/task-researcher/project_folder_hierarchy_provider_350.md @@ -0,0 +1,32 @@ +--- +name: folder-hierarchy-provider-350 +description: Issue #350 / epic 9101 — live folder-hierarchy provider should reuse existing snapshot infra, not add a new COM seam +metadata: + type: project +--- + +Epic `folder-tree-breadcrumb-redesign` child 9101 (issue #350, wave 0, C3): live Outlook +folder-hierarchy provider (ancestor chain + on-demand immediate subfolders) for the WebView2 +breadcrumb consumers 9102 (EfcViewer) and 9103 (QuickFiler). + +Key reconciliation finding (non-obvious, verified 2026-07-16): the live `MAPIFolder.Folders` query +is ALREADY isolated in `UtilitiesCS/OutlookObjects/Folder/`: +- `IOutlookFolderHierarchyReader` (COM-exempt impl recursively reads `MAPIFolder.Folders`) +- `FolderTreeSnapshotBuilder` -> immutable `FolderTreeSnapshot` +- `IOutlookFolderTreeService` (cached + notification-refreshed), exposed on `IOlObjects.FolderTreeService` +- `FolderTreeSnapshot.GetChildren(key)` already returns real immediate subfolders; nodes carry + `ParentKey`/`ChildKeys` and stable `FolderTreeNodeKey` identity. +Only genuine gap: no ordered root-to-leaf ancestor-chain helper. Recommendation: add pure +`FolderTreeSnapshotQueries.GetAncestorChain`, a shared `IFolderHierarchyProvider` facade over +`IOutlookFolderTreeService`, and a `FolderBreadcrumbSegment` DTO. Adds ZERO new COM/exempt code. + +**Why:** avoids duplicating the COM boundary + refresh state machine + fakes; keeps provider fully +unit-testable; keeps 9101 independently mergeable in wave 0. +**How to apply:** for 9102/9103, consume `IFolderHierarchyProvider` from globals; join the percentage +per FolderPath from the existing 324 plumbing (provider DTO is deliberately probability-free). +Scope note: 9101 does NOT delete `FolderSuggestionTree.BuildFromRows` / +`FolderHierarchyBuilder.Build` — their only live callers are `EfcFormController.BindFolderRows` +(#885) and `ItemViewer.FolderSearch.SetFolderSuggestions` (#26), which 9102/9103 replace; deletion is +deferred to those UI features. + +Research artifact: docs/features/active/2026-07-16-folder-hierarchy-live-provider-350/research/2026-07-16T21-40-folder-hierarchy-live-provider-research.md diff --git a/.claude/agent-memory/task-researcher/project_qfc_breadcrumb_webview2_351.md b/.claude/agent-memory/task-researcher/project_qfc_breadcrumb_webview2_351.md new file mode 100644 index 00000000..19420be8 --- /dev/null +++ b/.claude/agent-memory/task-researcher/project_qfc_breadcrumb_webview2_351.md @@ -0,0 +1,43 @@ +--- +name: qfc-breadcrumb-webview2-351 +description: Issue #351 (epic 9103) QuickFiler WebView2 breadcrumb research — 9101 provider absent, bridge is greenfield, Theme TODO is top percentage hypothesis +metadata: + type: project +--- + +Issue #351 (epic `folder-tree-breadcrumb-redesign`, child 9103, wave 1, C4): replace `CboFolders` +ComboBox with a WebView2 HTML/CSS/JS breadcrumb in the live `ItemViewer`. Research written +2026-07-16T22-30 to `docs/features/active/2026-07-16-quickfiler-breadcrumb-webview2-351/research/`. + +**Why:** several load-bearing facts are not derivable from the feature docs and were verified. +**How to apply:** cite these when planning/executing #351 or sibling 9102, and re-verify 9101 +presence before execution. + +Key verified non-obvious findings: +- Liveness re-confirmed unchanged from [[qfc-folder-tree-percentage-325]]: only `ItemViewer` is + constructed (`ItemViewerQueue.cs:105`, hard-bound `ViewerQueueCore<ItemViewer>`); 9 variants dead. +- **9101 provider does NOT exist on the branch** (no feature folder, no interface). Probable + substrate: `IOutlookFolderHierarchyReader` + `FolderTreeSnapshotNode` (has ParentKey/ChildKeys) + in `UtilitiesCS.OutlookObjects.Folder` — predates the epic. Plan codes against an assumed + `IFolderHierarchyProvider` (GetAncestorChainAsync / GetImmediateSubfoldersAsync), flagged + ASSUMED-PENDING-9101-MERGE. +- **JS<->.NET bridge is greenfield**: zero repo usages of WebMessageReceived / + AddHostObjectToScript / ExecuteScriptAsync. Existing WebView2 use is one-way NavigateToString; + init via injected `IWebViewCoreInitializer` (`QfcItemController.ViewerSetup.cs:36`), shared + env in `%LocalAppData%\WindowsFormsWebView2`, options string `"–incognito "` (en-dash typo, + silently ignored — reuse verbatim for env compat). SDK pinned 1.0.3912.50 in all csprojs. +- Percentage-obscuring top hypothesis: `Theme.Rendering.cs:96-98` sets CboFolders fore/back with + literal TODO "colors do not work as expected"; owner-draw geometry reserves a clean 46px right + column (no overlap). Secondary: dropdown scrollbar overlay, DPI clipping. Runtime repro required. +- Two population paths must both survive: FolderRow suggestions (`AssignFolderComboBox`, + FolderHandling.cs:161-200, only prod caller of `FolderHierarchyBuilder.Build` is + `ItemViewer.FolderSearch.cs:26`) AND plain `string[]` search results + (`TextBoxSearch_TextChanged`, EventHandlers.cs:164-178). `"Trash to Delete"` is a literal string + contract (MailActions.cs:90). Selection output = full folder path string (`GetSelectedFolder`). +- Keyboard Left/Right routing is ComboBox-shaped (`KeyboardHandler.cs:543-583`, + `CboFolders_KeyDownAsync` throws on non-ComboBox sender); browser consumes arrows, so bridge + must report unhandled arrows for legacy fall-throughs (Right -> Pop Out/Enumerate dialog). +- HTML asset precedent: `Resources\EmailHeader.html` Content include (QuickFiler.csproj:495); + recommend embedded string + NavigateToString over virtual-host folder mapping. +- Host-neutral precedent to mirror: `FolderTreeStateModel` + `FolderNodeViewModel` (not exempt); + glue stays in exempt ItemViewer partials. net481 everywhere (no record/init). diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.cs new file mode 100644 index 00000000..a9a0eb55 --- /dev/null +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Controllers; +using QuickFiler.Viewers; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Test.Controllers +{ + /// <summary> + /// Negative/edge-path tests for <see cref="BreadcrumbBridgeRouter"/> and + /// <see cref="BreadcrumbOutboundQueue"/> (#349): pre-initialization queueing and in-order + /// flush, provider failure and cancellation leaving state unchanged, malformed inbound JSON + /// rejection without state corruption, and idempotent duplicate initialization completion. + /// No timers, sleeps, or temp files are used. + /// </summary> + [TestClass] + public class BreadcrumbBridgeRouterQueueTests + { + private const string LeafPath = "Inbox\\Projects\\Alpha"; + + private Mock<IFolderHierarchyProvider> _provider; + private Mock<IBreadcrumbWebHost> _host; + private bool _initialized; + private List<string> _navigated; + private List<string> _posted; + private BreadcrumbBridgeRouter _router; + + [TestInitialize] + public void Setup() + { + _provider = new Mock<IFolderHierarchyProvider>(); + _host = new Mock<IBreadcrumbWebHost>(); + _initialized = false; + _navigated = new List<string>(); + _posted = new List<string>(); + _host.SetupGet(h => h.IsCoreInitialized).Returns(() => _initialized); + _host + .Setup(h => h.NavigateToString(It.IsAny<string>())) + .Callback<string>(html => _navigated.Add(html)); + _host + .Setup(h => h.PostMessageJson(It.IsAny<string>())) + .Callback<string>(json => _posted.Add(json)); + _provider + .Setup(p => + p.ResolveLeafKeyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()) + ) + .ReturnsAsync( + (string path, CancellationToken ct) => + new FolderTreeNodeKey("store-1", "entry", path) + ); + _provider + .Setup(p => + p.GetAncestorChainAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ReturnsAsync( + new[] { Segment("Inbox", "Inbox", true), Segment(LeafPath, "Alpha", true) } + ); + _router = new BreadcrumbBridgeRouter( + _provider.Object, + _host.Object, + new BreadcrumbMessageCodec(), + new BreadcrumbHtmlRenderer(), + new BreadcrumbOutboundQueue(_host.Object) + ); + } + + private static FolderBreadcrumbSegment Segment(string path, string name, bool hasChildren) + { + return new FolderBreadcrumbSegment( + new FolderTreeNodeKey("store-1", "entry", path), + name, + path, + hasChildren + ); + } + + private void Bind() + { + _router + .BindRowsAsync( + new[] { LeafPath }, + Enumerable.Empty<FolderScore>(), + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + } + + private void Inbound(string json) + { + _router.ProcessInboundAsync(json).GetAwaiter().GetResult(); + } + + [TestMethod] + public void OutboundPayloads_BeforeInitialization_AreQueuedAndFlushedInOrder() + { + // Arrange: host core not initialized; bind defers the document. + Bind(); + _navigated.Should().BeEmpty("NavigateToString requires an initialized core"); + + // Act: selection posts a render payload, Up-at-top posts focusSearch — both buffered. + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}"); + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-0\",\"key\":\"Up\"}"); + _posted.Should().BeEmpty("payloads must buffer while IsCoreInitialized is false"); + + // Initialization completes. + _initialized = true; + _router.NotifyCoreInitialized(); + + // Assert: deferred document delivered, then payloads flushed in enqueue order. + _navigated.Should().HaveCount(1); + _posted.Should().HaveCount(2); + _posted[0].Should().Contain("\"type\":\"render\""); + _posted[1].Should().Contain("\"type\":\"focusSearch\""); + } + + [TestMethod] + public void ProviderFailure_OnLeafExpand_LeavesRowStateUnchanged() + { + // Arrange: initialized host, bound rows, faulted subfolder task. + _initialized = true; + Bind(); + _provider + .Setup(p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ThrowsAsync(new InvalidOperationException("provider down")); + int postedBefore = _posted.Count; + + // Act + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-0\"}"); + + // Assert: no subfolderResult, no render update — the row stays collapsed/unchanged. + _posted.Count.Should().Be(postedBefore); + _posted.Should().NotContain(p => p.Contains("\"type\":\"subfolderResult\"")); + } + + [TestMethod] + public void CanceledProviderCall_OnLeafExpand_LeavesRowStateUnchanged() + { + // Arrange + _initialized = true; + Bind(); + _provider + .Setup(p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ThrowsAsync(new OperationCanceledException()); + int postedBefore = _posted.Count; + + // Act + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-0\"}"); + + // Assert + _posted.Count.Should().Be(postedBefore); + _posted.Should().NotContain(p => p.Contains("\"type\":\"subfolderResult\"")); + } + + [TestMethod] + public void MalformedInboundJson_ThrowsCodecExceptionWithoutCorruptingState() + { + // Arrange + _initialized = true; + Bind(); + + // Act: malformed payload is rejected via the codec's specific exception. + Action act = () => Inbound("{not valid json"); + + // Assert + act.Should().Throw<BreadcrumbMessageException>(); + _router.SelectedFolderPath.Should().BeNull(); + + // Router state is intact: a subsequent valid selection still works. + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}"); + _router.SelectedFolderPath.Should().Be(LeafPath); + } + + [TestMethod] + public void MalformedInboundJson_ViaHostEvent_IsContainedAtTheBoundary() + { + // Arrange: the async void host-event boundary catches only the codec exception. + _initialized = true; + Bind(); + + // Act: raising the host event with a malformed payload must not throw or corrupt state. + _host.Raise(h => h.MessageReceived += null, _host.Object, "{not valid json"); + + // Assert + _router.SelectedFolderPath.Should().BeNull(); + } + + [TestMethod] + public void OutboundQueue_NullArguments_ThrowArgumentNullException() + { + // Act / Assert: queue construction and posting fail fast on nulls. + ((Action)(() => new BreadcrumbOutboundQueue(null))) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("host"); + var queue = new BreadcrumbOutboundQueue(_host.Object); + ((Action)(() => queue.PostOrQueue(null))) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("json"); + } + + [TestMethod] + public void RouterConstructor_NullCollaborators_ThrowArgumentNullException() + { + // Arrange + var codec = new BreadcrumbMessageCodec(); + var renderer = new BreadcrumbHtmlRenderer(); + var queue = new BreadcrumbOutboundQueue(_host.Object); + + // Act / Assert: every collaborator seam is required. + ( + (Action)( + () => new BreadcrumbBridgeRouter(null, _host.Object, codec, renderer, queue) + ) + ) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("provider"); + ( + (Action)( + () => new BreadcrumbBridgeRouter(_provider.Object, null, codec, renderer, queue) + ) + ) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("host"); + ( + (Action)( + () => + new BreadcrumbBridgeRouter( + _provider.Object, + _host.Object, + null, + renderer, + queue + ) + ) + ) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("codec"); + ( + (Action)( + () => + new BreadcrumbBridgeRouter( + _provider.Object, + _host.Object, + codec, + null, + queue + ) + ) + ) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("renderer"); + ( + (Action)( + () => + new BreadcrumbBridgeRouter( + _provider.Object, + _host.Object, + codec, + renderer, + null + ) + ) + ) + .Should() + .Throw<ArgumentNullException>() + .WithParameterName("outboundQueue"); + } + + [TestMethod] + public void BindRowsAsync_NullPresentedRows_ThrowsArgumentNullException() + { + // Act + Action act = () => + _router + .BindRowsAsync(null, new FolderScore[0], CancellationToken.None) + .GetAwaiter() + .GetResult(); + + // Assert + act.Should().Throw<ArgumentNullException>().WithParameterName("presentedRows"); + } + + [TestMethod] + public void InboundMessage_ForUnknownRowId_IsLoggedNoOp() + { + // Arrange + _initialized = true; + Bind(); + int postedBefore = _posted.Count; + + // Act + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-99\"}"); + + // Assert: unknown targets change nothing. + _posted.Count.Should().Be(postedBefore); + _router.SelectedFolderPath.Should().BeNull(); + } + + [TestMethod] + public void Bind_WhenLeafKeyUnresolved_FallsBackToSingleSegmentRow() + { + // Arrange: the provider cannot resolve the presented path. + _initialized = true; + _provider + .Setup(p => + p.ResolveLeafKeyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()) + ) + .ReturnsAsync((FolderTreeNodeKey)null); + + // Act + Bind(); + + // Assert: the presented path renders as a single leaf-only segment. + _navigated.Should().HaveCount(1); + _navigated[0].Should().Contain(">Alpha<"); + _provider.Verify( + p => + p.GetAncestorChainAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Never + ); + } + + [TestMethod] + public void Bind_WhenProviderCanceled_PropagatesCancellation() + { + // Arrange: cancellation during binding propagates to the caller. + _provider + .Setup(p => + p.ResolveLeafKeyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()) + ) + .ThrowsAsync(new OperationCanceledException()); + + // Act + Action act = () => Bind(); + + // Assert + act.Should().Throw<OperationCanceledException>(); + } + + [TestMethod] + public void LeafExpand_WhenKeyUnresolvedAtExpandTime_LeavesStateUnchanged() + { + // Arrange: bind resolves normally, but the expand-time key lookup returns null. + _initialized = true; + Bind(); + _provider + .Setup(p => + p.ResolveLeafKeyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()) + ) + .ReturnsAsync((FolderTreeNodeKey)null); + int postedBefore = _posted.Count; + + // Act + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-0\"}"); + + // Assert + _posted.Count.Should().Be(postedBefore); + _provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Never + ); + } + + [TestMethod] + public void LeafExpand_OnLeafWithoutSubfolders_IsNoOpWithoutProviderQuery() + { + // Arrange: chain whose leaf has no children. + _initialized = true; + _provider + .Setup(p => + p.GetAncestorChainAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ReturnsAsync(new[] { Segment(LeafPath, "Alpha", false) }); + Bind(); + int postedBefore = _posted.Count; + + // Act + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-0\"}"); + + // Assert + _posted.Count.Should().Be(postedBefore); + _provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Never + ); + } + + [TestMethod] + public void DuplicateInitializationCompletion_IsIdempotent() + { + // Arrange: queue one payload pre-init, then complete initialization. + Bind(); + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}"); + _initialized = true; + _router.NotifyCoreInitialized(); + int navigatedAfterFirst = _navigated.Count; + int postedAfterFirst = _posted.Count; + + // Act: pooled-viewer re-init raises completion again. + _router.NotifyCoreInitialized(); + + // Assert: no duplicate navigation and no re-posted payloads. + _navigated.Count.Should().Be(navigatedAfterFirst); + _posted.Count.Should().Be(postedAfterFirst); + } + } +} diff --git a/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs new file mode 100644 index 00000000..05a59e14 --- /dev/null +++ b/QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs @@ -0,0 +1,435 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Controllers; +using QuickFiler.Viewers; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Test.Controllers +{ + /// <summary> + /// Happy-path/interaction tests for <see cref="BreadcrumbBridgeRouter"/> (#349) against + /// Mock<IFolderHierarchyProvider> and Mock<IBreadcrumbWebHost>: bind/render, + /// collapse, correlated subfolder query, arrows including focusSearch, selection, + /// SelectFirstRow, and ApplyTheme. Outbound payloads are asserted on their raw JSON text + /// (QuickFiler.Test deliberately carries no Newtonsoft reference). + /// </summary> + [TestClass] + public class BreadcrumbBridgeRouterTests + { + private const string LeafPath = "Inbox\\Projects\\Alpha"; + + private Mock<IFolderHierarchyProvider> _provider; + private Mock<IBreadcrumbWebHost> _host; + private List<string> _navigated; + private List<string> _posted; + private BreadcrumbBridgeRouter _router; + + [TestInitialize] + public void Setup() + { + _provider = new Mock<IFolderHierarchyProvider>(); + _host = new Mock<IBreadcrumbWebHost>(); + _navigated = new List<string>(); + _posted = new List<string>(); + _host.SetupGet(h => h.IsCoreInitialized).Returns(true); + _host + .Setup(h => h.NavigateToString(It.IsAny<string>())) + .Callback<string>(html => _navigated.Add(html)); + _host + .Setup(h => h.PostMessageJson(It.IsAny<string>())) + .Callback<string>(json => _posted.Add(json)); + SetupProviderChain(LeafPath, true); + _router = new BreadcrumbBridgeRouter( + _provider.Object, + _host.Object, + new BreadcrumbMessageCodec(), + new BreadcrumbHtmlRenderer(), + new BreadcrumbOutboundQueue(_host.Object) + ); + } + + /// <summary>JSON-escapes a fragment the way the codec embeds HTML in a payload.</summary> + private static string JsonEscaped(string fragment) + { + return fragment.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static FolderTreeNodeKey Key(string path) + { + return new FolderTreeNodeKey("store-1", "entry", path); + } + + private static FolderBreadcrumbSegment ProviderSegment( + string path, + string name, + bool hasChildren + ) + { + return new FolderBreadcrumbSegment(Key(path), name, path, hasChildren); + } + + private void SetupProviderChain(string leafPath, bool leafHasChildren) + { + IReadOnlyList<FolderBreadcrumbSegment> chain = new[] + { + ProviderSegment("Inbox", "Inbox", true), + ProviderSegment("Inbox\\Projects", "Projects", true), + ProviderSegment(leafPath, "Alpha", leafHasChildren), + }; + _provider + .Setup(p => + p.ResolveLeafKeyAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()) + ) + .ReturnsAsync((string path, CancellationToken ct) => Key(path)); + _provider + .Setup(p => + p.GetAncestorChainAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ReturnsAsync(chain); + _provider + .Setup(p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ReturnsAsync(new[] { ProviderSegment(leafPath + "\\Kid", "Kid", false) }); + } + + private void Bind() + { + _router + .BindRowsAsync( + new[] { "==== SUGGESTIONS ====", LeafPath }, + new[] { new FolderScore(LeafPath, 1000, 0.9) }, + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + } + + private void Inbound(string json) + { + _router.ProcessInboundAsync(json).GetAwaiter().GetResult(); + } + + [TestMethod] + public void BindRowsAsync_WithInitializedHost_DeliversGeneratedDocument() + { + // Act + Bind(); + + // Assert: full document navigated, containing the breadcrumb and joined percent. + _navigated.Should().HaveCount(1); + _navigated[0].Should().Contain("<!DOCTYPE html>"); + _navigated[0].Should().Contain("Alpha"); + _navigated[0].Should().Contain("90%"); + } + + [TestMethod] + public void SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders() + { + // Arrange + Bind(); + + // Act: double-click the root segment of the suggestion row (row-1). + Inbound("{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-1\",\"segmentIndex\":0}"); + + // Assert: a row-scoped render fragment with the re-expand affordance was posted. + string render = _posted.Single(p => p.Contains("\"type\":\"render\"")); + render.Should().Contain("\"rowId\":\"row-1\""); + render.Should().Contain(JsonEscaped("data-role=\"reexpand\"")); + render.Should().NotContain(">Alpha<"); + } + + [TestMethod] + public void LeafExpandToggle_IssuesSubfolderQueryAndPostsCorrelatedResult() + { + // Arrange + Bind(); + + // Act + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-1\"}"); + + // Assert: provider queried once; subfolderResult correlated by requestId. + _provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Once + ); + string result = _posted.Single(p => p.Contains("\"type\":\"subfolderResult\"")); + result.Should().Contain("\"requestId\":\"req-1\""); + result.Should().Contain("\"rowId\":\"row-1\""); + result.Should().Contain("\"displayName\":\"Kid\""); + } + + [TestMethod] + public void ArrowKeyRight_ThenLeft_ExpandsAndCollapses() + { + // Arrange + Bind(); + + // Act: Right expands the leaf (provider query), Left collapses the leaf children. + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Right\"}"); + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Left\"}"); + + // Assert: expand posted a minus-affordance fragment, collapse posted a plus fragment. + List<string> renders = _posted + .Where(p => p.Contains("\"type\":\"render\"") && p.Contains("\"rowId\":")) + .ToList(); + renders.Should().HaveCount(2); + renders[0].Should().Contain("−"); + renders[1].Should().Contain(JsonEscaped("data-role=\"leaf\">+</span>")); + } + + [TestMethod] + public void ArrowKeyUp_AtTopSelectableRow_PostsFocusSearchAndRaisesEvent() + { + // Arrange: row-1 is the top selectable row (row-0 is a banner). + Bind(); + bool raised = false; + _router.FocusSearchRequested += (s, e) => raised = true; + + // Act + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Up\"}"); + + // Assert + _posted.Should().Contain(p => p.Contains("\"type\":\"focusSearch\"")); + raised.Should().BeTrue(); + } + + [TestMethod] + public void RowSelected_UpdatesSelectedFolderPathAndRaisesEvent() + { + // Arrange + Bind(); + string observed = null; + _router.SelectedFolderPathChanged += (s, path) => observed = path; + + // Act + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-1\"}"); + + // Assert + _router.SelectedFolderPath.Should().Be(LeafPath); + observed.Should().Be(LeafPath); + } + + [TestMethod] + public void RowSelected_OnBannerRow_IsIgnored() + { + // Arrange + Bind(); + bool raised = false; + _router.SelectedFolderPathChanged += (s, path) => raised = true; + + // Act: row-0 is the banner row. + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}"); + + // Assert: banner rows are never selectable. + _router.SelectedFolderPath.Should().BeNull(); + raised.Should().BeFalse(); + } + + [TestMethod] + public void SelectFirstRow_SelectsTopSelectableRowAndPostsRender() + { + // Arrange + Bind(); + + // Act + _router.SelectFirstRow(); + + // Assert: the first selectable (non-banner) row is selected and re-rendered. + _router.SelectedFolderPath.Should().Be(LeafPath); + string render = _posted.Single(p => p.Contains("\"type\":\"render\"")); + render.Should().Contain(JsonEscaped("rowwrap selected")); + } + + [TestMethod] + public void ApplyTheme_Dark_ReDeliversDarkDocument() + { + // Arrange + Bind(); + + // Act + _router.ApplyTheme(true); + + // Assert: a second navigation carrying the dark theme block. + _navigated.Should().HaveCount(2); + _navigated[1].Should().Contain("background: #1e1e1e"); + } + + private void BindThreeRows() + { + // Second suggestion resolves to an unknown chain -> single-segment fallback. + _provider + .Setup(p => + p.GetAncestorChainAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ) + ) + .ReturnsAsync( + (FolderTreeNodeKey k, CancellationToken ct) => + k.FolderPath == LeafPath + ? new[] + { + ProviderSegment("Inbox", "Inbox", true), + ProviderSegment(LeafPath, "Alpha", true), + } + : (IReadOnlyList<FolderBreadcrumbSegment>)new FolderBreadcrumbSegment[0] + ); + _router + .BindRowsAsync( + new[] { "==== SUGGESTIONS ====", LeafPath, "Inbox\\Beta" }, + new FolderScore[0], + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + } + + [TestMethod] + public void ArrowKeyDown_SelectsNextSelectableRow() + { + // Arrange + BindThreeRows(); + + // Act + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Down\"}"); + + // Assert + _router.SelectedFolderPath.Should().Be("Inbox\\Beta"); + } + + [TestMethod] + public void ArrowKeyUp_OnNonTopRow_SelectsPreviousRowWithoutFocusSearch() + { + // Arrange + BindThreeRows(); + + // Act + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-2\",\"key\":\"Up\"}"); + + // Assert: selection moves up; focusSearch is reserved for the top row. + _router.SelectedFolderPath.Should().Be(LeafPath); + _posted.Should().NotContain(p => p.Contains("\"type\":\"focusSearch\"")); + } + + [TestMethod] + public void LeafExpandToggle_OnCollapsedRow_ReExpandsWithoutProviderQuery() + { + // Arrange: collapse the row first. + Bind(); + Inbound("{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-1\",\"segmentIndex\":0}"); + + // Act: the affordance now re-expands the full breadcrumb. + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-1\"}"); + + // Assert + _provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Never + ); + _posted.Last(p => p.Contains("\"type\":\"render\"")).Should().Contain(">Alpha<"); + } + + [TestMethod] + public void LeafExpandToggle_OnExpandedLeaf_CollapsesWithoutSecondQuery() + { + // Arrange: expand the leaf once (one provider query). + Bind(); + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-1\"}"); + + // Act: toggling again collapses the children. + Inbound("{\"type\":\"leafExpandToggle\",\"rowId\":\"row-1\"}"); + + // Assert: still exactly one query; last render shows the plus affordance again. + _provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Once + ); + _posted + .Last(p => p.Contains("\"type\":\"render\"")) + .Should() + .Contain(JsonEscaped("data-role=\"leaf\">+</span>")); + } + + [TestMethod] + public void ArrowKeyRight_WhenCollapsed_ReExpandsWithoutProviderQuery() + { + // Arrange + Bind(); + Inbound("{\"type\":\"segmentDoubleClick\",\"rowId\":\"row-1\",\"segmentIndex\":0}"); + + // Act + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Right\"}"); + + // Assert + _provider.Verify( + p => + p.GetImmediateSubfoldersAsync( + It.IsAny<FolderTreeNodeKey>(), + It.IsAny<CancellationToken>() + ), + Times.Never + ); + _posted.Last(p => p.Contains("\"type\":\"render\"")).Should().Contain(">Alpha<"); + } + + [TestMethod] + public void ArrowKey_UnknownKey_IsLoggedNoOp() + { + // Arrange + Bind(); + int postedBefore = _posted.Count; + + // Act + Inbound("{\"type\":\"arrowKey\",\"rowId\":\"row-1\",\"key\":\"Home\"}"); + + // Assert: no state change and no outbound payloads. + _posted.Count.Should().Be(postedBefore); + _router.SelectedFolderPath.Should().BeNull(); + } + + [TestMethod] + public void RowSelected_OnTrashPseudoRow_SelectsTrashPath() + { + // Arrange + _router + .BindRowsAsync( + new[] { "Trash to Delete", LeafPath }, + new FolderScore[0], + CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + + // Act + Inbound("{\"type\":\"rowSelected\",\"rowId\":\"row-0\"}"); + + // Assert + _router.SelectedFolderPath.Should().Be("Trash to Delete"); + } + } +} diff --git a/QuickFiler.Test/Controllers/EfcHomeControllerExecuteMovesTests.cs b/QuickFiler.Test/Controllers/EfcHomeControllerExecuteMovesTests.cs index 2685d99e..d8182fa4 100644 --- a/QuickFiler.Test/Controllers/EfcHomeControllerExecuteMovesTests.cs +++ b/QuickFiler.Test/Controllers/EfcHomeControllerExecuteMovesTests.cs @@ -233,21 +233,13 @@ bool moveConversation ) { var viewer = (EfcViewer)FormatterServices.GetUninitializedObject(typeof(EfcViewer)); - viewer.FolderListBox = new BrightIdeasSoftware.TreeListView(); var formController = (EfcFormController) FormatterServices.GetUninitializedObject(typeof(EfcFormController)); SetPrivateField(formController, "_formViewer", viewer); - // SelectedFolder now derives from the cached highlighted FolderSuggestionNode; inject it - // directly because the TreeListView cannot select an item without a native window handle. - SetPrivateField( - formController, - "_selectedNode", - new FolderSuggestionNode( - selectedFolder, - selectedFolder, - FolderSuggestionNodeKind.Folder - ) - ); + // SelectedFolder now derives from the breadcrumb router's selection tracking (#349); + // drive a real router over mocked seams to a selected state instead of injecting the + // removed _selectedNode field. + SetPrivateField(formController, "_router", CreateSelectedRouter(selectedFolder)); formController.SaveAttachments = saveAttachments; formController.SaveEmail = saveEmail; formController.SavePictures = savePictures; @@ -255,6 +247,34 @@ bool moveConversation return formController; } + // Builds a breadcrumb router over mocked host/provider seams with the supplied folder + // path selected, so EfcFormController.SelectedFolder returns it deterministically. + private static QuickFiler.Controllers.BreadcrumbBridgeRouter CreateSelectedRouter( + string selectedFolder + ) + { + var host = new Mock<QuickFiler.Viewers.IBreadcrumbWebHost>(); + host.SetupGet(h => h.IsCoreInitialized).Returns(true); + var provider = new Mock<UtilitiesCS.OutlookObjects.Folder.IFolderHierarchyProvider>(); + var router = new QuickFiler.Controllers.BreadcrumbBridgeRouter( + provider.Object, + host.Object, + new UtilitiesCS.OutlookObjects.Folder.BreadcrumbMessageCodec(), + new UtilitiesCS.OutlookObjects.Folder.BreadcrumbHtmlRenderer(), + new QuickFiler.Controllers.BreadcrumbOutboundQueue(host.Object) + ); + router + .BindRowsAsync( + new[] { selectedFolder }, + new FolderScore[0], + System.Threading.CancellationToken.None + ) + .GetAwaiter() + .GetResult(); + router.SelectFirstRow(); + return router; + } + private static void SetPrivateField(object target, string name, object value) { var field = target diff --git a/QuickFiler.Test/Controllers/QfcItemController.FocusAndThemeTests.cs b/QuickFiler.Test/Controllers/QfcItemController.FocusAndThemeTests.cs index 6ed5e967..5db6c689 100644 --- a/QuickFiler.Test/Controllers/QfcItemController.FocusAndThemeTests.cs +++ b/QuickFiler.Test/Controllers/QfcItemController.FocusAndThemeTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.Reflection; @@ -149,7 +149,7 @@ private static void EnableHandlelessThemeInvoke(FocusController controller) SetThemeField(theme, "_tipsExpanded", new List<IQfcTipsDetails>()); SetThemeField(theme, "_textboxSearch", new TextBox()); SetThemeField(theme, "_textboxBody", new TextBox()); - SetThemeField(theme, "_comboFolders", new ComboBox()); + SetThemeFieldViaActivator(theme, "_breadcrumbWebView2"); SetThemeFieldViaActivator(theme, "_topicThread"); SetThemeFieldViaActivator(theme, "_webView2"); SetThemeField(theme, "_viewer", (Control)new Panel()); diff --git a/QuickFiler.Test/Helper Classes/QfcThemeHelperTests.cs b/QuickFiler.Test/Helper Classes/QfcThemeHelperTests.cs index 46fee149..6047f680 100644 --- a/QuickFiler.Test/Helper Classes/QfcThemeHelperTests.cs +++ b/QuickFiler.Test/Helper Classes/QfcThemeHelperTests.cs @@ -253,7 +253,7 @@ private static ItemViewer CreateItemViewer() viewer.MoveOptionsStrip = new MenuStrip(); viewer.TxtboxSearch = new TextBox(); viewer.TxtboxBody = new TextBox(); - viewer.CboFolders = new ComboBox(); + viewer.L0vhBreadcrumb_WebView2 = CreateUninitialized<WebView2>(); viewer.TopicThread = new FastObjectListView(); viewer.L0v2h2_WebView2 = CreateUninitialized<WebView2>(); SetPrivateField( @@ -317,7 +317,8 @@ private static QfcThemeControlSet CreateControlSet( new List<IQfcTipsDetails> { tips.Object }, new TextBox(), new TextBox(), - new ComboBox(), + CreateUninitialized<WebView2>(), + _ => { }, new FastObjectListView(), CreateUninitialized<WebView2>(), new Panel(), diff --git a/QuickFiler.Test/QuickFiler.Test.csproj b/QuickFiler.Test/QuickFiler.Test.csproj index c7d3fce0..b484b994 100644 --- a/QuickFiler.Test/QuickFiler.Test.csproj +++ b/QuickFiler.Test/QuickFiler.Test.csproj @@ -55,6 +55,9 @@ <OutputPath>bin\x86\Release\</OutputPath> </PropertyGroup> <ItemGroup> + <Compile Include="Controllers\BreadcrumbBridgeRouterQueueTests.cs" /> + <Compile Include="Controllers\BreadcrumbBridgeRouterTests.cs" /> + <Compile Include="Viewers\BreadcrumbBridgeCoordinatorTests.cs" /> <Compile Include="Controllers\KbdActionsTests.cs" /> <Compile Include="Controllers\KbdActionsRemainingBranchesTests.cs" /> <Compile Include="Controllers\KaCharTests.cs" /> diff --git a/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs new file mode 100644 index 00000000..3fa78628 --- /dev/null +++ b/QuickFiler.Test/Viewers/BreadcrumbBridgeCoordinatorTests.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using QuickFiler.Viewers; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Test.Viewers +{ + /// <summary> + /// Unit tests for <see cref="BreadcrumbBridgeCoordinator"/> (#351 P4-T5) with Moq-mocked + /// <see cref="IWebViewMessenger"/> and <see cref="IFolderHierarchyProvider"/> (completed tasks + /// only, G11): positive message wiring (double-click collapse render, expand subfolder + /// response, selection -> SelectionChanged with the mapped path), negative posting of router + /// error responses (malformed inbound JSON; provider failure), and edge events + /// (UnhandledArrow left/right, synthetic arrow FolderKeyDown, Clear emptying selection). + /// No live WebView2 or Outlook. + /// </summary> + [TestClass] + public sealed class BreadcrumbBridgeCoordinatorTests + { + private const string LeafPath = "\\Inbox\\Projects\\Apollo"; + + private static readonly FolderTreeNodeKey RootKey = MakeKey("root", "\\Inbox"); + private static readonly FolderTreeNodeKey MidKey = MakeKey("mid", "\\Inbox\\Projects"); + private static readonly FolderTreeNodeKey LeafKey = MakeKey("leaf", LeafPath); + + private static FolderTreeNodeKey MakeKey(string entryId, string path) + { + return new FolderTreeNodeKey("store-a", entryId, path); + } + + private static FolderBreadcrumbSegment Segment( + FolderTreeNodeKey key, + string name, + bool hasChildren + ) + { + return new FolderBreadcrumbSegment(key, name, key.FolderPath, hasChildren); + } + + private static Mock<IFolderHierarchyProvider> ProviderMock(bool leafHasChildren) + { + var provider = new Mock<IFolderHierarchyProvider>(MockBehavior.Strict); + provider + .Setup(p => p.ResolveLeafKeyAsync(LeafPath, It.IsAny<CancellationToken>())) + .ReturnsAsync(LeafKey); + provider + .Setup(p => p.GetAncestorChainAsync(LeafKey, It.IsAny<CancellationToken>())) + .ReturnsAsync( + new[] + { + Segment(RootKey, "Inbox", true), + Segment(MidKey, "Projects", true), + Segment(LeafKey, "Apollo", leafHasChildren), + } + ); + provider + .Setup(p => p.GetImmediateSubfoldersAsync(LeafKey, It.IsAny<CancellationToken>())) + .ReturnsAsync( + new[] { Segment(MakeKey("s1", LeafPath + "\\Alpha"), "Alpha", false) } + ); + return provider; + } + + private sealed class Harness + { + public Mock<IWebViewMessenger> Messenger; + public Mock<IFolderHierarchyProvider> Provider; + public BreadcrumbBridgeCoordinator Coordinator; + public List<string> Posted; + + public void Receive(string json) + { + Messenger.Raise(m => m.MessageReceived += null, Messenger.Object, json); + Coordinator.LastDispatch.GetAwaiter().GetResult(); + } + + public List<BreadcrumbBridgeMessage> PostedMessages() + { + return Posted.Select(BreadcrumbBridgeSerializer.Parse).ToList(); + } + } + + private static Harness CreateHarness(bool leafHasChildren = true, bool populate = true) + { + var harness = new Harness + { + Messenger = new Mock<IWebViewMessenger>(), + Provider = ProviderMock(leafHasChildren), + Posted = new List<string>(), + }; + harness + .Messenger.Setup(m => m.PostJson(It.IsAny<string>())) + .Callback<string>(harness.Posted.Add); + harness.Coordinator = new BreadcrumbBridgeCoordinator( + harness.Messenger.Object, + harness.Provider.Object + ); + if (populate) + { + var row = new FolderRow( + LeafPath, + FolderRowKind.Suggestion, + new FolderScore(LeafPath, 1000, 0.73) + ); + harness + .Coordinator.SetSuggestionsAsync(new[] { row }, CancellationToken.None) + .GetAwaiter() + .GetResult(); + harness.Coordinator.SelectRow(0); + harness.Posted.Clear(); + } + return harness; + } + + // --- Positive wiring --- + + [TestMethod] + public void InboundDoubleClick_PostsCollapsedRenderJson() + { + // Arrange + var harness = CreateHarness(); + + // Act + harness.Receive("{\"type\":\"segmentDoubleClick\",\"rowIndex\":0,\"segmentIndex\":0}"); + + // Assert (FR-3): the posted render payload shows the collapsed row. + var render = harness.PostedMessages().OfType<RenderMessage>().Single(); + render.Rows[0].Collapsed.Should().BeTrue(); + render.Rows[0].Cells[0].Kind.Should().Be(BreadcrumbCellKind.Plus); + } + + [TestMethod] + public void InboundExpand_PostsRenderAndSubfolderResponse() + { + // Arrange + var harness = CreateHarness(); + + // Act + harness.Receive("{\"type\":\"affordanceToggle\",\"rowIndex\":0}"); + + // Assert (FR-4): render + subfolderResponse posted, provider queried once. + var messages = harness.PostedMessages(); + messages.OfType<RenderMessage>().Single().Rows[0].LeafExpanded.Should().BeTrue(); + messages + .OfType<SubfolderResponseMessage>() + .Single() + .Subfolders.Single() + .FolderPath.Should() + .Be(LeafPath + "\\Alpha"); + harness.Provider.Verify( + p => p.GetImmediateSubfoldersAsync(LeafKey, It.IsAny<CancellationToken>()), + Times.Once + ); + } + + [TestMethod] + public void InboundSelectionMessage_RaisesSelectionChangedWithMappedPath() + { + // Arrange + var harness = CreateHarness(); + string mappedAtEvent = null; + int raised = 0; + harness.Coordinator.SelectionChanged += (s, e) => + { + raised++; + mappedAtEvent = harness.Coordinator.GetSelectedFolder(); + }; + + // Act + harness.Receive("{\"type\":\"selectionChange\",\"rowIndex\":0}"); + + // Assert (FR-7): the event observes the mapped full folder path. + raised.Should().Be(1); + mappedAtEvent.Should().Be(LeafPath); + } + + // --- Negative wiring --- + + [TestMethod] + public void MalformedInboundMessage_PostsRouterErrorResponse() + { + // Arrange + var harness = CreateHarness(); + + // Act + harness.Receive("{oops"); + + // Assert + harness + .PostedMessages() + .OfType<BridgeErrorMessage>() + .Single() + .Message.Should() + .Contain("Malformed"); + } + + [TestMethod] + public void ProviderFailure_SurfacesExplicitErrorResponse() + { + // Arrange + var harness = CreateHarness(); + harness + .Provider.Setup(p => + p.GetImmediateSubfoldersAsync(LeafKey, It.IsAny<CancellationToken>()) + ) + .Returns( + Task.FromException<IReadOnlyList<FolderBreadcrumbSegment>>( + new InvalidOperationException("store offline") + ) + ); + + // Act + harness.Receive("{\"type\":\"affordanceToggle\",\"rowIndex\":0}"); + + // Assert + harness + .PostedMessages() + .OfType<BridgeErrorMessage>() + .Single() + .Message.Should() + .Contain("store offline"); + } + + // --- Edge events --- + + [TestMethod] + public void UnhandledRightArrow_RaisesUnhandledArrowRight() + { + // Arrange: leaf without subfolders -> Right cannot expand (legacy Pop Out fall-through). + var harness = CreateHarness(leafHasChildren: false); + var directions = new List<BreadcrumbArrowDirection>(); + harness.Coordinator.UnhandledArrow += (s, d) => directions.Add(d); + + // Act + harness.Receive("{\"type\":\"arrowKey\",\"direction\":\"right\"}"); + + // Assert + directions.Should().Equal(BreadcrumbArrowDirection.Right); + } + + [TestMethod] + public void UnhandledLeftArrow_RaisesUnhandledArrowLeft() + { + // Arrange: nothing expanded -> Left cannot collapse (legacy close fall-through). + var harness = CreateHarness(); + var directions = new List<BreadcrumbArrowDirection>(); + harness.Coordinator.UnhandledArrow += (s, d) => directions.Add(d); + + // Act: the page-side report path is honored identically to the routed arrow path. + harness.Receive("{\"type\":\"unhandledArrow\",\"direction\":\"left\"}"); + + // Assert + directions.Should().Equal(BreadcrumbArrowDirection.Left); + } + + [TestMethod] + public void ArrowMessages_RaiseSyntheticFolderKeyDown() + { + // Arrange + var harness = CreateHarness(); + var keys = new List<BreadcrumbArrowDirection>(); + harness.Coordinator.FolderArrowKeyDown += (s, d) => keys.Add(d); + + // Act: a handled Right (expands) and an unhandled Left report. + harness.Receive("{\"type\":\"arrowKey\",\"direction\":\"right\"}"); + harness.Receive("{\"type\":\"unhandledArrow\",\"direction\":\"left\"}"); + + // Assert: the synthetic key seam fires for every arrow message (FR-6). + keys.Should().Equal(BreadcrumbArrowDirection.Right, BreadcrumbArrowDirection.Left); + } + + [TestMethod] + public void Clear_EmptiesSelectionStateAndPostsEmptyRender() + { + // Arrange + var harness = CreateHarness(); + harness.Coordinator.GetSelectedFolder().Should().Be(LeafPath); + + // Act + harness.Coordinator.Clear(); + + // Assert + harness.Coordinator.GetSelectedFolder().Should().BeNull(); + harness.Coordinator.GetFolderItems().Should().BeEmpty(); + harness.PostedMessages().OfType<RenderMessage>().Single().Rows.Should().BeEmpty(); + } + + [TestMethod] + public void AddItems_AppendsPlainRowsAndContainsFindsThem() + { + // Arrange + var harness = CreateHarness(); + + // Act (Path B: verbatim rows including the literal "Trash to Delete"). + harness.Coordinator.AddItems(new[] { "Trash to Delete" }); + + // Assert + harness.Coordinator.Contains("Trash to Delete").Should().BeTrue(); + harness.Coordinator.GetFolderItems().Should().Equal(LeafPath, "Trash to Delete"); + } + + [TestMethod] + public void SetSuggestions_SyncFacade_PopulatesImmediatelyThenUpgradesPreservingSelection() + { + // Arrange: an empty coordinator (population not yet run). + var harness = CreateHarness(populate: false); + var row = new FolderRow( + LeafPath, + FolderRowKind.Suggestion, + new FolderScore(LeafPath, 1000, 0.73) + ); + + // Act: the void IItemViewer.SetFolderSuggestions path. + harness.Coordinator.SetSuggestions(new[] { row }); + + // Assert (immediate): the selection contract holds synchronously via plain-path rows. + harness.Coordinator.Contains(LeafPath).Should().BeTrue(); + harness.Coordinator.SelectRow(0); + harness.Coordinator.GetSelectedFolder().Should().Be(LeafPath); + + // Assert (upgrade): completed-task provider -> chain rows with preserved selection. + harness.Coordinator.SuggestionsUpgrade.GetAwaiter().GetResult(); + harness.Coordinator.GetSelectedFolder().Should().Be(LeafPath); + var lastRender = harness.PostedMessages().OfType<RenderMessage>().Last(); + lastRender.Rows[0].IsSuggestion.Should().BeTrue("the upgrade attaches the chain"); + lastRender.Rows[0].PercentText.Should().Be("73%"); + } + + [TestMethod] + public void SetSuggestions_NullRows_Throws() + { + var harness = CreateHarness(populate: false); + ((Action)(() => harness.Coordinator.SetSuggestions(null))) + .Should() + .Throw<ArgumentNullException>(); + } + + [TestMethod] + public void SelectItem_KnownItemSelects_UnknownItemIsNoOp() + { + // Arrange + var harness = CreateHarness(); + int raised = 0; + harness.Coordinator.SelectionChanged += (s, e) => raised++; + + // Act + Assert: known item selects and raises the event. + harness.Coordinator.SelectItem(LeafPath); + raised.Should().Be(1); + harness.Coordinator.GetSelectedFolder().Should().Be(LeafPath); + + // Unknown item: legacy ComboBox no-op — no event, selection untouched. + harness.Coordinator.SelectItem("\\Nope"); + raised.Should().Be(1); + harness.Coordinator.GetSelectedFolder().Should().Be(LeafPath); + } + + [TestMethod] + public void SetTheme_PostsThemeChangeMessage() + { + // Arrange + var harness = CreateHarness(); + + // Act + harness.Coordinator.SetTheme("dark"); + + // Assert + harness + .PostedMessages() + .OfType<ThemeChangeMessage>() + .Single() + .Theme.Should() + .Be("dark"); + } + + [TestMethod] + public void Constructor_NullArguments_Throw() + { + // Arrange + var messenger = new Mock<IWebViewMessenger>(); + var provider = new Mock<IFolderHierarchyProvider>(); + + // Act, Assert + ((Action)(() => new BreadcrumbBridgeCoordinator(null, provider.Object))) + .Should() + .Throw<ArgumentNullException>(); + ((Action)(() => new BreadcrumbBridgeCoordinator(messenger.Object, null))) + .Should() + .Throw<ArgumentNullException>(); + } + } +} diff --git a/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs new file mode 100644 index 00000000..35458562 --- /dev/null +++ b/QuickFiler/Controllers/BreadcrumbBridgeRouter.cs @@ -0,0 +1,450 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using QuickFiler.Viewers; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Controllers +{ + /// <summary> + /// Non-exempt bridge router for the EfcViewer breadcrumb control (#349): binds suggestion + /// rows to breadcrumb rows via the 9101 provider, routes inbound bridge messages to row + /// state transitions and provider queries, and delivers outbound documents/messages through + /// the <see cref="IBreadcrumbWebHost"/> seam. Contains no WebView2/WinForms/COM types and + /// derives no hierarchy from suggestion-row prefix matching. + /// </summary> + public sealed class BreadcrumbBridgeRouter + { + private static readonly log4net.ILog log = log4net.LogManager.GetLogger( + System.Reflection.MethodBase.GetCurrentMethod().DeclaringType + ); + + private readonly IFolderHierarchyProvider _provider; + private readonly IBreadcrumbWebHost _host; + private readonly BreadcrumbMessageCodec _codec; + private readonly BreadcrumbHtmlRenderer _renderer; + private readonly BreadcrumbOutboundQueue _outboundQueue; + private readonly BreadcrumbRowBuilder _builder = new BreadcrumbRowBuilder(); + + private IReadOnlyList<BreadcrumbRow> _rows = Array.Empty<BreadcrumbRow>(); + private string? _selectedRowId; + private string? _pendingDocument; + private bool _darkMode; + private int _requestSequence; + + /// <summary>Creates the router over its collaborator seams.</summary> + /// <exception cref="ArgumentNullException">Any collaborator is null.</exception> + public BreadcrumbBridgeRouter( + IFolderHierarchyProvider provider, + IBreadcrumbWebHost host, + BreadcrumbMessageCodec codec, + BreadcrumbHtmlRenderer renderer, + BreadcrumbOutboundQueue outboundQueue + ) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _host = host ?? throw new ArgumentNullException(nameof(host)); + _codec = codec ?? throw new ArgumentNullException(nameof(codec)); + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _outboundQueue = + outboundQueue ?? throw new ArgumentNullException(nameof(outboundQueue)); + _host.MessageReceived += OnHostMessageReceived; + } + + /// <summary>Full path of the selected folder row, or null when nothing is selected.</summary> + public string? SelectedFolderPath { get; private set; } + + /// <summary>Raised when <see cref="SelectedFolderPath"/> changes via a selection action.</summary> + public event EventHandler<string?>? SelectedFolderPathChanged; + + /// <summary>Raised when Up is pressed on the top row (SearchText focus parity).</summary> + public event EventHandler? FocusSearchRequested; + + /// <summary> + /// Builds breadcrumb rows from the presented suggestion rows (9101 ancestor chain per + /// suggestion via <c>ResolveLeafKeyAsync</c> + <c>GetAncestorChainAsync</c>), renders the + /// document, and delivers it via NavigateToString (or defers it until core init). + /// </summary> + /// <param name="presentedRows">Presented row texts in display order.</param> + /// <param name="scores">Score projections joined by full-path equality.</param> + /// <param name="cancellationToken">Token observed by the provider calls.</param> + public async Task BindRowsAsync( + IReadOnlyList<string> presentedRows, + IEnumerable<FolderScore> scores, + CancellationToken cancellationToken + ) + { + if (presentedRows == null) + { + throw new ArgumentNullException(nameof(presentedRows)); + } + + var chains = new Dictionary<string, IReadOnlyList<FolderBreadcrumbSegment>>( + StringComparer.OrdinalIgnoreCase + ); + foreach (string text in presentedRows) + { + if ( + text == null + || chains.ContainsKey(text) + || BreadcrumbRowBuilder.Classify(text) != BreadcrumbRowKind.Suggestion + ) + { + continue; + } + + IReadOnlyList<FolderBreadcrumbSegment>? chain = await FetchChainAsync( + text, + cancellationToken + ); + if (chain != null) + { + chains[text] = chain; + } + } + + _rows = _builder.BuildRows( + presentedRows, + text => chains.TryGetValue(text, out var chain) ? chain : null, + scores + ); + _selectedRowId = null; + DeliverDocument(); + } + + /// <summary>Selects the first selectable (non-banner) row and posts the updated render.</summary> + public void SelectFirstRow() + { + BreadcrumbRow? first = FindSelectable(startIndex: 0, step: 1); + if (first != null) + { + SelectRow(first); + } + } + + /// <summary>Re-renders and re-delivers the document with the requested theme.</summary> + /// <param name="darkMode">True for the dark CSS block.</param> + public void ApplyTheme(bool darkMode) + { + _darkMode = darkMode; + DeliverDocument(); + } + + /// <summary> + /// Signals CoreWebView2 initialization completion: delivers any deferred document and + /// flushes queued outbound payloads. Idempotent for pooled-viewer re-initialization. + /// </summary> + public void NotifyCoreInitialized() + { + if (_pendingDocument != null) + { + _host.NavigateToString(_pendingDocument); + _pendingDocument = null; + } + + _outboundQueue.OnInitializationCompleted(); + } + + /// <summary> + /// Routes one inbound bridge payload. Malformed payloads fail fast with the codec's + /// <see cref="BreadcrumbMessageException"/> (already logged) and leave state unchanged. + /// </summary> + /// <param name="json">The raw inbound JSON payload.</param> + public async Task ProcessInboundAsync(string json) + { + BreadcrumbInboundMessage message = _codec.DeserializeInbound(json); + BreadcrumbRow? row = FindRow(message.RowId); + if (row == null) + { + log.Error($"Inbound breadcrumb message targets unknown row '{message.RowId}'."); + return; + } + + switch (message.Type) + { + case BreadcrumbMessageTypes.SegmentDoubleClick: + if (row.CollapseAfter(message.SegmentIndex!.Value)) + { + PostRowRender(row); + } + + break; + case BreadcrumbMessageTypes.LeafExpandToggle: + await HandleLeafToggleAsync(row); + break; + case BreadcrumbMessageTypes.ArrowKey: + await HandleArrowKeyAsync(row, message.Key!); + break; + case BreadcrumbMessageTypes.RowSelected: + SelectRow(row); + break; + } + } + + private async void OnHostMessageReceived(object? sender, string json) + { + try + { + await ProcessInboundAsync(json); + } + catch (BreadcrumbMessageException) + { + // Boundary: the codec already logged the specific malformed-payload error; the + // router state is unchanged and the UI message pump must not be crashed. + } + } + + private async Task HandleLeafToggleAsync(BreadcrumbRow row) + { + if (row.IsCollapsed) + { + if (row.ReExpand()) + { + PostRowRender(row); + } + + return; + } + + if (row.IsLeafExpanded) + { + if (row.ToggleLeafExpanded()) + { + PostRowRender(row); + } + + return; + } + + await ExpandLeafAsync(row); + } + + private async Task HandleArrowKeyAsync(BreadcrumbRow row, string key) + { + switch (key) + { + case "Right": + if (row.IsCollapsed) + { + if (row.ReExpand()) + { + PostRowRender(row); + } + } + else if (!row.IsLeafExpanded) + { + await ExpandLeafAsync(row); + } + + break; + case "Left": + if (row.LeftArrow()) + { + PostRowRender(row); + } + + break; + case "Up": + HandleUpArrow(row); + break; + case "Down": + MoveSelection(row, step: 1); + break; + default: + log.Error($"Unknown breadcrumb arrow key '{key}' for row '{row.RowId}'."); + break; + } + } + + private void HandleUpArrow(BreadcrumbRow row) + { + BreadcrumbRow? previous = FindSelectable(IndexOf(row) - 1, step: -1); + if (previous == null) + { + // Up at the top row: hand focus back to the search box. + PostOutbound(new BreadcrumbFocusSearchMessage()); + FocusSearchRequested?.Invoke(this, EventArgs.Empty); + return; + } + + SelectRow(previous); + } + + private void MoveSelection(BreadcrumbRow row, int step) + { + BreadcrumbRow? next = FindSelectable(IndexOf(row) + step, step); + if (next != null) + { + SelectRow(next); + } + } + + private async Task ExpandLeafAsync(BreadcrumbRow row) + { + BreadcrumbSegment? leaf = row.LeafSegment; + if (row.Kind != BreadcrumbRowKind.Suggestion || leaf?.HasSubfolders != true) + { + return; // Leaf without subfolders (or non-suggestion row): documented no-op. + } + + string requestId = "req-" + (++_requestSequence); + try + { + FolderTreeNodeKey key = await _provider.ResolveLeafKeyAsync( + leaf.FullPath, + CancellationToken.None + ); + if (key == null) + { + log.Error( + $"Breadcrumb expand {requestId}: no provider key for '{leaf.FullPath}'; row '{row.RowId}' left unchanged." + ); + return; + } + + IReadOnlyList<FolderBreadcrumbSegment> children = + await _provider.GetImmediateSubfoldersAsync(key, CancellationToken.None); + IReadOnlyList<BreadcrumbSegment> mapped = BreadcrumbRowBuilder.MapSegments( + children + ); + row.SetLeafChildren(mapped); + row.ToggleLeafExpanded(); + PostOutbound(new BreadcrumbSubfolderResultMessage(requestId, row.RowId, mapped)); + PostRowRender(row); + } + catch (OperationCanceledException) + { + log.Error( + $"Breadcrumb expand {requestId} canceled for row '{row.RowId}'; state unchanged." + ); + } + catch (Exception ex) + { + // Provider I/O boundary: log the specific failure and leave row state unchanged. + log.Error( + $"Breadcrumb expand {requestId} failed for row '{row.RowId}': {ex.Message}", + ex + ); + } + } + + private async Task<IReadOnlyList<FolderBreadcrumbSegment>?> FetchChainAsync( + string folderPath, + CancellationToken cancellationToken + ) + { + try + { + FolderTreeNodeKey key = await _provider.ResolveLeafKeyAsync( + folderPath, + cancellationToken + ); + if (key == null) + { + return null; + } + + return await _provider.GetAncestorChainAsync(key, cancellationToken); + } + catch (OperationCanceledException) + { + throw; // Binding cancellation propagates to the caller. + } + catch (Exception ex) + { + // Provider I/O boundary: fall back to the builder's single-segment rendering. + log.Error($"Breadcrumb chain fetch failed for '{folderPath}': {ex.Message}", ex); + return null; + } + } + + private void SelectRow(BreadcrumbRow row) + { + if (row.Kind == BreadcrumbRowKind.Banner) + { + return; // Banner rows are never selectable. + } + + _selectedRowId = row.RowId; + SelectedFolderPath = + row.Kind == BreadcrumbRowKind.TrashPseudoRow + ? BreadcrumbRowBuilder.TrashRowText + : row.LeafSegment?.FullPath ?? string.Empty; + PostOutbound( + new BreadcrumbRenderMessage(_renderer.RenderRows(_rows, _selectedRowId), null) + ); + SelectedFolderPathChanged?.Invoke(this, SelectedFolderPath); + } + + private void PostRowRender(BreadcrumbRow row) + { + PostOutbound( + new BreadcrumbRenderMessage( + _renderer.RenderRowFragment(row, row.RowId == _selectedRowId), + row.RowId + ) + ); + } + + private void PostOutbound(BreadcrumbOutboundMessage message) + { + _outboundQueue.PostOrQueue(_codec.SerializeOutbound(message)); + } + + private void DeliverDocument() + { + string document = _renderer.RenderDocument(_rows, _darkMode, _selectedRowId); + if (_host.IsCoreInitialized) + { + _host.NavigateToString(document); + _pendingDocument = null; + } + else + { + _pendingDocument = document; + } + } + + private BreadcrumbRow? FindRow(string rowId) + { + foreach (BreadcrumbRow row in _rows) + { + if (row.RowId == rowId) + { + return row; + } + } + + return null; + } + + private int IndexOf(BreadcrumbRow row) + { + for (int i = 0; i < _rows.Count; i++) + { + if (ReferenceEquals(_rows[i], row)) + { + return i; + } + } + + return -1; + } + + private BreadcrumbRow? FindSelectable(int startIndex, int step) + { + for (int i = startIndex; i >= 0 && i < _rows.Count; i += step) + { + if (_rows[i].Kind != BreadcrumbRowKind.Banner) + { + return _rows[i]; + } + } + + return null; + } + } +} diff --git a/QuickFiler/Controllers/BreadcrumbOutboundQueue.cs b/QuickFiler/Controllers/BreadcrumbOutboundQueue.cs new file mode 100644 index 00000000..eee68beb --- /dev/null +++ b/QuickFiler/Controllers/BreadcrumbOutboundQueue.cs @@ -0,0 +1,67 @@ +#nullable enable +using System; +using System.Collections.Generic; +using QuickFiler.Viewers; + +namespace QuickFiler.Controllers +{ + /// <summary> + /// Buffers outbound breadcrumb bridge payloads while the host reports + /// <see cref="IBreadcrumbWebHost.IsCoreInitialized"/> false, and flushes them in order when + /// initialization completes (#349). Event-driven only — no polling, no timers, no delays. + /// Holds no WebView2 types; the host is reached solely through the + /// <see cref="IBreadcrumbWebHost"/> seam. + /// </summary> + public sealed class BreadcrumbOutboundQueue + { + private readonly IBreadcrumbWebHost _host; + private readonly Queue<string> _pending = new Queue<string>(); + + /// <summary>Creates the queue over the host seam.</summary> + /// <param name="host">The breadcrumb web host. Required.</param> + /// <exception cref="ArgumentNullException"><paramref name="host"/> is null.</exception> + public BreadcrumbOutboundQueue(IBreadcrumbWebHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + /// <summary>Number of payloads currently buffered.</summary> + public int PendingCount => _pending.Count; + + /// <summary> + /// Posts the payload immediately when the host core is initialized; otherwise buffers it + /// for the initialization-completed flush. + /// </summary> + /// <param name="json">The serialized outbound message. Required.</param> + /// <exception cref="ArgumentNullException"><paramref name="json"/> is null.</exception> + public void PostOrQueue(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + + if (_host.IsCoreInitialized) + { + _host.PostMessageJson(json); + } + else + { + _pending.Enqueue(json); + } + } + + /// <summary> + /// Flushes every buffered payload to the host in enqueue order. Called on + /// CoreWebView2 initialization completion; idempotent (a duplicate completion with an + /// empty buffer is a no-op, matching the pooled-viewer re-init lifecycle). + /// </summary> + public void OnInitializationCompleted() + { + while (_pending.Count > 0) + { + _host.PostMessageJson(_pending.Dequeue()); + } + } + } +} diff --git a/QuickFiler/Controllers/EfcFormController.cs b/QuickFiler/Controllers/EfcFormController.cs index 5f3c3214..95c64c0f 100644 --- a/QuickFiler/Controllers/EfcFormController.cs +++ b/QuickFiler/Controllers/EfcFormController.cs @@ -15,6 +15,7 @@ using QuickFiler.Helper_Classes; using QuickFiler.Interfaces; using QuickFiler.Properties; +using QuickFiler.Viewers; using TaskVisualization; using ToDoModel; using UtilitiesCS; @@ -130,15 +131,14 @@ internal EfcFormController InitializeDataFields(EfcDataModel dataModel) private EfcDataModel _dataModel; private EfcViewer _formViewer; - // Presented folder-suggestion rows and the host-neutral tree they build. The tree is rebuilt - // whenever the presented rows change; _folderRows retains the last raw rows so the delete path - // can prepend "Trash to Delete" and rebind. - private FolderSuggestionTree _folderTree; + // Presented folder-suggestion rows; retained so the delete path can prepend + // "Trash to Delete" and rebind through the breadcrumb router. private string[] _folderRows = Array.Empty<string>(); - // The currently highlighted tree node, cached from the TreeListView SelectionChanged event so - // SelectedFolder/IsValidSelection derive the filing path from the selected node. - private FolderSuggestionNode _selectedNode; + // Breadcrumb WebView2 wiring (#349): the exempt host adapter over the Designer control and + // the non-exempt router that owns all breadcrumb logic. This controller stays wiring-only. + private WebView2BreadcrumbHost _breadcrumbHost; + private BreadcrumbBridgeRouter _router; private EfcHomeController _homeController; private EfcItemController _itemController; @@ -288,11 +288,10 @@ public bool DarkMode public string SelectedFolder { - // The selected TreeListView model is a FolderSuggestionNode (cached in _selectedNode from - // SelectionChanged); its FullPath is the folder identity used for filing. Banner nodes - // carry the banner text (which starts with "===="), so IsValidSelection continues to - // reject them. - get => _selectedNode?.FullPath; + // Derived from the bridge router's selection tracking. The router never selects + // "===="-banner rows, and IsValidSelection keeps its "====" rejection as a second + // guard, so banner rows remain invalid filing targets. + get => _router?.SelectedFolderPath; } private bool _saveAttachments; @@ -391,8 +390,7 @@ public void WireEventHandlers() _formViewer.ConversationMenuItem.CheckedChanged += MoveConversation_CheckedChanged; _formViewer.Ok.Click += ButtonOK_Click; RegisterAlwaysOnAsyncKeyActions(); - ConfigureFolderTreeView(); - _formViewer.FolderListBox.KeyDown += FolderListBox_KeyDown; + ConfigureBreadcrumbControl(); _formViewer.Cancel.Click += ButtonCancel_Click; _formViewer.RefreshPredicted.Click += ButtonRefresh_Click; _formViewer.NewFolder.Click += ButtonCreate_Click; @@ -407,31 +405,10 @@ public void SearchText_DownArrow(object sender, KeyEventArgs e) { if (e.KeyCode == Keys.Down) { + // Enter the breadcrumb list and select its first row (parity with the prior + // TreeListView down-arrow behavior); further key handling happens in-document. _formViewer.FolderListBox.Select(); - } - } - - public void FolderListBox_KeyDown(object sender, KeyEventArgs e) - { - if (e.KeyCode == Keys.Up && _formViewer.FolderListBox.SelectedIndex == 0) - { - _formViewer.SearchText.Select(); - } - else if (e.KeyCode == Keys.Left) - { - // Collapse the highlighted node. The TreeListView (configured with - // CanExpandGetter/ChildrenGetter) already collapses the focused row natively on the - // Left arrow; this delegates to the host-neutral state model to keep it in sync - // without duplicating the control's own toggle (LeftArrow is a no-op on a leaf, an - // already-collapsed node, or a banner). - _folderTree?.LeftArrow(_selectedNode); - } - else if (e.KeyCode == Keys.Right) - { - // Expand the highlighted node; mirrors the native TreeListView Right-arrow behavior in - // the host-neutral state model (RightArrow is a no-op on a leaf, an already-expanded - // node, or a banner). - _folderTree?.RightArrow(_selectedNode); + _router?.SelectFirstRow(); } } @@ -712,6 +689,9 @@ internal void DarkMode_Changed(object sender, PropertyChangedEventArgs e) { ActiveTheme = "LightNormal"; } + + // Re-theme the breadcrumb document alongside the WinForms theme swap. + _router?.ApplyTheme(DarkMode); } } @@ -848,93 +828,77 @@ internal async Task JumpToAsync(Control control) control.Focus(); } - // Configures the TreeListView once: the tree glyph/children come from the host-neutral node - // model, and the two columns render the folder name and the right-aligned whole-number percent. - private void ConfigureFolderTreeView() + // Wiring-only breadcrumb setup (#349): constructs the exempt WebView2 host adapter and the + // non-exempt router where ConfigureFolderTreeView previously wired the TreeListView, then + // connects the router's events back to the form. All breadcrumb logic lives in the router. + private void ConfigureBreadcrumbControl() { - var tlv = _formViewer.FolderListBox; - tlv.CanExpandGetter = model => (model as FolderSuggestionNode)?.HasChildren ?? false; - tlv.ChildrenGetter = model => (model as FolderSuggestionNode)?.Children; - tlv.SelectionChanged += FolderListBox_SelectionChanged; - _formViewer.olvColumnFolder.AspectGetter = model => - (model as FolderSuggestionNode)?.DisplayName ?? string.Empty; - _formViewer.olvColumnPercent.AspectGetter = model => - PercentageFormatter.FormatPercent((model as FolderSuggestionNode)?.Probability); + _breadcrumbHost = new WebView2BreadcrumbHost( + _formViewer.BreadcrumbWebView, + new WebView2CoreInitializer() + ); + var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider( + _globals.Ol.FolderTreeService + ); + _router = new BreadcrumbBridgeRouter( + provider, + _breadcrumbHost, + new UtilitiesCS.OutlookObjects.Folder.BreadcrumbMessageCodec(), + new UtilitiesCS.OutlookObjects.Folder.BreadcrumbHtmlRenderer(), + new BreadcrumbOutboundQueue(_breadcrumbHost) + ); + _breadcrumbHost.CoreInitialized += (s, e) => _router.NotifyCoreInitialized(); + _router.FocusSearchRequested += (s, e) => _formViewer?.SearchText.Select(); + _router.ApplyTheme(DarkMode); + _ = InitializeBreadcrumbHostAsync(); } - // Caches the highlighted node so SelectedFolder/IsValidSelection and the Left/Right key - // transitions read the current selection without touching the control on each access. - private void FolderListBox_SelectionChanged(object sender, EventArgs e) + // Fire-and-forget host initialization with an error boundary (the router queues every + // outbound payload until CoreWebView2InitializationCompleted fires). + private async Task InitializeBreadcrumbHostAsync() { - _selectedNode = _formViewer?.FolderListBox.SelectedObject as FolderSuggestionNode; + try + { + await _breadcrumbHost.InitializeAsync(_formViewer.UiSyncContext); + } + catch (System.Exception ex) + { + logger.Error($"Breadcrumb WebView2 initialization failed: {ex.Message}", ex); + } } - // Builds the host-neutral suggestion tree from the presented rows, joins upstream - // probabilities, and feeds the TreeListView. Selects the first row after the leading banner, - // matching the prior ListBox behavior (index 1). Uses a local viewer reference so a concurrent - // Cleanup() that nulls _formViewer cannot cause a NullReferenceException. + // Routes the presented rows through the breadcrumb router (delete-path trash rebind, + // RefreshSuggestionsAsync, and SearchText_TextChanged all land here). Uses a local viewer + // reference so a concurrent Cleanup() cannot cause a NullReferenceException. private void BindFolderRows(string[] rows) { var formViewer = _formViewer; - if (formViewer == null) + if (formViewer == null || _router == null) { return; } _folderRows = rows ?? Array.Empty<string>(); - var tree = FolderSuggestionTree.BuildFromRows(_folderRows); - - var source = BuildProbabilitySource(); - if (source != null) - { - new FolderProbabilityAdapter(source).Apply(tree); - } - - _folderTree = tree; - _selectedNode = null; - - var tlv = formViewer.FolderListBox; - tlv.SetObjects(tree.Roots); - if (tlv.Items.Count > 1) - { - tlv.SelectedIndex = 1; - } + _ = BindBreadcrumbRowsAsync(_folderRows); } - // Projects the scored suggestions into a full-path -> probability lookup consumed by the - // FolderProbabilityAdapter. Returns null when no scored suggestions are available, in which - // case every percentage cell renders blank. - private IFolderProbabilitySource BuildProbabilitySource() + // Async bind boundary: joins the feature-324 score projection and delegates to the router. + private async Task BindBreadcrumbRowsAsync(string[] rows) { - var scores = _dataModel?.FolderHelper?.Suggestions?.ToScoredArray(); - if (scores == null || scores.Length == 0) + try { - return null; + var scores = + _dataModel?.FolderHelper?.Suggestions?.ToScoredArray() + ?? Array.Empty<FolderScore>(); + await _router.BindRowsAsync(rows, scores, Token); } - - return new DictionaryProbabilitySource(scores); - } - - // Thin, exempt implementation of the host-neutral 9001 seam backed by the merged - // FolderScore projection (FolderPath -> Probability in [0,1]). - [ExcludeFromCodeCoverage] - private sealed class DictionaryProbabilitySource : IFolderProbabilitySource - { - private readonly Dictionary<string, double> _map; - - public DictionaryProbabilitySource(IEnumerable<FolderScore> scores) + catch (OperationCanceledException) { - _map = new Dictionary<string, double>(StringComparer.Ordinal); - foreach (var score in scores) - { - // Last value wins for duplicate paths; avoids an add-collision exception. - _map[score.FolderPath] = score.Probability; - } + logger.Debug("Breadcrumb bind canceled."); } - - public bool TryGetProbability(string fullFolderPath, out double probability) + catch (System.Exception ex) { - return _map.TryGetValue(fullFolderPath, out probability); + logger.Error($"Breadcrumb bind failed: {ex.Message}", ex); } } diff --git a/QuickFiler/Controllers/EfcItemController.cs b/QuickFiler/Controllers/EfcItemController.cs index cf4ec4cb..42a5cbcc 100644 --- a/QuickFiler/Controllers/EfcItemController.cs +++ b/QuickFiler/Controllers/EfcItemController.cs @@ -587,7 +587,9 @@ public int ItemIndex public string SelectedFolder { - get => _itemViewer.CboFolders.SelectedItem.ToString(); + // #351: the folder control is the WebView2 breadcrumb; the selection contract is the + // same full-path/verbatim string the old ComboBox SelectedItem produced (G10). + get => _itemViewer.GetSelectedFolder(); } public string Sender @@ -653,7 +655,7 @@ internal void WireEvents() }, new List<Control> { - _itemViewer.CboFolders, + _itemViewer.L0vhBreadcrumb_WebView2, _itemViewer.TxtboxSearch, _itemViewer.TopicThread, } diff --git a/QuickFiler/Controllers/KeyboardHandler.cs b/QuickFiler/Controllers/KeyboardHandler.cs index 16d8f5e4..2d7d3226 100644 --- a/QuickFiler/Controllers/KeyboardHandler.cs +++ b/QuickFiler/Controllers/KeyboardHandler.cs @@ -260,257 +260,57 @@ internal ItemViewer GetItemViewer(Control control) } } - private List<Keys> _cboKeys = new List<Keys> - { - Keys.Up, - Keys.Down, - Keys.Left, - Keys.Right, - Keys.Escape, - Keys.Return, - }; - - public void CboFolders_KeyDown(object sender, KeyEventArgs e) + // #351: the breadcrumb surface raises its arrows through the JS bridge, so this handler + // now serves only genuine ComboBox senders (the old sender-type ArgumentException guard is + // bypassed for the breadcrumb's synthetic FolderKeyDown events instead of throwing). + public async void CboFolders_KeyDownAsync(object sender, KeyEventArgs e) { if (SynchronizationContext.Current is null) SynchronizationContext.SetSynchronizationContext( new WindowsFormsSynchronizationContext() ); - - ItemViewer viewer = null; - if (_cboKeys.Contains(e.KeyCode)) + if (!(sender is ComboBox cb)) { - viewer = GetItemViewer(sender as Control); + // Breadcrumb-originated synthetic key events are fully handled by the bridge + // (BreadcrumbBridgeCoordinator.UnhandledArrow drives the legacy fall-throughs). + return; } - - switch (e.KeyCode) + if (cb.DroppedDown) { - case Keys.Escape: - { - viewer.Controller.CounterEnter = 1; - viewer.Controller.CounterComboRight = 0; - viewer.CboFolders.DroppedDown = false; - e.SuppressKeyPress = true; - e.Handled = true; - break; - } - case Keys.Up: - { - viewer.Controller.CounterEnter = 0; - break; - } - case Keys.Down: - { - viewer.Controller.CounterEnter = 0; - break; - } - case Keys.Right: - { - viewer.Controller.CounterEnter = 0; - switch (viewer.Controller.CounterComboRight) - { - case 0: - { - viewer.CboFolders.DroppedDown = true; - viewer.Controller.CounterComboRight++; - break; - } - case 1: - { - viewer.CboFolders.DroppedDown = false; - viewer.Controller.CounterComboRight = 0; - MyBox.ShowDialog( - "Pop Out Item or Enumerate Conversation?", - "Dialog", - BoxIcon.Question, - viewer.Controller.RightKeyActions - ); - break; - } - default: - { - MessageBox.Show( - "Error in intComboRightCtr ... setting to 0 and continuing", - "Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ); - viewer.Controller.CounterComboRight = 0; - break; - } - } - e.SuppressKeyPress = true; - e.Handled = true; - break; - } - case Keys.Left: - { - viewer.Controller.CounterEnter = 1; - viewer.Controller.CounterComboRight = 0; - if (viewer.CboFolders.DroppedDown) - { - viewer.CboFolders.DroppedDown = false; - e.SuppressKeyPress = true; - e.Handled = true; - } - else - { - this.KeyboardHandler_KeyDown(sender, e); - } - - break; - } - case Keys.Return: - { - if (viewer.Controller.CounterEnter == 1) - { - viewer.Controller.CounterEnter = 0; - viewer.Controller.CounterComboRight = 0; - KeyboardHandler_KeyDown(sender, e); - } - else - { - viewer.Controller.CounterEnter = 1; - viewer.Controller.CounterComboRight = 0; - viewer.CboFolders.DroppedDown = false; - e.SuppressKeyPress = true; - e.Handled = true; - } - break; - } + await DdOpen_KeyDownAsync(cb, e); } - } - - public async void CboFolders_KeyDownAsyncOld(object sender, KeyEventArgs e) - { - await UiThread.Dispatcher.InvokeAsync(() => + else { - ItemViewer viewer = null; - if (_cboKeys.Contains(e.KeyCode)) - { - viewer = GetItemViewer(sender as Control); - } - - switch (e.KeyCode) - { - case Keys.Escape: - { - viewer.Controller.CounterEnter = 1; - viewer.Controller.CounterComboRight = 0; - viewer.CboFolders.DroppedDown = false; - e.SuppressKeyPress = true; - e.Handled = true; - break; - } - case Keys.Up: - { - viewer.Controller.CounterEnter = 0; - break; - } - case Keys.Down: - { - viewer.Controller.CounterEnter = 0; - break; - } - case Keys.Right: - { - viewer.Controller.CounterEnter = 0; - switch (viewer.Controller.CounterComboRight) - { - case 0: - { - viewer.CboFolders.DroppedDown = true; - viewer.Controller.CounterComboRight++; - break; - } - case 1: - { - viewer.CboFolders.DroppedDown = false; - viewer.Controller.CounterComboRight = 0; - MyBox.ShowDialog( - "Pop Out Item or Enumerate Conversation?", - "Dialog", - BoxIcon.Question, - viewer.Controller.RightKeyActions - ); - break; - } - default: - { - MessageBox.Show( - "Error in intComboRightCtr ... setting to 0 and continuing", - "Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error - ); - viewer.Controller.CounterComboRight = 0; - break; - } - } - e.SuppressKeyPress = true; - e.Handled = true; - break; - } - case Keys.Left: - { - viewer.Controller.CounterEnter = 1; - viewer.Controller.CounterComboRight = 0; - if (viewer.CboFolders.DroppedDown) - { - viewer.CboFolders.DroppedDown = false; - e.SuppressKeyPress = true; - e.Handled = true; - } - else - { - this.KeyboardHandler_KeyDownAsync(sender, e); - } - - break; - } - case Keys.Return: - { - if (viewer.Controller.CounterEnter == 1) - { - viewer.Controller.CounterEnter = 0; - viewer.Controller.CounterComboRight = 0; - KeyboardHandler_KeyDownAsync(sender, e); - } - else - { - viewer.Controller.CounterEnter = 1; - viewer.Controller.CounterComboRight = 0; - viewer.CboFolders.DroppedDown = false; - e.SuppressKeyPress = true; - e.Handled = true; - } - break; - } - } - }); + await DdClosed_KeyDownAsync(cb, e); + } } - public async void CboFolders_KeyDownAsync(object sender, KeyEventArgs e) + // #351: legacy fall-through target for BreadcrumbBridgeCoordinator.UnhandledArrow — + // Right (nothing to expand) opens the Pop Out / Enumerate Conversation dialog exactly as + // the old dropdown-open Right did; Left (nothing to collapse) closes the folder control + // via the SetFolderDroppedDown(false) intent, matching the old close-the-dropdown branch. + public void BreadcrumbArrowFallThrough( + ItemViewer viewer, + UtilitiesCS.OutlookObjects.Folder.BreadcrumbArrowDirection direction + ) { - if (SynchronizationContext.Current is null) - SynchronizationContext.SetSynchronizationContext( - new WindowsFormsSynchronizationContext() - ); - if (sender is not ComboBox) + if (viewer is null) { - throw new ArgumentException( - $"{nameof(CboFolders_KeyDownAsync)} event handler can " - + $"only be assigned to a ComboBox. must be a ComboBox" - ); + throw new ArgumentNullException(nameof(viewer)); } - var cb = (ComboBox)sender; - if (cb.DroppedDown) + + if (direction == UtilitiesCS.OutlookObjects.Folder.BreadcrumbArrowDirection.Right) { - await DdOpen_KeyDownAsync(cb, e); + MyBox.ShowDialog( + "Pop Out Item or Enumerate Conversation?", + "Dialog", + BoxIcon.Question, + viewer.Controller.RightKeyActions + ); } else { - await DdClosed_KeyDownAsync(cb, e); + viewer.SetFolderDroppedDown(false); } } @@ -542,17 +342,8 @@ public async Task DdOpen_KeyDownAsync(ComboBox cbo, KeyEventArgs e) // } case Keys.Right: { - // #325: with the dropdown open and a folder-tree node highlighted, Right expands - // it (no-op for a leaf or an already-expanded node). The transition itself is in - // the unit-tested FolderTreeStateModel; only route to it here. When nothing - // expands, fall through to the legacy Pop Out / Enumerate dialog. - if (cbo.GetAncestor<ItemViewer>().FolderTreeRightArrow()) - { - e.SuppressKeyPress = true; - e.Handled = true; - break; - } - + // #351: the breadcrumb owns Right-arrow expansion via the JS bridge; for a + // remaining ComboBox sender only the legacy Pop Out / Enumerate dialog applies. e.SuppressKeyPress = true; e.Handled = true; @@ -566,16 +357,8 @@ public async Task DdOpen_KeyDownAsync(ComboBox cbo, KeyEventArgs e) } case Keys.Left: { - // #325: with the dropdown open and a folder-tree node highlighted, Left collapses - // it (no-op for a leaf or an already-collapsed node). On a no-op, fall through to - // the legacy behavior that closes the dropdown. - if (cbo.GetAncestor<ItemViewer>().FolderTreeLeftArrow()) - { - e.SuppressKeyPress = true; - e.Handled = true; - break; - } - + // #351: the breadcrumb owns Left-arrow collapse via the JS bridge; for a + // remaining ComboBox sender only the legacy close-the-dropdown branch applies. UiThread.Dispatcher.Invoke(() => cbo.DroppedDown = false); e.SuppressKeyPress = true; e.Handled = true; diff --git a/QuickFiler/Controllers/QfcCollectionController.cs b/QuickFiler/Controllers/QfcCollectionController.cs index b0f2ab26..49401d75 100644 --- a/QuickFiler/Controllers/QfcCollectionController.cs +++ b/QuickFiler/Controllers/QfcCollectionController.cs @@ -1299,31 +1299,18 @@ internal async Task CustomReturnKeyHandler() } } + // #351: the WebView2 breadcrumb replaced the folder ComboBox and has no dropped-down list + // state, so no viewer can hold an open dropdown; the Return-key gate that used to close + // open dropdowns before acting is now always clear. internal bool AnyOpenDropDowns(bool close, CancellationToken token) { - return _itemGroups.Any(grp => DropDownState(grp.ItemViewer.CboFolders, close)); + return false; } internal async Task<bool> AnyOpenDropDownsAsync(bool close, CancellationToken token) { token.ThrowIfCancellationRequested(); - - return await Task.Factory.StartNew( - () => _itemGroups.Any(grp => DropDownState(grp.ItemViewer.CboFolders, close)), - token, - TaskCreationOptions.None, - _formViewer.UiScheduler - ); - } - - private bool DropDownState(ComboBox comboBox, bool close) - { - var state = comboBox.DroppedDown; - if (close && state) - { - comboBox.DroppedDown = false; - } - return state; + return await Task.FromResult(false); } public void RegisterNavigation() diff --git a/QuickFiler/Controllers/QfcItemController.EventWiring.cs b/QuickFiler/Controllers/QfcItemController.EventWiring.cs index 67855bf9..700cfe81 100644 --- a/QuickFiler/Controllers/QfcItemController.EventWiring.cs +++ b/QuickFiler/Controllers/QfcItemController.EventWiring.cs @@ -45,8 +45,9 @@ internal void WireControlTreeEvents() _kbdHandler.KeyboardHandler_KeyDownAsync ); }, - //new List<Control> { _itemViewer.CboFolders, _itemViewer.TxtboxSearch, _itemViewer.TopicThread }); - new List<Control> { ((ItemViewer)_itemViewer).CboFolders } // concrete-bound seam (P2-T4): control-host path, runs on real ItemViewer during init + // #351: the breadcrumb WebView2 replaced CboFolders; keep it excluded from the + // blanket key wiring exactly as the ComboBox was (its keys route via the bridge). + new List<Control> { ((ItemViewer)_itemViewer).L0vhBreadcrumb_WebView2 } // concrete-bound seam (P2-T4): control-host path, runs on real ItemViewer during init ); foreach (var btn in Buttons) @@ -80,7 +81,8 @@ internal void WireIntentEvents() _itemViewer.FolderKeyDown += new System.Windows.Forms.KeyEventHandler( _kbdHandler.CboFolders_KeyDownAsync ); - //_itemViewer.CboFolders.KeyDown += new System.Windows.Forms.KeyEventHandler(_kbdHandler.CboFolders_KeyDown); + // #351: FolderSelectionChanged is now raised by the breadcrumb bridge coordinator + // (synthetic .NET event) instead of the removed ComboBox; the wiring is unchanged. _itemViewer.FolderSelectionChanged += this.CboFolders_SelectedIndexChanged; _itemViewer.WebViewInitializationCompleted += WebView2Control_CoreWebView2InitializationCompleted; diff --git a/QuickFiler/Controllers/QfcItemController.FolderHandling.cs b/QuickFiler/Controllers/QfcItemController.FolderHandling.cs index d1b9582b..a09ca4ab 100644 --- a/QuickFiler/Controllers/QfcItemController.FolderHandling.cs +++ b/QuickFiler/Controllers/QfcItemController.FolderHandling.cs @@ -169,6 +169,12 @@ public void AssignFolderComboBox() if (_folderHandler?.FolderArray?.Length > 0) { + // #351: the breadcrumb pipeline (injected 9101 provider behind the coordinator) + // must exist before population so ancestor chains come from + // IFolderHierarchyProvider.GetAncestorChainAsync instead of the decommissioned + // FolderHierarchyBuilder.Build (AC-5); no-op once initialized. + EnsureBreadcrumbPipeline(); + // Intent-member equivalent of PopulateAndSelectFolder (Seam C): predetermined // high-confidence folder is preselected when present; otherwise the index-1 top // suggestion is selected. The standalone static PopulateAndSelectFolder is retained diff --git a/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs b/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs index 14b67532..b77eea30 100644 --- a/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs +++ b/QuickFiler/Controllers/QfcItemController.ViewerSetup.cs @@ -97,6 +97,16 @@ await _webViewInitializer.EnsureCoreWebView2Async( $"Content-Type: {mimeType}" ); }; + + // #351: initialize the breadcrumb WebView2 through the same injected seam and the + // same CoreWebView2Environment/options object created above for the message-body + // pane (G7); no second environment is negotiated against the user-data folder. + EnsureBreadcrumbPipeline(); + await _webViewInitializer.EnsureCoreWebView2Async( + ((ItemViewer)_itemViewer).L0vhBreadcrumb_WebView2, + _webViewEnvironment + ); + ((ItemViewer)_itemViewer).AttachBreadcrumbWebView(); //var task = CoreWebView2Environment.CreateAsync(null, cacheFolder, options); //await task.ContinueWith(t => @@ -106,6 +116,25 @@ await _webViewInitializer.EnsureCoreWebView2Async( //}, Token, TaskContinuationOptions.OnlyOnRanToCompletion, ui); } + // #351: idempotently creates the host-neutral breadcrumb pipeline on the concrete viewer + // so folder population/selection are correct even before WebView2 core init completes. + // The 9101 provider is DI-resolved from the injected globals' folder-tree service seam — + // no live Outlook query is issued inside breadcrumb code (G6). Skipped for mock viewers + // (unit tests drive the coordinator directly through its own seams). + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal void EnsureBreadcrumbPipeline() + { + if (_itemViewer is ItemViewer viewer && viewer.BreadcrumbCoordinator == null) + { + var provider = new UtilitiesCS.OutlookObjects.Folder.OutlookFolderHierarchyProvider( + _globals.Ol.FolderTreeService + ); + viewer.InitializeBreadcrumbPipeline(provider); + viewer.BreadcrumbUnhandledArrow += (s, direction) => + _kbdHandler?.BreadcrumbArrowFallThrough(viewer, direction); + } + } + // Minimal in-memory extension-to-MIME-type lookup for the WebResourceRequested handler // above; defaults to a generic octet stream for unrecognized/absent extensions rather than // failing the intercepted request. @@ -302,6 +331,9 @@ internal void AssignControls(MailItemHelper itemInfo, int viewerPosition) public void Cleanup() { + // #351: clear the breadcrumb rows/selection before releasing the pooled viewer, in + // step with the _webViewEnvironment clearing below. + (_itemViewer as ItemViewer)?.ResetBreadcrumb(); _globals = null; _itemViewer = null; _parent = null; diff --git a/QuickFiler/Helper Classes/QfcThemeControlSet.cs b/QuickFiler/Helper Classes/QfcThemeControlSet.cs index bdfc034c..ae7536a8 100644 --- a/QuickFiler/Helper Classes/QfcThemeControlSet.cs +++ b/QuickFiler/Helper Classes/QfcThemeControlSet.cs @@ -23,7 +23,8 @@ internal QfcThemeControlSet( IList<IQfcTipsDetails> tipsExpanded, TextBox textboxSearch, TextBox textboxBody, - ComboBox comboFolders, + WebView2 breadcrumbWebView2, + Action<string> breadcrumbThemeNotifier, FastObjectListView topicThread, WebView2 webView2, Control viewer, @@ -43,7 +44,11 @@ IUiDispatcher uiDispatcher TipsExpanded = RequireCollection(tipsExpanded, nameof(tipsExpanded)); TextboxSearch = textboxSearch ?? throw new ArgumentNullException(nameof(textboxSearch)); TextboxBody = textboxBody ?? throw new ArgumentNullException(nameof(textboxBody)); - ComboFolders = comboFolders ?? throw new ArgumentNullException(nameof(comboFolders)); + BreadcrumbWebView2 = + breadcrumbWebView2 ?? throw new ArgumentNullException(nameof(breadcrumbWebView2)); + BreadcrumbThemeNotifier = + breadcrumbThemeNotifier + ?? throw new ArgumentNullException(nameof(breadcrumbThemeNotifier)); TopicThread = topicThread ?? throw new ArgumentNullException(nameof(topicThread)); WebView2 = webView2 ?? throw new ArgumentNullException(nameof(webView2)); Viewer = viewer ?? throw new ArgumentNullException(nameof(viewer)); @@ -74,7 +79,11 @@ IUiDispatcher uiDispatcher internal TextBox TextboxBody { get; } - internal ComboBox ComboFolders { get; } + // #351: the folder control is the WebView2 breadcrumb; the notifier posts the themeChange + // bridge message through the viewer's coordinator. + internal WebView2 BreadcrumbWebView2 { get; } + + internal Action<string> BreadcrumbThemeNotifier { get; } internal FastObjectListView TopicThread { get; } diff --git a/QuickFiler/Helper Classes/QfcThemeHelper.cs b/QuickFiler/Helper Classes/QfcThemeHelper.cs index 00d9fd86..5bfa88b1 100644 --- a/QuickFiler/Helper Classes/QfcThemeHelper.cs +++ b/QuickFiler/Helper Classes/QfcThemeHelper.cs @@ -82,7 +82,8 @@ UtilitiesCS.Threading.IUiDispatcher uiDispatcher controller.ListTipsExpanded, viewer.TxtboxSearch, viewer.TxtboxBody, - viewer.CboFolders, + viewer.L0vhBreadcrumb_WebView2, + theme => viewer.BreadcrumbCoordinator?.SetTheme(theme), viewer.TopicThread, viewer.L0v2h2_WebView2, viewer, @@ -336,7 +337,8 @@ Color defaultForeColor tipsExpanded: controlSet.TipsExpanded, textboxSearch: controlSet.TextboxSearch, textboxBody: controlSet.TextboxBody, - comboFolders: controlSet.ComboFolders, + breadcrumbWebView2: controlSet.BreadcrumbWebView2, + breadcrumbThemeNotifier: controlSet.BreadcrumbThemeNotifier, topicThread: controlSet.TopicThread, webView2: controlSet.WebView2, viewer: controlSet.Viewer, diff --git a/QuickFiler/Interfaces/IQfcKeyboardHandler.cs b/QuickFiler/Interfaces/IQfcKeyboardHandler.cs index 4397684b..9352f8fe 100644 --- a/QuickFiler/Interfaces/IQfcKeyboardHandler.cs +++ b/QuickFiler/Interfaces/IQfcKeyboardHandler.cs @@ -25,7 +25,13 @@ public interface IQfcKeyboardHandler KbdActions<Keys, KaKeyAsync, Func<Keys, Task>> AlwaysOnKeyActionsAsync { get; set; } KbdActions<string, KaStringAsync, Func<string, Task>> StringActionsAsync { get; set; } - void CboFolders_KeyDown(object sender, KeyEventArgs e); void CboFolders_KeyDownAsync(object sender, KeyEventArgs e); + + // #351: legacy fall-through target for the breadcrumb bridge's unhandled Left/Right + // arrows (Right -> Pop Out/Enumerate dialog; Left -> close the folder control intent). + void BreadcrumbArrowFallThrough( + ItemViewer viewer, + UtilitiesCS.OutlookObjects.Folder.BreadcrumbArrowDirection direction + ); } } diff --git a/QuickFiler/Properties/Resources.Designer.cs b/QuickFiler/Properties/Resources.Designer.cs index 539465fd..18c7a2de 100644 --- a/QuickFiler/Properties/Resources.Designer.cs +++ b/QuickFiler/Properties/Resources.Designer.cs @@ -178,6 +178,16 @@ internal static string EmailHeader { return ResourceManager.GetString("EmailHeader", resourceCulture); } } + + /// <summary> + /// Looks up a localized resource of type System.String similar to the self-contained + /// FolderBreadcrumb.html page (#351) loaded via NavigateToString. + /// </summary> + internal static string FolderBreadcrumb { + get { + return ResourceManager.GetString("FolderBreadcrumb", resourceCulture); + } + } /// <summary> /// Looks up a localized resource of type System.Drawing.Bitmap. diff --git a/QuickFiler/Properties/Resources.resx b/QuickFiler/Properties/Resources.resx index 53615c93..076bc84c 100644 --- a/QuickFiler/Properties/Resources.resx +++ b/QuickFiler/Properties/Resources.resx @@ -130,6 +130,9 @@ <data name="EmailHeader" type="System.Resources.ResXFileRef, System.Windows.Forms"> <value>..\Resources\EmailHeader.html;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;Windows-1252</value> </data> + <data name="FolderBreadcrumb" type="System.Resources.ResXFileRef, System.Windows.Forms"> + <value>..\Resources\FolderBreadcrumb.html;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8</value> + </data> <data name="TextBlock1" type="System.Resources.ResXFileRef, System.Windows.Forms"> <value>..\Resources\TextBlock.svg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> </data> diff --git a/QuickFiler/QuickFiler.csproj b/QuickFiler/QuickFiler.csproj index d4157e5c..dc20e37d 100644 --- a/QuickFiler/QuickFiler.csproj +++ b/QuickFiler/QuickFiler.csproj @@ -287,6 +287,8 @@ <ItemGroup> <Compile Include="Controllers\BayesianPerformanceController.cs" /> <Compile Include="Controllers\EfcDataModel.cs" /> + <Compile Include="Controllers\BreadcrumbBridgeRouter.cs" /> + <Compile Include="Controllers\BreadcrumbOutboundQueue.cs" /> <Compile Include="Controllers\EfcFormController.cs" /> <Compile Include="Controllers\EfcHomeController.cs" /> <Compile Include="Controllers\EfcHomeController.Metrics.cs" /> @@ -385,8 +387,13 @@ <DependentUpon>EfcViewer.cs</DependentUpon> </Compile> <Compile Include="Viewers\IItemViewer.cs" /> + <Compile Include="Viewers\BreadcrumbBridgeCoordinator.cs" /> <Compile Include="Viewers\IWebViewCoreInitializer.cs" /> + <Compile Include="Viewers\IBreadcrumbWebHost.cs" /> + <Compile Include="Viewers\WebView2BreadcrumbHost.cs" /> + <Compile Include="Viewers\IWebViewMessenger.cs" /> <Compile Include="Viewers\WebView2CoreInitializer.cs" /> + <Compile Include="Viewers\WebView2Messenger.cs" /> <Compile Include="Viewers\ItemViewer.cs"> <SubType>UserControl</SubType> </Compile> @@ -398,6 +405,10 @@ <DependentUpon>ItemViewer.cs</DependentUpon> <SubType>UserControl</SubType> </Compile> + <Compile Include="Viewers\ItemViewer.Breadcrumb.cs"> + <DependentUpon>ItemViewer.cs</DependentUpon> + <SubType>UserControl</SubType> + </Compile> <Compile Include="Viewers\ItemViewer.FolderSearch.cs"> <DependentUpon>ItemViewer.cs</DependentUpon> <SubType>UserControl</SubType> @@ -496,6 +507,7 @@ <Content Include="Resources\Delete.png" /> <Content Include="Resources\Delete.svg" /> <Content Include="Resources\EmailHeader.html" /> + <Content Include="Resources\FolderBreadcrumb.html" /> <Content Include="Resources\FlagDarkRed.png" /> <Content Include="Resources\FlagDarkRed.svg" /> <None Include="Resources\Forward1.png" /> diff --git a/QuickFiler/Resources/FolderBreadcrumb.html b/QuickFiler/Resources/FolderBreadcrumb.html new file mode 100644 index 00000000..c64c54bf --- /dev/null +++ b/QuickFiler/Resources/FolderBreadcrumb.html @@ -0,0 +1,244 @@ +<!DOCTYPE html> +<!-- + QuickFiler breadcrumb page (#351). Self-contained: inline CSS + JS, no external fetches, no + third-party libraries (G1/G2). Rendering is driven entirely by 'render' bridge messages from the + host (Phase 3 JSON protocol) over window.chrome.webview; interactions post messages back. + The percentage cell CSS below is the FR-5 fix selected by the P1-T2 analysis. +--> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<title>Folder Breadcrumb + + + +
+ + + diff --git a/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs b/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs new file mode 100644 index 00000000..85cbcdd2 --- /dev/null +++ b/QuickFiler/Viewers/BreadcrumbBridgeCoordinator.cs @@ -0,0 +1,226 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using UtilitiesCS; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler.Viewers +{ + /// + /// Host-neutral coordinator for the QuickFiler breadcrumb (#351 P4-T4, NOT coverage-exempt): + /// wires -> + /// -> , exposes + /// the population/selection surface the viewer glue delegates to, and raises the .NET events + /// (, , + /// ) that preserve the existing controller seams. The + /// synthetic carries the arrow direction; the + /// coverage-exempt viewer partial adapts it to the WinForms KeyEventHandler shape of + /// IItemViewer.FolderKeyDown, keeping this type free of WinForms/WebView2/COM usage. + /// Router responses are awaited directly — no timers. + /// + public sealed class BreadcrumbBridgeCoordinator + { + private readonly IWebViewMessenger _messenger; + private readonly FolderBreadcrumbBridgeRouter _router; + + /// + /// Creates the coordinator and subscribes to inbound page messages. + /// + /// The post-init WebView2 messaging seam. Required. + /// The merged 9101 hierarchy provider. Required. + /// Either argument is null. + public BreadcrumbBridgeCoordinator( + IWebViewMessenger messenger, + IFolderHierarchyProvider provider + ) + { + _messenger = messenger ?? throw new ArgumentNullException(nameof(messenger)); + _router = new FolderBreadcrumbBridgeRouter(provider); + _messenger.MessageReceived += OnMessageReceived; + } + + /// Raised when the page selection changes (backs FolderSelectionChanged). + public event EventHandler? SelectionChanged; + + /// Raised when an arrow was not consumed, for the legacy fall-through (FR-6). + public event EventHandler? UnhandledArrow; + + /// Synthetic key event for every inbound arrow message (handled or not). + public event EventHandler? FolderArrowKeyDown; + + /// The dispatch task of the most recent inbound message (awaitable by tests/glue). + public Task LastDispatch { get; private set; } = Task.CompletedTask; + + /// + /// Populates Path A suggestion rows through the router/provider and posts the render + /// payload to the page (FR-1/FR-4). + /// + public async Task SetSuggestionsAsync( + IReadOnlyList rows, + CancellationToken cancellationToken + ) + { + var renderJson = await _router + .SetSuggestionsAsync(rows, cancellationToken) + .ConfigureAwait(false); + _messenger.PostJson(renderJson); + } + + /// + /// Synchronous population facade for the void IItemViewer.SetFolderSuggestions + /// contract: rows are populated immediately as plain full-path rows so the selection + /// contract (FolderContains/SetFolderSelectedItem/GetSelectedFolder readback) holds + /// without awaiting the provider, then the ancestor-chain upgrade runs asynchronously + /// () preserving the selected index (FR-1/G10). + /// + public void SetSuggestions(IReadOnlyList rows) + { + if (rows == null) + { + throw new ArgumentNullException(nameof(rows)); + } + + var immediate = new string[rows.Count]; + for (int i = 0; i < rows.Count; i++) + { + var score = rows[i].Score; + immediate[i] = score.HasValue ? score.Value.FolderPath : rows[i].Text; + } + _messenger.PostJson(_router.SetItems(immediate)); + SuggestionsUpgrade = UpgradeSuggestionsAsync(rows); + } + + /// The in-flight ancestor-chain upgrade of the latest call. + public Task SuggestionsUpgrade { get; private set; } = Task.CompletedTask; + + private async Task UpgradeSuggestionsAsync(IReadOnlyList rows) + { + int selected = _router.Model.SelectedIndex; + var renderJson = await _router + .SetSuggestionsAsync(rows, CancellationToken.None) + .ConfigureAwait(false); + if (selected >= 0 && selected < _router.Model.Rows.Count) + { + // Row order and count are preserved by the rebuild, so index selection carries over. + renderJson = _router.SelectRow(selected); + } + _messenger.PostJson(renderJson); + } + + /// Appends Path B plain rows verbatim and re-renders (legacy AddRange semantics). + public void AddItems(IReadOnlyList items) + { + _messenger.PostJson(_router.AddItems(items)); + } + + /// Clears all rows and the selection, emptying the page (backs ClearFolderItems). + public void Clear() + { + _messenger.PostJson(_router.Clear()); + } + + /// Selects the row at and re-renders (backs SetFolderSelectedIndex). + public void SelectRow(int index) + { + _messenger.PostJson(_router.SelectRow(index)); + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Selects the first row whose output string equals (backs + /// SetFolderSelectedItem); unknown items are a no-op per the legacy contract. + /// + public void SelectItem(string item) + { + if (BreadcrumbSelectionMap.TrySelectItem(_router.Model, item)) + { + _messenger.PostJson(_router.RenderJson()); + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + } + + /// The selection output string (backs GetSelectedFolder; FR-7/G10). + public string? GetSelectedFolder() + { + return BreadcrumbSelectionMap.GetSelectedFolder(_router.Model); + } + + /// The per-row output strings (backs GetFolderItems). + public string[] GetFolderItems() + { + return BreadcrumbSelectionMap.GetFolderItems(_router.Model); + } + + /// True when a row's output string equals (backs FolderContains). + public bool Contains(string item) + { + return BreadcrumbSelectionMap.FolderContains(_router.Model, item); + } + + /// Posts a theme switch to the page ("dark"/"light"; FR-5 theming). + public void SetTheme(string theme) + { + _messenger.PostJson( + BreadcrumbBridgeSerializer.Serialize(new ThemeChangeMessage(theme)) + ); + } + + private async void OnMessageReceived(object? sender, string json) + { + // Event-handler boundary: the dispatch task is tracked so callers/tests can observe + // completion; RouteAsync converts all routing/provider failures into explicit error + // responses, so this await only propagates cancellation. + var dispatch = DispatchAsync(json); + LastDispatch = dispatch; + await dispatch.ConfigureAwait(false); + } + + private async Task DispatchAsync(string json) + { + RaiseSyntheticArrowKey(json); + var outputs = await _router + .RouteAsync(json, CancellationToken.None) + .ConfigureAwait(false); + foreach (var output in outputs) + { + var message = BreadcrumbBridgeSerializer.Parse(output); + if (message is UnhandledArrowMessage unhandled) + { + // Not posted back to the page: the host owns the legacy fall-through (FR-6). + UnhandledArrow?.Invoke(this, unhandled.Direction); + continue; + } + _messenger.PostJson(output); + if (message is SelectionChangeMessage) + { + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + } + } + + private void RaiseSyntheticArrowKey(string json) + { + BreadcrumbBridgeMessage message; + try + { + message = BreadcrumbBridgeSerializer.Parse(json); + } + catch (FormatException) + { + // Malformed inbound JSON is routed anyway and surfaces as an error response. + return; + } + + if (message is ArrowKeyMessage arrow) + { + FolderArrowKeyDown?.Invoke(this, arrow.Direction); + } + else if (message is UnhandledArrowMessage report) + { + FolderArrowKeyDown?.Invoke(this, report.Direction); + } + } + } +} diff --git a/QuickFiler/Viewers/EfcViewer.Designer.cs b/QuickFiler/Viewers/EfcViewer.Designer.cs index 9e39179c..b75c1708 100644 --- a/QuickFiler/Viewers/EfcViewer.Designer.cs +++ b/QuickFiler/Viewers/EfcViewer.Designer.cs @@ -47,9 +47,7 @@ private void InitializeComponent() this.label1 = new System.Windows.Forms.Label(); this.label2 = new System.Windows.Forms.Label(); this.BtnDelItem = new SVGControl.ButtonSVG(); - this.FolderListBox = new BrightIdeasSoftware.TreeListView(); - this.olvColumnFolder = new BrightIdeasSoftware.OLVColumn(); - this.olvColumnPercent = new BrightIdeasSoftware.OLVColumn(); + this.FolderListBox = new Microsoft.Web.WebView2.WinForms.WebView2(); this.Ok = new SVGControl.ButtonSVG(); this.Cancel = new SVGControl.ButtonSVG(); this.RefreshPredicted = new SVGControl.ButtonSVG(); @@ -883,43 +881,15 @@ private void InitializeComponent() // ((System.ComponentModel.ISupportInitialize)(this.FolderListBox)).BeginInit(); this.Tlp.SetColumnSpan(this.FolderListBox, 14); - this.FolderListBox.AllColumns.Add(this.olvColumnFolder); - this.FolderListBox.AllColumns.Add(this.olvColumnPercent); - this.FolderListBox.Columns.AddRange( - new System.Windows.Forms.ColumnHeader[] - { - this.olvColumnFolder, - this.olvColumnPercent, - } - ); this.FolderListBox.Dock = System.Windows.Forms.DockStyle.Fill; - this.FolderListBox.Font = new System.Drawing.Font("Microsoft Sans Serif", 10.125F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this.FolderListBox.FullRowSelect = true; - this.FolderListBox.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.Nonclickable; - this.FolderListBox.HideSelection = false; this.FolderListBox.Location = new System.Drawing.Point(44, 235); this.FolderListBox.Margin = new System.Windows.Forms.Padding(4, 6, 4, 6); this.FolderListBox.Name = "FolderListBox"; - this.FolderListBox.OwnerDraw = true; - this.FolderListBox.ShowGroups = false; this.FolderListBox.Size = new System.Drawing.Size(3728, 1); this.FolderListBox.TabIndex = 11; - this.FolderListBox.UseCompatibleStateImageBehavior = false; - this.FolderListBox.View = System.Windows.Forms.View.Details; + this.FolderListBox.ZoomFactor = 1D; ((System.ComponentModel.ISupportInitialize)(this.FolderListBox)).EndInit(); // - // olvColumnFolder - // - this.olvColumnFolder.AspectName = "DisplayName"; - this.olvColumnFolder.Text = "Folder"; - this.olvColumnFolder.Width = 3200; - // - // olvColumnPercent - // - this.olvColumnPercent.Text = "%"; - this.olvColumnPercent.TextAlign = System.Windows.Forms.HorizontalAlignment.Right; - this.olvColumnPercent.Width = 500; - // // Ok // this.Ok.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right))); @@ -4277,9 +4247,7 @@ private void InitializeComponent() internal System.Windows.Forms.Label label1; internal System.Windows.Forms.Label label2; internal ButtonSVG BtnDelItem; - internal BrightIdeasSoftware.TreeListView FolderListBox; - internal BrightIdeasSoftware.OLVColumn olvColumnFolder; - internal BrightIdeasSoftware.OLVColumn olvColumnPercent; + internal Microsoft.Web.WebView2.WinForms.WebView2 FolderListBox; internal ButtonSVG Ok; internal ButtonSVG Cancel; internal ButtonSVG RefreshPredicted; diff --git a/QuickFiler/Viewers/EfcViewer.cs b/QuickFiler/Viewers/EfcViewer.cs index 9e860296..4292e7f9 100644 --- a/QuickFiler/Viewers/EfcViewer.cs +++ b/QuickFiler/Viewers/EfcViewer.cs @@ -85,6 +85,12 @@ private void InitTipsLabelsList() }; } + /// + /// Exposes the Designer-owned breadcrumb WebView2 control to the form controller + /// (consumed by the WebView2BreadcrumbHost adapter wiring). + /// + internal Microsoft.Web.WebView2.WinForms.WebView2 BreadcrumbWebView => FolderListBox; + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if ((_keyboardHandler is not null) && (keyData.HasFlag(Keys.Alt))) diff --git a/QuickFiler/Viewers/EfcViewer3.Designer.cs b/QuickFiler/Viewers/EfcViewer3.Designer.cs index 9e7861f1..0d60e39d 100644 --- a/QuickFiler/Viewers/EfcViewer3.Designer.cs +++ b/QuickFiler/Viewers/EfcViewer3.Designer.cs @@ -36,9 +36,7 @@ private void InitializeComponent() this.label1 = new System.Windows.Forms.Label(); this.label2 = new System.Windows.Forms.Label(); this.BtnDelItem = new System.Windows.Forms.Button(); - this.FolderListBox = new BrightIdeasSoftware.TreeListView(); - this.olvColumnFolder = new BrightIdeasSoftware.OLVColumn(); - this.olvColumnPercent = new BrightIdeasSoftware.OLVColumn(); + this.FolderListBox = new Microsoft.Web.WebView2.WinForms.WebView2(); this.Ok = new System.Windows.Forms.Button(); this.Cancel = new System.Windows.Forms.Button(); this.RefreshPredicted = new System.Windows.Forms.Button(); @@ -228,42 +226,15 @@ private void InitializeComponent() // ((System.ComponentModel.ISupportInitialize)(this.FolderListBox)).BeginInit(); this.Tlp.SetColumnSpan(this.FolderListBox, 14); - this.FolderListBox.AllColumns.Add(this.olvColumnFolder); - this.FolderListBox.AllColumns.Add(this.olvColumnPercent); - this.FolderListBox.Columns.AddRange( - new System.Windows.Forms.ColumnHeader[] - { - this.olvColumnFolder, - this.olvColumnPercent, - } - ); this.FolderListBox.Dock = System.Windows.Forms.DockStyle.Fill; - this.FolderListBox.FullRowSelect = true; - this.FolderListBox.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.Nonclickable; - this.FolderListBox.HideSelection = false; this.FolderListBox.Location = new System.Drawing.Point(75, 262); this.FolderListBox.Margin = new System.Windows.Forms.Padding(5); this.FolderListBox.Name = "FolderListBox"; - this.FolderListBox.OwnerDraw = true; - this.FolderListBox.ShowGroups = false; this.FolderListBox.Size = new System.Drawing.Size(1965, 287); this.FolderListBox.TabIndex = 11; - this.FolderListBox.UseCompatibleStateImageBehavior = false; - this.FolderListBox.View = System.Windows.Forms.View.Details; + this.FolderListBox.ZoomFactor = 1D; ((System.ComponentModel.ISupportInitialize)(this.FolderListBox)).EndInit(); // - // olvColumnFolder - // - this.olvColumnFolder.AspectName = "DisplayName"; - this.olvColumnFolder.Text = "Folder"; - this.olvColumnFolder.Width = 1600; - // - // olvColumnPercent - // - this.olvColumnPercent.Text = "%"; - this.olvColumnPercent.TextAlign = System.Windows.Forms.HorizontalAlignment.Right; - this.olvColumnPercent.Width = 300; - // // Ok // this.Ok.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right))); @@ -522,9 +493,7 @@ private void InitializeComponent() internal System.Windows.Forms.Button Cancel; internal System.Windows.Forms.Button RefreshPredicted; internal System.Windows.Forms.Button NewFolder; - internal BrightIdeasSoftware.TreeListView FolderListBox; - internal BrightIdeasSoftware.OLVColumn olvColumnFolder; - internal BrightIdeasSoftware.OLVColumn olvColumnPercent; + internal Microsoft.Web.WebView2.WinForms.WebView2 FolderListBox; internal System.Windows.Forms.TextBox SearchText; internal System.Windows.Forms.Label LblAcSearch; internal System.Windows.Forms.Label LblAcFolderList; diff --git a/QuickFiler/Viewers/IBreadcrumbWebHost.cs b/QuickFiler/Viewers/IBreadcrumbWebHost.cs new file mode 100644 index 00000000..edd88519 --- /dev/null +++ b/QuickFiler/Viewers/IBreadcrumbWebHost.cs @@ -0,0 +1,27 @@ +#nullable enable +using System; + +namespace QuickFiler.Viewers +{ + /// + /// Narrow host seam over the breadcrumb WebView2 control (#349). Implemented by the + /// coverage-exempt WebView2BreadcrumbHost adapter and mocked in router tests, so the + /// non-exempt bridge router never touches WebView2 types directly. + /// + public interface IBreadcrumbWebHost + { + /// Delivers a full generated HTML document to the hosted control. + /// The complete document markup. + void NavigateToString(string html); + + /// Posts an outbound bridge payload as JSON to the hosted document. + /// The serialized outbound message. + void PostMessageJson(string json); + + /// Raised with the raw JSON payload of each inbound web message. + event EventHandler MessageReceived; + + /// True once CoreWebView2 initialization has completed. + bool IsCoreInitialized { get; } + } +} diff --git a/QuickFiler/Viewers/IWebViewMessenger.cs b/QuickFiler/Viewers/IWebViewMessenger.cs new file mode 100644 index 00000000..5b6c7f1c --- /dev/null +++ b/QuickFiler/Viewers/IWebViewMessenger.cs @@ -0,0 +1,27 @@ +using System; + +namespace QuickFiler.Viewers +{ + /// + /// Narrow post-init WebView2 messaging seam for the breadcrumb bridge (#351 FR-6): inbound JSON + /// messages from the page surface through and outbound JSON is + /// posted via . All bridge correctness lives behind this seam in + /// host-neutral, unit-tested types; production is served by , + /// a 1:1 forwarder over CoreWebView2.WebMessageReceived/PostWebMessageAsJson, + /// and tests supply a Moq mock. + /// + public interface IWebViewMessenger + { + /// + /// Raised once per JSON message received from the page. The payload is the raw JSON string + /// of one breadcrumb bridge message. + /// + event EventHandler MessageReceived; + + /// + /// Posts one JSON message to the page. + /// + /// The serialized bridge message. Must be non-null. + void PostJson(string json); + } +} diff --git a/QuickFiler/Viewers/ItemViewer.Breadcrumb.cs b/QuickFiler/Viewers/ItemViewer.Breadcrumb.cs new file mode 100644 index 00000000..0e42b936 --- /dev/null +++ b/QuickFiler/Viewers/ItemViewer.Breadcrumb.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Windows.Forms; +using QuickFiler.Viewers; +using UtilitiesCS.OutlookObjects.Folder; + +namespace QuickFiler +{ + // Breadcrumb WinForms glue (#351). The host-neutral seams (BreadcrumbBridgeCoordinator -> + // FolderBreadcrumbBridgeRouter/BreadcrumbStateModel/BreadcrumbSelectionMap in UtilitiesCS) own all + // correctness; this partial holds only the WinForms glue (WebView2 property exposure, page + // load via NavigateToString, focus hand-off, and the pre-init message relay). The whole + // ItemViewer type is [ExcludeFromCodeCoverage] via its primary partial in ItemViewer.cs + // (the attribute is not repeated here to avoid duplicate-attribute CS0579 on the partial type). + public partial class ItemViewer + { + private BreadcrumbMessengerRelay _breadcrumbRelay; + private WebView2Messenger _breadcrumbMessenger; + + /// The Designer-declared breadcrumb WebView2 occupying the old CboFolders cell. + public Microsoft.Web.WebView2.WinForms.WebView2 L0vhBreadcrumb_WebView2 + { + get => _l0vhBreadcrumb_WebView2; + set => _l0vhBreadcrumb_WebView2 = value; + } + + /// + /// The breadcrumb coordinator once the pipeline is initialized; null on a bare viewer + /// (folder intent members are inert no-ops until the controller initializes the pipeline). + /// + internal BreadcrumbBridgeCoordinator BreadcrumbCoordinator { get; private set; } + + /// + /// Raised when the breadcrumb reports an arrow it could not consume, so the keyboard + /// handler can run the legacy fall-through behavior (FR-6). + /// + internal event EventHandler BreadcrumbUnhandledArrow; + + // Multicast backing delegates for the IItemViewer folder events (raised synthetically from + // the coordinator; add/remove implemented in ItemViewer.FolderSearch.cs). + private EventHandler _folderSelectionChangedHandlers; + private KeyEventHandler _folderKeyDownHandlers; + + /// + /// Creates the host-neutral breadcrumb pipeline (relay messenger + coordinator) so folder + /// population and selection are correct even before the WebView2 core init completes. + /// Idempotent; called by the controller with the DI-resolved 9101 provider (G6). + /// + internal void InitializeBreadcrumbPipeline(IFolderHierarchyProvider provider) + { + if (BreadcrumbCoordinator != null) + { + return; + } + + _breadcrumbRelay = new BreadcrumbMessengerRelay(); + BreadcrumbCoordinator = new BreadcrumbBridgeCoordinator(_breadcrumbRelay, provider); + BreadcrumbCoordinator.SelectionChanged += (s, e) => + _folderSelectionChangedHandlers?.Invoke(this, EventArgs.Empty); + BreadcrumbCoordinator.FolderArrowKeyDown += (s, direction) => + _folderKeyDownHandlers?.Invoke( + this, + new KeyEventArgs( + direction == BreadcrumbArrowDirection.Right ? Keys.Right : Keys.Left + ) + ); + BreadcrumbCoordinator.UnhandledArrow += (s, direction) => + BreadcrumbUnhandledArrow?.Invoke(this, direction); + } + + /// + /// Attaches the initialized breadcrumb CoreWebView2: loads the self-contained page via + /// NavigateToString (mirroring ItemViewer.WebViewThread.cs) and flushes every buffered + /// bridge message through the real messenger. + /// + internal void AttachBreadcrumbWebView() + { + if (_breadcrumbRelay == null || _breadcrumbMessenger != null) + { + return; + } + + _l0vhBreadcrumb_WebView2.NavigateToString(Properties.Resources.FolderBreadcrumb); + _breadcrumbMessenger = new WebView2Messenger(_l0vhBreadcrumb_WebView2.CoreWebView2); + _breadcrumbRelay.Attach(_breadcrumbMessenger); + } + + /// + /// Focus glue for FocusFolderDropDown()/SetFolderDroppedDown(true): keyboard focus lands + /// in the breadcrumb WebView2 and the page focuses its list container on window focus. + /// + internal void FocusBreadcrumb() + { + _l0vhBreadcrumb_WebView2.Focus(); + } + + /// Clears breadcrumb rows/selection when the pooled viewer is recycled. + internal void ResetBreadcrumb() + { + BreadcrumbCoordinator?.Clear(); + } + + /// + /// Buffering relay implementing the messenger seam before the WebView2 core exists: + /// outbound JSON is queued and flushed on ; inbound messages from the + /// real messenger are forwarded 1:1. Pure glue with no message interpretation. + /// + internal sealed class BreadcrumbMessengerRelay : IWebViewMessenger + { + private readonly Queue _pending = new Queue(); + private IWebViewMessenger _real; + + public event EventHandler MessageReceived; + + public void PostJson(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + + if (_real != null) + { + _real.PostJson(json); + return; + } + _pending.Enqueue(json); + } + + public void Attach(IWebViewMessenger real) + { + _real = real ?? throw new ArgumentNullException(nameof(real)); + _real.MessageReceived += (s, json) => MessageReceived?.Invoke(this, json); + while (_pending.Count > 0) + { + _real.PostJson(_pending.Dequeue()); + } + } + } + } +} diff --git a/QuickFiler/Viewers/ItemViewer.Designer.cs b/QuickFiler/Viewers/ItemViewer.Designer.cs index 8e7daa3e..4339451d 100644 --- a/QuickFiler/Viewers/ItemViewer.Designer.cs +++ b/QuickFiler/Viewers/ItemViewer.Designer.cs @@ -30,6 +30,7 @@ protected override void Dispose(bool disposing) /// private void InitializeComponent() { + Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties coreWebView2CreationProperties1 = new Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties(); Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties coreWebView2CreationProperties2 = new Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties(); System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(ItemViewer)); SVGControl.SvgResource svgResource7 = new SVGControl.SvgResource(); @@ -42,7 +43,7 @@ private void InitializeComponent() this._moveOptionsStrip = new System.Windows.Forms.MenuStrip(); this._moveOptionsMenu = new System.Windows.Forms.ToolStripMenuItem(); this._lblAcSearch = new System.Windows.Forms.Label(); - this._cboFolders = new System.Windows.Forms.ComboBox(); + this._l0vhBreadcrumb_WebView2 = new Microsoft.Web.WebView2.WinForms.WebView2(); this._lblSearch = new System.Windows.Forms.Label(); this._txtboxSearch = new System.Windows.Forms.TextBox(); this._l0v2h2_WebView2 = new Microsoft.Web.WebView2.WinForms.WebView2(); @@ -86,6 +87,7 @@ private void InitializeComponent() this._l0vh_Tlp.SuspendLayout(); this._moveOptionsStrip.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this._l0v2h2_WebView2)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this._l0vhBreadcrumb_WebView2)).BeginInit(); this._l1h0L2hv3h_TlpBodyToggle.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this._topicThread)).BeginInit(); this._l1h1L2v1h3Panel.SuspendLayout(); @@ -111,7 +113,7 @@ private void InitializeComponent() this._l0vh_Tlp.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 325F)); this._l0vh_Tlp.Controls.Add(this._moveOptionsStrip, 12, 0); this._l0vh_Tlp.Controls.Add(this._lblAcSearch, 11, 2); - this._l0vh_Tlp.Controls.Add(this._cboFolders, 13, 4); + this._l0vh_Tlp.Controls.Add(this._l0vhBreadcrumb_WebView2, 13, 4); this._l0vh_Tlp.Controls.Add(this._lblSearch, 12, 2); this._l0vh_Tlp.Controls.Add(this._txtboxSearch, 13, 2); this._l0vh_Tlp.Controls.Add(this._l0v2h2_WebView2, 1, 5); @@ -185,23 +187,26 @@ private void InitializeComponent() this._lblAcSearch.Size = new System.Drawing.Size(20, 19); this._lblAcSearch.TabIndex = 10; this._lblAcSearch.Text = "S"; - // - // CboFolders - // - this._l0vh_Tlp.SetColumnSpan(this._cboFolders, 2); - this._cboFolders.Dock = System.Windows.Forms.DockStyle.Fill; - this._cboFolders.DrawMode = System.Windows.Forms.DrawMode.OwnerDrawFixed; - this._cboFolders.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; - this._cboFolders.FlatStyle = System.Windows.Forms.FlatStyle.Flat; - this._cboFolders.DrawItem += new System.Windows.Forms.DrawItemEventHandler(this.CboFolders_DrawItem); - this._cboFolders.MouseDown += new System.Windows.Forms.MouseEventHandler(this.CboFolders_MouseDown); - this._cboFolders.Font = new System.Drawing.Font("Microsoft Sans Serif", 10.875F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); - this._cboFolders.FormattingEnabled = true; - this._cboFolders.Location = new System.Drawing.Point(807, 64); - this._cboFolders.Margin = new System.Windows.Forms.Padding(0, 4, 0, 0); - this._cboFolders.Name = "CboFolders"; - this._cboFolders.Size = new System.Drawing.Size(391, 25); - this._cboFolders.TabIndex = 42; + // + // L0vhBreadcrumb_WebView2 + // + this._l0vhBreadcrumb_WebView2.AllowExternalDrop = true; + this._l0vh_Tlp.SetColumnSpan(this._l0vhBreadcrumb_WebView2, 2); + coreWebView2CreationProperties1.AdditionalBrowserArguments = null; + coreWebView2CreationProperties1.BrowserExecutableFolder = null; + coreWebView2CreationProperties1.IsInPrivateModeEnabled = null; + coreWebView2CreationProperties1.Language = null; + coreWebView2CreationProperties1.ProfileName = null; + coreWebView2CreationProperties1.UserDataFolder = null; + this._l0vhBreadcrumb_WebView2.CreationProperties = coreWebView2CreationProperties1; + this._l0vhBreadcrumb_WebView2.DefaultBackgroundColor = System.Drawing.Color.Transparent; + this._l0vhBreadcrumb_WebView2.Dock = System.Windows.Forms.DockStyle.Fill; + this._l0vhBreadcrumb_WebView2.Location = new System.Drawing.Point(807, 64); + this._l0vhBreadcrumb_WebView2.Margin = new System.Windows.Forms.Padding(0, 4, 0, 0); + this._l0vhBreadcrumb_WebView2.Name = "L0vhBreadcrumb_WebView2"; + this._l0vhBreadcrumb_WebView2.Size = new System.Drawing.Size(391, 25); + this._l0vhBreadcrumb_WebView2.TabIndex = 42; + this._l0vhBreadcrumb_WebView2.ZoomFactor = 1D; // // LblSearch // @@ -6159,6 +6164,7 @@ private void InitializeComponent() this._moveOptionsStrip.ResumeLayout(false); this._moveOptionsStrip.PerformLayout(); ((System.ComponentModel.ISupportInitialize)(this._l0v2h2_WebView2)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this._l0vhBreadcrumb_WebView2)).EndInit(); this._l1h0L2hv3h_TlpBodyToggle.ResumeLayout(false); this._l1h0L2hv3h_TlpBodyToggle.PerformLayout(); ((System.ComponentModel.ISupportInitialize)(this._topicThread)).EndInit(); @@ -6205,7 +6211,7 @@ private void InitializeComponent() internal System.Windows.Forms.Label _lblAcFwd; internal ButtonSVG _btnReply; internal ButtonSVG _btnReplyAll; - internal System.Windows.Forms.ComboBox _cboFolders; + internal Microsoft.Web.WebView2.WinForms.WebView2 _l0vhBreadcrumb_WebView2; internal System.Windows.Forms.TextBox _txtboxSearch; internal System.Windows.Forms.MenuStrip _moveOptionsStrip; internal System.Windows.Forms.ToolStripMenuItem _moveOptionsMenu; diff --git a/QuickFiler/Viewers/ItemViewer.FolderSearch.cs b/QuickFiler/Viewers/ItemViewer.FolderSearch.cs index 294aaa8d..593620e0 100644 --- a/QuickFiler/Viewers/ItemViewer.FolderSearch.cs +++ b/QuickFiler/Viewers/ItemViewer.FolderSearch.cs @@ -1,143 +1,65 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.Linq; using System.Windows.Forms; using UtilitiesCS; namespace QuickFiler { - // Forwarding implementations for the narrowed IItemViewer folder-combo and search intent members - // (Seam C, Cluster 2c). Each member forwards to the underlying Designer-backed CboFolders / - // TxtboxSearch controls. The whole ItemViewer type is [ExcludeFromCodeCoverage] via its primary - // partial in ItemViewer.cs. + // Forwarding implementations for the narrowed IItemViewer folder and search intent members + // (#351). Every folder member is a thin delegation to the host-neutral, unit-tested + // BreadcrumbBridgeCoordinator pipeline (see ItemViewer.Breadcrumb.cs); the legacy CboFolders + // owner-draw machinery and the FolderHierarchyBuilder.Build call are decommissioned (AC-5). + // Path A (FolderRow suggestions) and Path B (plain string[] search results, verbatim + // including "Trash to Delete") semantics are preserved bit-for-bit through + // BreadcrumbSelectionMap (G10). On a bare viewer (no pipeline yet) the members are inert: + // setters no-op and getters return the legacy empty-combo values. The whole ItemViewer type + // is [ExcludeFromCodeCoverage] via its primary partial in ItemViewer.cs. public partial class ItemViewer { - public void SetFolderItems(string[] items) => CboFolders.Items.AddRange(items); + public void SetFolderItems(string[] items) => BreadcrumbCoordinator?.AddItems(items); - // #325 suggestion-tree state. The host-neutral seams own all correctness; this partial holds - // only the WinForms glue (owner-draw paint, glyph hit-test, Items rebind) and is exempt. - private FolderTreeStateModel _folderTreeState; - private IReadOnlyList> _visibleFolderNodes = - new List>(); + public void SetFolderSuggestions(IReadOnlyList rows) => + BreadcrumbCoordinator?.SetSuggestions(rows); - public void SetFolderSuggestions(IReadOnlyList rows) - { - var forest = new FolderHierarchyBuilder().Build(rows); - _folderTreeState = new FolderTreeStateModel(forest); - RebindFolderTree(); - } - - // Rebuilds CboFolders.Items from the tree state's current visible-row projection. Each combo - // item is a FolderNodeViewModel; DrawItem paints it and GetSelectedFolder maps it back to the - // full folder path. - private void RebindFolderTree() - { - if (_folderTreeState is null) - { - return; - } - - _visibleFolderNodes = _folderTreeState.GetVisibleNodes(); - CboFolders.BeginUpdate(); - try - { - CboFolders.Items.Clear(); - CboFolders.Items.AddRange( - _visibleFolderNodes.Select(n => (object)n.Value).ToArray() - ); - } - finally - { - CboFolders.EndUpdate(); - } - } - - // Expands or collapses the highlighted node in response to a keyboard arrow, then re-projects. - // All transition and no-op logic lives in the unit-tested FolderTreeStateModel; this glue only - // highlights the selected row, invokes the transition, and reports whether the expansion state - // actually changed so the keyboard handler can fall through to legacy behavior on a no-op. - internal bool FolderTreeRightArrow() - { - if (_folderTreeState is null) - { - return false; - } - HighlightSelectedFolderNode(); - var node = _folderTreeState.Highlighted; - bool before = node != null && node.Value.Expanded; - _folderTreeState.RightArrow(); - bool after = node != null && node.Value.Expanded; - if (before != after) - { - RebindFolderTree(); - return true; - } - return false; - } + public string GetSelectedFolder() => BreadcrumbCoordinator?.GetSelectedFolder(); - internal bool FolderTreeLeftArrow() - { - if (_folderTreeState is null) - { - return false; - } - HighlightSelectedFolderNode(); - var node = _folderTreeState.Highlighted; - bool before = node != null && node.Value.Expanded; - _folderTreeState.LeftArrow(); - bool after = node != null && node.Value.Expanded; - if (before != after) - { - RebindFolderTree(); - return true; - } - return false; - } + public void SetFolderSelectedIndex(int index) => BreadcrumbCoordinator?.SelectRow(index); - private void HighlightSelectedFolderNode() - { - int index = CboFolders.SelectedIndex; - if (_folderTreeState != null && index >= 0 && index < _visibleFolderNodes.Count) - { - _folderTreeState.Highlight(_visibleFolderNodes[index]); - } - } + public void SetFolderSelectedItem(string item) => BreadcrumbCoordinator?.SelectItem(item); - public string GetSelectedFolder() + // A breadcrumb has no literal dropped-down state; the intent maps to focus/expansion + // visibility per spec FR-7 (callers: QfcItemController.Navigation.cs, EventHandlers.cs). + public void SetFolderDroppedDown(bool droppedDown) { - if (CboFolders.SelectedItem is FolderNodeViewModel vm) + if (droppedDown) { - return vm.FolderPath; + FocusBreadcrumb(); } - return CboFolders.SelectedItem as string; } - public void SetFolderSelectedIndex(int index) => CboFolders.SelectedIndex = index; - - public void SetFolderSelectedItem(string item) => CboFolders.SelectedItem = item; + public void ClearFolderItems() => BreadcrumbCoordinator?.Clear(); - public void SetFolderDroppedDown(bool droppedDown) => CboFolders.DroppedDown = droppedDown; + public void FocusFolderDropDown() => FocusBreadcrumb(); - public void ClearFolderItems() => CboFolders.Items.Clear(); - - public void FocusFolderDropDown() => CboFolders.Focus(); - - public bool FolderContains(string item) => CboFolders.Items.Contains(item); + public bool FolderContains(string item) => + BreadcrumbCoordinator != null && BreadcrumbCoordinator.Contains(item); public string[] GetFolderItems() => - CboFolders.Items.Cast().Select(item => item.ToString()).ToArray(); + BreadcrumbCoordinator?.GetFolderItems() ?? Array.Empty(); public event EventHandler FolderSelectionChanged { - add => CboFolders.SelectedIndexChanged += value; - remove => CboFolders.SelectedIndexChanged -= value; + add => _folderSelectionChangedHandlers += value; + remove => _folderSelectionChangedHandlers -= value; } + // Raised synthetically from the bridge for Left/Right arrow messages (FR-6), preserving + // the IItemViewer.FolderKeyDown seam for existing consumers. public event KeyEventHandler FolderKeyDown { - add => CboFolders.KeyDown += value; - remove => CboFolders.KeyDown -= value; + add => _folderKeyDownHandlers += value; + remove => _folderKeyDownHandlers -= value; } public string SearchText => TxtboxSearch.Text; @@ -155,129 +77,5 @@ public event KeyEventHandler SearchKeyDown } public void FocusSearch() => TxtboxSearch.Invoke(new Action(() => TxtboxSearch.Focus())); - - // Per-node horizontal budget (pixels): indent step per depth level, glyph column width, and - // the reserved right column into which the percentage is right-aligned. - private const int FolderIndentPerDepth = 14; - private const int FolderGlyphWidth = 14; - private const int FolderPercentColumnWidth = 46; - - // Owner-draw paint for CboFolders (DrawMode.OwnerDrawFixed). Per visible row it draws an indent - // proportional to Depth, a +/- glyph when the node has children, the display name, and the - // right-aligned whole-number percentage produced by the host-neutral FolderNodeViewModel. Non - // FolderNodeViewModel items (for example the injected "Trash to Delete" string) are drawn as - // plain left-aligned text so the retained SetFolderItems(string[]) path renders unchanged. - internal void CboFolders_DrawItem(object sender, DrawItemEventArgs e) - { - e.DrawBackground(); - if (e.Index < 0 || e.Index >= CboFolders.Items.Count) - { - e.DrawFocusRectangle(); - return; - } - - using (var textBrush = new SolidBrush(e.ForeColor)) - { - var item = CboFolders.Items[e.Index]; - if (item is FolderNodeViewModel vm) - { - int indent = e.Bounds.Left + (vm.Depth * FolderIndentPerDepth); - - if (vm.Glyph.HasValue) - { - var glyphRect = new Rectangle( - indent, - e.Bounds.Top, - FolderGlyphWidth, - e.Bounds.Height - ); - TextRenderer.DrawText( - e.Graphics, - vm.Glyph.Value.ToString(), - e.Font, - glyphRect, - e.ForeColor, - TextFormatFlags.Left | TextFormatFlags.VerticalCenter - ); - } - - int nameLeft = indent + FolderGlyphWidth; - var nameRect = new Rectangle( - nameLeft, - e.Bounds.Top, - Math.Max(0, e.Bounds.Right - FolderPercentColumnWidth - nameLeft), - e.Bounds.Height - ); - TextRenderer.DrawText( - e.Graphics, - vm.DisplayName, - e.Font, - nameRect, - e.ForeColor, - TextFormatFlags.Left | TextFormatFlags.VerticalCenter - ); - - // Right-aligned percentage into a fixed-width column anchored at e.Bounds.Right. - var percentRect = new Rectangle( - e.Bounds.Right - FolderPercentColumnWidth, - e.Bounds.Top, - FolderPercentColumnWidth, - e.Bounds.Height - ); - TextRenderer.DrawText( - e.Graphics, - vm.FormattedPercentage, - e.Font, - percentRect, - e.ForeColor, - TextFormatFlags.Right | TextFormatFlags.VerticalCenter - ); - } - else - { - e.Graphics.DrawString( - item?.ToString() ?? string.Empty, - e.Font, - textBrush, - e.Bounds, - StringFormat.GenericDefault - ); - } - } - - e.DrawFocusRectangle(); - } - - // Glyph mouse hit-test: on a click within the glyph column of a parent row, toggle that node - // in the tree state and re-project. The toggle correctness lives in FolderTreeStateModel; this - // computes the drawn glyph rectangle for the row under the mouse and tests the X coordinate. - internal void CboFolders_MouseDown(object sender, MouseEventArgs e) - { - if (_folderTreeState is null || CboFolders.ItemHeight <= 0) - { - return; - } - - int index = e.Y / CboFolders.ItemHeight; - if (index < 0 || index >= _visibleFolderNodes.Count) - { - return; - } - - var node = _visibleFolderNodes[index]; - if (!node.Value.HasChildren) - { - return; - } - - int glyphLeft = node.Value.Depth * FolderIndentPerDepth; - int glyphRight = glyphLeft + FolderGlyphWidth; - if (e.X >= glyphLeft && e.X <= glyphRight) - { - _folderTreeState.Highlight(node); - _folderTreeState.Toggle(node); - RebindFolderTree(); - } - } } } diff --git a/QuickFiler/Viewers/ItemViewer.cs b/QuickFiler/Viewers/ItemViewer.cs index fbf3a77a..9ded5a7d 100644 --- a/QuickFiler/Viewers/ItemViewer.cs +++ b/QuickFiler/Viewers/ItemViewer.cs @@ -386,11 +386,6 @@ public ButtonSVG BtnReplyAll get => _btnReplyAll; set => _btnReplyAll = value; } - public System.Windows.Forms.ComboBox CboFolders - { - get => _cboFolders; - set => _cboFolders = value; - } public System.Windows.Forms.TextBox TxtboxSearch { get => _txtboxSearch; diff --git a/QuickFiler/Viewers/WebView2BreadcrumbHost.cs b/QuickFiler/Viewers/WebView2BreadcrumbHost.cs new file mode 100644 index 00000000..987efa04 --- /dev/null +++ b/QuickFiler/Viewers/WebView2BreadcrumbHost.cs @@ -0,0 +1,143 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.WinForms; +using UtilitiesCS; + +namespace QuickFiler.Viewers +{ + /// + /// 1:1 SDK-forwarding adapter implementing over the + /// Designer-owned control (#349). Initialization awaits the form's + /// UI SynchronizationContext BEFORE EnsureCoreWebView2Async (pattern + /// QfcItemController.ViewerSetup), uses the shared %LocalAppData%\WindowsFormsWebView2 cache + /// folder through the existing seam, and hooks + /// CoreWebView2 events idempotently for pooled-viewer re-initialization (EfcViewerQueue). + /// Waiting is event-driven (CoreWebView2InitializationCompleted) — no polling, no delays. + /// + /// + /// Coverage exemption justification (precedent ): every + /// member forwards 1:1 to the WebView2 SDK or reacts to its events on a live control that + /// cannot exist in a unit-test host; all routing/decision logic lives in the non-exempt + /// BreadcrumbBridgeRouter/BreadcrumbOutboundQueue, tested via + /// Mock<IBreadcrumbWebHost>. + /// + [ExcludeFromCodeCoverage] + public sealed class WebView2BreadcrumbHost : IBreadcrumbWebHost + { + private static readonly log4net.ILog log = log4net.LogManager.GetLogger( + System.Reflection.MethodBase.GetCurrentMethod().DeclaringType + ); + + private readonly WebView2 _control; + private readonly IWebViewCoreInitializer _initializer; + + /// Creates the adapter over the Designer-owned control. + /// The WebView2 control hosting the breadcrumb document. + /// The existing core-initializer seam. + /// Any argument is null. + public WebView2BreadcrumbHost(WebView2 control, IWebViewCoreInitializer initializer) + { + _control = control ?? throw new ArgumentNullException(nameof(control)); + _initializer = initializer ?? throw new ArgumentNullException(nameof(initializer)); + + // Idempotent hookup: pooled viewers re-run initialization, so unhook before hooking. + _control.CoreWebView2InitializationCompleted -= OnCoreInitializationCompleted; + _control.CoreWebView2InitializationCompleted += OnCoreInitializationCompleted; + } + + /// + public bool IsCoreInitialized { get; private set; } + + /// + public event EventHandler? MessageReceived; + + /// + /// Raised after CoreWebView2 initialization completes successfully; the controller wires + /// this to BreadcrumbBridgeRouter.NotifyCoreInitialized. + /// + public event EventHandler? CoreInitialized; + + /// + public void NavigateToString(string html) + { + _control.NavigateToString(html); + } + + /// + public void PostMessageJson(string json) + { + CoreWebView2? core = _control.CoreWebView2; + if (core == null) + { + log.Error( + "PostMessageJson called before CoreWebView2 initialization; payload dropped." + ); + return; + } + + core.PostWebMessageAsJson(json); + } + + /// + /// Initializes the CoreWebView2 through the seam: + /// awaits the UI SynchronizationContext first, then creates the shared-cache environment + /// and calls EnsureCoreWebView2Async. Safe to re-run for pooled viewers. + /// + /// The form's UI synchronization context. + public async Task InitializeAsync(SynchronizationContext uiSyncContext) + { + if (uiSyncContext == null) + { + throw new ArgumentNullException(nameof(uiSyncContext)); + } + + string cacheFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "WindowsFormsWebView2" + ); + var options = new CoreWebView2EnvironmentOptions(); + + // WebView2 controls must be touched on the WinForms UI (STA) thread. + await uiSyncContext; + + CoreWebView2Environment environment = await _initializer.CreateEnvironmentAsync( + cacheFolder, + options + ); + await _initializer.EnsureCoreWebView2Async(_control, environment); + } + + private void OnCoreInitializationCompleted( + object? sender, + CoreWebView2InitializationCompletedEventArgs e + ) + { + if (!e.IsSuccess) + { + log.Error( + $"Breadcrumb CoreWebView2 initialization failed: {e.InitializationException?.Message}", + e.InitializationException + ); + return; + } + + CoreWebView2 core = _control.CoreWebView2; + // Idempotent event hookup across pooled-viewer re-initialization. + core.WebMessageReceived -= OnWebMessageReceived; + core.WebMessageReceived += OnWebMessageReceived; + + IsCoreInitialized = true; + CoreInitialized?.Invoke(this, EventArgs.Empty); + } + + private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e) + { + MessageReceived?.Invoke(this, e.WebMessageAsJson); + } + } +} diff --git a/QuickFiler/Viewers/WebView2Messenger.cs b/QuickFiler/Viewers/WebView2Messenger.cs new file mode 100644 index 00000000..19de735b --- /dev/null +++ b/QuickFiler/Viewers/WebView2Messenger.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Web.WebView2.Core; + +namespace QuickFiler.Viewers +{ + /// + /// Production adapter that forwards every member 1:1 to the + /// WebView2 SDK: is re-raised as + /// (string payload via TryGetWebMessageAsString, falling + /// back to the raw JSON), and forwards to + /// . The body is a thin forwarding shim over a + /// third-party API with no branching logic of its own (matching the + /// exempt-forwarder pattern), so it legitimately carries + /// ; nothing testable is exempted here — all message + /// handling correctness lives in the host-neutral router/coordinator, which tests drive + /// through a Moq . + /// + [ExcludeFromCodeCoverage] + public sealed class WebView2Messenger : IWebViewMessenger + { + private readonly CoreWebView2 _coreWebView; + + /// + /// Wraps the initialized . + /// + /// The initialized CoreWebView2. Required. + /// is null. + public WebView2Messenger(CoreWebView2 coreWebView) + { + _coreWebView = coreWebView ?? throw new ArgumentNullException(nameof(coreWebView)); + _coreWebView.WebMessageReceived += OnWebMessageReceived; + } + + /// + public event EventHandler MessageReceived; + + /// + public void PostJson(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + _coreWebView.PostWebMessageAsJson(json); + } + + private void OnWebMessageReceived(object sender, CoreWebView2WebMessageReceivedEventArgs e) + { + string payload; + try + { + payload = e.TryGetWebMessageAsString(); + } + catch (ArgumentException) + { + // The page posts JSON objects (not plain strings); fall back to the raw JSON. + payload = e.WebMessageAsJson; + } + MessageReceived?.Invoke(this, payload ?? e.WebMessageAsJson); + } + } +} diff --git a/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.DispatcherTests.cs b/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.DispatcherTests.cs index d8a981d3..80839bd0 100644 --- a/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.DispatcherTests.cs +++ b/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.DispatcherTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.Reflection; @@ -101,7 +101,7 @@ public void Constructor_BigOverload_WithNullUiDispatcher_DefaultsToWpfUiDispatch tipsExpanded: new List(), textboxSearch: new TextBox(), textboxBody: new TextBox(), - comboFolders: new ComboBox(), + breadcrumbWebView2: null, topicThread: new BrightIdeasSoftware.FastObjectListView(), webView2: new Microsoft.Web.WebView2.WinForms.WebView2(), viewer: new Panel(), diff --git a/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.MailLabelThemingTests.cs b/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.MailLabelThemingTests.cs index 006e6be1..e9008bf9 100644 --- a/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.MailLabelThemingTests.cs +++ b/UtilitiesCS.Test/HelperClasses/ThemeHelpers/Theme.MailLabelThemingTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.Runtime.InteropServices; @@ -63,7 +63,7 @@ out Label lblSubject tipsExpanded: new List(), textboxSearch: new TextBox(), textboxBody: new TextBox(), - comboFolders: new ComboBox(), + breadcrumbWebView2: null, topicThread: new BrightIdeasSoftware.FastObjectListView(), webView2: new Microsoft.Web.WebView2.WinForms.WebView2(), viewer: new Panel(), diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeMessagesTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeMessagesTests.cs new file mode 100644 index 00000000..68c2cdaf --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbBridgeMessagesTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Unit tests for the breadcrumb bridge protocol (#351 P3-T6): round-trip serialization of + /// every message family, explicit parse failures (malformed JSON, unknown type, missing + /// fields), and edge payloads (empty subfolder response, maximal-length paths, non-ASCII + /// names). Deterministic; no Outlook, WebView2, or I/O. + /// + [TestClass] + public sealed class BreadcrumbBridgeMessagesTests + { + private static string RoundTrip( + BreadcrumbBridgeMessage message, + out BreadcrumbBridgeMessage parsed + ) + { + var json = BreadcrumbBridgeSerializer.Serialize(message); + parsed = BreadcrumbBridgeSerializer.Parse(json); + return json; + } + + // --- Round trips for every message family --- + + [TestMethod] + public void RoundTrip_SegmentDoubleClick_PreservesIndexes() + { + RoundTrip(new SegmentDoubleClickMessage(2, 1), out var parsed); + var m = parsed.Should().BeOfType().Subject; + m.RowIndex.Should().Be(2); + m.SegmentIndex.Should().Be(1); + } + + [TestMethod] + public void RoundTrip_AffordanceToggle_PreservesRowIndex() + { + RoundTrip(new AffordanceToggleMessage(4), out var parsed); + parsed.Should().BeOfType().Subject.RowIndex.Should().Be(4); + } + + [TestMethod] + public void RoundTrip_ArrowKey_PreservesBothDirections() + { + RoundTrip(new ArrowKeyMessage(BreadcrumbArrowDirection.Left), out var left); + RoundTrip(new ArrowKeyMessage(BreadcrumbArrowDirection.Right), out var right); + left.Should() + .BeOfType() + .Subject.Direction.Should() + .Be(BreadcrumbArrowDirection.Left); + right + .Should() + .BeOfType() + .Subject.Direction.Should() + .Be(BreadcrumbArrowDirection.Right); + } + + [TestMethod] + public void RoundTrip_SelectionChange_PreservesSubfolderIndexAndMappedFolder() + { + RoundTrip( + new SelectionChangeMessage(1, 3, "\\Inbox\\Projects\\Apollo"), + out var parsed + ); + var m = parsed.Should().BeOfType().Subject; + m.RowIndex.Should().Be(1); + m.SubfolderIndex.Should().Be(3); + m.SelectedFolder.Should().Be("\\Inbox\\Projects\\Apollo"); + } + + [TestMethod] + public void RoundTrip_SelectionChange_WithoutFolderOrSubfolder_UsesExplicitDefaults() + { + RoundTrip(new SelectionChangeMessage(0, -1, null), out var parsed); + var m = parsed.Should().BeOfType().Subject; + m.SubfolderIndex.Should().Be(-1); + m.SelectedFolder.Should().BeNull(); + } + + [TestMethod] + public void RoundTrip_SubfolderRequest_PreservesRowIndex() + { + RoundTrip(new SubfolderRequestMessage(7), out var parsed); + parsed.Should().BeOfType().Subject.RowIndex.Should().Be(7); + } + + [TestMethod] + public void RoundTrip_SubfolderResponse_PreservesSubfolderList() + { + var model = new BreadcrumbStateModel(); + model.AddSuggestionRow(SampleChain(), 0.4); + model.Rows[0].TryExpandLeaf(); + model + .Rows[0] + .SetSubfolders( + new[] + { + Segment("s1", "\\Inbox\\Léaf\\Ärchiv", "Ärchiv", true), + Segment("s2", "\\Inbox\\Léaf\\日本語", "日本語", false), + } + ); + var rendered = BreadcrumbRenderProjection.Project(model)[0].Subfolders; + + RoundTrip(new SubfolderResponseMessage(0, rendered), out var parsed); + + var m = parsed.Should().BeOfType().Subject; + m.Subfolders.Select(s => s.DisplayName).Should().Equal("Ärchiv", "日本語"); + m.Subfolders.Select(s => s.HasChildren).Should().Equal(true, false); + } + + [TestMethod] + public void RoundTrip_ThemeChange_PreservesTheme() + { + RoundTrip(new ThemeChangeMessage("dark"), out var parsed); + parsed.Should().BeOfType().Subject.Theme.Should().Be("dark"); + } + + [TestMethod] + public void RoundTrip_Render_PreservesRowsCellsAndPercentText() + { + var model = new BreadcrumbStateModel(); + model.AddSuggestionRow(SampleChain(), 0.73); + model.AddPlainRow("Trash to Delete"); + model.SelectRow(1); + var rows = BreadcrumbRenderProjection.Project(model); + + var json = RoundTrip(new RenderMessage(rows), out var parsed); + + json.Should().Contain("\"type\":\"render\""); + var m = parsed.Should().BeOfType().Subject; + m.Rows.Should().HaveCount(2); + m.Rows[0].PercentText.Should().Be("73%"); + m.Rows[0].Cells.Select(c => c.Kind).Should().Equal(rows[0].Cells.Select(c => c.Kind)); + m.Rows[0].Cells.Select(c => c.Text).Should().Equal(rows[0].Cells.Select(c => c.Text)); + m.Rows[1].Selected.Should().BeTrue(); + m.Rows[1].IsSuggestion.Should().BeFalse(); + } + + [TestMethod] + public void RoundTrip_UnhandledArrow_PreservesDirection() + { + RoundTrip(new UnhandledArrowMessage(BreadcrumbArrowDirection.Right), out var parsed); + parsed + .Should() + .BeOfType() + .Subject.Direction.Should() + .Be(BreadcrumbArrowDirection.Right); + } + + [TestMethod] + public void RoundTrip_Error_PreservesMessage() + { + RoundTrip(new BridgeErrorMessage("provider failed"), out var parsed); + parsed + .Should() + .BeOfType() + .Subject.Message.Should() + .Be("provider failed"); + } + + // --- Negative parsing: explicit errors, never silent null --- + + [TestMethod] + public void Parse_MalformedJson_ThrowsFormatException() + { + Action act = () => BreadcrumbBridgeSerializer.Parse("{not json"); + act.Should().Throw().WithMessage("*Malformed*"); + } + + [TestMethod] + public void Parse_EmptyOrWhitespace_ThrowsFormatException() + { + ((Action)(() => BreadcrumbBridgeSerializer.Parse(""))) + .Should() + .Throw(); + ((Action)(() => BreadcrumbBridgeSerializer.Parse(" "))) + .Should() + .Throw(); + } + + [TestMethod] + public void Parse_UnknownType_ThrowsFormatException() + { + Action act = () => BreadcrumbBridgeSerializer.Parse("{\"type\":\"teleport\"}"); + act.Should().Throw().WithMessage("*teleport*"); + } + + [TestMethod] + public void Parse_MissingTypeField_ThrowsFormatException() + { + Action act = () => BreadcrumbBridgeSerializer.Parse("{\"rowIndex\":1}"); + act.Should().Throw().WithMessage("*'type'*"); + } + + [TestMethod] + public void Parse_MissingRequiredField_ThrowsFormatException() + { + Action act = () => + BreadcrumbBridgeSerializer.Parse( + "{\"type\":\"segmentDoubleClick\",\"rowIndex\":1}" + ); + act.Should().Throw().WithMessage("*segmentIndex*"); + } + + [TestMethod] + public void Parse_UnknownArrowDirection_ThrowsFormatException() + { + Action act = () => + BreadcrumbBridgeSerializer.Parse("{\"type\":\"arrowKey\",\"direction\":\"up\"}"); + act.Should().Throw().WithMessage("*up*"); + } + + // --- Edge payloads --- + + [TestMethod] + public void RoundTrip_EmptySubfolderResponse_YieldsEmptyList() + { + RoundTrip( + new SubfolderResponseMessage(3, new BreadcrumbSubfolderRender[0]), + out var parsed + ); + parsed + .Should() + .BeOfType() + .Subject.Subfolders.Should() + .BeEmpty(); + } + + [TestMethod] + public void RoundTrip_MaximalLengthFolderPath_SurvivesVerbatim() + { + // Outlook full paths max out near 255 chars per segment; stress well past that. + string longPath = "\\" + string.Join("\\", Enumerable.Repeat(new string('x', 250), 4)); + RoundTrip(new SelectionChangeMessage(0, -1, longPath), out var parsed); + parsed + .Should() + .BeOfType() + .Subject.SelectedFolder.Should() + .Be(longPath); + } + + [TestMethod] + public void ThemeChangeMessage_EmptyOrWhitespaceTheme_Throws() + { + ((Action)(() => new ThemeChangeMessage(""))).Should().Throw(); + ((Action)(() => new ThemeChangeMessage(" "))).Should().Throw(); + ((Action)(() => new ThemeChangeMessage(null))).Should().Throw(); + } + + [TestMethod] + public void Serialize_NullMessage_Throws() + { + Action act = () => BreadcrumbBridgeSerializer.Serialize(null); + act.Should().Throw(); + } + + private static FolderBreadcrumbSegment Segment( + string entryId, + string path, + string name, + bool hasChildren + ) => + new FolderBreadcrumbSegment( + new FolderTreeNodeKey("store-a", entryId, path), + name, + path, + hasChildren + ); + + private static FolderBreadcrumbSegment[] SampleChain() => + new[] + { + Segment("root", "\\Inbox", "Inbox", true), + Segment("leaf", "\\Inbox\\Léaf", "Léaf", true), + }; + } +} diff --git a/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs new file mode 100644 index 00000000..907a6401 --- /dev/null +++ b/UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs @@ -0,0 +1,281 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using UtilitiesCS.OutlookObjects.Folder; + +namespace UtilitiesCS.Test.OutlookObjects.Folder +{ + /// + /// Unit tests for invariants (#349): trailing fixed + /// percent item on every row, affordance gating on HasSubfolders, HTML-encoding of hostile + /// names, non-interactive banners, selectable trash pseudo-row, themes, collapsed fragments, + /// and the empty row list. + /// + [TestClass] + public class BreadcrumbHtmlRendererTests + { + private readonly BreadcrumbHtmlRenderer _renderer = new BreadcrumbHtmlRenderer(); + + private static BreadcrumbSegment Segment(string name, bool hasSubfolders) + { + return new BreadcrumbSegment(@"Inbox\" + name, name, hasSubfolders); + } + + private static BreadcrumbRow SuggestionRow(bool leafHasSubfolders, double? probability) + { + return new BreadcrumbRow( + "row-0", + BreadcrumbRowKind.Suggestion, + new[] + { + Segment("Root", true), + Segment("Mid", true), + Segment("Leaf", leafHasSubfolders), + }, + probability + ); + } + + [TestMethod] + public void RenderRowFragment_EveryRowKind_EmitsTrailingPctFlexItem() + { + // Arrange + var suggestion = SuggestionRow(leafHasSubfolders: false, probability: 0.87); + var banner = new BreadcrumbRow( + "row-1", + BreadcrumbRowKind.Banner, + new[] { Segment("==== X ====", false) }, + null + ); + var trash = new BreadcrumbRow( + "row-2", + BreadcrumbRowKind.TrashPseudoRow, + Array.Empty(), + null + ); + + // Act / Assert: percent span present on every row and positioned after the crumb div. + foreach (var row in new[] { suggestion, banner, trash }) + { + string html = _renderer.RenderRowFragment(row, isSelected: false); + html.Should() + .MatchRegex( + "", + $"row '{row.RowId}' must end its flex row with the trailing .pct item" + ); + } + + _renderer + .RenderRowFragment(suggestion, false) + .Should() + .Contain("87%"); + } + + [TestMethod] + public void RenderRowFragment_CollapsedRow_StillEmitsTrailingPercent() + { + // Arrange + var row = SuggestionRow(leafHasSubfolders: true, probability: 0.5); + row.CollapseAfter(0); + + // Act + string html = _renderer.RenderRowFragment(row, isSelected: false); + + // Assert + html.Should().Contain("50%"); + } + + [TestMethod] + public void RenderRowFragment_LeafWithSubfolders_EmitsPlusWhenCollapsedMinusWhenExpanded() + { + // Arrange + var row = SuggestionRow(leafHasSubfolders: true, probability: null); + + // Act / Assert: plus while leaf children are collapsed. + _renderer + .RenderRowFragment(row, false) + .Should() + .Contain("data-role=\"leaf\">+"); + + // Expand the leaf: minus (U+2212 entity). + row.SetLeafChildren(new[] { Segment("Child", false) }); + row.ToggleLeafExpanded(); + _renderer + .RenderRowFragment(row, false) + .Should() + .Contain("data-role=\"leaf\">−"); + } + + [TestMethod] + public void RenderRowFragment_LeafWithoutSubfolders_EmitsNoAffordance() + { + // Arrange + var row = SuggestionRow(leafHasSubfolders: false, probability: null); + + // Act + string html = _renderer.RenderRowFragment(row, isSelected: false); + + // Assert + html.Should().NotContain("affordance"); + } + + [TestMethod] + public void RenderRowFragment_HostileFolderNames_AreHtmlEncoded() + { + // Arrange: hostile display name with script tag, ampersand, and quotes. + string hostile = " & \"quotes\""; + var row = new BreadcrumbRow( + "row-0", + BreadcrumbRowKind.Suggestion, + new[] { new BreadcrumbSegment(@"Inbox\" + hostile, hostile, false) }, + null + ); + + // Act + string html = _renderer.RenderRowFragment(row, isSelected: false); + + // Assert: raw markup never survives; encoded entities do. + html.Should().NotContain(""); + return sb.ToString(); + } + + /// + /// Renders the row-list fragment (the #rows innerHTML) for a full-list + /// render message update. + /// + /// The breadcrumb rows in presented order. + /// Row id to mark selected, or null. + /// The concatenated row fragments. + /// is null. + public string RenderRows(IReadOnlyList rows, string? selectedRowId) + { + if (rows == null) + { + throw new ArgumentNullException(nameof(rows)); + } + + var sb = new StringBuilder(); + foreach (BreadcrumbRow row in rows) + { + sb.Append( + RenderRowFragment(row, selectedRowId != null && row.RowId == selectedRowId) + ); + } + + return sb.ToString(); + } + + /// + /// Renders one row's update fragment (the [data-row-id] wrapper element). + /// + /// The row to render. + /// True to mark the row selected. + /// The row fragment HTML. + /// is null. + public string RenderRowFragment(BreadcrumbRow row, bool isSelected) + { + if (row == null) + { + throw new ArgumentNullException(nameof(row)); + } + + var sb = new StringBuilder(); + string wrapClass = "rowwrap" + (isSelected ? " selected" : string.Empty); + sb.Append("
"); + + switch (row.Kind) + { + case BreadcrumbRowKind.Banner: + AppendBannerRow(sb, row); + break; + case BreadcrumbRowKind.TrashPseudoRow: + AppendTrashRow(sb); + break; + default: + AppendSuggestionRow(sb, row); + break; + } + + sb.Append("
"); + return sb.ToString(); + } + + private static void AppendBannerRow(StringBuilder sb, BreadcrumbRow row) + { + // Non-interactive: no selectable class, no seg indices, no affordance, no handlers. + string text = row.Segments.Count > 0 ? row.Segments[0].DisplayName : string.Empty; + sb.Append("
") + .Append(WebUtility.HtmlEncode(text)) + .Append("
"); + AppendPercent(sb, row.Probability); + sb.Append("
"); + } + + private static void AppendTrashRow(StringBuilder sb) + { + // Selectable pseudo-row without segments or affordance. + sb.Append("
") + .Append(WebUtility.HtmlEncode(BreadcrumbRowBuilder.TrashRowText)) + .Append("
"); + AppendPercent(sb, null); + sb.Append("
"); + } + + private static void AppendSuggestionRow(StringBuilder sb, BreadcrumbRow row) + { + sb.Append("
"); + + IReadOnlyList visible = row.VisibleSegments(); + for (int i = 0; i < visible.Count; i++) + { + if (i > 0) + { + sb.Append(" > "); + } + + bool isCollapsedTerminal = row.IsCollapsed && i == visible.Count - 1; + if (isCollapsedTerminal) + { + // Re-expand plus to the LEFT of the now-terminal segment. + sb.Append( + "+" + ); + } + + AppendSegment(sb, visible[i], i); + } + + if (!row.IsCollapsed) + { + AppendLeafAffordance(sb, row); + } + + sb.Append("
"); + AppendPercent(sb, row.Probability); + sb.Append("
"); + AppendChildren(sb, row); + } + + private static void AppendSegment(StringBuilder sb, BreadcrumbSegment segment, int index) + { + sb.Append("") + .Append(WebUtility.HtmlEncode(segment.DisplayName)) + .Append(""); + } + + private static void AppendLeafAffordance(StringBuilder sb, BreadcrumbRow row) + { + // Emitted only when the leaf has subfolders: plus collapsed, minus expanded. + if (row.LeafSegment?.HasSubfolders != true) + { + return; + } + + sb.Append("") + .Append(row.IsLeafExpanded ? "−" : "+") + .Append(""); + } + + private static void AppendPercent(StringBuilder sb, double? probability) + { + // Invariant: the percent is ALWAYS the trailing fixed .pct flex item on every row. + sb.Append("") + .Append(WebUtility.HtmlEncode(PercentageFormatter.FormatPercent(probability))) + .Append(""); + } + + private static void AppendChildren(StringBuilder sb, BreadcrumbRow row) + { + sb.Append("
"); + if (row.IsLeafExpanded) + { + foreach (BreadcrumbSegment child in row.LeafChildren) + { + sb.Append("
") + .Append(WebUtility.HtmlEncode(child.DisplayName)) + .Append("
"); + } + } + + sb.Append("
"); + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs new file mode 100644 index 00000000..6e23384d --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs @@ -0,0 +1,184 @@ +#nullable enable +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Raised when an inbound breadcrumb bridge payload is malformed: invalid JSON, unknown + /// type, or missing/wrong-typed required fields (#349). The codec logs a specific + /// log4net error before throwing; callers must not swallow this exception silently. + /// + public sealed class BreadcrumbMessageException : Exception + { + /// Creates the exception with a diagnostic message. + public BreadcrumbMessageException(string message) + : base(message) { } + + /// Creates the exception wrapping the underlying JSON parse failure. + public BreadcrumbMessageException(string message, Exception innerException) + : base(message, innerException) { } + } + + /// + /// Serializes outbound and deserializes inbound breadcrumb bridge messages over + /// Newtonsoft.Json (#349). Outbound JSON is camelCase-discriminated per the bridge contract; + /// inbound parsing fails fast (log + ) on malformed + /// input — no silent swallow, no broad catch without rethrow. + /// + public sealed class BreadcrumbMessageCodec + { + private static readonly log4net.ILog log = log4net.LogManager.GetLogger( + System.Reflection.MethodBase.GetCurrentMethod().DeclaringType + ); + + private static readonly JsonSerializerSettings OutboundSettings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.None, + }; + + /// + /// Serializes an outbound message to its discriminated camelCase JSON form + /// ({ "type": ..., ... }; null optional fields omitted). + /// + /// The outbound message. Required. + /// The JSON payload. + /// is null. + public string SerializeOutbound(BreadcrumbOutboundMessage message) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + return JsonConvert.SerializeObject(message, OutboundSettings); + } + + /// + /// Deserializes an inbound bridge payload, validating the discriminator and the + /// per-type required fields (segmentDoubleClick requires segmentIndex; + /// arrowKey requires key; every type requires rowId). + /// + /// The raw JSON payload from the hosted document. + /// The validated inbound message. + /// + /// The payload is not valid JSON, has an unknown type, or is missing/wrong-typed + /// on a required field. + /// + public BreadcrumbInboundMessage DeserializeInbound(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw Fail("Inbound breadcrumb payload is null or empty."); + } + + JObject root; + try + { + root = JObject.Parse(json); + } + catch (JsonException ex) + { + log.Error($"Inbound breadcrumb payload is not valid JSON: {ex.Message}"); + throw new BreadcrumbMessageException( + "Inbound breadcrumb payload is not valid JSON.", + ex + ); + } + + string type = RequireString(root, "type"); + if (!IsKnownInboundType(type)) + { + throw Fail($"Unknown inbound breadcrumb message type '{type}'."); + } + + string rowId = RequireString(root, "rowId"); + int? segmentIndex = OptionalInt(root, "segmentIndex"); + string? key = OptionalString(root, "key"); + + if (type == BreadcrumbMessageTypes.SegmentDoubleClick && !segmentIndex.HasValue) + { + throw Fail("segmentDoubleClick requires an integer 'segmentIndex' field."); + } + + if (type == BreadcrumbMessageTypes.ArrowKey && string.IsNullOrEmpty(key)) + { + throw Fail("arrowKey requires a non-empty 'key' field."); + } + + return new BreadcrumbInboundMessage(type, rowId, segmentIndex, key); + } + + private static bool IsKnownInboundType(string type) + { + return type == BreadcrumbMessageTypes.SegmentDoubleClick + || type == BreadcrumbMessageTypes.LeafExpandToggle + || type == BreadcrumbMessageTypes.ArrowKey + || type == BreadcrumbMessageTypes.RowSelected; + } + + private static string RequireString(JObject root, string fieldName) + { + JToken? token = root[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + throw Fail($"Inbound breadcrumb message is missing required field '{fieldName}'."); + } + + if (token.Type != JTokenType.String) + { + throw Fail( + $"Inbound breadcrumb field '{fieldName}' must be a string but was {token.Type}." + ); + } + + return token.Value()!; + } + + private static int? OptionalInt(JObject root, string fieldName) + { + JToken? token = root[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return null; + } + + if (token.Type != JTokenType.Integer) + { + throw Fail( + $"Inbound breadcrumb field '{fieldName}' must be an integer but was {token.Type}." + ); + } + + return token.Value(); + } + + private static string? OptionalString(JObject root, string fieldName) + { + JToken? token = root[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return null; + } + + if (token.Type != JTokenType.String) + { + throw Fail( + $"Inbound breadcrumb field '{fieldName}' must be a string but was {token.Type}." + ); + } + + return token.Value(); + } + + private static BreadcrumbMessageException Fail(string message) + { + log.Error(message); + return new BreadcrumbMessageException(message); + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs new file mode 100644 index 00000000..e8bc61f4 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs @@ -0,0 +1,152 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Discriminator values for the breadcrumb JS<->.NET bridge messages (#349). + /// + public static class BreadcrumbMessageTypes + { + /// Inbound: double-click on a non-leaf breadcrumb segment. + public const string SegmentDoubleClick = "segmentDoubleClick"; + + /// Inbound: activation of the leaf (or re-expand) affordance. + public const string LeafExpandToggle = "leafExpandToggle"; + + /// Inbound: arrow-key press routed from the hosted document. + public const string ArrowKey = "arrowKey"; + + /// Inbound: a row was selected in the hosted document. + public const string RowSelected = "rowSelected"; + + /// Outbound: render a full document or per-row fragment. + public const string Render = "render"; + + /// Outbound: correlated response to a leaf expand request. + public const string SubfolderResult = "subfolderResult"; + + /// Outbound: move focus to the search text box (Up at the top row). + public const string FocusSearch = "focusSearch"; + } + + /// + /// Inbound bridge message (JS -> .NET), discriminated by : + /// { type, rowId, segmentIndex?, key? }. net48-safe plain class (no record/init). + /// + public sealed class BreadcrumbInboundMessage + { + /// + /// Creates an inbound message. + /// + /// Message discriminator (an inbound value). + /// Target row identifier. Required. + /// Segment index for segment-scoped messages, or null. + /// Arrow-key name (Left/Right/Up/Down), or null. + /// or is null. + public BreadcrumbInboundMessage(string type, string rowId, int? segmentIndex, string? key) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + RowId = rowId ?? throw new ArgumentNullException(nameof(rowId)); + SegmentIndex = segmentIndex; + Key = key; + } + + /// Message discriminator. + public string Type { get; } + + /// Target row identifier. + public string RowId { get; } + + /// Segment index for segment-scoped messages; null otherwise. + public int? SegmentIndex { get; } + + /// Arrow-key name for ; null otherwise. + public string? Key { get; } + } + + /// + /// Base outbound bridge message (.NET -> JS), discriminated by . + /// + public abstract class BreadcrumbOutboundMessage + { + /// Creates the outbound base with its discriminator. + /// Message discriminator (an outbound value). + /// is null. + protected BreadcrumbOutboundMessage(string type) + { + Type = type ?? throw new ArgumentNullException(nameof(type)); + } + + /// Message discriminator. + public string Type { get; } + } + + /// + /// Outbound render message carrying generated HTML — a full document + /// ( null) or a per-row update fragment ( set). + /// + public sealed class BreadcrumbRenderMessage : BreadcrumbOutboundMessage + { + /// Creates a render message. + /// The generated HTML payload. Required. + /// Target row for a fragment update, or null for a full document. + /// is null. + public BreadcrumbRenderMessage(string html, string? rowId) + : base(BreadcrumbMessageTypes.Render) + { + Html = html ?? throw new ArgumentNullException(nameof(html)); + RowId = rowId; + } + + /// The generated HTML payload. + public string Html { get; } + + /// Target row for a fragment update; null for a full document. + public string? RowId { get; } + } + + /// + /// Outbound subfolderResult message correlated to its originating leaf expand request + /// by , carrying the child-segment payload. + /// + public sealed class BreadcrumbSubfolderResultMessage : BreadcrumbOutboundMessage + { + /// Creates a subfolder-result message. + /// Correlation id of the originating expand request. Required. + /// Row whose leaf was expanded. Required. + /// Immediate child segments of the expanded leaf. Required. + /// Any argument is null. + public BreadcrumbSubfolderResultMessage( + string requestId, + string rowId, + IReadOnlyList children + ) + : base(BreadcrumbMessageTypes.SubfolderResult) + { + RequestId = requestId ?? throw new ArgumentNullException(nameof(requestId)); + RowId = rowId ?? throw new ArgumentNullException(nameof(rowId)); + Children = children ?? throw new ArgumentNullException(nameof(children)); + } + + /// Correlation id of the originating expand request. + public string RequestId { get; } + + /// Row whose leaf was expanded. + public string RowId { get; } + + /// Immediate child segments of the expanded leaf. + public IReadOnlyList Children { get; } + } + + /// + /// Outbound focusSearch message (Up-arrow at the top row); carries no payload. + /// + public sealed class BreadcrumbFocusSearchMessage : BreadcrumbOutboundMessage + { + /// Creates a focus-search message. + public BreadcrumbFocusSearchMessage() + : base(BreadcrumbMessageTypes.FocusSearch) { } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs new file mode 100644 index 00000000..63e2b6ed --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRenderProjection.cs @@ -0,0 +1,230 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// The kind of one rendered breadcrumb cell. + public enum BreadcrumbCellKind + { + /// A folder-name segment. + Segment, + + /// The arrow separator between segments. + Arrow, + + /// A plus affordance (re-expand a collapsed chain, or open the leaf expansion). + Plus, + + /// A minus affordance (close the open leaf expansion). + Minus, + } + + /// One rendered cell of a breadcrumb row (segment, arrow, or affordance). + public sealed class BreadcrumbCellRender + { + internal BreadcrumbCellRender( + BreadcrumbCellKind kind, + string text, + int segmentIndex, + bool truncationEligible + ) + { + Kind = kind; + Text = text; + SegmentIndex = segmentIndex; + TruncationEligible = truncationEligible; + } + + /// The cell kind. + public BreadcrumbCellKind Kind { get; } + + /// The display text (segment name; empty for arrows/affordances). + public string Text { get; } + + /// The chain index for segment cells; -1 for arrows and affordances. + public int SegmentIndex { get; } + + /// True when the cell may ellipsize (interior segments of long chains, FR-1). + public bool TruncationEligible { get; } + } + + /// One rendered immediate subfolder in an open leaf expansion (FR-4). + public sealed class BreadcrumbSubfolderRender + { + internal BreadcrumbSubfolderRender(string displayName, string folderPath, bool hasChildren) + { + DisplayName = displayName; + FolderPath = folderPath; + HasChildren = hasChildren; + } + + /// The subfolder display name. + public string DisplayName { get; } + + /// The subfolder full path (the selection value). + public string FolderPath { get; } + + /// True when the subfolder itself has children. + public bool HasChildren { get; } + } + + /// One rendered breadcrumb row: ordered cells, percentage text, and subfolders. + public sealed class BreadcrumbRowRender + { + internal BreadcrumbRowRender( + int rowIndex, + bool isSuggestion, + bool selected, + bool collapsed, + bool leafExpanded, + string percentText, + IReadOnlyList cells, + IReadOnlyList subfolders + ) + { + RowIndex = rowIndex; + IsSuggestion = isSuggestion; + Selected = selected; + Collapsed = collapsed; + LeafExpanded = leafExpanded; + PercentText = percentText; + Cells = cells; + Subfolders = subfolders; + } + + /// The row's index in display order. + public int RowIndex { get; } + + /// True for a Path A suggestion row; false for a Path B plain row. + public bool IsSuggestion { get; } + + /// True when the row is the current selection. + public bool Selected { get; } + + /// True when the chain is collapsed after a pivot segment (FR-3). + public bool Collapsed { get; } + + /// True while the leaf subfolder expansion is open (FR-2). + public bool LeafExpanded { get; } + + /// The formatted percentage; empty for probability-free rows (FR-5 cell content). + public string PercentText { get; } + + /// The ordered cells (segments, arrows, affordances). + public IReadOnlyList Cells { get; } + + /// The rendered subfolders of an open leaf expansion; empty otherwise. + public IReadOnlyList Subfolders { get; } + } + + /// + /// Pure projection from state to the ordered render DTO list + /// the breadcrumb page's JS consumes (#351 P3-T3). Percentage text comes from the existing + /// (consumed read-only); Path B rows render as ancestor-split + /// chains with an empty percentage cell. No I/O, WinForms, or WebView2 references. + /// + public static class BreadcrumbRenderProjection + { + private static readonly char[] PathSeparators = { '\\' }; + + /// + /// Projects the model's rows into render DTOs in display order. + /// + /// The state model to project. Required. + /// is null. + public static IReadOnlyList Project(BreadcrumbStateModel model) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + + var rows = new List(model.Rows.Count); + for (int i = 0; i < model.Rows.Count; i++) + { + rows.Add(ProjectRow(model.Rows[i], i, i == model.SelectedIndex)); + } + return rows; + } + + private static BreadcrumbRowRender ProjectRow( + BreadcrumbStateRow row, + int rowIndex, + bool selected + ) + { + var names = row.IsSuggestion + ? row.Chain.Select(segment => segment.DisplayName).ToArray() + : SplitVerbatim(row.VerbatimText!); + + int visibleCount = row.CollapsedAfterIndex.HasValue + ? row.CollapsedAfterIndex.Value + 1 + : names.Length; + + var cells = new List(); + for (int s = 0; s < visibleCount; s++) + { + bool isTerminal = s == visibleCount - 1; + if (s > 0) + { + cells.Add( + new BreadcrumbCellRender(BreadcrumbCellKind.Arrow, string.Empty, -1, false) + ); + } + if (row.CollapsedAfterIndex.HasValue && isTerminal) + { + // The re-expand plus sits to the left of the now-terminal segment (FR-3). + cells.Add( + new BreadcrumbCellRender(BreadcrumbCellKind.Plus, string.Empty, -1, false) + ); + } + bool interior = s > 0 && !isTerminal; + cells.Add( + new BreadcrumbCellRender(BreadcrumbCellKind.Segment, names[s], s, interior) + ); + } + + if (row.LeafHasSubfolders) + { + // Leaf-only affordance: plus when the expansion is closed, minus when open (FR-2). + cells.Add( + new BreadcrumbCellRender( + row.LeafExpanded ? BreadcrumbCellKind.Minus : BreadcrumbCellKind.Plus, + string.Empty, + -1, + false + ) + ); + } + + var subfolders = row + .Subfolders.Select(s => new BreadcrumbSubfolderRender( + s.DisplayName, + s.FolderPath, + s.HasChildren + )) + .ToArray(); + + return new BreadcrumbRowRender( + rowIndex, + row.IsSuggestion, + selected, + row.CollapsedAfterIndex.HasValue, + row.LeafExpanded, + row.IsSuggestion + ? PercentageFormatter.FormatPercent(row.Probability) + : string.Empty, + cells, + subfolders + ); + } + + private static string[] SplitVerbatim(string verbatimText) + { + var parts = verbatimText.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 0 ? new[] { verbatimText } : parts; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs new file mode 100644 index 00000000..2c55a0eb --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs @@ -0,0 +1,265 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Classifies a breadcrumb row in the EfcViewer suggestion list (#349). + /// + public enum BreadcrumbRowKind + { + /// A ranked folder suggestion rendered as a breadcrumb. + Suggestion, + + /// A "===="-prefixed section banner; never interactive. + Banner, + + /// The "Trash to Delete" pseudo-row; selectable, no segments. + TrashPseudoRow, + } + + /// + /// Per-row breadcrumb model plus its collapse/expand view-state machine. Pure and host-neutral + /// (no WinForms/COM/WebView2 types). Transitions mutate ONLY view state; , + /// , and the filing target path are immutable after construction. + /// + /// + /// No-op rules: and + /// rows never collapse or expand; a leaf without + /// subfolders is a toggle no-op. Arrow semantics follow the TreeListView parity of the spec — + /// Right expands (restores a collapsed breadcrumb, else expands the leaf children), Left + /// collapses (leaf children first, else hides the trailing segment). + /// + public sealed class BreadcrumbRow + { + private readonly List _segments; + private List _leafChildren = new List(); + + /// + /// Creates a breadcrumb row. + /// + /// Stable identifier used to correlate bridge messages. Required. + /// Row kind; only rows carry state. + /// Ordered root-to-leaf segments (may be empty for banner/pseudo rows). + /// Prediction probability joined by full-path equality, or null. + /// or is null. + public BreadcrumbRow( + string rowId, + BreadcrumbRowKind kind, + IEnumerable segments, + double? probability + ) + { + RowId = rowId ?? throw new ArgumentNullException(nameof(rowId)); + Kind = kind; + _segments = new List( + segments ?? throw new ArgumentNullException(nameof(segments)) + ); + Probability = probability; + } + + /// Stable identifier used to correlate bridge messages. + public string RowId { get; } + + /// Row kind (suggestion, banner, or trash pseudo-row). + public BreadcrumbRowKind Kind { get; } + + /// Ordered root-to-leaf segments; immutable after construction. + public IReadOnlyList Segments => _segments; + + /// Prediction probability, or null when no score was joined. + public double? Probability { get; } + + /// + /// Index of the segment after which the breadcrumb is collapsed, or null when fully + /// expanded. The segment at this index is the now-terminal segment carrying the re-expand + /// affordance. + /// + public int? CollapsedAfterIndex { get; private set; } + + /// True when the breadcrumb is collapsed after a non-leaf segment. + public bool IsCollapsed => CollapsedAfterIndex.HasValue; + + /// True when the leaf's immediate subfolders are shown. + public bool IsLeafExpanded { get; private set; } + + /// Immediate subfolders of the leaf, populated via . + public IReadOnlyList LeafChildren => _leafChildren; + + /// The anchored predicted leaf segment, or null when the row has no segments. + public BreadcrumbSegment? LeafSegment => + _segments.Count > 0 ? _segments[_segments.Count - 1] : null; + + /// + /// Collapses the breadcrumb after the non-leaf segment at : + /// all downstream segments (including the leaf) are hidden and the now-terminal segment + /// carries the re-expand affordance. Collapsing also hides any expanded leaf children. + /// + /// Index of a non-leaf segment (0-based). + /// True when the view state changed; false for the documented no-ops. + /// + /// is outside the segment list of a suggestion row. + /// + public bool CollapseAfter(int segmentIndex) + { + if (Kind != BreadcrumbRowKind.Suggestion) + { + return false; // Banner/pseudo rows never collapse. + } + + if (segmentIndex < 0 || segmentIndex >= _segments.Count) + { + throw new ArgumentOutOfRangeException( + nameof(segmentIndex), + segmentIndex, + $"Segment index must be within [0, {_segments.Count - 1}] for row '{RowId}'." + ); + } + + if (segmentIndex == _segments.Count - 1) + { + return false; // Collapse-after applies to non-leaf segments only. + } + + if (CollapsedAfterIndex == segmentIndex) + { + return false; + } + + CollapsedAfterIndex = segmentIndex; + IsLeafExpanded = false; + return true; + } + + /// + /// Restores the full breadcrumb after a collapse. No-op when not collapsed. + /// + /// True when the view state changed. + public bool ReExpand() + { + if (!IsCollapsed) + { + return false; + } + + CollapsedAfterIndex = null; + return true; + } + + /// + /// Stores the leaf's immediate subfolders. Valid only on a suggestion row whose leaf has + /// subfolders; otherwise a no-op. + /// + /// Immediate child segments of the leaf. + /// True when the children list was stored. + /// is null. + public bool SetLeafChildren(IEnumerable children) + { + if (children == null) + { + throw new ArgumentNullException(nameof(children)); + } + + if (!CanExpandLeaf()) + { + return false; + } + + _leafChildren = new List(children); + return true; + } + + /// + /// Toggles the leaf expand state. No-op for banner/pseudo rows, for a leaf without + /// subfolders, and while the breadcrumb is collapsed (the leaf is hidden). + /// + /// True when the view state changed. + public bool ToggleLeafExpanded() + { + if (!CanExpandLeaf() || IsCollapsed) + { + return false; + } + + IsLeafExpanded = !IsLeafExpanded; + return true; + } + + /// + /// Left-arrow transition: collapses expanded leaf children first; otherwise hides the + /// trailing segment (collapse-after the previous segment). No-op for banner/pseudo rows, + /// single-segment rows, and rows already collapsed at the root segment. + /// + /// True when the view state changed. + public bool LeftArrow() + { + if (Kind != BreadcrumbRowKind.Suggestion || _segments.Count == 0) + { + return false; + } + + if (IsLeafExpanded) + { + IsLeafExpanded = false; + return true; + } + + int terminalIndex = CollapsedAfterIndex ?? (_segments.Count - 1); + if (terminalIndex == 0) + { + return false; // Only the root segment remains visible. + } + + CollapsedAfterIndex = terminalIndex - 1; + return true; + } + + /// + /// Right-arrow transition: restores the full breadcrumb when collapsed; otherwise expands + /// the leaf children when the leaf has subfolders. No-op for banner/pseudo rows, for a + /// leaf without subfolders, and when already fully expanded. + /// + /// True when the view state changed. + public bool RightArrow() + { + if (Kind != BreadcrumbRowKind.Suggestion || _segments.Count == 0) + { + return false; + } + + if (IsCollapsed) + { + return ReExpand(); + } + + if (CanExpandLeaf() && !IsLeafExpanded) + { + IsLeafExpanded = true; + return true; + } + + return false; + } + + /// + /// Projects the currently visible segments: all segments when fully expanded, or the + /// segments up to and including when collapsed. + /// + /// The visible segment list in root-to-terminal order. + public IReadOnlyList VisibleSegments() + { + if (!CollapsedAfterIndex.HasValue) + { + return _segments; + } + + return _segments.GetRange(0, CollapsedAfterIndex.Value + 1); + } + + private bool CanExpandLeaf() + { + return Kind == BreadcrumbRowKind.Suggestion && LeafSegment?.HasSubfolders == true; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs new file mode 100644 index 00000000..838924c3 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs @@ -0,0 +1,236 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Builds instances from presented suggestion rows plus 9101 + /// ancestor chains (#349). Pure and host-neutral; consumes the 9101 + /// type directly (P0-T6 record) and derives NO hierarchy + /// from suggestion-row prefix matching — every chain comes from the injected lookup. + /// + public sealed class BreadcrumbRowBuilder + { + /// The exact presented text of the trash pseudo-row. + public const string TrashRowText = "Trash to Delete"; + + /// Prefix identifying non-interactive section banner rows. + public const string BannerPrefix = "===="; + + private static readonly char[] PathSeparators = { '\\', '/' }; + + /// + /// Builds breadcrumb rows for the presented rows, preserving presented order. Row ids are + /// row-<index> over the presented sequence. + /// + /// Presented row texts in display order. + /// + /// Root-to-leaf 9101 ancestor chain per suggestion folder path; may return null or an + /// empty list when the path is unknown. + /// + /// Score projections joined to rows by full-path equality. + /// The breadcrumb rows in presented order. + /// Any argument is null. + public IReadOnlyList BuildRows( + IReadOnlyList presentedRows, + Func?> ancestorChainLookup, + IEnumerable scores + ) + { + if (presentedRows == null) + { + throw new ArgumentNullException(nameof(presentedRows)); + } + + if (ancestorChainLookup == null) + { + throw new ArgumentNullException(nameof(ancestorChainLookup)); + } + + var probabilityByPath = BuildProbabilityIndex(scores); + var rows = new List(presentedRows.Count); + for (int i = 0; i < presentedRows.Count; i++) + { + string text = presentedRows[i] ?? string.Empty; + rows.Add(BuildRow($"row-{i}", text, ancestorChainLookup(text), probabilityByPath)); + } + + return rows; + } + + /// + /// Builds a single breadcrumb row from one presented row text. + /// + /// Stable row identifier. + /// The exact presented row text (path, banner, or trash row). + /// + /// The 9101 root-to-leaf chain for a suggestion row, or null/empty when unknown. + /// + /// Probability values keyed by folder full path. + /// The constructed row. + /// + /// , , or + /// is null. + /// + public BreadcrumbRow BuildRow( + string rowId, + string presentedText, + IReadOnlyList? ancestorChain, + IReadOnlyDictionary probabilityByPath + ) + { + if (rowId == null) + { + throw new ArgumentNullException(nameof(rowId)); + } + + if (presentedText == null) + { + throw new ArgumentNullException(nameof(presentedText)); + } + + if (probabilityByPath == null) + { + throw new ArgumentNullException(nameof(probabilityByPath)); + } + + BreadcrumbRowKind kind = Classify(presentedText); + switch (kind) + { + case BreadcrumbRowKind.Banner: + // Banner text travels as a single inert segment (banner rows never + // collapse/expand, so the segment is display data only). + return new BreadcrumbRow( + rowId, + BreadcrumbRowKind.Banner, + new[] { new BreadcrumbSegment(presentedText, presentedText, false) }, + null + ); + + case BreadcrumbRowKind.TrashPseudoRow: + return new BreadcrumbRow( + rowId, + BreadcrumbRowKind.TrashPseudoRow, + Array.Empty(), + null + ); + + default: + IReadOnlyList segments = MapSegments(ancestorChain); + if (segments.Count == 0) + { + // Unknown/empty chain fallback: render the presented path as a single + // leaf-only segment so the suggestion stays visible and selectable. + segments = new[] + { + new BreadcrumbSegment(presentedText, LeafToken(presentedText), false), + }; + } + + string joinPath = segments[segments.Count - 1].FullPath; + double? probability = probabilityByPath.TryGetValue(joinPath, out double p) + ? p + : (double?)null; + return new BreadcrumbRow( + rowId, + BreadcrumbRowKind.Suggestion, + segments, + probability + ); + } + } + + /// + /// Classifies a presented row text: "===="-prefixed rows are banners, the exact + /// is the trash pseudo-row, everything else is a suggestion. + /// + /// The exact presented row text. + /// The row kind. + public static BreadcrumbRowKind Classify(string presentedText) + { + if (presentedText == null) + { + throw new ArgumentNullException(nameof(presentedText)); + } + + if (presentedText.StartsWith(BannerPrefix, StringComparison.Ordinal)) + { + return BreadcrumbRowKind.Banner; + } + + if (string.Equals(presentedText, TrashRowText, StringComparison.Ordinal)) + { + return BreadcrumbRowKind.TrashPseudoRow; + } + + return BreadcrumbRowKind.Suggestion; + } + + /// + /// Maps an ordered root-to-leaf 9101 chain to pure breadcrumb segments + /// (FolderPath to FullPath, HasChildren to HasSubfolders). + /// + /// The 9101 segments, or null. + /// The mapped segments in the same order; empty when the chain is null/empty. + public static IReadOnlyList MapSegments( + IReadOnlyList? chain + ) + { + if (chain == null || chain.Count == 0) + { + return Array.Empty(); + } + + var mapped = new List(chain.Count); + foreach (FolderBreadcrumbSegment segment in chain) + { + if (segment == null) + { + throw new ArgumentException( + "Ancestor chains must not contain null segments.", + nameof(chain) + ); + } + + mapped.Add( + new BreadcrumbSegment( + segment.FolderPath, + segment.DisplayName, + segment.HasChildren + ) + ); + } + + return mapped; + } + + private static IReadOnlyDictionary BuildProbabilityIndex( + IEnumerable scores + ) + { + if (scores == null) + { + throw new ArgumentNullException(nameof(scores)); + } + + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (FolderScore score in scores) + { + if (!string.IsNullOrEmpty(score.FolderPath)) + { + index[score.FolderPath] = score.Probability; + } + } + + return index; + } + + private static string LeafToken(string path) + { + string trimmed = path.TrimEnd(PathSeparators); + int last = trimmed.LastIndexOfAny(PathSeparators); + return last >= 0 ? trimmed.Substring(last + 1) : trimmed; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSegment.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSegment.cs new file mode 100644 index 00000000..45412d62 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSegment.cs @@ -0,0 +1,45 @@ +#nullable enable +using System; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Pure, host-neutral breadcrumb segment used by the EfcViewer breadcrumb row model (#349). + /// Immutable net48-safe class (no record/init); carries no WinForms, COM, or + /// WebView2 types. + /// + /// + /// Built from the 9101 provider's by + /// BreadcrumbRowBuilder (mapping FolderPath to and + /// HasChildren to per the P0-T6 dependency-gate record). + /// + public sealed class BreadcrumbSegment + { + /// + /// Creates a breadcrumb segment. + /// + /// Full folder path; also the filing-target selection value. + /// Folder display name shown in the breadcrumb. + /// + /// True when the folder has at least one child folder (gates the expand affordance). + /// + /// + /// or is null. + /// + public BreadcrumbSegment(string fullPath, string displayName, bool hasSubfolders) + { + FullPath = fullPath ?? throw new ArgumentNullException(nameof(fullPath)); + DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName)); + HasSubfolders = hasSubfolders; + } + + /// Full folder path; the selection value returned to the host. + public string FullPath { get; } + + /// Folder display name (the leaf path segment). + public string DisplayName { get; } + + /// True when the folder has at least one child folder. + public bool HasSubfolders { get; } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs new file mode 100644 index 00000000..67d922ab --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbSelectionMap.cs @@ -0,0 +1,120 @@ +#nullable enable +using System; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Pure selection mapping for the QuickFiler breadcrumb (#351 P3-T9): maps the visible + /// row/subfolder selection of a to the exact + /// GetSelectedFolder() output string — the full folder path for Path A suggestion rows + /// and expanded-subfolder selections, the verbatim string (including the literal + /// "Trash to Delete") for Path B rows (G10/FR-7) — plus the index/item lookup helpers + /// backing SetFolderSelectedIndex/SetFolderSelectedItem/FolderContains/ + /// GetFolderItems. No I/O, WinForms, or WebView2 references. + /// + public static class BreadcrumbSelectionMap + { + /// + /// The selection output string: the selected subfolder's full path when a subfolder is + /// selected, the leaf's full path for a suggestion row, the verbatim string for a plain + /// row, or null when nothing is selected (legacy no-selection contract). + /// + /// is null. + public static string? GetSelectedFolder(BreadcrumbStateModel model) + { + RequireModel(model); + var row = model.SelectedRow; + if (row == null) + { + return null; + } + if (model.SelectedSubfolderIndex >= 0) + { + return row.Subfolders[model.SelectedSubfolderIndex].FolderPath; + } + return RowValue(row); + } + + /// + /// The per-row output strings in display order (the GetFolderItems() contract). + /// + /// is null. + public static string[] GetFolderItems(BreadcrumbStateModel model) + { + RequireModel(model); + var items = new string[model.Rows.Count]; + for (int i = 0; i < model.Rows.Count; i++) + { + items[i] = RowValue(model.Rows[i]); + } + return items; + } + + /// + /// True when a row's output string equals exactly (ordinal; the + /// FolderContains(string) contract). + /// + /// or is null. + public static bool FolderContains(BreadcrumbStateModel model, string item) + { + return IndexOfItem(model, item) >= 0; + } + + /// + /// The first row index whose output string equals exactly + /// (ordinal), or -1 when no row matches (the explicit unknown-item signal). + /// + /// or is null. + public static int IndexOfItem(BreadcrumbStateModel model, string item) + { + RequireModel(model); + if (item == null) + { + throw new ArgumentNullException(nameof(item)); + } + + for (int i = 0; i < model.Rows.Count; i++) + { + if (string.Equals(RowValue(model.Rows[i]), item, StringComparison.Ordinal)) + { + return i; + } + } + return -1; + } + + /// + /// Selects the first row whose output string equals (the + /// SetFolderSelectedItem contract). Returns false without changing the selection + /// when no row matches, mirroring the legacy ComboBox unknown-item no-op. + /// + /// or is null. + public static bool TrySelectItem(BreadcrumbStateModel model, string item) + { + int index = IndexOfItem(model, item); + if (index < 0) + { + return false; + } + model.SelectRow(index); + return true; + } + + /// + /// The output string of one row: leaf full path for suggestion rows, verbatim text for + /// plain rows (byte-identical, including "Trash to Delete"). + /// + private static string RowValue(BreadcrumbStateRow row) + { + return row.IsSuggestion ? row.Chain[row.Chain.Count - 1].FolderPath : row.VerbatimText!; + } + + private static void RequireModel(BreadcrumbStateModel model) + { + if (model == null) + { + throw new ArgumentNullException(nameof(model)); + } + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs new file mode 100644 index 00000000..ea6f46d8 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/BreadcrumbStateModel.cs @@ -0,0 +1,313 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// One visible row of the QuickFiler breadcrumb control: either a Path A suggestion row carrying + /// a root-to-leaf ancestor chain of plus an optional + /// probability, or a Path B plain-string row carried verbatim without probability. Holds the + /// per-row collapse/expand state; all transitions validate their preconditions and fail fast. + /// + public sealed class BreadcrumbStateRow + { + private static readonly IReadOnlyList EmptySegments = + new FolderBreadcrumbSegment[0]; + + internal BreadcrumbStateRow( + IReadOnlyList chain, + double? probability + ) + { + if (chain == null || chain.Count == 0) + { + throw new ArgumentException( + "A suggestion row requires a non-empty ancestor chain.", + nameof(chain) + ); + } + if (chain.Any(segment => segment == null)) + { + throw new ArgumentException( + "The ancestor chain must not contain null segments.", + nameof(chain) + ); + } + + Chain = chain.ToArray(); + Probability = probability; + VerbatimText = null; + Subfolders = EmptySegments; + } + + internal BreadcrumbStateRow(string verbatimText) + { + VerbatimText = verbatimText ?? throw new ArgumentNullException(nameof(verbatimText)); + Chain = EmptySegments; + Probability = null; + Subfolders = EmptySegments; + } + + /// True for a Path A suggestion row; false for a Path B plain-string row. + public bool IsSuggestion => VerbatimText == null; + + /// Root-first ancestor chain for a suggestion row; empty for a plain row. + public IReadOnlyList Chain { get; } + + /// The consumed prediction probability, or null when the row carries none. + public double? Probability { get; } + + /// The exact Path B string (returned verbatim on selection), or null for Path A rows. + public string? VerbatimText { get; } + + /// + /// The segment index after which the row is collapsed (that segment is the visible terminal), + /// or null when the full chain is visible. + /// + public int? CollapsedAfterIndex { get; private set; } + + /// True while the leaf's subfolder list is expanded. + public bool LeafExpanded { get; private set; } + + /// The fetched immediate subfolders shown while . + public IReadOnlyList Subfolders { get; private set; } + + /// + /// True when the leaf carries the plus/minus affordance: a fully-expanded suggestion row + /// whose leaf segment has real subfolders (FR-2). + /// + public bool LeafHasSubfolders => + IsSuggestion && CollapsedAfterIndex == null && Chain[Chain.Count - 1].HasChildren; + + /// + /// Collapses the row after the non-leaf segment at (FR-3): + /// downstream segments and the leaf are hidden and the segment becomes the visible terminal. + /// Any open leaf expansion is closed. + /// + /// The row is a plain (Path B) row. + /// + /// is negative, beyond the chain, or the leaf index (the + /// leaf cannot be collapsed-after). + /// + public void CollapseAfter(int segmentIndex) + { + if (!IsSuggestion) + { + throw new InvalidOperationException( + "Collapse is defined only for suggestion rows with an ancestor chain." + ); + } + if (segmentIndex < 0 || segmentIndex >= Chain.Count - 1) + { + throw new ArgumentOutOfRangeException( + nameof(segmentIndex), + segmentIndex, + $"Collapse-after requires a non-leaf segment index in [0, {Chain.Count - 2}]." + ); + } + + CollapsedAfterIndex = segmentIndex; + LeafExpanded = false; + Subfolders = EmptySegments; + } + + /// Restores the full chain after a collapse; a no-op when not collapsed (FR-3). + public void ReExpand() + { + CollapsedAfterIndex = null; + } + + /// + /// Opens the leaf subfolder expansion when the affordance is available (FR-2); returns false + /// (no-op by contract) for plain rows, collapsed rows, affordance-less leaves, or when + /// already expanded, so the caller can fall through to legacy behavior. + /// + public bool TryExpandLeaf() + { + if (!LeafHasSubfolders || LeafExpanded) + { + return false; + } + + LeafExpanded = true; + return true; + } + + /// + /// Closes the leaf subfolder expansion; returns false (no-op) when nothing is expanded. + /// + public bool TryCollapseLeaf() + { + if (!LeafExpanded) + { + return false; + } + + LeafExpanded = false; + Subfolders = EmptySegments; + return true; + } + + /// + /// Stores the fetched immediate subfolders for the open expansion (FR-4). + /// + /// The leaf is not expanded. + public void SetSubfolders(IReadOnlyList subfolders) + { + if (!LeafExpanded) + { + throw new InvalidOperationException( + "Subfolders can be attached only while the leaf expansion is open." + ); + } + + Subfolders = (subfolders ?? EmptySegments).ToArray(); + } + + /// Resets collapse, expansion, and subfolder state to the initial full chain. + public void Reset() + { + CollapsedAfterIndex = null; + LeafExpanded = false; + Subfolders = EmptySegments; + } + } + + /// + /// Pure, host-neutral collapse/expand state machine for the QuickFiler WebView2 breadcrumb + /// (#351): ordered rows, selected-row/subfolder tracking, and keyboard transitions. Mirrors the + /// tested precedent — no WinForms, COM, WebView2, or I/O work + /// — and is NOT coverage-exempt. + /// + public sealed class BreadcrumbStateModel + { + private readonly List _rows = new List(); + private int _selectedIndex = -1; + private int _selectedSubfolderIndex = -1; + + /// The rows in display order. + public IReadOnlyList Rows => _rows; + + /// The selected row index, or -1 when nothing is selected. + public int SelectedIndex => _selectedIndex; + + /// + /// The selected subfolder index within the selected row's open expansion, or -1 when the + /// selection is the row itself. + /// + public int SelectedSubfolderIndex => _selectedSubfolderIndex; + + /// The selected row, or null when nothing is selected. + public BreadcrumbStateRow? SelectedRow => _selectedIndex < 0 ? null : _rows[_selectedIndex]; + + /// Removes all rows and clears the selection. + public void Clear() + { + _rows.Clear(); + _selectedIndex = -1; + _selectedSubfolderIndex = -1; + } + + /// Appends a Path A suggestion row (root-first chain, optional probability). + public void AddSuggestionRow( + IReadOnlyList chain, + double? probability + ) + { + _rows.Add(new BreadcrumbStateRow(chain, probability)); + } + + /// Appends a Path B plain-string row carried verbatim without probability. + public void AddPlainRow(string verbatimText) + { + _rows.Add(new BreadcrumbStateRow(verbatimText)); + } + + /// + /// Selects the row at (or -1 to clear the selection) and resets any + /// subfolder selection. + /// + /// is not -1 and not a valid row index. + public void SelectRow(int index) + { + if (index < -1 || index >= _rows.Count) + { + throw new ArgumentOutOfRangeException( + nameof(index), + index, + $"Row selection requires -1 or an index in [0, {_rows.Count - 1}]." + ); + } + + _selectedIndex = index; + _selectedSubfolderIndex = -1; + } + + /// + /// Selects a subfolder of the selected row's open leaf expansion. + /// + /// No row is selected, or the selected row has no open expansion. + /// is outside the fetched subfolder list. + public void SelectSubfolder(int subfolderIndex) + { + var row = SelectedRow; + if (row == null || !row.LeafExpanded) + { + throw new InvalidOperationException( + "Subfolder selection requires a selected row with an open leaf expansion." + ); + } + if (subfolderIndex < 0 || subfolderIndex >= row.Subfolders.Count) + { + throw new ArgumentOutOfRangeException( + nameof(subfolderIndex), + subfolderIndex, + $"Subfolder selection requires an index in [0, {row.Subfolders.Count - 1}]." + ); + } + + _selectedSubfolderIndex = subfolderIndex; + } + + /// + /// Right-arrow transition on the selected row: re-expands a collapsed chain, else opens the + /// leaf expansion when the affordance is available. Returns false when nothing changed so the + /// caller can report an unhandled arrow (FR-6 legacy fall-through). A successful leaf + /// expansion still requires the caller to fetch and attach subfolders. + /// + public bool RightArrow() + { + var row = SelectedRow; + if (row == null) + { + return false; + } + if (row.CollapsedAfterIndex != null) + { + row.ReExpand(); + return true; + } + return row.TryExpandLeaf(); + } + + /// + /// Left-arrow transition on the selected row: closes an open leaf expansion. Returns false + /// when nothing changed so the caller can report an unhandled arrow (FR-6 legacy fall-through). + /// + public bool LeftArrow() + { + var row = SelectedRow; + if (row == null) + { + return false; + } + if (_selectedSubfolderIndex >= 0) + { + _selectedSubfolderIndex = -1; + } + return row.TryCollapseLeaf(); + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs new file mode 100644 index 00000000..2ef85a25 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbBridgeRouter.cs @@ -0,0 +1,348 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Pure async message router for the QuickFiler breadcrumb bridge (#351 P3-T7): JSON string in + /// -> typed message -> transition and/or + /// call -> JSON string(s) out. Also owns the + /// host-driven population entry points (suggestions, plain items, clear, selection) so all + /// correctness lives in this host-neutral, fully unit-testable type. No WebView2, WinForms, or + /// COM references; the only I/O reachable from here is behind the injected provider (G6). + /// + public sealed class FolderBreadcrumbBridgeRouter + { + private readonly IFolderHierarchyProvider _provider; + private readonly BreadcrumbStateModel _model = new BreadcrumbStateModel(); + + /// + /// Creates a router over the injected 9101 provider. + /// + /// The merged 9101 hierarchy provider. Required. + /// is null. + public FolderBreadcrumbBridgeRouter(IFolderHierarchyProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// The routed state model (selection reads go through the selection map). + public BreadcrumbStateModel Model => _model; + + /// + /// Populates Path A suggestion rows: each scored row's ancestor chain comes from the + /// provider (ResolveLeafKeyAsync + GetAncestorChainAsync, FR-1/FR-4); scored + /// rows whose path cannot be resolved fall back to a plain row carrying the score's folder + /// path so the selection contract still yields the exact path (G10). Non-scored rows + /// (separators, search results, recents) become plain verbatim rows. Returns the render + /// payload JSON. + /// + public async Task SetSuggestionsAsync( + IReadOnlyList rows, + CancellationToken cancellationToken + ) + { + if (rows == null) + { + throw new ArgumentNullException(nameof(rows)); + } + + _model.Clear(); + foreach (var row in rows) + { + if (row.Score.HasValue) + { + string path = row.Score.Value.FolderPath; + var key = await _provider + .ResolveLeafKeyAsync(path, cancellationToken) + .ConfigureAwait(false); + IReadOnlyList chain = + key == null + ? new FolderBreadcrumbSegment[0] + : await _provider + .GetAncestorChainAsync(key, cancellationToken) + .ConfigureAwait(false); + if (chain.Count > 0) + { + _model.AddSuggestionRow(chain, row.Score.Value.Probability); + } + else + { + _model.AddPlainRow(path); + } + } + else + { + _model.AddPlainRow(row.Text); + } + } + return RenderJson(); + } + + /// + /// Populates Path B plain rows verbatim (search results, including the literal + /// "Trash to Delete"; G10). Returns the render payload JSON. + /// + public string SetItems(IReadOnlyList items) + { + if (items == null) + { + throw new ArgumentNullException(nameof(items)); + } + + _model.Clear(); + foreach (var item in items) + { + _model.AddPlainRow(item); + } + return RenderJson(); + } + + /// Appends Path B plain rows without clearing (legacy AddRange semantics). + public string AddItems(IReadOnlyList items) + { + if (items == null) + { + throw new ArgumentNullException(nameof(items)); + } + + foreach (var item in items) + { + _model.AddPlainRow(item); + } + return RenderJson(); + } + + /// Clears all rows and the selection. Returns the (empty) render payload JSON. + public string Clear() + { + _model.Clear(); + return RenderJson(); + } + + /// Host-driven row selection (SetFolderSelectedIndex). Returns the render payload JSON. + public string SelectRow(int index) + { + _model.SelectRow(index); + return RenderJson(); + } + + /// The current render payload JSON (full re-render message, FR-6). + public string RenderJson() + { + return BreadcrumbBridgeSerializer.Serialize( + new RenderMessage(BreadcrumbRenderProjection.Project(_model)) + ); + } + + /// + /// Routes one inbound bridge message and returns the ordered outbound JSON messages. + /// Malformed input, unroutable messages, invalid indexes, and provider failures surface as + /// an explicit error response (fail fast at this boundary — never silently dropped); + /// cancellation propagates. + /// + public async Task> RouteAsync( + string inboundJson, + CancellationToken cancellationToken + ) + { + BreadcrumbBridgeMessage message; + try + { + message = BreadcrumbBridgeSerializer.Parse(inboundJson); + } + catch (FormatException ex) + { + return ErrorResponse(ex.Message); + } + + try + { + switch (message) + { + case SegmentDoubleClickMessage m: + RowAt(m.RowIndex).CollapseAfter(m.SegmentIndex); + return new[] { RenderJson() }; + case AffordanceToggleMessage m: + return await ToggleAsync(m.RowIndex, cancellationToken) + .ConfigureAwait(false); + case ArrowKeyMessage m: + return await ArrowAsync(m.Direction, cancellationToken) + .ConfigureAwait(false); + case SelectionChangeMessage m: + _model.SelectRow(m.RowIndex); + if (m.SubfolderIndex >= 0) + { + _model.SelectSubfolder(m.SubfolderIndex); + } + // The ack carries no mapped folder; the coordinator resolves the output + // string through the selection map when raising SelectionChanged (FR-7). + return new[] + { + RenderJson(), + BreadcrumbBridgeSerializer.Serialize( + new SelectionChangeMessage(m.RowIndex, m.SubfolderIndex, null) + ), + }; + case SubfolderRequestMessage m: + return await SubfolderResponseAsync(m.RowIndex, cancellationToken) + .ConfigureAwait(false); + case ThemeChangeMessage m: + return new[] + { + BreadcrumbBridgeSerializer.Serialize(new ThemeChangeMessage(m.Theme)), + RenderJson(), + }; + case UnhandledArrowMessage m: + // The JS-side report is re-emitted so the coordinator can invoke the + // legacy fall-through behavior (FR-6). + return new[] { BreadcrumbBridgeSerializer.Serialize(m) }; + default: + return ErrorResponse( + $"Message type '{message.Type}' is not routable inbound." + ); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Boundary catch (JS bridge edge): state/provider failures must surface to the page + // as an explicit error response instead of tearing down the message pump. + return ErrorResponse(ex.Message); + } + } + + private async Task> ToggleAsync( + int rowIndex, + CancellationToken cancellationToken + ) + { + var row = RowAt(rowIndex); + if (row.CollapsedAfterIndex != null) + { + row.ReExpand(); + return new[] { RenderJson() }; + } + if (row.LeafExpanded) + { + row.TryCollapseLeaf(); + return new[] { RenderJson() }; + } + if (!row.TryExpandLeaf()) + { + return ErrorResponse($"Row {rowIndex} has no expand affordance."); + } + return await FetchAndAttachSubfoldersAsync(rowIndex, row, cancellationToken) + .ConfigureAwait(false); + } + + private async Task> ArrowAsync( + BreadcrumbArrowDirection direction, + CancellationToken cancellationToken + ) + { + bool handled = + direction == BreadcrumbArrowDirection.Right + ? _model.RightArrow() + : _model.LeftArrow(); + if (!handled) + { + return new[] + { + BreadcrumbBridgeSerializer.Serialize(new UnhandledArrowMessage(direction)), + }; + } + + var row = _model.SelectedRow!; + if (row.LeafExpanded && row.Subfolders.Count == 0) + { + return await FetchAndAttachSubfoldersAsync( + _model.SelectedIndex, + row, + cancellationToken + ) + .ConfigureAwait(false); + } + return new[] { RenderJson() }; + } + + private async Task> FetchAndAttachSubfoldersAsync( + int rowIndex, + BreadcrumbStateRow row, + CancellationToken cancellationToken + ) + { + try + { + var leafKey = row.Chain[row.Chain.Count - 1].Key; + var subfolders = await _provider + .GetImmediateSubfoldersAsync(leafKey, cancellationToken) + .ConfigureAwait(false); + row.SetSubfolders(subfolders); + } + catch (OperationCanceledException) + { + row.TryCollapseLeaf(); + throw; + } + catch (Exception ex) + { + // Boundary catch: revert the expansion so the model stays consistent, then surface + // the provider failure explicitly (P3-T8 negative contract). + row.TryCollapseLeaf(); + return ErrorResponse($"Subfolder query failed: {ex.Message}"); + } + + var renderJson = RenderJson(); + var responseJson = BreadcrumbBridgeSerializer.Serialize( + new SubfolderResponseMessage( + rowIndex, + BreadcrumbRenderProjection.Project(_model)[rowIndex].Subfolders + ) + ); + return new[] { renderJson, responseJson }; + } + + private async Task> SubfolderResponseAsync( + int rowIndex, + CancellationToken cancellationToken + ) + { + var row = RowAt(rowIndex); + if (!row.IsSuggestion) + { + return ErrorResponse($"Row {rowIndex} is a plain row without a subfolder query."); + } + if (!row.LeafExpanded && !row.TryExpandLeaf()) + { + return ErrorResponse($"Row {rowIndex} has no expand affordance."); + } + return await FetchAndAttachSubfoldersAsync(rowIndex, row, cancellationToken) + .ConfigureAwait(false); + } + + private BreadcrumbStateRow RowAt(int rowIndex) + { + if (rowIndex < 0 || rowIndex >= _model.Rows.Count) + { + throw new ArgumentOutOfRangeException( + nameof(rowIndex), + rowIndex, + $"Row index must be in [0, {_model.Rows.Count - 1}]." + ); + } + return _model.Rows[rowIndex]; + } + + private static IReadOnlyList ErrorResponse(string message) + { + return new[] { BreadcrumbBridgeSerializer.Serialize(new BridgeErrorMessage(message)) }; + } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbSegment.cs b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbSegment.cs new file mode 100644 index 00000000..12e0e3df --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbSegment.cs @@ -0,0 +1,53 @@ +using System; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Immutable breadcrumb segment describing one folder in an ancestor chain or an immediate + /// subfolder list. Host-neutral and net48-safe (plain class; no init/record). + /// + /// + /// The segment is deliberately probability-free. Each consuming UI feature joins the prediction + /// percentage from the existing feature-324 plumbing keyed by , which + /// keeps the scoring/probability boundary untouched. + /// + public sealed class FolderBreadcrumbSegment + { + /// + /// Creates a breadcrumb segment for a single folder node. + /// + /// + /// Stable identity of the folder node; used to route the expand-this-segment call. Required. + /// + /// Folder display name (the leaf path segment). + /// Full folder path; the selection value returned to the host. + /// + /// True when the node has at least one child folder, so the UI renders the expand affordance. + /// + /// is null. + public FolderBreadcrumbSegment( + FolderTreeNodeKey key, + string displayName, + string folderPath, + bool hasChildren + ) + { + Key = key ?? throw new ArgumentNullException(nameof(key)); + DisplayName = displayName ?? string.Empty; + FolderPath = folderPath ?? string.Empty; + HasChildren = hasChildren; + } + + /// Stable identity for the expand-this-segment call. + public FolderTreeNodeKey Key { get; } + + /// Folder display name (the leaf path segment). + public string DisplayName { get; } + + /// Full folder path; the selection value returned to the host. + public string FolderPath { get; } + + /// True when the node has at least one child folder (render the expand affordance). + public bool HasChildren { get; } + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshotQueries.cs b/UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshotQueries.cs index ef2c8776..8771e9ec 100644 --- a/UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshotQueries.cs +++ b/UtilitiesCS/OutlookObjects/Folder/FolderTreeSnapshotQueries.cs @@ -91,6 +91,59 @@ public static IReadOnlyList< .ToArray(); } + /// + /// Returns the ordered root-to-leaf ancestor chain for by walking + /// to the store root and reversing to root-first + /// order. + /// + /// The immutable snapshot to walk. Required. + /// Identity of the leaf folder node. + /// + /// Nodes ordered root-first / leaf-last, with the last element equal to the requested leaf. + /// An empty list (never null) when is null or absent from the + /// snapshot. A malformed cyclic yields the + /// partial chain rather than looping. + /// + /// is null. + public static IReadOnlyList GetAncestorChain( + FolderTreeSnapshot snapshot, + FolderTreeNodeKey leafKey + ) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + if (leafKey == null || !snapshot.TryGetNode(leafKey, out var leaf)) + { + return Array.Empty(); + } + + var chain = new List(); + var visited = new HashSet(); + var current = leaf; + + // visited.Add returns false on a repeat key, which terminates a malformed cyclic ParentKey + // walk while preserving the partial chain collected so far. + while (current != null && visited.Add(current.Key)) + { + chain.Add(current); + if ( + current.ParentKey == null + || !snapshot.TryGetNode(current.ParentKey, out var parent) + ) + { + break; + } + + current = parent; + } + + chain.Reverse(); + return chain.ToArray(); + } + public static FolderTreeSnapshot CreateSubtreeSnapshot( FolderTreeSnapshot snapshot, FolderTreeSnapshotNode rootNode diff --git a/UtilitiesCS/OutlookObjects/Folder/IFolderHierarchyProvider.cs b/UtilitiesCS/OutlookObjects/Folder/IFolderHierarchyProvider.cs new file mode 100644 index 00000000..13d62b19 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/IFolderHierarchyProvider.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// The epic's single shared upstream contract for live folder-hierarchy queries. Consumed across + /// module boundaries by the EfcViewer (9102) and QuickFiler (9103) breadcrumb features. + /// + /// + /// Host-neutral: the only dependency is (an interface, not + /// COM), so the contract is unit-testable without a live Outlook process. All members are + /// -returning only because snapshot acquisition is async; the ancestor walk and + /// children projection themselves are synchronous and independently unit-tested via + /// . + /// + public interface IFolderHierarchyProvider + { + /// + /// Returns the ordered root-to-leaf ancestor chain for the selected leaf folder. + /// + /// Identity of the selected leaf folder node. + /// Token observed while acquiring the snapshot. + /// + /// Segments ordered root-first / leaf-last, with the last segment equal to the requested leaf. + /// An empty list (never null) when is null or absent from the + /// current snapshot. + /// + Task> GetAncestorChainAsync( + FolderTreeNodeKey leafKey, + CancellationToken cancellationToken + ); + + /// + /// Returns the real immediate subfolders of a given segment, sourced from the live cached + /// snapshot rather than from suggestion rows. + /// + /// Identity of the segment whose children are requested. + /// Token observed while acquiring the snapshot. + /// + /// The immediate child segments, or an empty list (never null) when the segment has no children + /// or is unknown. + /// + Task> GetImmediateSubfoldersAsync( + FolderTreeNodeKey segmentKey, + CancellationToken cancellationToken + ); + + /// + /// Resolves a UI-selected folder path to a stable node key against the current snapshot. + /// + /// Full folder path selected in the host. + /// Token observed while acquiring the snapshot. + /// + /// The matching , or null when no matching node exists. + /// Identity is by key, so duplicate segment names at different depths are distinguished. + /// + Task ResolveLeafKeyAsync( + string folderPath, + CancellationToken cancellationToken + ); + } +} diff --git a/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs b/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs new file mode 100644 index 00000000..6e1d16a2 --- /dev/null +++ b/UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace UtilitiesCS.OutlookObjects.Folder +{ + /// + /// Host-neutral facade over that projects the cached + /// into breadcrumb segments. Adds no COM code and is not + /// coverage-exempt; the live Outlook query stays isolated behind the injected service interface. + /// + public sealed class OutlookFolderHierarchyProvider : IFolderHierarchyProvider + { + private readonly IOutlookFolderTreeService _treeService; + + /// + /// Creates a provider over the supplied folder-tree service. + /// + /// The cached snapshot service. Required. + /// is null. + public OutlookFolderHierarchyProvider(IOutlookFolderTreeService treeService) + { + _treeService = treeService ?? throw new ArgumentNullException(nameof(treeService)); + } + + /// + public async Task> GetAncestorChainAsync( + FolderTreeNodeKey leafKey, + CancellationToken cancellationToken + ) + { + var snapshot = await AcquireSnapshotAsync(cancellationToken).ConfigureAwait(false); + var chain = FolderTreeSnapshotQueries.GetAncestorChain(snapshot, leafKey); + return MapNodes(chain); + } + + /// + public async Task> GetImmediateSubfoldersAsync( + FolderTreeNodeKey segmentKey, + CancellationToken cancellationToken + ) + { + var snapshot = await AcquireSnapshotAsync(cancellationToken).ConfigureAwait(false); + var children = snapshot.GetChildren(segmentKey); + return MapNodes(children); + } + + /// + public async Task ResolveLeafKeyAsync( + string folderPath, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(folderPath)) + { + return null; + } + + var snapshot = await AcquireSnapshotAsync(cancellationToken).ConfigureAwait(false); + + // First-match on duplicate paths is the documented behavior; real Outlook full paths embed + // the store name and are unique in practice. + var match = snapshot.NodesByKey.Values.FirstOrDefault(node => + string.Equals(node.FolderPath, folderPath, StringComparison.OrdinalIgnoreCase) + ); + + return match?.Key; + } + + private Task AcquireSnapshotAsync(CancellationToken cancellationToken) + { + return _treeService.GetSnapshotAsync( + FolderTreeRequest.AllStores(allowStaleSnapshot: true), + cancellationToken + ); + } + + private static IReadOnlyList MapNodes( + IReadOnlyList nodes + ) + { + return nodes.Select(MapNode).ToArray(); + } + + private static FolderBreadcrumbSegment MapNode(FolderTreeSnapshotNode node) + { + return new FolderBreadcrumbSegment( + node.Key, + node.DisplayName, + node.FolderPath, + node.ChildKeys.Count > 0 + ); + } + } +} diff --git a/UtilitiesCS/UtilitiesCS.csproj b/UtilitiesCS/UtilitiesCS.csproj index e15cf2ac..a469f2fd 100644 --- a/UtilitiesCS/UtilitiesCS.csproj +++ b/UtilitiesCS/UtilitiesCS.csproj @@ -604,6 +604,21 @@ + + + + + + + + + + + + + + + diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/code-review.2026-07-18T09-54.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/code-review.2026-07-18T09-54.md new file mode 100644 index 00000000..3c9abfd6 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/code-review.2026-07-18T09-54.md @@ -0,0 +1,55 @@ +# Code Review — efcviewer-breadcrumb-webview2 (#349) + +- Timestamp: 2026-07-18T09-54 +- Branch: `feature/efcviewer-breadcrumb-webview2-349` +- Diff base: `8e242692` (merge-base with `origin/epic/folder-tree-breadcrumb-redesign-integration`) +- HEAD: `be6f38d1` + +## Executive Summary + +The change replaces the EfcViewer `TreeListView` matching-folders control with a WebView2-hosted +HTML/CSS/JS breadcrumb, plus a JS<->.NET message bridge. The implementation follows a clean +separation: pure, host-neutral logic (row model + state machine, HTML renderer, JSON codec, row +builder, message contracts) lives in UtilitiesCS; the bridge router and outbound queue are pure +QuickFiler classes tested against mocked seams; and the only host-bound code (the WebView2 adapter, +the WinForms form, the wholly-exempt controller wiring) is confined behind `[ExcludeFromCodeCoverage]` +seams with in-code justification. The diff shrinks the exempt `EfcFormController` by 36 lines while +adding coverage-bearing logic in non-exempt classes. + +Reviewed for the specific behaviors named in the task: the row state machine, codec fail-fast, router +queue ordering and idempotent re-init, HTML encoding of folder names, the renderer percent-visibility +invariant, the wiring-only nature of `EfcFormController`, the Designer swaps, and the csproj +`` wiring. No correctness defects were found. Findings are limited to low-severity +observations that do not affect correctness or policy compliance. + +## Findings Table + +| Severity | File | Location | Finding | Recommendation | Rationale | Evidence | +|---|---|---|---|---|---|---| +| Info | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs | `CollapseAfter`, `LeftArrow`, `RightArrow`, `ToggleLeafExpanded` | Row state machine transitions are total and side-effect-scoped: banner/pseudo rows and empty-segment rows short-circuit to no-op; collapse-after rejects the leaf index and out-of-range; leaf toggle is gated on `CanExpandLeaf()` and suppressed while collapsed. No state-corruption path found. | None (confirming). | Matches spec state-machine invariants; verified against `BreadcrumbRowStateTests` scenarios (collapse-after, re-expand, leaf toggle no-op, arrow transitions). | BreadcrumbRow.cs:104-263; ac-verification-map AC2/AC3. | +| Info | UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs | `DeserializeInbound` | Fail-fast is correct and specific: empty/whitespace, invalid JSON, unknown `type`, missing/wrong-typed `rowId`, missing `segmentIndex` on `segmentDoubleClick`, and empty `key` on `arrowKey` each log a specific log4net error and throw `BreadcrumbMessageException` (no silent swallow). | None (confirming). | Satisfies the spec validation rule and the fail-fast policy; the boundary catch in the router rethrows-by-suppress only after the codec has logged. | BreadcrumbMessageCodec.cs:72-182; BreadcrumbBridgeRouter.cs:187-198. | +| Info | QuickFiler/Controllers/BreadcrumbBridgeRouter.cs | `BindRowsAsync`, `ExpandLeafAsync` | Hierarchy is sourced only from the 9101 provider (`ResolveLeafKeyAsync` + `GetAncestorChainAsync`/`GetImmediateSubfoldersAsync`); there is no prefix-matching over suggestion rows. Provider I/O is wrapped with `OperationCanceledException` propagation on bind and specific-error-plus-unchanged-state on expand. | None (confirming). | Satisfies AC4 "no prefix-matching" and the I/O-isolation policy. | BreadcrumbBridgeRouter.cs:74-116, 285-362. | +| Info | QuickFiler/Controllers/BreadcrumbOutboundQueue.cs | `OnInitializationCompleted` | Outbound ordering and idempotent re-init are correct: payloads posted before init are FIFO-buffered and flushed in enqueue order; a duplicate completion with an empty buffer is a no-op, matching the pooled-viewer lifecycle. Event-driven only (no polling/timers/delays). | None (confirming). | Satisfies the pre-init queueing invariant and the banned-API/no-delay constraint. | BreadcrumbOutboundQueue.cs:37-65; BreadcrumbBridgeRouter.cs:140-149. | +| Info | UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs | `AppendSegment`, `AppendPercent`, `AppendChildren`, `AppendLeafAffordance` | HTML encoding is applied to every dynamic value (`RowId`, segment `FullPath`/`DisplayName`, child names, formatted percent) via `WebUtility.HtmlEncode`. The percent is unconditionally emitted as the trailing `.pct` item on every row kind (banner, trash, suggestion, collapsed). Leaf affordance is emitted only when `LeafSegment.HasSubfolders`. | None (confirming). | Satisfies the HTML-encoding invariant, the always-visible-percent invariant (AC5 CSS side), and the affordance-gating invariant (AC2). | BreadcrumbHtmlRenderer.cs:119-223; BreadcrumbDocumentAssets.cs:21 (`.pct { flex: 0 0 auto }`). | +| Info | UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs | `BridgeJs` inbound listener | XSS surface is handled defensively on both sides: server-generated fragments are HtmlEncoded before delivery (`render` applies them via `outerHTML`/`innerHTML`), and provider-sourced child names are inserted via `textContent`/`title` (never markup). Row-id selector concatenation uses only server-generated `row-{i}` ids, not user data. | None (confirming). | No injection path from folder names or provider data. | BreadcrumbDocumentAssets.cs:94-113. | +| Info | QuickFiler/Controllers/EfcFormController.cs | `ConfigureBreadcrumbControl`, `BindBreadcrumbRowsAsync`, `DarkMode_Changed` | Controller changes are wiring-only: construct host adapter + router, connect `CoreInitialized`/`FocusSearchRequested` events, delegate binds and theme swaps to the router. No decision logic added; net -36 lines. `SelectedFolder` now derives from `_router.SelectedFolderPath` with `IsValidSelection` retaining its `"===="` rejection as a second guard. | None (confirming). | Satisfies "no new testable logic in EfcFormController" (AC11) and behavior-parity wiring (AC10). | EfcFormController.cs diff (constructor/event wiring); WebView2BreadcrumbHost.cs:29 exemption. | +| Info | QuickFiler/Viewers/EfcViewer.Designer.cs, EfcViewer3.Designer.cs | `FolderListBox` field + init | Designer swap replaces the `TreeListView`/two `OLVColumn`s with `Microsoft.Web.WebView2.WinForms.WebView2` in the same TableLayoutPanel cell (span 14, Dock=Fill). The fixed 3200/500 (and 1600/300) `ColumnHeader` widths — the ranked percent-obscuring cause — are removed. `EfcViewer3.cs` is byte-identical (empty diff), confirming the mechanical-only swap for the dead variant. | None (confirming). | Satisfies AC7 (mechanical EfcViewer3 swap) and removes the AC5 defect mechanism. | EfcViewer.Designer.cs / EfcViewer3.Designer.cs diffs; efcviewer3-mechanical-swap-verification.md. | +| Low | QuickFiler/Controllers/BreadcrumbBridgeRouter.cs | `OnHostMessageReceived` (async void) | The host message handler is `async void` with a boundary catch that suppresses `BreadcrumbMessageException` only (after the codec has logged). This is the correct and conventional pattern for an event handler over a message pump, but an unexpected non-codec exception in `ProcessInboundAsync` would propagate out of the `async void` and could surface on the UI pump. In practice the router's per-branch handlers each guard their own I/O, so no such path was found. | Consider an outer catch that logs-and-swallows unexpected exceptions at this UI-pump boundary, or document that all downstream paths are individually guarded. | Defense-in-depth for an `async void` UI-pump handler; not a current defect because every inner branch already guards its I/O. | BreadcrumbBridgeRouter.cs:187-198, 285-362. | +| Low | UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs | `BuildProbabilityIndex` vs `BreadcrumbBridgeRouter` chain dedup | Probability join keys use `StringComparer.OrdinalIgnoreCase`, while the router's presented-row dedup dictionary also uses `OrdinalIgnoreCase`; the ancestor `joinPath` equality is thus case-insensitive. This is consistent internally and matches the 9101 key's case-insensitive store/path equality, so no mismatch was found. | None; noting the case-sensitivity choice for future maintainers. | Consistency confirmed across builder, router, and the 9101 `FolderTreeNodeKey` equality. | BreadcrumbRowBuilder.cs:208-227; BreadcrumbBridgeRouter.cs:85-107; phase0-9101-provider-gate.md. | + +## Design and Policy Observations + +- Separation of concerns is clean: no WinForms/COM/WebView2 type appears in any coverage-bearing + class; the router depends only on `IFolderHierarchyProvider` and `IBreadcrumbWebHost`. +- The message contracts are net48-safe plain classes with explicit constructors and null-guards; + the outbound hierarchy uses an abstract base with sealed discriminated subclasses. +- Error handling is fail-fast at the codec and log-and-continue at the provider I/O boundaries, both + consistent with the general and C# error-handling policies. +- csproj wiring adds `` entries for all 7 new UtilitiesCS sources, 4 new QuickFiler + sources, and 6 new test files across the four affected projects; no dependency additions. + +## Verdict + +No blocking or high/medium-severity findings. Two low-severity, non-blocking observations (async-void +boundary hardening; documented case-insensitivity). Code quality is consistent with repository +standards and the feature spec. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-analyzers.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-analyzers.md new file mode 100644 index 00000000..6277e2f2 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-analyzers.md @@ -0,0 +1,6 @@ +# Phase 0 — Baseline Analyzer Build (P0-T3) + +Timestamp: 2026-07-18T08-45 +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded. 0 errors; 77 pre-existing warning lines (dominant: CS8632 nullable-annotation-context warnings and CS0067 unused-event warnings in UtilitiesCS.Test sources). All projects including UtilitiesCS, QuickFiler, UtilitiesCS.Test, QuickFiler.Test built successfully. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-csharpier.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-csharpier.md new file mode 100644 index 00000000..74756790 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-csharpier.md @@ -0,0 +1,6 @@ +# Phase 0 — Baseline CSharpier Check (P0-T2) + +Timestamp: 2026-07-18T08-42 +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" check . (run at worktree root C:\Users\DanMoisan\repos\TaskMaster\.claude\worktrees\agent-a7071cb39df527237) +EXIT_CODE: 0 +Output Summary: Checked 1370 files in 3242ms. No formatting differences reported; baseline is format-clean. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-nullable.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-nullable.md new file mode 100644 index 00000000..2bab8137 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-nullable.md @@ -0,0 +1,6 @@ +# Phase 0 — Baseline Nullable/TreatWarningsAsErrors Build (P0-T4) + +Timestamp: 2026-07-18T08-47 +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded with 0 warnings and 0 errors (incremental build over the P0-T3 outputs; all projects up-to-date or rebuilt cleanly). Nullable gate baseline is green. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-tests-coverage.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-tests-coverage.md new file mode 100644 index 00000000..d48a0097 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-tests-coverage.md @@ -0,0 +1,12 @@ +# Phase 0 — Baseline Tests + Coverage (P0-T5) + +Timestamp: 2026-07-18T08-52 +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: +- Total tests: 4838; Passed: 4838; Failed: 0; Skipped: 0. +- Coverage report: TestResults\f2f1472c-7b80-4cb8-a0e1-833f353fd44f\DanMoisan_MEGALODON4_2026-07-18.08_45_10.cobertura.xml +- OVERALL (all instrumented modules loaded by the two test hosts): line 58.74% (42623/72557), branch 46.33% (9560/20635). The overall figure is depressed by assemblies loaded but not targeted by these two test projects (log4net 6.08%, TaskMaster 9.04%, Tags/ToDoModel/TaskVisualization 0%, System.Interactive/System.Linq.Async ~3%, SVGControl 16.22%, Mono.Reflection 39.30%). +- Feature-relevant package baselines (the like-for-like comparison basis for P9-T5): + - UtilitiesCS: line 88.55%, branch 82.22% + - QuickFiler: line 72.32%, branch 62.32% diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-instructions-read.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 00000000..6ce0ec48 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,19 @@ +# Phase 0 — Policy Instructions Read (P0-T1) + +Timestamp: 2026-07-18T08-41 +Policy Order: CLAUDE.md -> .claude/rules/general-code-change.md -> .claude/rules/general-unit-test.md -> .claude/rules/csharp.md (per policy-compliance-order skill) + +Files read (from the current worktree `C:\Users\DanMoisan\repos\TaskMaster\.claude\worktrees\agent-a7071cb39df527237`): + +1. `CLAUDE.md` (sha256 ed6ca760280cb5d2ed07d6771a7a0042487f920739f4517bf61d01234b8653e8) +2. `.claude/rules/general-code-change.md` (sha256 f2b8683b12d10dd2add50e9bd30fbb5a3657231f676fdbf024165dcf4ea21889) +3. `.claude/rules/general-unit-test.md` (sha256 39e83d3a9dc8c11c12c24bdc5fbfe533a65c4c90e0f38c03ff4067933c5221ef) +4. `.claude/rules/csharp.md` (sha256 3e2fe077d79fe40eaf851d066404185f1fdcf6c05d7a8d98d662623ed629165a) + +Key constraints noted for this feature: +- Toolchain order: csharpier -> analyzers msbuild -> nullable msbuild -> vstest with coverage; restart loop on any failure or file change. +- MSTest + Moq + FluentAssertions; Arrange-Act-Assert; no temp files; no external dependencies in tests. +- Coverage: repo floor >= 80% (testable denominator per CLAUDE.md COM/VSTO exemption); new modules >= 90% line; no changed-line regression. +- Banned APIs (BannedSymbols.txt / RS0030): DateTime.Now, DateTime.UtcNow, Random.Shared, Thread.Sleep, Task.Delay. +- 500-line file cap; net48-safe types only (no record / record struct / init accessors); #nullable enable in new files. +- Legacy packages.config projects require explicit items for every new .cs file. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/ac-verification-map.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/ac-verification-map.md new file mode 100644 index 00000000..b81d9cde --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/ac-verification-map.md @@ -0,0 +1,24 @@ +# Acceptance-Criteria Verification Map (P8-T5) + +Timestamp: 2026-07-18T12-15 (AC-12 verdict updated 2026-07-18T12-55 after the Phase 9 final QA loop completed green) +Sources: `spec.md` §Definition of Done / Acceptance Criteria == `user-story.md` §Acceptance Criteria (identical 12-item list; verdicts apply to both files). + +| # | AC (abbreviated) | Verdict | Evidence pointer | +|---|---|---|---| +| 1 | Single-line breadcrumb per suggestion in live `EfcViewer` via WebView2 | PARTIAL | Implementation delivered (BreadcrumbRow/Builder/Renderer; P6-T2 Designer swap to `Microsoft.Web.WebView2.WinForms.WebView2`); rendering verified by `BreadcrumbRowBuilderTests` + `BreadcrumbHtmlRendererTests` + `BreadcrumbBridgeRouterTests.BindRowsAsync_WithInitializedHost_DeliversGeneratedDocument`. GAP: live-EfcViewer runtime observation outstanding (`evidence/other/manual-parity-verification.2026-07-18T11-50.md`). | +| 2 | Leaf-only plus/minus affordance gated on `HasSubfolders` | PASS | `BreadcrumbHtmlRendererTests.RenderRowFragment_LeafWithSubfolders_EmitsPlusWhenCollapsedMinusWhenExpanded`, `RenderRowFragment_LeafWithoutSubfolders_EmitsNoAffordance`; state gating in `BreadcrumbRowStateTests.ToggleLeafExpanded_WithoutSubfolders_IsNoOp`. | +| 3 | Non-leaf double-click collapse + plus re-expand | PASS | `BreadcrumbRowStateTests.CollapseAfter_OnNonLeafSegment_HidesDownstreamAndMarksTerminal` / `ReExpand_AfterCollapse_RestoresFullBreadcrumb`; `BreadcrumbHtmlRendererTests.RenderRowFragment_CollapsedState_RendersReExpandPlusAtTerminalSegment`; `BreadcrumbBridgeRouterTests.SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders` + `LeafExpandToggle_OnCollapsedRow_ReExpandsWithoutProviderQuery`. | +| 4 | Real immediate subfolders via 9101 `IFolderHierarchyProvider`; no prefix-matching | PARTIAL | Seam consumption verified: P0-T6 gate record (`evidence/other/phase0-9101-provider-gate.md`); `BreadcrumbBridgeRouterTests.LeafExpandToggle_IssuesSubfolderQueryAndPostsCorrelatedResult`; zero prefix-matching in the breadcrumb path (router/builder consume provider chains only). GAP: live-Outlook subfolder listing (incl. a folder not among ranked suggestions) outstanding (parity item 7). | +| 5 | Percent always visible; repro first, CSS fix after | PARTIAL | Fail-before: `evidence/regression-testing/fail-before-exception.2026-07-18T09-00.md` (plan-authorized dossier; live repro structurally unavailable). Fix: §D.4 flex CSS (`BreadcrumbDocumentAssets`), trailing-`.pct` invariant asserted by `BreadcrumbHtmlRendererTests.RenderRowFragment_EveryRowKind_EmitsTrailingPctFlexItem` + `RenderRowFragment_CollapsedRow_StillEmitsTrailingPercent`. GAP: runtime pass-after at minimum width outstanding (`evidence/regression-testing/percent-visible-pass-after.2026-07-18T11-45.md`, remediation-required). | +| 6 | JS<->.NET bridge carries interactions and the subfolder query | PARTIAL | Bridge implemented end-to-end: `BreadcrumbDocumentAssets.BridgeJs` (postMessage emitters + message listener), `WebView2BreadcrumbHost` (`WebMessageReceived`/`PostWebMessageAsJson`/`NavigateToString`), codec round-trips (`BreadcrumbMessageCodecTests`, 17 tests), router interaction tests. GAP: live WebView2 round-trip verified only against `Mock`; manual live check outstanding. | +| 7 | EfcViewer3 mechanical Designer-only swap, no behavioral wiring | PASS | P7-T1 swap + `evidence/other/efcviewer3-mechanical-swap-verification.md` (zero construction sites, zero non-Designer wiring, `EfcViewer3.cs` untouched). | +| 8 | No third-party tree/list control, no WPF/ElementHost | PASS | No package or reference additions (`packages.config`/csproj references unchanged except `` items); control technology is the already-referenced WebView2; `ObjectListView.Official` retained for other viewers only; zero `BrightIdeasSoftware` references remain in the Efc Designer files. | +| 9 | Scoring unchanged; feature-324 percent plumbing reused as-is | PASS | No scoring/ranking file touched (Scope Lock respected; `git status` shows only Scope Lock files); renderer consumes `PercentageFormatter.FormatPercent` (`BreadcrumbHtmlRenderer.AppendPercent`), joined from `FolderScore.Probability` by full-path equality (`BreadcrumbRowBuilderTests.BuildRow_WithMatchingProbability_JoinsByFullPathEquality`). | +| 10 | Behavior parity (focusSearch, trash row, banners, 'F', dark mode) | PARTIAL | All wiring delivered and unit-verified per item (see the eight-item table in `evidence/other/manual-parity-verification.2026-07-18T11-50.md`). GAP: all eight runtime confirmations outstanding (live session unavailable). | +| 11 | Unit tests (MSTest+Moq+FluentAssertions), >= 90% new modules, no new logic in `EfcFormController` | PASS | 102 breadcrumb unit tests green (MSTest+Moq+FluentAssertions, AAA). Per-module line coverage (full-suite Cobertura run 2026-07-18T09-37, incl. compiler-generated nested types; re-verified at P9-T4/T5): BreadcrumbSegment 100%, BreadcrumbRow 98.02%, BreadcrumbRowBuilder 100%, BreadcrumbMessages types 100%, BreadcrumbMessageCodec 95.56% (+exception 100%), BreadcrumbHtmlRenderer 96.9%, BreadcrumbOutboundQueue 95.83%, BreadcrumbBridgeRouter 97.87% — all >= 90%. `EfcFormController` is wiring-only and shrank (67 insertions / 103 deletions); `WebView2BreadcrumbHost` exempt with in-code justification. | +| 12 | Full toolchain single pass; no banned APIs | PASS | Banned-API scan zero hits (`evidence/qa-gates/banned-api-scan.md`). Phase 9 final QA loop green in a single pass: `evidence/qa-gates/phase9-final-csharpier.md` (EXIT 0, check clean), `phase9-final-analyzers.md` (EXIT 0, 0 errors), `phase9-final-nullable.md` (EXIT 0, 0 warnings-as-errors), `phase9-final-tests-coverage.md` (4935/4935 passed with coverage). | + +Summary (final): 7 PASS (2, 3, 7, 8, 9, 11, 12), 5 PARTIAL (1, 4, 5, 6, 10). Every remaining PARTIAL +rests solely on the outstanding live-Outlook manual verification — all automated deliverables and +tests for those items are complete and green. PARTIAL items remain unchecked in both AC source files +per acceptance-criteria-tracking. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/efcviewer3-mechanical-swap-verification.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/efcviewer3-mechanical-swap-verification.md new file mode 100644 index 00000000..d39e2411 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/efcviewer3-mechanical-swap-verification.md @@ -0,0 +1,24 @@ +# EfcViewer3 Mechanical-Swap Verification (P7-T2) + +Timestamp: 2026-07-18T11-30 + +Search commands (run at the worktree root, ripgrep/grep over `QuickFiler/`): + +1. `grep -rn "new EfcViewer3|EfcViewer3(" QuickFiler/ --include=*.cs` (excluding the two EfcViewer3 files themselves) + Result: NO matches — zero runtime construction sites of `EfcViewer3` anywhere in `QuickFiler/`. +2. `grep -rln "EfcViewer3" QuickFiler/ --include=*.cs` + Result: only `QuickFiler/Viewers/EfcViewer3.cs` and `QuickFiler/Viewers/EfcViewer3.Designer.cs` reference the type. +3. `grep -n "FolderListBox" QuickFiler/Viewers/EfcViewer3.cs` + Result: NO matches — the code-behind never touches the folder-list control. +4. `grep -rn "EfcViewer3" QuickFiler/Controllers/ "QuickFiler/Helper Classes/" --include=*.cs` + Result: NO matches — zero controller/event wiring of EfcViewer3 or its folder-list control. +5. `grep -n "new EfcViewer" "QuickFiler/Helper Classes/EfcViewerQueue.cs"` + Result: line 83 `return new EfcViewer();` — the sole runtime instantiation of an Efc form viewer is the concrete `EfcViewer`, confirming EfcViewer3 is dead code. + +Post-swap state of `EfcViewer3.Designer.cs` (P7-T1): +- `FolderListBox` is now `Microsoft.Web.WebView2.WinForms.WebView2` in the same TableLayoutPanel cell (`Tlp.Controls.Add(FolderListBox, 2, 5)` unchanged, `SetColumnSpan(..., 14)`, `Dock = Fill`). +- Both `OLVColumn` declarations (`olvColumnFolder` width 1600, `olvColumnPercent` width 300) deleted. +- Zero `BrightIdeasSoftware` folder-list references remain in either EfcViewer3 file (search 3 above plus `grep -n "BrightIdeasSoftware|olvColumn"` over both files: no matches). +- NO event subscriptions or handlers were added in either EfcViewer3 file; `EfcViewer3.cs` is byte-identical to its pre-task state (`git diff --stat -- QuickFiler/Viewers/EfcViewer3.cs` is empty; no compile-fix edits were required). + +Conclusion: the no-behavioral-wiring invariant holds — the swap is Designer-only and EfcViewer3 remains dead code. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/manual-parity-verification.2026-07-18T11-50.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/manual-parity-verification.2026-07-18T11-50.md new file mode 100644 index 00000000..8d8aff0f --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/manual-parity-verification.2026-07-18T11-50.md @@ -0,0 +1,22 @@ +# Behavior-Parity Manual Verification Checklist (P8-T2) + +Timestamp: 2026-07-18T11-50 +Status: REMEDIATION-REQUIRED on all eight items (manual verification outstanding) — a live +Outlook runtime session is structurally unavailable to the executing agent, so no item is +recorded as a runtime PASS. Each item lists the automated evidence that exists today and the +outstanding manual step. + +| # | Parity item | Verdict | Automated evidence available (not a runtime pass) | +|---|---|---|---| +| 1 | Up-at-top focuses `SearchText` | remediation-required | Router posts `focusSearch` and raises `FocusSearchRequested` (BreadcrumbBridgeRouterTests.ArrowKeyUp_AtTopSelectableRow_PostsFocusSearchAndRaisesEvent); controller wires `FocusSearchRequested -> SearchText.Select()` (EfcFormController.ConfigureBreadcrumbControl). | +| 2 | SearchText down-arrow enters the list and selects the first row | remediation-required | `SearchText_DownArrow` focuses the WebView2 control and calls `Router.SelectFirstRow()`; SelectFirstRow behavior covered by BreadcrumbBridgeRouterTests.SelectFirstRow_SelectsTopSelectableRowAndPostsRender. | +| 3 | `"Trash to Delete"` pseudo-row selectable after delete | remediation-required | `ActionDeleteAsync` prepends the pseudo-row and rebinds through `Router.BindRowsAsync`; trash classification/selectability covered by BreadcrumbRowBuilderTests + BreadcrumbHtmlRendererTests.RenderRowFragment_TrashPseudoRow_IsSelectableWithoutAffordance. | +| 4 | `"===="` banner rows non-interactive and rejected as filing targets | remediation-required | Renderer emits non-interactive banner markup (RenderRowFragment_BannerRow_IsNonInteractive); router never selects banners (RowSelected_OnBannerRow_IsIgnored); `IsValidSelection` retains its `"===="` rejection unchanged. | +| 5 | `'F'` focuses the breadcrumb control | remediation-required | The `'F'` KbdAction still targets `_formViewer.FolderListBox`, now the WebView2 control (unchanged `JumpToAsync(_formViewer.FolderListBox)` wiring). | +| 6 | Dark-mode toggle re-themes the document | remediation-required | `DarkMode_Changed` routes to `Router.ApplyTheme(DarkMode)`; dark re-render covered by BreadcrumbBridgeRouterTests.ApplyTheme_Dark_ReDeliversDarkDocument and renderer theme tests. | +| 7 | Leaf expand lists real Outlook subfolders (incl. one not among ranked suggestions) | remediation-required | Router issues `GetImmediateSubfoldersAsync` via the 9101 provider with requestId correlation (LeafExpandToggle_IssuesSubfolderQueryAndPostsCorrelatedResult); no suggestion-row prefix matching exists in the breadcrumb path. Live-subfolder observation requires Outlook. | +| 8 | Selection feeds filing via `SelectedFolder` | remediation-required | `SelectedFolder` derives from `Router.SelectedFolderPath` (RowSelected_UpdatesSelectedFolderPathAndRaisesEvent); ExecuteMoves path re-verified green (EfcHomeControllerExecuteMovesTests, 7/7). | + +Required remediation: run the eight checks in a live Outlook session and update each verdict to +PASS/FAIL with observations before treating spec AC-10 (and the runtime aspects of AC-1..AC-4) +as fully verified. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/phase0-9101-provider-gate.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/phase0-9101-provider-gate.md new file mode 100644 index 00000000..19fc8877 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/phase0-9101-provider-gate.md @@ -0,0 +1,31 @@ +# Phase 0 — 9101 Provider Dependency Gate (P0-T6) + +Timestamp: 2026-07-18T08-55 +Gate result: PASS — both provider types are present in `UtilitiesCS`; execution proceeds past Phase 0. + +Search commands used: +- `grep -r "IFolderHierarchyProvider" UtilitiesCS/` (ripgrep files-with-matches) +- `grep -r "FolderBreadcrumbSegment|FolderSegmentInfo" UtilitiesCS/` (ripgrep files-with-matches) +- `grep -rn "class FolderTreeNodeKey|struct FolderTreeNodeKey" UtilitiesCS/` + +Resolved actual merged surface (feature #350 / PR #353), namespace `UtilitiesCS.OutlookObjects.Folder`: + +1. `UtilitiesCS/OutlookObjects/Folder/IFolderHierarchyProvider.cs` — `public interface IFolderHierarchyProvider` + - `Task> GetAncestorChainAsync(FolderTreeNodeKey leafKey, CancellationToken cancellationToken)` — root-first/leaf-last chain; empty list (never null) when leafKey null/absent. + - `Task> GetImmediateSubfoldersAsync(FolderTreeNodeKey segmentKey, CancellationToken cancellationToken)` — real immediate children from the live cached snapshot; empty list (never null) on unknown/childless. + - `Task ResolveLeafKeyAsync(string folderPath, CancellationToken cancellationToken)` — resolves a UI-selected full folder path to a stable node key; null when no match. + - Concrete implementation: `UtilitiesCS/OutlookObjects/Folder/OutlookFolderHierarchyProvider.cs`. + +2. `UtilitiesCS/OutlookObjects/Folder/FolderBreadcrumbSegment.cs` — `public sealed class FolderBreadcrumbSegment` (plain sealed class, net48-safe, get-only properties, explicit ctor) + - ctor `(FolderTreeNodeKey key, string displayName, string folderPath, bool hasChildren)`; throws `ArgumentNullException` on null key; null displayName/folderPath coerced to `string.Empty`. + - Members: `FolderTreeNodeKey Key`, `string DisplayName`, `string FolderPath`, `bool HasChildren`. + +3. Supporting identity type: `UtilitiesCS/OutlookObjects/Folder/FolderTreeNodeKey.cs` — `public sealed class FolderTreeNodeKey : IEquatable` + - ctor `(string storeId, string entryId, string folderPath)`; `StoreId`/`FolderPath` require non-whitespace text (ArgumentException), `EntryId` null-coerced to empty; value equality (StoreId + EntryId + FolderPath, case-insensitive on store/path). + +Deviations from the research §C.3 assumed shape (Phases 2 and 5 MUST code against the actual surface below; re-alignment point is the row-builder/router input as pre-authorized by the plan): +- DTO name is `FolderBreadcrumbSegment`, NOT `FolderSegmentInfo`. +- Full-path member is `FolderPath`, NOT `FullPath`. +- Children flag is `HasChildren`, NOT `HasSubfolders`. +- `GetAncestorChainAsync` / `GetImmediateSubfoldersAsync` take a `FolderTreeNodeKey`, NOT a `string`; the string-path bridge is `ResolveLeafKeyAsync(string, CancellationToken)`. +- Segments carry a `Key` used to route expand-this-segment calls. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/banned-api-scan.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/banned-api-scan.md new file mode 100644 index 00000000..71cb0431 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/banned-api-scan.md @@ -0,0 +1,11 @@ +# Banned-API Scan (P8-T4) + +Timestamp: 2026-07-18T11-55 +Command: grep -n "DateTime\.Now\|DateTime\.UtcNow\|Random\.Shared\|Thread\.Sleep\|Task\.Delay" +EXIT_CODE: 1 (grep: no matches found — the desired outcome) +Output Summary: ZERO banned-API hits across every new/touched file from the Scope Lock: +- New UtilitiesCS sources: BreadcrumbSegment.cs, BreadcrumbRow.cs, BreadcrumbRowBuilder.cs, BreadcrumbMessages.cs, BreadcrumbMessageCodec.cs, BreadcrumbDocumentAssets.cs, BreadcrumbHtmlRenderer.cs +- New QuickFiler sources: IBreadcrumbWebHost.cs, BreadcrumbOutboundQueue.cs, BreadcrumbBridgeRouter.cs, WebView2BreadcrumbHost.cs +- Modified WinForms/controller: EfcViewer.cs, EfcViewer.Designer.cs, EfcFormController.cs, EfcViewer3.Designer.cs, EfcViewer3.cs +- New tests: BreadcrumbRowBuilderTests.cs, BreadcrumbRowStateTests.cs, BreadcrumbMessageCodecTests.cs, BreadcrumbHtmlRendererTests.cs, BreadcrumbBridgeRouterTests.cs, BreadcrumbBridgeRouterQueueTests.cs, plus the compile-fixed EfcHomeControllerExecuteMovesTests.cs +Initialization waiting is gated on CoreWebView2InitializationCompleted plus the BreadcrumbOutboundQueue pending-message buffer; no polling, sleeps, or delays exist in the feature code or tests. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase1-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase1-toolchain.md new file mode 100644 index 00000000..d143d5ab --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase1-toolchain.md @@ -0,0 +1,25 @@ +# Phase 1 — Toolchain Gate (P1-T3) + +Timestamp: 2026-07-18T09-05 + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . (worktree root) +EXIT_CODE: 0 +Output Summary: Formatted 1370 files in 4011ms; no file content changed beyond the intentional P1-T1 edit (git status shows only QuickFiler/Viewers/EfcViewer.cs modified, which is the task's own change). + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings emitted in this (incremental) pass. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4838; Passed: 4838; Failed: 0. + +All four steps green in a single pass; no restart required. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase2-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase2-toolchain.md new file mode 100644 index 00000000..bfc6393d --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase2-toolchain.md @@ -0,0 +1,25 @@ +# Phase 2 — Toolchain Gate (P2-T6) + +Timestamp: 2026-07-18T09-35 + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . (worktree root) +EXIT_CODE: 0 +Output Summary: Formatted 1375 files in 3776ms; only intentional Phase 2 files present in git status (BreadcrumbSegment/Row/RowBuilder + tests + csproj wiring + P1-T1 instrumentation). + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings in this pass. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings (new #nullable enable files clean under TreatWarningsAsErrors). + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4869; Passed: 4869; Failed: 0 (baseline 4838 + 31 new Phase 2 tests: 11 builder + 20 row-state). + +All four steps green in a single pass; no restart required. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase3-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase3-toolchain.md new file mode 100644 index 00000000..eaa238c1 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase3-toolchain.md @@ -0,0 +1,29 @@ +# Phase 3 — Toolchain Gate (P3-T4) + +Timestamp: 2026-07-18T09-50 + +Loop note: the first format pass reformatted the newly added Phase 3 files, so the loop was +restarted; the pass recorded below is the clean single pass (format verified idempotent via +`csharpier check` EXIT 0 before the test step). + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . ; verification: csharpier check . +EXIT_CODE: 0 (format), 0 (check — no remaining differences) +Output Summary: Formatted/checked 1378 files; repository format-clean. + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4886; Passed: 4886; Failed: 0 (adds 17 codec tests over Phase 2). + +All four steps green in a single pass after the format restart. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase4-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase4-toolchain.md new file mode 100644 index 00000000..5914e99f --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase4-toolchain.md @@ -0,0 +1,28 @@ +# Phase 4 — Toolchain Gate (P4-T4) + +Timestamp: 2026-07-18T10-10 + +Loop note: the first format pass reformatted the new renderer file, restarting the loop; the pass +below is the clean single pass (`csharpier check` EXIT 0 confirms idempotence). + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . ; verification: csharpier check . +EXIT_CODE: 0 (format), 0 (check) +Output Summary: Repository format-clean (1379 files). + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4898; Passed: 4898; Failed: 0 (adds 12 renderer tests over Phase 3). + +All four steps green in a single pass after the format restart. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase5-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase5-toolchain.md new file mode 100644 index 00000000..31fc6fe9 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase5-toolchain.md @@ -0,0 +1,28 @@ +# Phase 5 — Toolchain Gate (P5-T6) + +Timestamp: 2026-07-18T10-45 + +Loop note: the first format pass reformatted the new Phase 5 files, restarting the loop; the pass +below is the clean single pass (`csharpier check` EXIT 0 confirms idempotence). + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . ; verification: csharpier check . +EXIT_CODE: 0 (format), 0 (check) +Output Summary: Repository format-clean (1383 files). + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4913; Passed: 4913; Failed: 0 (adds 9 router + 6 queue/error-path tests over Phase 4). + +All four steps green in a single pass after the format restart. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase6-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase6-toolchain.md new file mode 100644 index 00000000..98bb16b3 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase6-toolchain.md @@ -0,0 +1,28 @@ +# Phase 6 — Toolchain Gate (P6-T4) + +Timestamp: 2026-07-18T11-20 + +Loop note: the first format pass reformatted the Phase 6 touched files, restarting the loop; the +pass below is the clean single pass (`csharpier check` EXIT 0 confirms idempotence). + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . ; verification: csharpier check . +EXIT_CODE: 0 (format), 0 (check) +Output Summary: Repository format-clean (1384 files). + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. Exempt wiring (WebView2BreadcrumbHost, Designer swap, EfcFormController rewire) compiles. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4913; Passed: 4913; Failed: 0 — no host-neutral test regressed. (EfcHomeControllerExecuteMovesTests helper was compile-fixed for the FolderListBox field retype: the removed `_selectedNode` injection is replaced by a router-backed selection over mocked seams; all 7 of its tests pass.) + +All four steps green in a single pass after the format restart. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase7-toolchain.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase7-toolchain.md new file mode 100644 index 00000000..d50bb178 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase7-toolchain.md @@ -0,0 +1,25 @@ +# Phase 7 — Toolchain Gate (P7-T3) + +Timestamp: 2026-07-18T11-40 + +## Step 1 — Format +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . ; verification: csharpier check . +EXIT_CODE: 0 (format), 0 (check) +Output Summary: Repository format-clean. + +## Step 2 — Analyzers +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 3 — Nullable / TreatWarningsAsErrors +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; 0 errors, 0 warnings. + +## Step 4 — Tests with coverage +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: Total tests: 4913; Passed: 4913; Failed: 0. + +All four steps green in a single pass; no restart required. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-coverage-delta.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-coverage-delta.md new file mode 100644 index 00000000..0be885a7 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-coverage-delta.md @@ -0,0 +1,43 @@ +# Phase 9 — Coverage Delta / Threshold Verification (P9-T5) + +Timestamp: 2026-07-18T12-50 +Inputs: baseline `evidence/baseline/phase0-baseline-tests-coverage.md` (P0-T5) vs post-change `evidence/qa-gates/phase9-final-tests-coverage.md` (P9-T4); identical runsettings and test-assembly set for both runs. + +## Baseline vs post-change + +| Scope | Baseline (P0-T5) | Post-change (P9-T4) | Delta | +|---|---|---|---| +| OVERALL line (all instrumented modules) | 58.74% (42623/72557) | 59.15% (43377/73328) | +0.41 pp | +| OVERALL branch | 46.33% (9560/20635) | 46.94% (9821/20923) | +0.61 pp | +| UtilitiesCS line | 88.55% | 88.65% | +0.10 pp | +| UtilitiesCS branch | 82.22% | 82.36% | +0.14 pp | +| QuickFiler line | 72.32% | 73.34% | +1.02 pp | +| QuickFiler branch | 62.32% | 64.30% | +1.98 pp | +| Tests | 4838 passed | 4935 passed | +97 tests, 0 failures | + +## New-code coverage per module (threshold >= 90% line) + +| New non-exempt module | Line | Branch | >= 90%? | +|---|---|---|---| +| BreadcrumbSegment | 100% | n/a (guard-only branches 50% of 4) | YES | +| BreadcrumbRow | 98.02% | 88.46% | YES | +| BreadcrumbRowBuilder | 100% | 100% | YES | +| BreadcrumbMessages (5 types) | 100% each | guard-only branches | YES | +| BreadcrumbMessageCodec (+exception) | 95.56% / 100% | 97.37% | YES | +| BreadcrumbDocumentAssets | constant-only (no executable lines) | n/a | exempt-by-shape (type-only/constant module clarification) | +| BreadcrumbHtmlRenderer | 96.90% | 89.47% | YES | +| BreadcrumbOutboundQueue | 95.83% | 100% | YES | +| BreadcrumbBridgeRouter (incl. async state machines) | 97.87% | 92.22% | YES | + +## Changed-line coverage + +- All new non-exempt code is in the modules above (>= 95% line each), so no changed non-exempt line lost coverage. +- Remaining modified files are coverage-exempt by policy: `EfcViewer.cs`/`EfcViewer.Designer.cs`/`EfcViewer3.Designer.cs` (Form/Designer, `[ExcludeFromCodeCoverage]`), `EfcFormController.cs` (wholly `[ExcludeFromCodeCoverage]`, wiring-only, net -36 lines), `WebView2BreadcrumbHost.cs` (`[ExcludeFromCodeCoverage]` with in-code justification), plus test files and `` csproj wiring. + +## Verdict + +- Every new non-exempt module meets the >= 90% line threshold: PASS. +- Repository floor not regressed: both feature packages and the overall figure IMPROVED vs the P0-T5 baseline (UtilitiesCS 88.65% line / 82.36% branch exceeds the 85%/75% spec bars; the like-for-like overall and QuickFiler figures rose; the sub-floor overall headline is the known uninstrumented-assembly artifact documented in the baseline and unchanged in kind): PASS (no regression). +- No changed line lost coverage: PASS. + +Outcome: PASS — all coverage thresholds of the plan are met. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-analyzers.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-analyzers.md new file mode 100644 index 00000000..54b93368 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-analyzers.md @@ -0,0 +1,6 @@ +# Phase 9 — Final Analyzer Build (P9-T2) + +Timestamp: 2026-07-18T12-35 +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +EXIT_CODE: 0 +Output Summary: Build succeeded; zero analyzer errors and zero warnings emitted in this pass. All feature projects (UtilitiesCS, QuickFiler, UtilitiesCS.Test, QuickFiler.Test) built cleanly. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-csharpier.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-csharpier.md new file mode 100644 index 00000000..bc666054 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-csharpier.md @@ -0,0 +1,6 @@ +# Phase 9 — Final CSharpier (P9-T1) + +Timestamp: 2026-07-18T12-30 +Command: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" format . ; verification: & "$env:USERPROFILE\.dotnet\tools\csharpier.exe" check . +EXIT_CODE: 0 (format), 0 (check) +Output Summary: Formatted 1387 files in 1949ms (minor reflow of the Phase 8 gap-test additions), then check confirmed 0 remaining differences — no formatting changes remain. The loop proceeds from this clean state; only the 22 intentional feature files appear in git status. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-nullable.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-nullable.md new file mode 100644 index 00000000..605e2902 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-nullable.md @@ -0,0 +1,6 @@ +# Phase 9 — Final Nullable/TreatWarningsAsErrors Build (P9-T3) + +Timestamp: 2026-07-18T12-38 +Command: msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true +EXIT_CODE: 0 +Output Summary: Build succeeded; zero warnings-as-errors failures. Every new `#nullable enable` feature file compiles clean under the promoted-warning gate. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-tests-coverage.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-tests-coverage.md new file mode 100644 index 00000000..4656958a --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase9-final-tests-coverage.md @@ -0,0 +1,21 @@ +# Phase 9 — Final Tests + Coverage (P9-T4) + +Timestamp: 2026-07-18T12-45 +Command: vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage /Settings: +EXIT_CODE: 0 +Output Summary: +- Total tests: 4935; Passed: 4935; Failed: 0 (baseline 4838 + 97 new feature tests). +- Coverage report: TestResults\eeb45ce3-62b6-456b-8512-deaf9737042f\DanMoisan_MEGALODON4_2026-07-18.09_42_27.cobertura.xml +- OVERALL (all instrumented modules): line 59.15% (43377/73328), branch 46.94% (9821/20923) — baseline-comparable figure (P0-T5 was 58.74% / 46.33%). +- Feature-relevant packages: UtilitiesCS line 88.65% / branch 82.36% (baseline 88.55% / 82.22%); QuickFiler line 73.34% / branch 64.30% (baseline 72.32% / 62.32%). +- Per-module (per new non-exempt file; line % aggregating compiler-generated nested types): + - BreadcrumbSegment.cs (BreadcrumbSegment): 100% line + - BreadcrumbRow.cs (BreadcrumbRow + enum): 98.02% line / 88.46% branch + - BreadcrumbRowBuilder.cs (BreadcrumbRowBuilder): 100% line / 100% branch + - BreadcrumbMessages.cs (Inbound/Outbound/Render/SubfolderResult/FocusSearch message types): 100% line each + - BreadcrumbMessageCodec.cs (BreadcrumbMessageCodec 95.56% line / 97.37% branch; BreadcrumbMessageException 100%) + - BreadcrumbDocumentAssets.cs: constant-only type (no executable lines; legitimately absent from the line report per the type-only/constant-module clarification) + - BreadcrumbHtmlRenderer.cs (BreadcrumbHtmlRenderer): 96.90% line / 89.47% branch + - BreadcrumbOutboundQueue.cs (BreadcrumbOutboundQueue): 95.83% line / 100% branch + - BreadcrumbBridgeRouter.cs (BreadcrumbBridgeRouter incl. async state machines): 97.87% line / 92.22% branch +- Every new non-exempt module is >= 90% line coverage. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/fail-before-exception.2026-07-18T09-00.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/fail-before-exception.2026-07-18T09-00.md new file mode 100644 index 00000000..40ba3975 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/fail-before-exception.2026-07-18T09-00.md @@ -0,0 +1,34 @@ +# Fail-Before Exception Dossier — Percentage-Obscuring Defect (P1-T2, [expect-fail]) + +Timestamp: 2026-07-18T09-00 +Task: [P1-T2] runtime reproduction of the EfcViewer percentage-obscuring defect (#349) + +WhyFailingRunImpossible: A live Outlook runtime session is structurally unavailable to the executing agent — repo policy prohibits unit tests and agent runs from launching live Outlook, real WinForms forms, or a live WebView2, and the EfcViewer can only be driven end-to-end from a live Outlook explorer session on the user's display. The runtime screenshot + Form.Shown diagnostic capture therefore cannot be produced in this environment. This dossier records the plan-authorized alternative geometry proof instead. + +Capture method (alternative proof): static Designer-geometry analysis per research §D.2 (candidate cause 1 — unscaled ColumnHeader widths under WinForms font autoscaling), cross-checked against the committed Designer source. The P1-T1 temporary log4net instrumentation (`// TEMP repro instrumentation (#349) — removed in P8-T3` in `QuickFiler/Viewers/EfcViewer.cs`, OnShown override) remains in place so a manual runtime capture can still be taken during Phase 8 manual verification if a live session becomes available. + +## Alternative geometry proof (Designer widths vs expected runtime client width) + +Observed committed values in `QuickFiler/Viewers/EfcViewer.Designer.cs` (verified 2026-07-18): + +| Item | Value | Source line | +|---|---|---| +| `olvColumnFolder.Width` | 3200 px (fixed) | line 915 | +| `olvColumnPercent.Width` | 500 px (fixed, right-aligned) | line 921 | +| `FolderListBox` design size | (3728, 1) | line 905 | +| Form `AutoScaleDimensions` | (12F, 25F), `AutoScaleMode.Font` | lines ~4250-4251 | +| Form design `ClientSize` | (3844, 1065) | line ~4252 | + +Reasoning chain: +1. Design-time math shows no overlap: 3200 + 500 = 3700 <= 3728 (the control's design width), matching the epic's "static column/rect math shows no overlap" observation (research §D.1). +2. The form is authored at a high-DPI design scale (`AutoScaleDimensions (12F, 25F)` ~ 250-300% of a standard 96-DPI font scale). WinForms `AutoScaleMode.Font` rescales `Control` bounds at runtime but does NOT rescale `ColumnHeader.Width` values (ColumnHeaders are not `Control`s). The two fixed pixel widths (3200 / 500) therefore survive unscaled at runtime. +3. At runtime, `EfcFormController.CaptureConfigureItemViewer` additionally sizes the form to 75% of the explorer screen. On an ordinary display (e.g., 1920- or 2560-px-wide screens) the rescaled `FolderListBox.ClientSize.Width` lands far below 3700 px — on the order of 1000-1800 px. +4. Expected runtime condition (research §D.2/§D.3): `olvColumnFolder.Width (3200) > FolderListBox.ClientSize.Width`, so the right-aligned `%` column begins beyond the right edge of the viewport and is reachable only by horizontal scroll — i.e., the percent is always obscured pre-fix. + +This documents the pre-fix defect state BEFORE any fix is applied (no rendering/layout change has been made at this point in the plan; only the temporary diagnostic was added by P1-T1). + +## Cross-references + +- Research: `research/2026-07-16T22-30-efcviewer-breadcrumb-webview2-research.md` §D.1, §D.2 (candidate 1), §D.3. +- Instrumentation: `QuickFiler/Viewers/EfcViewer.cs` OnShown override (P1-T1), logging `FolderListBox.ClientSize.Width`, `olvColumnFolder.Width`, `olvColumnPercent.Width`, `CurrentAutoScaleDimensions`, `DeviceDpi`. +- Pass-after counterpart: P8-T1 (`evidence/regression-testing/percent-visible-pass-after..md`) — will record remediation-required if the live session remains unavailable. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/percent-visible-pass-after.2026-07-18T11-45.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/percent-visible-pass-after.2026-07-18T11-45.md new file mode 100644 index 00000000..9dc38d10 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/percent-visible-pass-after.2026-07-18T11-45.md @@ -0,0 +1,29 @@ +# Percentage-Visibility Pass-After Verification (P8-T1) + +Timestamp: 2026-07-18T11-45 +Status: REMEDIATION-REQUIRED (manual verification outstanding) — this artifact does NOT record a pass. + +Verification method (planned, outstanding): launch the EfcViewer against live Outlook, resize the +form to its minimum width, confirm via a JS-side rect check +(`document.querySelectorAll('.pct')` each `getBoundingClientRect().right <=` its row's +`getBoundingClientRect().right` and fully within the viewport) that every percent text node is +fully inside its row's client rect, and capture a sibling screenshot. + +WhyOutstanding: a live Outlook runtime session is structurally unavailable to the executing agent +(repo policy prohibits agent runs from launching live Outlook, real forms, or a live WebView2), so +the runtime pass-after observation cannot be captured in this environment. + +Structural evidence available now (NOT a substitute for the runtime pass): +- The CSS fix is implemented and unit-verified: `.pct { flex: 0 0 auto; margin-left: auto; white-space: nowrap; }` + is a fixed non-shrinking flex item and only `.crumb` may truncate + (`UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs`); the renderer emits the percent + as the trailing `.pct` item on EVERY row, asserted by + `BreadcrumbHtmlRendererTests.RenderRowFragment_EveryRowKind_EmitsTrailingPctFlexItem` and + `RenderRowFragment_CollapsedRow_StillEmitsTrailingPercent` (both green in the Phase 7 gate). +- The defect mechanism (fixed 3200/500 px unscaled ColumnHeader widths) is eliminated: the + columns no longer exist after the P6-T2 Designer swap. +- Fail-before counterpart: `evidence/regression-testing/fail-before-exception.2026-07-18T09-00.md`. + +Required remediation: perform the manual runtime check above during the next live Outlook session +and update this artifact (or add a sibling pass artifact with screenshot) before treating spec +AC-5 as fully verified. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/feature-audit.2026-07-18T09-54.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/feature-audit.2026-07-18T09-54.md new file mode 100644 index 00000000..38e768fc --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/feature-audit.2026-07-18T09-54.md @@ -0,0 +1,85 @@ +# Feature Audit — efcviewer-breadcrumb-webview2 (#349) + +- Timestamp: 2026-07-18T09-54 +- Branch: `feature/efcviewer-breadcrumb-webview2-349` +- Diff base: `8e242692` (merge-base with `origin/epic/folder-tree-breadcrumb-redesign-integration`) +- HEAD: `be6f38d1` +- Work mode: `full-feature` + +## Scope and Baseline + +AC sources per `full-feature` mode: `spec.md` (Definition of Done / Acceptance Criteria) and +`user-story.md` (Acceptance Criteria). Both files carry an identical 12-item list and are jointly +authoritative; verdicts below apply to both files. Baseline is the merge-base above; the audit +evaluates the full branch diff against tests and evidence artifacts under the feature `evidence/` +tree. + +Manual-verification ground rule (epic mode, supplied by the orchestrator): live-Outlook runtime +verification is structurally unavailable to agents in this environment. Five ACs that rest on live +runtime observation are evaluated as PARTIAL (non-blocking, deferred to maintainer runtime QA per the +plan fallback) when the automated evidence (unit tests + geometry proof + documented fallback +records) is present and sound, and only as blocking when that automated evidence is missing or +contradicted. This rule governs verdict severity only, not audit scope. + +## Acceptance Criteria Inventory + +Twelve criteria, identical in `spec.md` and `user-story.md`: + +1. Single-line breadcrumb per suggestion in the live EfcViewer via a WebView2 control replacing the TreeListView. +2. Leaf-only plus/minus affordance, gated on `HasSubfolders`. +3. Non-leaf double-click collapse-after with a re-expand plus. +4. Expand lists real immediate Outlook subfolders via the 9101 `IFolderHierarchyProvider` seam; no prefix-matching. +5. Prediction percentage always fully visible; runtime repro captured first, CSS fix after. +6. JS<->.NET bridge carries double-click, arrow keys, and the live subfolder query. +7. EfcViewer3 handled as a mechanical Designer-only swap/removal with no behavioral wiring. +8. No third-party WinForms tree/list control and no WPF/ElementHost; technology is WebView2. +9. Scoring/ranking unchanged; feature-324 percentage plumbing reused as-is. +10. Behavior parity preserved (focusSearch, Trash pseudo-row, `"===="` banners, `'F'`, dark mode). +11. Pure model/state-machine, bridge contracts, renderer, and router unit-tested (MSTest+Moq+FluentAssertions), >= 90% new modules, no new logic in EfcFormController. +12. Full C# toolchain passes single-pass; no banned APIs. + +## Acceptance Criteria Evaluation + +| # | Verdict | Blocking? | Evidence and reasoning | +|---|---|---|---| +| 1 | PARTIAL | No | Implementation delivered: `BreadcrumbRowBuilder` builds single-line breadcrumb rows from 9101 ancestor chains; `BreadcrumbHtmlRenderer` emits one `.row` per suggestion; Designer swap installs the WebView2 control; router delivers the generated document. Unit-verified by `BreadcrumbRowBuilderTests`, `BreadcrumbHtmlRendererTests`, and `BreadcrumbBridgeRouterTests.BindRowsAsync_WithInitializedHost_DeliversGeneratedDocument`. GAP: live-EfcViewer runtime observation is structurally unavailable (documented in `manual-parity-verification.2026-07-18T11-50.md`). Automated evidence present and sound -> PARTIAL, deferred to maintainer runtime QA. | +| 2 | PASS | No | Affordance gated on `HasSubfolders` in both the state model (`CanExpandLeaf`) and the renderer (`AppendLeafAffordance`). Tests: `RenderRowFragment_LeafWithSubfolders_EmitsPlusWhenCollapsedMinusWhenExpanded`, `RenderRowFragment_LeafWithoutSubfolders_EmitsNoAffordance`, `ToggleLeafExpanded_WithoutSubfolders_IsNoOp`. Verified in source (BreadcrumbHtmlRenderer.cs:186-197, BreadcrumbRow.cs:260-263). | +| 3 | PASS | No | `CollapseAfter` hides downstream segments and marks the terminal; the renderer emits the re-expand plus to the left of the now-terminal segment; `ReExpand`/`RightArrow` restore. Tests: `CollapseAfter_OnNonLeafSegment_HidesDownstreamAndMarksTerminal`, `ReExpand_AfterCollapse_RestoresFullBreadcrumb`, `RenderRowFragment_CollapsedState_RendersReExpandPlusAtTerminalSegment`, `SegmentDoubleClick_OnNonLeafSegment_CollapsesAndReRenders`. Verified in source (BreadcrumbRow.cs:104-148, BreadcrumbHtmlRenderer.cs:152-159). | +| 4 | PARTIAL | No | Subfolders sourced only via the 9101 seam (`ResolveLeafKeyAsync` + `GetImmediateSubfoldersAsync`) with `requestId` correlation; zero prefix-matching in the breadcrumb path (verified in `BreadcrumbRowBuilder`/`BreadcrumbBridgeRouter` source). Test: `LeafExpandToggle_IssuesSubfolderQueryAndPostsCorrelatedResult`; seam presence recorded in `phase0-9101-provider-gate.md`. GAP: live-Outlook subfolder listing (incl. a folder not among ranked suggestions) outstanding. Automated evidence sound -> PARTIAL. | +| 5 | PARTIAL | No | Fail-before dossier `fail-before-exception.2026-07-18T09-00.md` records the plan-authorized geometry proof (live repro structurally unavailable); the CSS fix (`.pct { flex: 0 0 auto; margin-left: auto }`, trailing item on every row) is implemented in `BreadcrumbDocumentAssets` and unit-asserted by `RenderRowFragment_EveryRowKind_EmitsTrailingPctFlexItem` + `RenderRowFragment_CollapsedRow_StillEmitsTrailingPercent`; the defect mechanism (fixed unscaled ColumnHeader widths) is removed by the Designer swap. GAP: runtime pass-after at minimum width outstanding (`percent-visible-pass-after.2026-07-18T11-45.md`, marked remediation-required). Deviation noted: the repro is a geometry proof under `evidence/regression-testing/` rather than a screenshot under `evidence/repro/`; this is plan-authorized given the environment constraint. Automated evidence present and sound -> PARTIAL, deferred to maintainer runtime QA. | +| 6 | PARTIAL | No | Bridge implemented end-to-end: `BridgeJs` posts inbound messages and applies `render`/`subfolderResult`; `WebView2BreadcrumbHost` wires `WebMessageReceived`/`PostWebMessageAsJson`/`NavigateToString`; codec round-trips verified by `BreadcrumbMessageCodecTests`; router interaction tests cover routing. GAP: live WebView2 round-trip verified only against `Mock`. Automated evidence sound -> PARTIAL. | +| 7 | PASS | No | `EfcViewer3.Designer.cs` swapped mechanically to the WebView2 control with both OLVColumns removed; `EfcViewer3.cs` diff is empty (byte-identical); zero construction sites / controller wiring. Evidence: `efcviewer3-mechanical-swap-verification.md`; verified against the branch diff. | +| 8 | PASS | No | No ``/``/`packages.config` change in the diff (only `` entries); the control is the already-referenced WebView2; zero `BrightIdeasSoftware` references remain in the Efc Designer files. No WPF/ElementHost introduced. | +| 9 | PASS | No | No scoring/ranking source touched (diff is confined to breadcrumb, viewer, controller-wiring, test, and csproj files). The renderer reuses `PercentageFormatter.FormatPercent`; probabilities join by full-path equality (`BuildRow_WithMatchingProbability_JoinsByFullPathEquality`). | +| 10 | PARTIAL | No | All eight parity behaviors are wired and unit-verified: `focusSearch` (`ArrowKeyUp_AtTopSelectableRow_PostsFocusSearchAndRaisesEvent` + controller wiring), SearchText down-arrow (`SelectFirstRow`), Trash pseudo-row selectable, `"===="` banners non-interactive and rejected (`RowSelected_OnBannerRow_IsIgnored`, `IsValidSelection` unchanged), `'F'` focuses the control, dark-mode re-theme (`ApplyTheme_Dark_ReDeliversDarkDocument`), leaf expand, selection feeds `SelectedFolder`. GAP: all eight runtime confirmations outstanding (`manual-parity-verification.2026-07-18T11-50.md`). Automated evidence sound -> PARTIAL. | +| 11 | PASS | No | 6 new test files, ~102 breadcrumb unit tests (MSTest+Moq+FluentAssertions, AAA), router against mocked provider/host. Per-module line coverage all >= 90% (100/100/100/98.02/97.87/96.90/95.83/95.56%). `EfcFormController` is wiring-only (net -36 lines); host adapter exempt with in-code justification. Verified against `phase9-final-tests-coverage.md`, `phase9-coverage-delta.md`, and source. | +| 12 | PASS | No | csharpier EXIT 0 + check clean; analyzer build EXIT 0 (0 errors/warnings); nullable/TreatWarningsAsErrors EXIT 0; vstest 4935/4935 passed with coverage. Banned-API scan zero hits (independently reconfirmed by reviewer grep). Evidence: the four `phase9-final-*.md` gates + `banned-api-scan.md`. | + +## Acceptance Criteria Check-off + +Reviewer check-off protocol applied. PASS-verdict items are checked in both source files; PARTIAL +items remain unchecked and are documented as gaps above. + +- PASS (checked): AC2, AC3, AC7, AC8, AC9, AC11, AC12 — all seven were already `[x]` in both + `spec.md` and `user-story.md`; no new check-off was required. +- PARTIAL (unchecked): AC1, AC4, AC5, AC6, AC10 — correctly remain `[ ]` in both source files per + the acceptance-criteria-tracking rule (PARTIAL items are not checked off). +- No modifications were made to `spec.md` or `user-story.md` because the checkbox state already + matches the evaluated verdicts (7 PASS checked, 5 PARTIAL unchecked). + +## Acceptance Criteria Status + +- Source: `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/spec.md` and `.../user-story.md` +- Total AC items: 12 +- Checked off (delivered/PASS): 7 (AC2, AC3, AC7, AC8, AC9, AC11, AC12) +- Remaining (unchecked, PARTIAL): 5 (AC1, AC4, AC5, AC6, AC10) +- Items remaining: AC1 (live single-line render), AC4 (live subfolder listing), AC5 (runtime percent-visibility pass-after), AC6 (live bridge round-trip), AC10 (runtime behavior-parity confirmations) + +## Summary + +- 7 PASS, 5 PARTIAL, 0 FAIL. Every PARTIAL rests solely on the structurally-unavailable live-Outlook + runtime observation; all automated deliverables, unit tests, and toolchain gates for those items + are complete and green. Under the epic manual-verification ground rule, these five are non-blocking + and deferred to maintainer runtime QA. +- No delivered code defect was found that would make any PARTIAL a blocking FAIL. +- Blocking count: 0. diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/issue.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/issue.md new file mode 100644 index 00000000..ffa89603 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/issue.md @@ -0,0 +1,84 @@ +# efcviewer-breadcrumb-webview2 (Issue #349) + +- Date captured: 2026-07-16 +- Author: Dan Moisan +- Status: Promoted -> docs/features/active/efcviewer-breadcrumb-webview2/ (Issue #349) +- Epic: `folder-tree-breadcrumb-redesign` (integration branch `epic/folder-tree-breadcrumb-redesign-integration`) +- Manifest child: wave 1, complexity band C4, `depends_on: [9101]` +- Source objective: `docs/features/potential/2026-07-16-folder-tree-breadcrumb-redesign-epic-request.md` (decomposition item 2) + +- Issue: #349 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/349 +- Last Updated: 2026-07-17 +- Work Mode: full-feature + +## Problem / Why + +The EfcViewer matching-folders control currently renders folder suggestions as a conventional +indented multi-row tree using `BrightIdeasSoftware.TreeListView` (`QuickFiler/Viewers/EfcViewer.cs`, +`QuickFiler/Viewers/EfcViewer3.cs`, plus their Designer files). The intended design is a single-line +breadcrumb per suggestion anchored at the selected leaf. The current hierarchy is synthesized by +`FolderSuggestionTree.BuildFromRows` via prefix-matching over the top-ranked suggestion rows, so +expanding a folder does not reveal its real Outlook subfolders. The prediction percentage is also +reported as obscured at runtime even though static column/rect math shows no overlap. + +`TreeListView` (as currently used) does not naturally support single-line breadcrumb rendering with +per-segment double-click collapse, and it is a VSTO/WinForms-hosting-specific investment that would +not carry forward to the planned VSTO migration. The redesign targets WebView2 (HTML/CSS/JS), which +is largely reusable across a post-VSTO UI stack and reuses a dependency already proven in this +codebase (QuickFiler's WebView2 message-body pane, including the `cid:` fix from feature 326). + +## Proposed Behavior + +Replace the EfcViewer matching-folders `TreeListView` in both `EfcViewer` and `EfcViewer3` with a +WebView2-hosted HTML/CSS/JS breadcrumb control that: + +1. Renders each suggestion as a single-line breadcrumb `Folder -> SubFolder -> Leaf` anchored at the + selected/predicted leaf. +2. Shows an expand affordance (plus when collapsed, minus when expanded) only on the leaf, and only + when the leaf has subfolders. +3. Collapses a row on double-click of a non-leaf segment, hiding everything after that segment and + showing a plus to re-expand back to the full breadcrumb. +4. On expand of a segment, lists every real immediate Outlook subfolder of that folder via the + shared live folder-hierarchy provider from feature 9101 (not prefix-matching over suggestion rows). +5. Keeps the prediction percentage always fully visible: first capture a runtime reproduction of the + current obscuring defect, then apply a CSS-based fix. + +A JS<->.NET event bridge carries double-click and keyboard (left/right arrow) interaction and routes +the live subfolder query across the WebView2 boundary. The percentage plumbing (feature 324) is reused +as-is; the scoring/ranking algorithm is not changed. + +## Acceptance Criteria (early draft) + +- [ ] Each EfcViewer suggestion renders as a single-line breadcrumb anchored at the selected leaf in a WebView2-hosted control (both `EfcViewer` and `EfcViewer3`). +- [ ] The leaf shows a plus/minus expand affordance only when it has subfolders. +- [ ] Double-clicking a non-leaf segment collapses the row after that segment with a plus to re-expand. +- [ ] Expanding a segment lists every real immediate Outlook subfolder via the 9101 provider. +- [ ] The prediction percentage is always fully visible; a runtime reproduction of the current defect precedes the CSS-based fix. +- [ ] A JS<->.NET bridge carries double-click and left/right-arrow keyboard events and routes the live subfolder query. +- [ ] No third-party WinForms tree/list control and no WPF/ElementHost are introduced; the control is WebView2 (HTML/CSS/JS). +- [ ] No change to the scoring/ranking algorithm; the C# toolchain (csharpier, analyzers, nullable, MSTest) is green and coverage thresholds are met. + +## Constraints & Risks + +- Depends on issue 9101 (live Outlook folder-hierarchy provider), which is merged before this feature + during epic execution. This feature consumes 9101's contract (ancestor chain + on-demand real + immediate subfolders behind an injectable seam) rather than re-deriving hierarchy from suggestion rows. +- Ruled out: `BrightIdeasSoftware.TreeListView` (or any third-party WinForms tree/list control) and + WPF/`ElementHost`. +- The live Outlook subfolder query is I/O-bound and must be isolated from pure breadcrumb logic per + the repository I/O-boundary policy so the core is unit-testable without a live Outlook process. +- Two parallel non-shared EfcViewer implementations (`EfcViewer`, `EfcViewer3`) must both be converted. +- WinForms/WebView2 host wiring and Designer-generated code are coverage-exempt per policy; testable + pure logic (breadcrumb model, bridge message shaping) is not exempt and must meet coverage floors. + +## Test Conditions to Consider + +- [ ] Unit coverage: breadcrumb model construction from an ancestor chain; collapse/expand state transitions; bridge message serialization/deserialization; segment-children request shaping. +- [ ] Integration scenarios: WebView2 host initialization; JS<->.NET bridge round-trip for double-click, arrow keys, and subfolder query. +- [ ] Runtime reproduction of the percentage-obscuring defect and verification of the CSS fix. + +## Next Step + +- [ ] Promote to GitHub issue (feature request template) +- [ ] Create `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2/` folder from the template diff --git a/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/plan.2026-07-16T21-52.md b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/plan.2026-07-16T21-52.md new file mode 100644 index 00000000..7dbd1ac5 --- /dev/null +++ b/docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/plan.2026-07-16T21-52.md @@ -0,0 +1,258 @@ +# efcviewer-breadcrumb-webview2 — Plan + +- **Issue:** #349 +- **Parent:** Epic `folder-tree-breadcrumb-redesign` (child 9102, wave 1, band C4, `depends_on: [9101]`) +- **Owner:** drmoisan +- **Branch:** feature/efcviewer-breadcrumb-webview2-349 (cut from epic/folder-tree-breadcrumb-redesign-integration) +- **Last Updated:** 2026-07-17T00-30 +- **Status:** Draft +- **Version:** 1.0 +- **Work Mode:** full-feature + +## Requirements Sources + +- `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/spec.md` (authoritative AC) +- `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/user-story.md` (authoritative AC) +- `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/issue.md` +- `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/research/2026-07-16T22-30-efcviewer-breadcrumb-webview2-research.md` + +**All work must comply with the repository policies (CLAUDE.md, `.claude/rules/general-code-change.md`, `.claude/rules/general-unit-test.md`, `.claude/rules/csharp.md`). Do not duplicate their content here.** + +## Upstream Dependency Note (9101 — live Outlook folder-hierarchy provider) + +This feature CONSUMES the 9101 provider contract; it does not implement it. During epic execution +9101 merges into `epic/folder-tree-breadcrumb-redesign-integration` BEFORE this feature runs, so the +provider types (`IFolderHierarchyProvider`, `FolderSegmentInfo` with `FullPath`, `DisplayName`, +`HasSubfolders` — assumed shape per research §C.3) are expected to be present in `UtilitiesCS` at +execution time. Verified at planning time (2026-07-17): the types do NOT yet exist on this branch, +which is why Phase 0 contains a hard dependency gate (P0-T6) that records the actual merged surface +and HALTS execution if the provider is absent. If the merged shape deviates from §C.3, the single +re-alignment point is the row-builder/router input; P0-T6 records the actual namespace and member +shape that Phases 2 and 5 must code against. No hierarchy may be re-derived from suggestion rows. + +## Scope Lock (files created / modified) + +New host-neutral source (non-exempt, target >= 90% line coverage) — `UtilitiesCS` is a legacy +`packages.config` project; every new `.cs` file MUST be wired with an explicit `` +item in `UtilitiesCS/UtilitiesCS.csproj` in the same task that creates it: +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbSegment.cs` +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs` +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs` +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs` +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs` +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs` +- CREATE `UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs` + +New QuickFiler source — `QuickFiler` is likewise legacy `packages.config` (explicit +`` in `QuickFiler/QuickFiler.csproj` required per file): +- CREATE `QuickFiler/Viewers/IBreadcrumbWebHost.cs` (interface-only seam) +- CREATE `QuickFiler/Controllers/BreadcrumbOutboundQueue.cs` (non-exempt, >= 90%) +- CREATE `QuickFiler/Controllers/BreadcrumbBridgeRouter.cs` (non-exempt, >= 90%) +- CREATE `QuickFiler/Viewers/WebView2BreadcrumbHost.cs` (coverage-exempt adapter, in-code justification) + +New tests — both test projects are legacy `packages.config` (explicit `` required): +- CREATE `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowBuilderTests.cs` (+ item in `UtilitiesCS.Test/UtilitiesCS.Test.csproj`) +- CREATE `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs` (+ item) +- CREATE `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs` (+ item) +- CREATE `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs` (+ item) +- CREATE `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterTests.cs` (+ item in `QuickFiler.Test/QuickFiler.Test.csproj`) +- CREATE `QuickFiler.Test/Controllers/BreadcrumbBridgeRouterQueueTests.cs` (+ item) + +Modified WinForms / controller (coverage-exempt, build + manual QA only): +- MODIFY `QuickFiler/Viewers/EfcViewer.cs` (temporary repro instrumentation added in Phase 1, removed in Phase 8; WebView2 control exposure) +- MODIFY `QuickFiler/Viewers/EfcViewer.Designer.cs` (TreeListView -> WebView2 swap, delete `olvColumnFolder`/`olvColumnPercent`) +- MODIFY `QuickFiler/Controllers/EfcFormController.cs` (wiring only — NO new testable logic; class stays wholly `[ExcludeFromCodeCoverage]`) +- MODIFY `QuickFiler/Viewers/EfcViewer3.Designer.cs` (mechanical Designer-only control swap; NO behavioral wiring — dead code per research §B.2) +- MODIFY `UtilitiesCS/UtilitiesCS.csproj`, `UtilitiesCS.Test/UtilitiesCS.Test.csproj`, `QuickFiler/QuickFiler.csproj`, `QuickFiler.Test/QuickFiler.Test.csproj` (`` wiring only) + +Constraints applying to every new/touched file: net48-safe types only (no `record`, no `record struct`, +no `init` accessors — plain classes / `readonly struct` with explicit constructors); `#nullable enable` +in new files; no banned APIs (`DateTime.Now`, `DateTime.UtcNow`, `Random.Shared`, `Thread.Sleep`, +`Task.Delay`) — initialization waiting is gated on `CoreWebView2InitializationCompleted` plus the +pending-message queue, never a delay; every file stays under the 500-line cap (`EfcFormController.cs` +is pre-existing over-limit and must not materially grow); no new NuGet packages +(WebView2 1.0.3912.50 and Newtonsoft.Json 13.0.4 are already referenced where consumed — +Newtonsoft-consuming code lives ONLY in `UtilitiesCS`, which already references it; `QuickFiler` +gains no Newtonsoft reference). + +## Evidence Location Invariant + +All evidence artifacts are written ONLY under +`docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence//` +using kinds `baseline/`, `qa-gates/`, `regression-testing/`, `repro/`, and `other/`. +Writing to `artifacts/baselines/`, `artifacts/qa/`, `artifacts/coverage/`, or any other non-canonical +path is a policy violation. Timestamps use `yyyy-MM-ddTHH-mm`. (The Phase 9 JaCoCo export to +`artifacts/csharp/coverage.xml` is a machine-consumed feature-review gate input, not an evidence +artifact, and is the one permitted `artifacts/` output.) + +## Implementation Plan (Atomic Tasks) + +### Phase 0 — Baseline Capture, Policy Review, and 9101 Dependency Gate + +- [x] [P0-T1] Read the policy files in policy-compliance order (`CLAUDE.md`, `.claude/rules/general-code-change.md`, `.claude/rules/general-unit-test.md`, `.claude/rules/csharp.md`) and record the read in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-instructions-read.md` + - Acceptance: artifact exists containing `Timestamp:`, `Policy Order:`, and the explicit list of the four files read. +- [x] [P0-T2] Run `dotnet tool run csharpier . --check` (or `csharpier . --check`) at repo root and record the result in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-csharpier.md` + - Acceptance: artifact contains `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. +- [x] [P0-T3] Run `msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` and record the result in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-analyzers.md` + - Acceptance: artifact contains `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` (warning/error counts). +- [x] [P0-T4] Run `msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true` and record the result in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-nullable.md` + - Acceptance: artifact contains `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:`. +- [x] [P0-T5] Run baseline tests with coverage via `vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage` and record the numeric baseline coverage headline in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/baseline/phase0-baseline-tests-coverage.md` + - Acceptance: artifact contains `Timestamp:`, `Command:`, `EXIT_CODE:`, `Output Summary:` including numeric baseline line-coverage and branch-coverage percentages and total passed/failed test counts. +- [x] [P0-T6] Verify the merged 9101 provider surface is present: search `UtilitiesCS/` for the `IFolderHierarchyProvider` interface and `FolderSegmentInfo` type (expected members per research §C.3: `GetAncestorChainAsync(string, CancellationToken)`, `GetImmediateSubfoldersAsync(string, CancellationToken)`, `FullPath`/`DisplayName`/`HasSubfolders`) and record the actual namespace, file path, and member shape in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/other/phase0-9101-provider-gate.md`; if the provider types are ABSENT, record `DEPENDENCY GATE FAILED: 9101 provider not merged`, HALT execution (do not start Phase 1), and report blocked state to the orchestrator + - Acceptance: artifact contains `Timestamp:`, the search commands used, the resolved type locations and member shape (or the explicit gate-failure record); execution proceeds past Phase 0 only when both types are present. + +### Phase 1 — Percentage-Obscuring Defect Runtime Reproduction + +- [x] [P1-T1] Add a temporary log4net diagnostic to `QuickFiler/Viewers/EfcViewer.cs` that logs, on `Form.Shown`, `FolderListBox.ClientSize.Width`, `olvColumnFolder.Width`, `olvColumnPercent.Width`, `CurrentAutoScaleDimensions`, and `DeviceDpi`, marked with the exact comment `// TEMP repro instrumentation (#349) — removed in P8-T3` + - Acceptance: instrumentation compiles inside the already-exempt Form class; the marker comment is present; solution builds. +- [x] [P1-T2] [expect-fail] Capture the runtime reproduction of the percentage-obscuring defect: launch the EfcViewer against live Outlook on the user's normal display and store (a) a screenshot of the suggestion list with the obscured/missing percent and (b) the P1-T1 diagnostic log line in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/repro/` as `percent-obscured-repro..md` (embedding or referencing the sibling screenshot file); if a live Outlook session is structurally unavailable to the executor, instead write a fail-before exception dossier `fail-before-exception..md` under `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/regression-testing/` with `WhyFailingRunImpossible:` and an alternative geometry proof (Designer widths vs expected runtime client width per research §D.2) + - Acceptance: the repro artifact records `Timestamp:`, capture method, and the observed geometry values demonstrating the pre-fix defect (expected: `olvColumnFolder.Width (3200) > FolderListBox.ClientSize.Width`), OR the schema-valid exception dossier exists; the defect state is documented BEFORE any fix is applied. +- [x] [P1-T3] Run the full C# toolchain loop in order (`csharpier .` -> `msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` -> `msbuild TaskMaster.sln /t:Build /p:Configuration=Debug /p:Platform="Any CPU" /p:Nullable=enable /p:TreatWarningsAsErrors=true` -> `vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage`), restarting from step 1 on any failure or file change, and record the green pass in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase1-toolchain.md` + - Acceptance: artifact contains `Timestamp:`, `Command:` per step, `EXIT_CODE:` per step, `Output Summary:`; all four steps green in a single pass. + +### Phase 2 — Breadcrumb Row Model and Collapse/Expand State Machine + +- [x] [P2-T1] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbSegment.cs` defining the pure segment model (`FullPath`, `DisplayName`, `HasSubfolders`; net48-safe class or `readonly struct` with explicit constructor, `#nullable enable`, no WinForms/COM/WebView2 types) and add a matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists, csproj item present, `msbuild TaskMaster.sln` compiles the type into `UtilitiesCS.dll`. +- [x] [P2-T2] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbRow.cs` defining the per-row model and state machine: `RowId`, `Kind` enum `{ Suggestion, Banner, TrashPseudoRow }`, ordered `Segments`, `Probability` (`double?`), collapse-after-segment state (`CollapseAfter(int segmentIndex)` on a non-leaf segment hides all segments after it and marks the now-terminal segment with a re-expand affordance; `ReExpand()` restores the full breadcrumb), leaf expand state with a children list (`SetLeafChildren`, `ToggleLeafExpanded` valid only when the leaf `HasSubfolders`), `LeftArrow()`/`RightArrow()` transitions, `VisibleSegments()` projection, and documented no-op rules (banner and pseudo-rows never collapse/expand; leaf without subfolders is a toggle no-op); add the matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists (< 500 lines), csproj item present, compiles; transitions mutate only row view-state and never alter `Segments`, `Probability`, or the filing target path. +- [x] [P2-T3] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbRowBuilder.cs` that builds `BreadcrumbRow` instances from suggestion rows plus 9101 ancestor chains: maps an ordered root-to-leaf `FolderSegmentInfo` chain (actual 9101 type per the P0-T6 record) to `BreadcrumbSegment`s anchored at the predicted leaf, joins `Probability` by full-path equality, classifies `"===="`-prefixed rows as `Kind.Banner` (non-interactive) and the `"Trash to Delete"` pseudo-row as `Kind.TrashPseudoRow` (selectable, no segments/affordance), and preserves presented row order; add the matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists, csproj item present, compiles; builder consumes the 9101 segment type directly (no prefix-matching over suggestion rows anywhere in the file). +- [x] [P2-T4] Create `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowBuilderTests.cs` (MSTest + FluentAssertions, Arrange–Act–Assert) covering: chain-to-row construction anchored at the leaf, probability join (matched, unmatched, null), banner classification, trash pseudo-row classification, empty chain, single-segment chain, and preserved row order; add the matching `` item to `UtilitiesCS.Test/UtilitiesCS.Test.csproj` + - Acceptance: file exists, csproj item present, all builder tests pass under `vstest.console.exe`. +- [x] [P2-T5] Create `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbRowStateTests.cs` covering: collapse-after-segment hides downstream segments and exposes the re-expand affordance at the now-terminal segment, re-expand restores the full breadcrumb, leaf toggle with/without subfolders (no-op when `HasSubfolders == false`), `LeftArrow`/`RightArrow` transitions and their no-op rules, banner/pseudo-row no-ops, `VisibleSegments()` projection for every state, and state-transition sequences (collapse -> re-expand -> leaf expand); add the matching `` item to `UtilitiesCS.Test/UtilitiesCS.Test.csproj` + - Acceptance: file exists, csproj item present, all state tests pass; positive, negative, edge, and state-transition scenarios are each present. +- [x] [P2-T6] Run the full C# toolchain loop in order (`csharpier .` -> analyzers `msbuild` -> nullable `msbuild` -> `vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage`), restarting from step 1 on any failure or file change, and record the green pass in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase2-toolchain.md` + - Acceptance: artifact contains `Timestamp:`, `Command:` per step, `EXIT_CODE:` per step, `Output Summary:`; all four steps green in a single pass. + +### Phase 3 — Bridge Message Contracts and JSON Codec + +- [x] [P3-T1] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessages.cs` defining the net48-safe bridge contract types (plain classes with explicit constructors, `#nullable enable`): inbound `{ type: "segmentDoubleClick" | "leafExpandToggle" | "arrowKey" | "rowSelected", rowId, segmentIndex?, key? }` and outbound `{ type: "render" | "subfolderResult" | "focusSearch", requestId?, ... }` with `subfolderResult` carrying the correlating `requestId` and the child-segment payload; add the matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists (< 500 lines), csproj item present, compiles; no `record`/`init` usage. +- [x] [P3-T2] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbMessageCodec.cs` implementing serialize/deserialize over Newtonsoft.Json 13.0.4: `SerializeOutbound(...)` producing the discriminated JSON, and `DeserializeInbound(string json)` that fails fast on malformed JSON, unknown `type`, or missing required fields by throwing a specific exception after logging a specific log4net error (no silent swallow, no broad catch without rethrow); add the matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists, csproj item present, compiles in `UtilitiesCS` (the only project consuming Newtonsoft for this feature); malformed input raises the documented specific exception. +- [x] [P3-T3] Create `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbMessageCodecTests.cs` covering: JSON round-trip for every inbound and outbound message type, `requestId` correlation on `subfolderResult`, and malformed-input negatives (invalid JSON, unknown `type`, missing `rowId`, wrong field types) each asserting the specific exception via FluentAssertions; add the matching `` item to `UtilitiesCS.Test/UtilitiesCS.Test.csproj` + - Acceptance: file exists, csproj item present, all codec tests pass; every message type has a round-trip test and at least four malformed negatives exist. +- [x] [P3-T4] Run the full C# toolchain loop in order (`csharpier .` -> analyzers `msbuild` -> nullable `msbuild` -> `vstest.console.exe UtilitiesCS.Test\bin\Debug\UtilitiesCS.Test.dll QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /EnableCodeCoverage`), restarting from step 1 on any failure or file change, and record the green pass in `docs/features/active/2026-07-16-efcviewer-breadcrumb-webview2-349/evidence/qa-gates/phase3-toolchain.md` + - Acceptance: artifact contains `Timestamp:`, `Command:` per step, `EXIT_CODE:` per step, `Output Summary:`; all four steps green in a single pass. + +### Phase 4 — Breadcrumb HTML Renderer with CSS Percentage-Visibility Fix + +- [x] [P4-T1] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbDocumentAssets.cs` holding the inline CSS and JS string constants for the generated document: the research §D.4 flex layout (`.row { display: flex; align-items: center; }`, `.crumb { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }`, `.pct { flex: 0 0 auto; margin-left: auto; white-space: nowrap; }`), dark/light theme CSS blocks, and the bridge JS (`window.chrome.webview.postMessage` emitters for segment double-click, leaf affordance activation, left/right/up/down arrow keys, and row selection; `window.chrome.webview.addEventListener('message', ...)` applying `render`/`subfolderResult` updates); add the matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists (< 500 lines), csproj item present, compiles; percent CSS class is a fixed non-shrinking flex item and only the crumb class may truncate. +- [x] [P4-T2] Create `UtilitiesCS/OutlookObjects/Folder/BreadcrumbHtmlRenderer.cs` generating the full HTML document (and per-row update fragments) from `BreadcrumbRow` collections plus a theme flag, enforcing the renderer invariants: percent markup (via the existing `PercentageFormatter.FormatPercent`) is ALWAYS emitted as the trailing `.pct` flex item; the plus/minus affordance is emitted only when the relevant segment's `HasSubfolders` is true (plus when collapsed, minus when expanded); banner rows are rendered non-interactive (no handlers, no affordance); folder display names are HTML-encoded; collapsed rows render the re-expand plus at the now-terminal segment; add the matching `` item to `UtilitiesCS/UtilitiesCS.csproj` + - Acceptance: file exists (< 500 lines), csproj item present, compiles; renderer consumes only the pure row model and `BreadcrumbDocumentAssets` (no I/O, no WebView2 types). +- [x] [P4-T3] Create `UtilitiesCS.Test/OutlookObjects/Folder/BreadcrumbHtmlRendererTests.cs` asserting the renderer invariants: trailing fixed percent item on every row (including collapsed rows), affordance gating on `HasSubfolders` (present/absent, plus/minus by state), HTML-encoding of hostile folder names (`